From 07eea6824ddf3ba7bd105017e9edd53a350d288b Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 11 Nov 2026 22:08:45 -0800 Subject: [PATCH 1/5] Add scoped JSON-RPC resource support Implement lazy per-Configurator resource process reuse with bounded lifecycle cleanup, configurable timeouts, executable fallback, and adapter support. Add a dsctest JSON-RPC server, lifecycle and regression coverage, and transport documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/concepts/resources/anatomy.md | 88 ++- docs/reference/schemas/config/directives.md | 19 + docs/reference/schemas/config/resource.md | 17 + .../schemas/resource/manifest/adapter.md | 4 + .../schemas/resource/manifest/delete.md | 11 +- .../schemas/resource/manifest/export.md | 10 +- .../schemas/resource/manifest/get.md | 4 +- .../schemas/resource/manifest/resolve.md | 4 +- .../schemas/resource/manifest/root.md | 58 +- .../resource/manifest/schema/property.md | 6 +- .../schemas/resource/manifest/set.md | 20 +- .../schemas/resource/manifest/test.md | 4 +- .../schemas/resource/manifest/validate.md | 4 +- .../schemas/resource/manifest/whatif.md | 4 +- dsc/src/resource_command.rs | 20 +- dsc/src/server/invoke_dsc_resource.rs | 40 +- dsc/src/server/show_dsc_resource.rs | 4 +- dsc/src/subcommand.rs | 2 +- dsc/tests/config/dsc_jsonrpc.tests.ps1 | 432 ++++++++++++++ lib/dsc-lib/locales/en-us.toml | 12 + lib/dsc-lib/src/configure/config_doc.rs | 6 + lib/dsc-lib/src/configure/context.rs | 21 + lib/dsc-lib/src/configure/mod.rs | 68 ++- .../src/discovery/command_discovery.rs | 9 +- lib/dsc-lib/src/dscerror.rs | 2 + .../src/dscresources/command_resource.rs | 161 ++++-- lib/dsc-lib/src/dscresources/jsonrpc.rs | 525 ++++++++++++++++++ lib/dsc-lib/src/dscresources/mod.rs | 1 + .../src/dscresources/resource_manifest.rs | 217 +++++++- lib/dsc-lib/src/progress.rs | 48 +- lib/dsc-lib/src/settings/dsc_settings.rs | 8 + lib/dsc-lib/src/settings/fields/mod.rs | 3 + .../src/settings/fields/resource_timeouts.rs | 40 ++ lib/dsc-lib/src/settings/resolved/settings.rs | 4 + .../src/settings/sources/code_defaults.rs | 8 + .../src/settings/sources/environment.rs | 19 + .../src/settings/sources/policy_file.rs | 5 + .../src/settings/sources/preference_file.rs | 5 + .../tests/integration/settings/builders.rs | 3 + .../tests/integration/settings/jsonrpc.rs | 50 ++ lib/dsc-lib/tests/integration/settings/mod.rs | 1 + .../integration/settings/resolved/settings.rs | 7 + .../settings/sources/policy_file.rs | 3 +- .../settings/sources/preference_file.rs | 3 +- tools/dsctest/README.md | 25 + tools/dsctest/dsctest.dsc.manifests.json | 65 +++ tools/dsctest/src/args.rs | 7 + tools/dsctest/src/jsonrpc.rs | 217 ++++++++ tools/dsctest/src/main.rs | 9 + 49 files changed, 2167 insertions(+), 136 deletions(-) create mode 100644 dsc/tests/config/dsc_jsonrpc.tests.ps1 create mode 100644 lib/dsc-lib/src/dscresources/jsonrpc.rs create mode 100644 lib/dsc-lib/src/settings/fields/resource_timeouts.rs create mode 100644 lib/dsc-lib/tests/integration/settings/jsonrpc.rs create mode 100644 tools/dsctest/src/jsonrpc.rs diff --git a/docs/concepts/resources/anatomy.md b/docs/concepts/resources/anatomy.md index bf0758dc8..a8a30c7b2 100644 --- a/docs/concepts/resources/anatomy.md +++ b/docs/concepts/resources/anatomy.md @@ -87,7 +87,93 @@ application or a shell script. A resource can use different executables for diff For DSC to use an executable, it must be discoverable in the `PATH` environment variable. DSC calls the executable once per operation, using the exit code returned by the executable to determine if the command was successful. DSC treats exit code `0` as a success and all other exit codes as an -error. +error. Resources can instead opt into a reusable JSON-RPC process. + +### JSON-RPC resources + +Declare a `jsonrpc` entry point to let DSC invoke multiple operations in one process: + +```json +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Example/Reusable", + "version": "1.0.0", + "jsonrpc": { + "executable": "example-resource", + "args": ["jsonrpc"], + "initializeTimeoutMS": 250, + "requestTimeoutMS": 0, + "shutdownTimeoutMS": 250 + }, + "get": {}, + "set": {"return": "stateAndDiff", "supportsWhatIf": true}, + "schema": {"embedded": {"type": "object"}} +} +``` + +Operation declarations still determine which operations are available. An empty declaration like +`get: {}` uses JSON-RPC without an executable fallback. An operation can also declare its existing +`executable`, `args`, and `input` as a fallback. These fields only apply to executable invocation; +the JSON-RPC entry point accepts static string arguments and receives properties in request params. +Without `jsonrpc`, operations still require an executable. JSON-RPC adapters must use +`inputKind: single`; adapter enumeration continues to use its executable command. + +DSC prefers JSON-RPC while reuse is enabled. It starts the process on first use, sends `initialize`, +and invokes operations sequentially through `dsc_invoke`. Messages are UTF-8 JSON-RPC 2.0 objects, +one complete JSON value per newline, flushed after writing. Request IDs are integers. The resource +must respond with the same ID and exactly one of `result` or `error`. + +An initialize response selects `protocolVersion: 1`. An invocation includes `operation`, +`executionType` (`actual` or `whatIf`), `resourceType`, optional object `properties`, and `context`. +For adapted resources, `context.targetResource` describes the target while the process belongs to +the adapter host. Schema requests set `context.schemaKind` to `resource` or `exportInput`. + +Return the existing logical operation output as a single `result` value. For `stateAndDiff`, return +`{"actualState": {...}, "diff": ["propertyName"]}`. Export returns an array of instances. Operations +without declared return output and actual delete return `null`. Schema commands return a JSON +Schema value. Existing group results remain arrays. Native set or delete what-if requires +`supportsWhatIf: true`; if that operation also has an executable fallback, it must retain a +`whatIfArg` in its fallback arguments. + +During an invocation, the resource can send `dsc_trace` or `dsc_progress` notifications correlated +by `params.requestId`. Progress accepts a message and optional fractional `completed` and `total`. +Completed work must be nonnegative and nondecreasing. A total requires completed work, must be +positive and stable, and can't be below completed work. Notifications don't extend request deadlines. + +After configuration completion or failure, DSC sends `shutdown`, expects `result: null`, sends +the `exit` notification, and waits for process termination. Standalone `dsc resource` commands use +the same transport preference and clean up before returning. Malformed protocol messages, +initialization failures, or timeouts terminate the process without retrying. + +#### Reuse and timeout settings + +`resourceReuse` defaults to `true`. Preference files and policy can set it, and +`DSC_RESOURCE_REUSE=false` disables reuse through the environment setting. Existing settings +precedence applies, including policy enforcement. A document can further restrict reuse with +`directives.reuseResources: false`, and an instance with `directives.reuseResource: false`. +Restrictions combine: an inner scope can't re-enable reuse forbidden by an outer scope. + +When reuse is disabled, DSC prefers the selected operation's executable fallback. Without a +fallback, it initializes a fresh JSON-RPC process for each underlying call and shuts it down +immediately afterward. Nested configurations have their own process pools. A document-wide +prohibition is inherited by nested engines; an instance-only prohibition isn't. + +Preference files and policy can override individual timeout fields: + +```json +{ + "resourceReuse": true, + "resourceTimeouts": { + "initializeTimeoutMS": 500, + "requestTimeoutMS": 30000, + "shutdownTimeoutMS": 500 + } +} +``` + +An explicitly configured timeout overrides the manifest value, even when it is smaller. +Otherwise, DSC uses the manifest value, then the defaults shown in the manifest example above. +Initialize and shutdown timeouts must be positive; a request timeout of `0` means no deadline. ### Inputs diff --git a/docs/reference/schemas/config/directives.md b/docs/reference/schemas/config/directives.md index 43b255d45..833a102b6 100644 --- a/docs/reference/schemas/config/directives.md +++ b/docs/reference/schemas/config/directives.md @@ -65,6 +65,25 @@ ERROR Error: Security context: Elevated security context required ## Properties +### reuseResources + +The `reuseResources` directive controls whether DSC can reuse JSON-RPC resource processes for this +configuration operation. The default is `true`. A process starts only when an operation first needs +it and remains available until the configuration completes or fails. Each Configurator owns a +separate process pool, including when configurations run concurrently. + +Set this directive to `false` to disable reuse for every instance and any nested configuration. +DSC then uses an operation's executable fallback when available; otherwise, it starts and shuts +down a separate JSON-RPC process for each underlying invocation. Setting this directive to `true` +can't override a `resourceReuse: false` setting or a prohibition inherited from a parent engine. +An instance's `reuseResource` directive can further restrict reuse, but can't re-enable it. + +```yaml +Type: boolean +Required: false +Default: true +``` + ### resourceDiscovery The `resourceDiscovery` directive controls when DSC raises an error for a resource that it can't diff --git a/docs/reference/schemas/config/resource.md b/docs/reference/schemas/config/resource.md index 72db9292a..1fe105f67 100644 --- a/docs/reference/schemas/config/resource.md +++ b/docs/reference/schemas/config/resource.md @@ -135,6 +135,23 @@ Required: false You can define the following directives for a resource instance: +#### reuseResource + +Set `reuseResource` to `false` to prevent this instance from using a reused JSON-RPC resource +process. DSC uses an executable fallback when the selected operation defines one. Otherwise, each +underlying invocation gets a separate JSON-RPC process, including schema, validation, and follow-up +get calls. Disabling reuse for one instance doesn't discard a process used by other instances. + +The default is `true`. This directive can't override a `resourceReuse: false` setting, a document's +`reuseResources: false` directive, or an inherited prohibition. Unlike the document directive, an +instance-only restriction doesn't automatically disable reuse inside a nested configuration. + +```yaml +Type: boolean +Required: false +Default: true +``` + #### requireAdapter The `requireAdapter` directive indicates that DSC should use the specified adapter to invoke the diff --git a/docs/reference/schemas/resource/manifest/adapter.md b/docs/reference/schemas/resource/manifest/adapter.md index afc43522d..793e14a72 100644 --- a/docs/reference/schemas/resource/manifest/adapter.md +++ b/docs/reference/schemas/resource/manifest/adapter.md @@ -109,6 +109,10 @@ The `adapter` definition must include these properties: ## Properties +Adapters that define [jsonrpc](root.md#jsonrpc) must use `inputKind: single`. Enumeration through +`adapter.list` remains executable-based. All adapted resource targets share their adapter host's +JSON-RPC process within the configuration operation, subject to reuse restrictions. + ### inputKind The `inputKind` property defines how the adapter expects to receive resource configurations. The diff --git a/docs/reference/schemas/resource/manifest/delete.md b/docs/reference/schemas/resource/manifest/delete.md index 193979a49..b62e632d6 100644 --- a/docs/reference/schemas/resource/manifest/delete.md +++ b/docs/reference/schemas/resource/manifest/delete.md @@ -92,7 +92,7 @@ Because the manifest defines the what-if argument, the resource has the `deleteW ## Required properties -The `delete` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `delete` definition must include: - [executable](#executable) @@ -107,9 +107,16 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` +### supportsWhatIf + +Set `supportsWhatIf` to `true` when the JSON-RPC `delete` operation supports +`executionType: whatIf` without removing the instance. This advertises the `deleteWhatIf` +capability and requires `jsonrpc`. If an executable fallback is declared, its `args` must also +include a [what-if argument](#what-if-argument). The default is `false`. + ### args The `args` property defines the list of arguments to pass to the command. Each item in the array diff --git a/docs/reference/schemas/resource/manifest/export.md b/docs/reference/schemas/resource/manifest/export.md index 3f830f008..34d630977 100644 --- a/docs/reference/schemas/resource/manifest/export.md +++ b/docs/reference/schemas/resource/manifest/export.md @@ -66,7 +66,7 @@ the exported instances. You can only define one JSON input argument for a comman ## Required properties -The `export` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `export` definition must include: - [executable](#executable) @@ -81,7 +81,7 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` ### args @@ -285,8 +285,10 @@ manifest [schema][12] property. The value must be an object that defines one of properties: - `command` - When you specify the `command` property, DSC calls the defined command to get the - JSON schema for the export input. The `command` object must define the `executable` property and - may define the `args` property, which accepts the same argument kinds as the manifest + JSON schema for the export input. The `command` object must define `executable` unless + [jsonrpc](root.md#jsonrpc) is defined. A JSON-RPC-only command is `{}` and uses + `context.schemaKind: exportInput`. An executable command may define `args`, which accepts the + same argument kinds as the manifest [schema.command.args][13] property. - `embedded` - When you specify the `embedded` property, DSC uses the defined value as the JSON schema for the export input. diff --git a/docs/reference/schemas/resource/manifest/get.md b/docs/reference/schemas/resource/manifest/get.md index 086d88b42..91377e07d 100644 --- a/docs/reference/schemas/resource/manifest/get.md +++ b/docs/reference/schemas/resource/manifest/get.md @@ -155,7 +155,7 @@ double quotes, to the `-ResourcePath` argument. DSC sends the instance propertie ## Required properties -The `get` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `get` definition must include: - [executable](#executable) @@ -170,7 +170,7 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` ### args diff --git a/docs/reference/schemas/resource/manifest/resolve.md b/docs/reference/schemas/resource/manifest/resolve.md index ed278784f..537cb6fbc 100644 --- a/docs/reference/schemas/resource/manifest/resolve.md +++ b/docs/reference/schemas/resource/manifest/resolve.md @@ -44,7 +44,7 @@ both. ## Required properties -The `resolve` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `resolve` definition must include: - [executable](#executable) @@ -59,7 +59,7 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` ### args diff --git a/docs/reference/schemas/resource/manifest/root.md b/docs/reference/schemas/resource/manifest/root.md index d173f92ad..e155fc57b 100644 --- a/docs/reference/schemas/resource/manifest/root.md +++ b/docs/reference/schemas/resource/manifest/root.md @@ -286,6 +286,28 @@ ItemsType: string ItemsPattern: ^\w+$ ``` +### jsonrpc + +The optional `jsonrpc` object defines a persistent JSON-RPC 2.0 process over newline-delimited +stdin and stdout. It requires a nonempty `executable` and accepts a static string array `args`. +The optional `initializeTimeoutMS` and `shutdownTimeoutMS` fields are positive integers and default +to `250`. `requestTimeoutMS` is a nonnegative integer and defaults to `0`, meaning no deadline. +Explicit timeout settings override these manifest values. + +Operation objects still declare capabilities and return contracts. When `jsonrpc` is defined, an +operation or schema command can omit `executable`; in that case, it must also omit `args` and +`input`. A declared executable is the operation's fallback when reuse is disabled. Embedded +schemas remain local. Adapter enumeration always uses an executable, and JSON-RPC adapters must +use `inputKind: single`. + +For lifecycle, messages, settings, and examples, see +[JSON-RPC resources](../../../../concepts/resources/anatomy.md#json-rpc-resources). + +```yaml +Type: object +Required: false +``` + ### get The `get` property defines how to call the resource to get the current state of an instance. @@ -295,8 +317,9 @@ retrieve the current state of the resource's instances or synthesize results for `set` operations. The value of this property must be an object. The object's `executable` property, defining the name -of the command to call, is mandatory. The `args`, `input`, and `requireSecurityContext` properties -are optional. For more information, see [DSC Resource manifest get property schema reference][05]. +of the command to call, is mandatory unless `jsonrpc` is defined. The `args`, `input`, and +`requireSecurityContext` properties are optional. For more information, see +[DSC Resource manifest get property schema reference][05]. ```yaml Type: object @@ -311,8 +334,9 @@ defined, the DSC can't manage instances of the resource. It can only get their c test whether the instance is in the desired state. The value of this property must be an object. The `executable` property, defining the name of the -command to call, is mandatory. The `args`, `input`, `implementsPretest`, `handlesExist`, `return`, -`requireSecurityContext`, and `whatIfReturns` properties are optional. For more information, see +command to call, is mandatory unless `jsonrpc` is defined. The `args`, `input`, `implementsPretest`, +`handlesExist`, `return`, `requireSecurityContext`, `whatIfReturns`, and `supportsWhatIf` properties +are optional. For more information, see [DSC Resource manifest set property schema reference][06]. ```yaml @@ -334,8 +358,9 @@ the manifest defines neither, DSC synthesizes the what-if result by converting t `test` operation for the resource into a set result. The value of this property must be an object. The `executable` property, defining the name of the -command to call, is mandatory. The `args`, `input`, `implementsPretest`, `handlesExist`, `return`, -`requireSecurityContext`, and `whatIfReturns` properties are optional. For more information, see +command to call, is mandatory unless `jsonrpc` is defined. The `args`, `input`, `implementsPretest`, +`handlesExist`, `return`, `requireSecurityContext`, and `whatIfReturns` properties are optional. +For more information, see [DSC Resource manifest whatIf property schema reference][08]. ```yaml @@ -350,8 +375,8 @@ state. It also defines how to process the output from the resource for this meth property isn't defined, DSC performs a basic synthetic test for instances of the DSC Resource. The value of this property must be an object. The object's `executable` property, defining the name -of the command to call, is mandatory. The `args`, `input`, `return`, and `requireSecurityContext` -properties are optional. For more information, see +of the command to call, is mandatory unless `jsonrpc` is defined. The `args`, `input`, `return`, and +`requireSecurityContext` properties are optional. For more information, see [DSC Resource manifest test property schema reference][09]. ```yaml @@ -368,8 +393,8 @@ removing an instance when `_exist` is `false`, define the `handlesExist` propert method as `true` instead. The value of this property must be an object. The object's `executable` property, defining the name -of the command to call, is mandatory. The `args`, `input`, and `requireSecurityContext` properties -are optional. For more information, see +of the command to call, is mandatory unless `jsonrpc` is defined. The `args`, `input`, +`requireSecurityContext`, and `supportsWhatIf` properties are optional. For more information, see [DSC Resource manifest delete property schema reference][11]. ```yaml @@ -390,8 +415,9 @@ When this property is defined, the resource has the `export` capability and user the current state for every instance of the resource. The value of this property must be an object. The object's `executable` property, defining the name -of the command to call, is mandatory. The `args`, `input`, `requireSecurityContext`, `schema`, and -`supportsFiltering` properties are optional. For more information, see +of the command to call, is mandatory unless `jsonrpc` is defined. The `args`, `input`, +`requireSecurityContext`, `schema`, and `supportsFiltering` properties are optional. For more +information, see [DSC Resource manifest export property schema reference][16]. ```yaml @@ -407,8 +433,8 @@ capability. Define this property for [importer resources][04] and set the `kind` `importer`. The value of this property must be an object. The object's `executable` property, defining the name -of the command to call, is mandatory. The `args` and `input` properties are optional. For more -information, see [DSC Resource manifest resolve property schema reference][17]. +of the command to call, is mandatory unless `jsonrpc` is defined. The `args` and `input` properties +are optional. For more information, see [DSC Resource manifest resolve property schema reference][17]. ```yaml Type: object @@ -424,8 +450,8 @@ and resource adapters process nested resource instances that don't share a singl Always define this property for those resources. The value of this property must be an object. The object's `executable` property, defining the name -of the command to call, is mandatory. The `args` and `input` properties are optional. For more -information, see [DSC Resource manifest validate property schema reference][18]. +of the command to call, is mandatory unless `jsonrpc` is defined. The `args` and `input` properties +are optional. For more information, see [DSC Resource manifest validate property schema reference][18]. ```yaml Type: object diff --git a/docs/reference/schemas/resource/manifest/schema/property.md b/docs/reference/schemas/resource/manifest/schema/property.md index a54cc4a06..b195ff2d0 100644 --- a/docs/reference/schemas/resource/manifest/schema/property.md +++ b/docs/reference/schemas/resource/manifest/schema/property.md @@ -128,7 +128,9 @@ The `schema` definition must include exactly one of these properties: ### command The `command` property defines how DSC must call the resource to get the JSON schema that validates -its instances. The value of this property must be an object and define the `executable` property. +its instances. The value must be an object and define `executable` unless the manifest defines +[jsonrpc](../root.md#jsonrpc). A JSON-RPC-only schema command is `{}` and uses +`context.schemaKind: resource`; without an executable, it can't define `args`. When publishing a manifest with the `command` property, Microsoft recommends publishing the JSON schema to a publicly available URI that matches the `$id` property of the instance schema. This @@ -152,7 +154,7 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` #### args diff --git a/docs/reference/schemas/resource/manifest/set.md b/docs/reference/schemas/resource/manifest/set.md index 3ca90392b..1c712da89 100644 --- a/docs/reference/schemas/resource/manifest/set.md +++ b/docs/reference/schemas/resource/manifest/set.md @@ -37,11 +37,12 @@ DSC sends data to the command in three ways: 1. When the `args` array includes a JSON input argument definition, DSC sends the data as a string representing the data as a compressed JSON object to the specified argument. -If you don't define the `input` property and don't define a JSON input argument, DSC can't pass the -desired state to the resource. You can only define one JSON input argument for a command. +For executable calls, if you don't define the `input` property and don't define a JSON input +argument, DSC can't pass the desired state to the resource. You can only define one JSON input +argument for a command. -You must define the `input` property, one JSON input argument in the `args` property array, or -both. +For executable calls, define the `input` property, one JSON input argument in the `args` property +array, or both. JSON-RPC sends the object directly as `params.properties`. When the `args` array includes a [what-if argument](#what-if-argument), the resource has the `setWhatIf` capability. DSC calls the `set` command with the what-if argument when a user invokes @@ -124,7 +125,7 @@ Because the manifest defines the what-if argument, the resource has the `setWhat ## Required properties -The `set` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `set` definition must include: - [executable](#executable) @@ -139,9 +140,16 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` +### supportsWhatIf + +Set `supportsWhatIf` to `true` when the JSON-RPC `set` operation supports +`executionType: whatIf` without modifying the system. This advertises the `setWhatIf` capability +and requires `jsonrpc`. If an executable fallback is declared, its `args` must also include a +[what-if argument](#what-if-argument). The default is `false`. + ### args The `args` property defines the list of arguments to pass to the command. Each item in the array diff --git a/docs/reference/schemas/resource/manifest/test.md b/docs/reference/schemas/resource/manifest/test.md index 9519e4d3f..90c7963d6 100644 --- a/docs/reference/schemas/resource/manifest/test.md +++ b/docs/reference/schemas/resource/manifest/test.md @@ -93,7 +93,7 @@ resource when the `test` method runs. ## Required properties -The `test` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `test` definition must include: - [executable](#executable) @@ -108,7 +108,7 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` ### args diff --git a/docs/reference/schemas/resource/manifest/validate.md b/docs/reference/schemas/resource/manifest/validate.md index ca1c8ef3b..3034bf2a3 100644 --- a/docs/reference/schemas/resource/manifest/validate.md +++ b/docs/reference/schemas/resource/manifest/validate.md @@ -78,7 +78,7 @@ dsc --trace-format pass-through config validate --input "{ ... }" ## Required properties -The `validate` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `validate` definition must include: - [executable](#executable) @@ -93,7 +93,7 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` ### args diff --git a/docs/reference/schemas/resource/manifest/whatif.md b/docs/reference/schemas/resource/manifest/whatif.md index f6528b1a9..3d62902b1 100644 --- a/docs/reference/schemas/resource/manifest/whatif.md +++ b/docs/reference/schemas/resource/manifest/whatif.md @@ -131,7 +131,7 @@ capability. ## Required properties -The `whatIf` definition must include these properties: +Unless the manifest defines [jsonrpc](root.md#jsonrpc), the `whatIf` definition must include: - [executable](#executable) @@ -146,7 +146,7 @@ executable. ```yaml Type: string -Required: true +Required: true unless jsonrpc is defined ``` ### args diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs index 50eaebd6b..93124ff07 100644 --- a/dsc/src/resource_command.rs +++ b/dsc/src/resource_command.rs @@ -32,7 +32,7 @@ pub fn get(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version let mut context = Context::new(); context.properties = Some(convert_input_to_map(input)?); - match resource.get(input, &context) { + match context.complete_resource_operation(resource.get(input, &context)) { Ok(result) => { if let GetResult::Resource(response) = &result && format == Some(&GetOutputFormat::PassThrough) { @@ -85,7 +85,7 @@ pub fn get_all(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, ver let mut context = Context::new(); context.properties = Some(convert_input_to_map(&input)?); - let export_result = match resource.export(&input, &context) { + let export_result = match context.complete_resource_operation(resource.export(&input, &context)) { Ok(export) => { export } Err(err) => { error!("{err}"); @@ -161,6 +161,7 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version }; let mut context = Context::new(); + context.execution_type = execution_kind; context.properties = Some(convert_input_to_map(input)?); if !exist && resource.has_resource_capability(&ResourceCapability::Delete) && !resource.has_resource_capability(&ResourceCapability::SetHandlesExist) { debug!("{}", t!("resource_command.routingToDelete")); @@ -189,6 +190,10 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version }; let diff = get_diff(&before_state, &after_state); + if let Err(err) = context.complete_resource_operation(Ok(())) { + error!("{err}"); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + } let result = SetResult::Resource(ResourceSetResponse { before_state, @@ -207,8 +212,7 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version return Ok(()); } - context.execution_type = execution_kind; - match resource.set(input, true, &context) { + match context.complete_resource_operation(resource.set(input, true, &context)) { Ok(result) => { // convert to json let json = match serde_json::to_string(&result) { @@ -246,7 +250,7 @@ pub fn test(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, versio let mut context = Context::new(); context.properties = Some(convert_input_to_map(input)?); - match resource.test(input, &context) { + match context.complete_resource_operation(resource.test(input, &context)) { Ok(result) => { // convert to json let json = match serde_json::to_string(&result) { @@ -282,7 +286,7 @@ pub fn delete(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers let mut context = Context::new(); context.execution_type = execution_kind; context.properties = Some(convert_input_to_map(input)?); - match resource.delete(input, &context) { + match context.complete_resource_operation(resource.delete(input, &context)) { Ok(result) => { match result { DeleteResultKind::ResourceActual => { @@ -321,7 +325,7 @@ pub fn schema(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers } let context = Context::new(); - match resource.schema(&context) { + match context.complete_resource_operation(resource.schema(&context)) { Ok(json) => { // verify is json match serde_json::from_str::(json.as_str()) { @@ -357,7 +361,7 @@ pub fn export(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers context.properties = serde_json::from_str::(input) .ok() .and_then(|value| value.as_object().cloned()); - if let Err(err) = add_resource_export_results_to_configuration(&*dsc_resource, &mut conf, input, &context) { + if let Err(err) = context.complete_resource_operation(add_resource_export_results_to_configuration(&*dsc_resource, &mut conf, input, &context)) { error!("{err}"); return Err(ExitCode::from(EXIT_DSC_ERROR)); } diff --git a/dsc/src/server/invoke_dsc_resource.rs b/dsc/src/server/invoke_dsc_resource.rs index b182ea033..df3809249 100644 --- a/dsc/src/server/invoke_dsc_resource.rs +++ b/dsc/src/server/invoke_dsc_resource.rs @@ -104,44 +104,28 @@ impl McpServer { }; context.properties = Some(json_map); } - match operation { + let result = match operation { DscOperation::Get => { - let result = match resource.get(&properties_json, &context) { - Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), - }; - Ok(ResourceOperationResult::GetResult(result)) + resource.get(&properties_json, &context).map(ResourceOperationResult::GetResult) }, DscOperation::Set => { - let result = match resource.set(&properties_json, false, &context) { - Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), - }; - Ok(ResourceOperationResult::SetResult(result)) + resource.set(&properties_json, false, &context).map(ResourceOperationResult::SetResult) }, DscOperation::Test => { - let result = match resource.test(&properties_json, &context) { - Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), - }; - Ok(ResourceOperationResult::TestResult(result)) + resource.test(&properties_json, &context).map(ResourceOperationResult::TestResult) }, DscOperation::Delete => { - match resource.delete(&properties_json, &context) { - Ok(DeleteResultKind::ResourceActual) => Ok(ResourceOperationResult::DeleteResult { success: true }), - Ok(DeleteResultKind::ResourceWhatIf(delete_result)) => Ok(ResourceOperationResult::DeleteWhatIfResult(delete_result)), - Ok(DeleteResultKind::SyntheticWhatIf(test_result)) => Ok(ResourceOperationResult::TestResult(test_result)), - Err(e) => Err(McpError::internal_error(e.to_string(), None)), - } + resource.delete(&properties_json, &context).map(|result| match result { + DeleteResultKind::ResourceActual => ResourceOperationResult::DeleteResult { success: true }, + DeleteResultKind::ResourceWhatIf(delete_result) => ResourceOperationResult::DeleteWhatIfResult(delete_result), + DeleteResultKind::SyntheticWhatIf(test_result) => ResourceOperationResult::TestResult(test_result), + }) }, DscOperation::Export => { - let result = match resource.export(&properties_json, &context) { - Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), - }; - Ok(ResourceOperationResult::ExportResult(result)) + resource.export(&properties_json, &context).map(ResourceOperationResult::ExportResult) } - } + }; + context.complete_resource_operation(result).map_err(|e| McpError::internal_error(e.to_string(), None)) }).await.map_err(|e| McpError::internal_error(e.to_string(), None))??; Ok(Json(InvokeDscResourceResponse { result })) diff --git a/dsc/src/server/show_dsc_resource.rs b/dsc/src/server/show_dsc_resource.rs index 536894b38..e5811e8c6 100644 --- a/dsc/src/server/show_dsc_resource.rs +++ b/dsc/src/server/show_dsc_resource.rs @@ -61,8 +61,10 @@ impl McpServer { }; let context = Context::new(); - let schema = match resource.schema(&context) { + let schema = match context.complete_resource_operation(resource.schema(&context)) { Ok(schema_str) => serde_json::from_str(&schema_str).ok(), + Err(err) if resource.as_dsc_resource().and_then(|resource| resource.manifest.as_ref()).is_some_and(|manifest| manifest.jsonrpc.is_some()) => + return Err(McpError::internal_error(err.to_string(), None)), Err(_) => None, }; diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 3603f0294..e24ce116a 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -531,7 +531,7 @@ pub fn validate_config(config: &Configuration, progress_format: ProgressFormat) }; let context = Context::new(); - validate_properties(&*resource, &resource_block["properties"], &context)?; + context.complete_resource_operation(validate_properties(&*resource, &resource_block["properties"], &context))?; } Ok(()) diff --git a/dsc/tests/config/dsc_jsonrpc.tests.ps1 b/dsc/tests/config/dsc_jsonrpc.tests.ps1 new file mode 100644 index 000000000..6c5ca52f0 --- /dev/null +++ b/dsc/tests/config/dsc_jsonrpc.tests.ps1 @@ -0,0 +1,432 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'JSON-RPC resource lifecycle' { + BeforeAll { + function New-RpcConfiguration { + param( + [string]$ResourceType = 'Test/JsonRpc', + [hashtable]$Properties = @{}, + [hashtable]$Directives = @{} + ) + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + directives = $Directives + resources = @( + @{ name = 'first'; type = $ResourceType; properties = $Properties } + @{ name = 'second'; type = $ResourceType; properties = $Properties } + ) + } + } + + function Get-RpcEvent { + if (Test-Path -LiteralPath $env:DSC_TEST_JSONRPC_LOG) { + Get-Content -LiteralPath $env:DSC_TEST_JSONRPC_LOG | ConvertFrom-Json + } + } + } + + BeforeEach { + $previousLog = $env:DSC_TEST_JSONRPC_LOG + $previousReuse = $env:DSC_RESOURCE_REUSE + $previousFault = $env:DSC_TEST_JSONRPC_FAULT + $env:DSC_TEST_JSONRPC_LOG = Join-Path $TestDrive "$([guid]::NewGuid()).jsonl" + $env:DSC_RESOURCE_REUSE = $null + $env:DSC_TEST_JSONRPC_FAULT = $null + } + + AfterEach { + foreach ($processId in (Get-RpcEvent).pid | Select-Object -Unique) { + Get-Process -Id $processId -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } + $env:DSC_TEST_JSONRPC_LOG = $previousLog + $env:DSC_RESOURCE_REUSE = $previousReuse + $env:DSC_TEST_JSONRPC_FAULT = $previousFault + } + + It 'reuses one process for schema, validation, and all instances until completion' { + $configuration = New-RpcConfiguration + $result = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.hadErrors | Should -BeFalse + $states = $result.results.result.actualState + $states | Should -HaveCount 2 + $states[0].processId | Should -Be $states[1].processId + $states[1].requestCount | Should -BeGreaterThan $states[0].requestCount + $events = @(Get-RpcEvent) + @($events | Where-Object event -EQ initialize) | Should -HaveCount 1 + @($events | Where-Object event -EQ shutdown) | Should -HaveCount 1 + $events[-1].event | Should -BeExactly 'exit' + $operations = ($events | Where-Object event -EQ dsc_invoke).params.operation + $operations | Should -Contain 'schema' + $operations | Should -Contain 'get' + } + + It 'shares the process with a custom validation operation' { + $configuration = New-RpcConfiguration -ResourceType 'Test/JsonRpcValidate' + $null = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) + $LASTEXITCODE | Should -Be 0 + $events = @(Get-RpcEvent) + @($events | Where-Object event -EQ initialize) | Should -HaveCount 1 + ($events | Where-Object event -EQ dsc_invoke).params.operation | Should -Contain 'validate' + } + + It 'does not reuse a process across configuration runs' { + $configuration = New-RpcConfiguration | ConvertTo-Json -Depth 20 + $first = dsc config get -i $configuration | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $second = dsc config get -i $configuration | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $first.results[0].result.actualState.processId | Should -Not -Be $second.results[0].result.actualState.processId + @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 2 + } + + It 'shares one adapter host across target resources during ' -ForEach @( + @{ Operation = 'get' } + @{ Operation = 'set' } + @{ Operation = 'test' } + @{ Operation = 'export' } + ) { + $configuration = New-RpcConfiguration -ResourceType 'Adapted/JsonRpcOne' -Properties @{ value = 5 } + $configuration.resources[1].type = 'Adapted/JsonRpcTwo' + $result = dsc config $Operation -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json -Depth 30 + $LASTEXITCODE | Should -Be 0 + $events = @(Get-RpcEvent) + @($events | Where-Object event -EQ initialize) | Should -HaveCount 1 + $invocations = @($events | Where-Object event -EQ dsc_invoke) + $targets = @($invocations | Where-Object { $_.params.operation -eq $Operation }) + $targets | Should -HaveCount 2 + $targets[0].params.resourceType | Should -BeExactly 'Adapted/JsonRpcOne' + $targets[1].params.resourceType | Should -BeExactly 'Adapted/JsonRpcTwo' + $targets[0].params.context.targetResource.type | Should -BeExactly 'Adapted/JsonRpcOne' + $targets[1].params.context.targetResource.path | Should -BeExactly 'jsonrpc/Two' + $targets[0].params.context.targetResource.version | Should -BeExactly '0.1.0' + $targets[0].params.properties.value | Should -Be 5 + if ($Operation -eq 'export') { + @($invocations | Where-Object { $_.params.context.schemaKind -eq 'exportInput' }) | Should -HaveCount 2 + $result.resources | Should -HaveCount 2 + } else { + $result.hadErrors | Should -BeFalse + } + $events[-1].event | Should -BeExactly 'exit' + } + + It 'uses isolated RPC calls when document reuse is disabled' { + $configuration = New-RpcConfiguration -Directives @{ reuseResources = $false } + $result = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.results[0].result.actualState.processId | Should -Not -Be $result.results[1].result.actualState.processId + $invocations = @((Get-RpcEvent) | Where-Object event -EQ dsc_invoke) + @($invocations.pid | Select-Object -Unique) | Should -HaveCount $invocations.Count + @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount $invocations.Count + } + + It 'prefers RPC over fallback unless reuse is disabled by ' -ForEach @( + @{ Scope = 'setting' } + @{ Scope = 'document' } + @{ Scope = 'instance' } + ) { + $configuration = New-RpcConfiguration -ResourceType 'Test/JsonRpcFallback' + $preferred = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $preferred.results[0].result.actualState.transport | Should -BeExactly 'jsonrpc' + switch ($Scope) { + 'setting' { $env:DSC_RESOURCE_REUSE = 'false' } + 'document' { $configuration.directives.reuseResources = $false } + 'instance' { + foreach ($resource in $configuration.resources) { + $resource.directives = @{ reuseResource = $false } + } + } + } + $isolated = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $isolated.results[0].result.actualState.transport | Should -BeExactly 'executable' + $isolated.results[1].result.actualState.transport | Should -BeExactly 'executable' + } + + It 'does not let instance reuse override a document prohibition' { + $configuration = New-RpcConfiguration -ResourceType 'Test/JsonRpcFallback' -Directives @{ reuseResources = $false } + $configuration.resources[0].directives = @{ reuseResource = $true } + $result = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.results[0].result.actualState.transport | Should -BeExactly 'executable' + @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 0 + } + + It 'propagates a document prohibition but not an instance prohibition into nested configurations' -ForEach @( + @{ DocumentReuse = $false; ExpectedTransport = 'executable' } + @{ DocumentReuse = $true; ExpectedTransport = 'jsonrpc' } + ) { + $nested = New-RpcConfiguration -ResourceType 'Test/JsonRpcFallback' + $configuration = New-RpcConfiguration -Directives @{ reuseResources = $DocumentReuse } + $configuration.resources = @(@{ + name = 'nested' + type = 'Microsoft.DSC/Group' + directives = @{ reuseResource = $false } + properties = $nested + }) + $result = dsc config get -i ($configuration | ConvertTo-Json -Depth 30) | ConvertFrom-Json -Depth 30 + $LASTEXITCODE | Should -Be 0 + $result.hadErrors | Should -BeFalse + $states = $result.results[0].result.result.actualState + $states | Should -HaveCount 2 + $states[0].transport | Should -BeExactly $ExpectedTransport + $states[1].transport | Should -BeExactly $ExpectedTransport + if ($DocumentReuse) { + $states[0].processId | Should -Be $states[1].processId + } + } + + It 'initializes with the effective tracing, DSC version, security context, and configuration root' { + $configuration = New-RpcConfiguration + $configurationPath = Join-Path $TestDrive 'initialize.dsc.json' + $configuration | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $configurationPath + $result = dsc --trace-level debug --trace-format json config get -f $configurationPath 2> (Join-Path $TestDrive 'initialize-trace.jsonl') | + ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $initialize = @((Get-RpcEvent) | Where-Object event -EQ initialize) + $initialize | Should -HaveCount 1 + $initialize[0].params.protocolVersions | Should -Be @(1) + $initialize[0].params.traceLevel | Should -BeExactly 'debug' + $initialize[0].params.traceFormat | Should -BeExactly 'json' + $initialize[0].params.dscVersion | Should -BeExactly $result.executionInformation.version + $initialize[0].params.securityContext | Should -BeIn @('elevated', 'restricted') + (Resolve-Path $initialize[0].params.configRoot).Path | Should -BeExactly (Resolve-Path $TestDrive).Path + } + + It 'keeps the pooled process when another instance disables reuse' { + $configuration = New-RpcConfiguration + $configuration.resources[1].directives = @{ reuseResource = $false } + $configuration.resources += @{ name = 'third'; type = 'Test/JsonRpc'; properties = @{} } + $result = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $states = $result.results.result.actualState + $states[0].processId | Should -Not -Be $states[1].processId + $states[0].processId | Should -Be $states[2].processId + @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 2 + } + + It 'does not apply executable-only environment input conversion to RPC properties' { + $result = dsc resource get -r Test/JsonRpcFallback -i '{"nested":{"value":1}}' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.actualState.nested.value | Should -Be 1 + $result.actualState.transport | Should -BeExactly 'jsonrpc' + } + + It 'preserves before state, native set, and stateAndDiff semantics' { + $configuration = New-RpcConfiguration -Properties @{ value = 5 } + $result = dsc config set -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $first = $result.results[0].result + $second = $result.results[1].result + $first.beforeState.value | Should -Be 0 + $first.afterState.value | Should -Be 5 + $first.changedProperties | Should -Contain 'value' + $second.beforeState.value | Should -Be 5 + $second.afterState.processId | Should -Be $first.afterState.processId + } + + It 'does not mutate resource state during native what-if' { + $configuration = New-RpcConfiguration -Properties @{ value = 5 } + $result = dsc config set -w -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.results[0].result.afterState.value | Should -Be 5 + $result.results[1].result.beforeState.value | Should -Be 0 + $mutations = @((Get-RpcEvent) | Where-Object { $_.event -eq 'dsc_invoke' -and $_.params.operation -eq 'set' }) + $mutations | Should -HaveCount 2 + $mutations.params.executionType | Select-Object -Unique | Should -BeExactly 'whatIf' + } + + It 'normalizes native test and export results' { + $test = dsc resource test -r Test/JsonRpc -i '{"value":5}' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $test.inDesiredState | Should -BeFalse + $test.differingProperties | Should -Contain 'value' + $export = dsc resource export -r Test/JsonRpc | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $export.resources | Should -HaveCount 1 + $export.resources[0].properties.transport | Should -BeExactly 'jsonrpc' + } + + It 'preserves follow-up get, synthetic export, and synthetic what-if semantics' { + $set = dsc resource set -r Test/JsonRpcSynthetic -i '{"value":5}' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $set.beforeState.value | Should -Be 0 + $set.afterState.value | Should -Be 5 + $set.beforeState.processId | Should -Be $set.afterState.processId + $test = dsc resource test -r Test/JsonRpcSynthetic -i '{"value":5}' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $test.inDesiredState | Should -BeFalse + $export = dsc resource export -r Test/JsonRpcSynthetic | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $export.resources | Should -HaveCount 1 + $configuration = New-RpcConfiguration -ResourceType 'Test/JsonRpcSynthetic' -Properties @{ value = 5 } + $whatIf = dsc config set -w -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $whatIf.results[0].result.afterState.value | Should -Be 5 + } + + It 'does not send an actual delete when standalone set routes what-if through delete' { + $null = dsc resource set -w -r Test/JsonRpc -i '{"_exist":false}' + $LASTEXITCODE | Should -Be 0 + $deletes = @((Get-RpcEvent) | Where-Object { $_.event -eq 'dsc_invoke' -and $_.params.operation -eq 'delete' }) + $deletes | Should -HaveCount 1 + $deletes[0].params.executionType | Should -BeExactly 'whatIf' + } + + It 'cleans up standalone resource before returning' -ForEach @( + @{ Operation = 'get' } + @{ Operation = 'set' } + @{ Operation = 'test' } + @{ Operation = 'delete' } + @{ Operation = 'schema' } + @{ Operation = 'export' } + ) { + if ($Operation -in 'schema', 'export') { + $null = dsc resource $Operation -r Test/JsonRpc + } else { + $null = dsc resource $Operation -r Test/JsonRpc -i '{"value":5}' + } + $LASTEXITCODE | Should -Be 0 + $events = @(Get-RpcEvent) + @($events | Where-Object event -EQ initialize) | Should -HaveCount 1 + $events[-2].event | Should -BeExactly 'shutdown' + $events[-1].event | Should -BeExactly 'exit' + } + + It 'gracefully shuts down after error code without invoking another instance' -ForEach @( + @{ Code = 0 } + @{ Code = 7 } + @{ Code = -32601 } + @{ Code = -32001 } + ) { + $configuration = New-RpcConfiguration -Properties @{ fault = 'error'; errorCode = $Code } + $output = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $output | Out-String | Should -Match 'Requested test error' + $events = @(Get-RpcEvent) + @($events | Where-Object { $_.event -eq 'dsc_invoke' -and $_.params.properties.fault -eq 'error' }) | Should -HaveCount 1 + $events[-1].event | Should -BeExactly 'exit' + } + + It 'fails and terminates the child for ' -ForEach @( + @{ Fault = 'malformed' } + @{ Fault = 'wrongId' } + @{ Fault = 'exit' } + @{ Fault = 'nullId' } + @{ Fault = 'missingId' } + @{ Fault = 'both' } + @{ Fault = 'batch' } + @{ Fault = 'duplicate' } + ) { + $null = dsc resource get -r Test/JsonRpc -i (@{ fault = $Fault } | ConvertTo-Json) 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount 0 + } + + It 'rejects invalid operation result shapes without invoking another instance' { + $configuration = New-RpcConfiguration -Properties @{ fault = 'invalidResult' } + $null = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + @((Get-RpcEvent) | Where-Object { $_.event -eq 'dsc_invoke' -and $_.params.operation -eq 'get' }) | Should -HaveCount 1 + (Get-RpcEvent)[-1].event | Should -BeExactly 'exit' + } + + It 'fails and cleans up for lifecycle fault ' -ForEach @( + @{ Fault = 'initializeError' } + @{ Fault = 'initializeVersion' } + @{ Fault = 'initializeTimeout' } + @{ Fault = 'shutdownError' } + @{ Fault = 'shutdownTimeout' } + @{ Fault = 'exitTimeout' } + @{ Fault = 'exitOutput' } + ) { + $env:DSC_TEST_JSONRPC_FAULT = $Fault + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $null = dsc resource get -r Test/JsonRpc 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $timer.Elapsed.TotalSeconds | Should -BeLessThan 6 + if ($Fault.StartsWith('initialize')) { + @((Get-RpcEvent) | Where-Object event -EQ dsc_invoke) | Should -HaveCount 0 + } + } + + It 'preserves the operation failure when shutdown also fails' { + $env:DSC_TEST_JSONRPC_FAULT = 'shutdownError' + $output = dsc resource get -r Test/JsonRpc -i '{"fault":"error"}' 2>&1 | Out-String + $LASTEXITCODE | Should -Not -Be 0 + $output | Should -Match 'Requested test error' + } + + It 'rejects invalid progress ' -ForEach @( + @{ Case = 'negative completed'; Progress = @(@{ completed = -1 }) } + @{ Case = 'zero total'; Progress = @(@{ completed = 0; total = 0 }) } + @{ Case = 'total without completed'; Progress = @(@{ total = 1 }) } + @{ Case = 'completed above total'; Progress = @(@{ completed = 2; total = 1 }) } + @{ Case = 'regressing completed'; Progress = @(@{ completed = 1 }, @{ completed = 0 }) } + @{ Case = 'changing total'; Progress = @(@{ completed = 0; total = 1 }, @{ completed = 0; total = 2 }) } + @{ Case = 'completed above previous total'; Progress = @(@{ completed = 0; total = 1 }, @{ completed = 2 }) } + @{ Case = 'wrong request'; Progress = @(@{ requestId = 0 }) } + ) { + $null = dsc resource get -r Test/JsonRpc -i (@{ progress = $Progress } | ConvertTo-Json -Depth 20) 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount 0 + } + + It 'does not reset request deadlines when progress notifications arrive' { + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $output = dsc resource get -r Test/JsonRpc -i '{"notificationDelayMS":100}' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $timer.Elapsed.TotalSeconds | Should -BeLessThan 3 + $output | Out-String | Should -Match 'timed out' + } + + It 'enforces the request deadline and terminates an unresponsive resource' { + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $output = dsc resource get -r Test/JsonRpc -i '{"delayMS":10000}' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $timer.Elapsed.TotalSeconds | Should -BeLessThan 5 + $output | Out-String | Should -Match 'timed out' + @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount 0 + } + + It 'applies explicit requestTimeoutMS instead of the manifest default' -ForEach @( + @{ Timeout = 150; Delay = 600 } + @{ Timeout = 0; Delay = 1200 } + ) { + $settingsDirectory = New-Item -ItemType Directory -Path (Join-Path $TestDrive ([guid]::NewGuid())) + @{ resourceTimeouts = @{ requestTimeoutMS = $Timeout } } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $settingsDirectory.FullName 'dsc.settings.json') + Push-Location $settingsDirectory.FullName + try { + $output = dsc resource get -r Test/JsonRpc -i (@{ delayMS = $Delay } | ConvertTo-Json) 2>&1 + if ($Timeout -eq 0) { + $LASTEXITCODE | Should -Be 0 + ($output | ConvertFrom-Json).actualState.transport | Should -BeExactly 'jsonrpc' + } else { + $LASTEXITCODE | Should -Not -Be 0 + $output | Out-String | Should -Match '150' + $output | Out-String | Should -Match 'timed out' + } + } finally { + Pop-Location + } + } + + It 'forwards fractional progress without changing configuration identity or counters' { + $configuration = New-RpcConfiguration -Properties @{ notifications = $true } + $progressPath = Join-Path $TestDrive 'progress.jsonl' + $null = dsc --progress-format json config get -i ($configuration | ConvertTo-Json -Depth 20) 2> $progressPath + $LASTEXITCODE | Should -Be 0 + $records = @(Get-Content $progressPath | Where-Object { $_.StartsWith('{') } | ConvertFrom-Json) + $subordinate = @($records | Where-Object resourceProgress) + $subordinate.Count | Should -BeGreaterThan 0 + @($records | Where-Object resourceType -EQ 'Test/JsonRpc' | Select-Object -ExpandProperty id -Unique) | Should -HaveCount 1 + $subordinate[0].resourceProgress.completed | Should -Be 0.5 + $subordinate[0].totalItems | Should -Be 2 + $subordinate[0].completedItems | Should -Be 0 + $subordinate[0].resourceName | Should -BeExactly 'first' + $records[-1].resourceProgress | Should -BeNullOrEmpty + } +} diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index dc66170cc..1dfcf3df4 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -1,5 +1,17 @@ _version = 1 +[dscresources.jsonrpc] +applicationError = "JSON-RPC resource error" +protocolError = "Invalid JSON-RPC resource protocol message" +invalidManifest = "Invalid JSON-RPC transport or executable fallback declaration for '%{resource}'" +emptyExecutable = "A declared resource executable must not be empty" +runFailed = "The JSON-RPC invocation scope has already failed" +timeout = "JSON-RPC resource '%{resource}' timed out waiting for '%{method}' after %{timeout}ms" +timeoutOutOfRange = "JSON-RPC timeout %{timeout}ms exceeds the supported clock range" +unexpectedExit = "JSON-RPC resource '%{resource}' exited unexpectedly: %{status}" +operationsMismatch = "JSON-RPC operations do not match the manifest for '%{resource}'" +readerFailed = "JSON-RPC resource stream reader failed" + [actions.action_manifest] actionManifestSchemaTitle = "Action manifest schema URI" actionManifestSchemaDescription = "Defines the JSON Schema the action manifest adheres to." diff --git a/lib/dsc-lib/src/configure/config_doc.rs b/lib/dsc-lib/src/configure/config_doc.rs index bd289730d..194d0c940 100644 --- a/lib/dsc-lib/src/configure/config_doc.rs +++ b/lib/dsc-lib/src/configure/config_doc.rs @@ -256,6 +256,9 @@ impl ExecutionInformation { )] #[dsc_repo_schema(base_name = "directive", folder_path = "config")] pub struct ConfigDirective { + /// Allow persistent resource processes for this document and nested runs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reuse_resources: Option, /// Indicates if resources are discovered pre-deployment or during deployment #[serde(skip_serializing_if = "Option::is_none")] pub resource_discovery: Option, @@ -275,6 +278,9 @@ pub struct ConfigDirective { )] #[dsc_repo_schema(base_name = "directive", folder_path = "resource")] pub struct ResourceDirective { + /// Allow persistent resource processes for this instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub reuse_resource: Option, /// Specify specific adapter type used for implicit operations #[serde(skip_serializing_if = "Option::is_none")] pub require_adapter: Option, diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index 70a0bedda..aa6301cda 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -22,6 +22,10 @@ pub enum ProcessMode { #[derive(Clone, Debug)] pub struct Context { + pub(crate) resource_processes: std::sync::Arc>, + pub(crate) reuse_resources: bool, + pub(crate) reuse_resource: bool, + pub(crate) resource_progress: Option, pub copy: HashMap, pub copy_current_loop_name: String, /// The semantic version of DSC that is processing the configuration. @@ -55,9 +59,26 @@ pub struct Context { } impl Context { + /// Finish an invocation scope, preserving its original failure if cleanup also fails. + pub fn complete_resource_operation(&self, result: Result) -> Result { + let cleanup = self.resource_processes.lock().unwrap().finish(); + match (result, cleanup) { + (Err(err), cleanup) => { + if let Err(cleanup) = cleanup { tracing::warn!("{cleanup}"); } + Err(err) + }, + (Ok(_), Err(err)) => Err(err), + (Ok(value), Ok(())) => Ok(value), + } + } #[must_use] pub fn new() -> Self { Self { + resource_processes: Default::default(), + reuse_resources: std::env::var("DSC_RESOURCE_REUSE_FORBIDDEN").as_deref() != Ok("1") + && crate::settings::global::get_or_init().resolved().resource_reuse.value, + reuse_resource: true, + resource_progress: None, copy: HashMap::new(), copy_current_loop_name: String::new(), dsc_version: dsc_lib_version(), diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index ae6c6870a..218700d12 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -555,7 +555,9 @@ impl Configurator { /// # Errors /// /// This function will return an error if the configuration is invalid or the underlying discovery fails. - fn new_with_context(json: &str, progress_format: ProgressFormat, context: Context) -> Result { + fn new_with_context(json: &str, progress_format: ProgressFormat, mut context: Context) -> Result { + let document: Configuration = serde_json::from_str(json)?; + context.reuse_resources &= document.directives.as_ref().and_then(|directives| directives.reuse_resources).unwrap_or(true); let discovery = Discovery::new(); let mut config = Configurator { json: json.to_owned(), @@ -630,6 +632,12 @@ impl Configurator { /// /// This function will return an error if the underlying resource fails. pub fn invoke_get(&mut self) -> Result { + let result = self.invoke_get_inner(); + self.context.resource_progress = None; + self.context.complete_resource_operation(result) + } + + fn invoke_get_inner(&mut self) -> Result { let mut result = ConfigurationGetResult::new(); self.context.operation = Some(Operation::Get); let resources = get_resource_invocation_order(&self.config, &mut self.statement_parser, &mut self.context)?; @@ -640,6 +648,8 @@ impl Configurator { progress.set_resource(&evaluated_name, &resource.resource_type); progress.write_activity(format!("Get '{evaluated_name}'").as_str()); + self.context.resource_progress = Some(progress.clone()); + self.context.reuse_resource = resource.directives.as_ref().and_then(|directives| directives.reuse_resource).unwrap_or(true); if self.skip_resource(&resource)? { progress.write_increment(1); continue; @@ -722,6 +732,13 @@ impl Configurator { /// This function will return an error if the underlying resource fails. #[allow(clippy::too_many_lines)] pub fn invoke_set(&mut self, skip_test: bool) -> Result { + let result = self.invoke_set_inner(skip_test); + self.context.resource_progress = None; + self.context.complete_resource_operation(result) + } + + #[allow(clippy::too_many_lines)] + fn invoke_set_inner(&mut self, skip_test: bool) -> Result { let mut result = ConfigurationSetResult::new(); self.context.operation = Some(Operation::Set); let resources = get_resource_invocation_order(&self.config, &mut self.statement_parser, &mut self.context)?; @@ -732,6 +749,8 @@ impl Configurator { progress.set_resource(&evaluated_name, &resource.resource_type); progress.write_activity(format!("Set '{evaluated_name}'").as_str()); + self.context.resource_progress = Some(progress.clone()); + self.context.reuse_resource = resource.directives.as_ref().and_then(|directives| directives.reuse_resource).unwrap_or(true); if self.skip_resource(&resource)? { progress.write_increment(1); continue; @@ -910,6 +929,12 @@ impl Configurator { /// /// This function will return an error if the underlying resource fails. pub fn invoke_test(&mut self) -> Result { + let result = self.invoke_test_inner(); + self.context.resource_progress = None; + self.context.complete_resource_operation(result) + } + + fn invoke_test_inner(&mut self) -> Result { let mut result = ConfigurationTestResult::new(); self.context.operation = Some(Operation::Test); let resources = get_resource_invocation_order(&self.config, &mut self.statement_parser, &mut self.context)?; @@ -920,6 +945,8 @@ impl Configurator { progress.set_resource(&evaluated_name, &resource.resource_type); progress.write_activity(format!("Test '{evaluated_name}'").as_str()); + self.context.resource_progress = Some(progress.clone()); + self.context.reuse_resource = resource.directives.as_ref().and_then(|directives| directives.reuse_resource).unwrap_or(true); if self.skip_resource(&resource)? { progress.write_increment(1); continue; @@ -999,6 +1026,12 @@ impl Configurator { /// /// This function will return an error if the underlying resource fails. pub fn invoke_export(&mut self) -> Result { + let result = self.invoke_export_inner(); + self.context.resource_progress = None; + self.context.complete_resource_operation(result) + } + + fn invoke_export_inner(&mut self) -> Result { let mut result = ConfigurationExportResult::new(); self.context.operation = Some(Operation::Export); let mut conf = config_doc::Configuration::new(); @@ -1012,6 +1045,8 @@ impl Configurator { progress.set_resource(&evaluated_name, &resource.resource_type); progress.write_activity(format!("Export '{evaluated_name}'").as_str()); + self.context.resource_progress = Some(progress.clone()); + self.context.reuse_resource = resource.directives.as_ref().and_then(|directives| directives.reuse_resource).unwrap_or(true); if self.skip_resource(resource)? { progress.write_increment(1); continue; @@ -1582,6 +1617,35 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn jsonrpc_concurrent_configurators_own_independent_pools() { + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let threads: Vec<_> = (0..2).map(|_| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + let document = json!({ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/config/document.json", + "resources": [ + {"name": "first", "type": "Test/JsonRpc", "properties": {"delayMS": 100}}, + {"name": "second", "type": "Test/JsonRpc", "properties": {"delayMS": 100}} + ] + }); + barrier.wait(); + let mut configurator = Configurator::new(&document.to_string(), ProgressFormat::None).unwrap(); + let first = serde_json::to_value(configurator.invoke_get().unwrap()).unwrap(); + let first_pid = first["results"][0]["result"]["actualState"]["processId"].as_u64().unwrap(); + assert_eq!(first["results"][1]["result"]["actualState"]["processId"], first_pid); + let second = serde_json::to_value(configurator.invoke_get().unwrap()).unwrap(); + let second_pid = second["results"][0]["result"]["actualState"]["processId"].as_u64().unwrap(); + assert_ne!(first_pid, second_pid); + (first_pid, second_pid) + }) + }).collect(); + let processes: Vec<_> = threads.into_iter().map(|thread| thread.join().unwrap()).collect(); + assert_ne!(processes[0].0, processes[1].0); + assert_ne!(processes[0].1, processes[1].1); + } + fn resource_with_export(export: Value) -> DscResource { let mut resource = DscResource::new(); resource.manifest = Some(serde_json::from_value(json!({ @@ -1607,7 +1671,7 @@ mod tests { #[test] fn export_with_schema_supports_filtering() { - let resource = resource_with_export(json!({ "executable": "test", "schema": { "command": "test" } })); + let resource = resource_with_export(json!({ "executable": "test", "schema": { "command": { "executable": "test" } } })); assert!(resource_supports_export_filtering(&resource)); } diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index b1a254f5d..eb159e6b0 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -1104,6 +1104,7 @@ pub fn load_adapted_resource_manifest(path: Option<&Path>, manifest: &AdaptedDsc } fn load_resource_manifest(path: Option<&Path>, manifest: &ResourceManifest) -> Result { + manifest.validate_transport()?; if manifest.version.is_date_version() { warn!("{}", t!( "discovery.commandDiscovery.invalidManifestVersion", @@ -1122,6 +1123,9 @@ fn load_resource_manifest(path: Option<&Path>, manifest: &ResourceManifest) -> R let mut capabilities: HashSet = HashSet::new(); let parent = path.and_then(|p| p.parent()); + if let Some(rpc) = &manifest.jsonrpc { + verify_executable(&manifest.resource_type, "jsonrpc", &rpc.executable, parent); + } if let Some(get) = &manifest.get { verify_executable(&manifest.resource_type, "get", &get.executable, parent); capabilities.insert(ResourceCapability::Get); @@ -1132,7 +1136,7 @@ fn load_resource_manifest(path: Option<&Path>, manifest: &ResourceManifest) -> R if set.handles_exist == Some(true) { capabilities.insert(ResourceCapability::SetHandlesExist); } - if let Some(args) = &set.args && args_contains_what_if(args) { + if set.supports_what_if == Some(true) || set.args.as_ref().is_some_and(|args| args_contains_what_if(args)) { capabilities.insert(ResourceCapability::SetWhatIf); } } @@ -1143,7 +1147,7 @@ fn load_resource_manifest(path: Option<&Path>, manifest: &ResourceManifest) -> R if let Some(delete) = &manifest.delete { verify_executable(&manifest.resource_type, "delete", &delete.executable, parent); capabilities.insert(ResourceCapability::Delete); - if let Some(args) = &delete.args && args_contains_what_if(args) { + if delete.supports_what_if == Some(true) || delete.args.as_ref().is_some_and(|args| args_contains_what_if(args)) { capabilities.insert(ResourceCapability::DeleteWhatIf); } } @@ -1226,6 +1230,7 @@ fn load_extension_manifest(path: Option<&Path>, manifest: &ExtensionManifest) -> } pub fn verify_executable(resource: &str, operation: &str, executable: &str, directory: Option<&Path>) { + if executable.is_empty() { return; } if canonicalize_which(executable, directory).is_err() { info!("{}", t!("discovery.commandDiscovery.executableNotFound", resource = resource, operation = operation, executable = executable)); } diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index d97ebe78e..ddd2f8b67 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -11,6 +11,8 @@ use tree_sitter::LanguageError; #[derive(Error, Debug, Diagnostic)] pub enum DscError { + #[error("{t}: {resource} ({code}): {message}", t = t!("dscresources.jsonrpc.applicationError"))] + JsonRpc { resource: String, code: i32, message: String, data: Option> }, #[error("{t}: {0}", t = t!("dscerror.adapterNotFound"))] AdapterNotFound(String), diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 01cd3f3ae..10ad7e755 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -24,6 +24,40 @@ use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::Command}; pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; +fn use_jsonrpc(resource: &DscResource, executable: &str, context: &Context) -> bool { + resource.manifest.as_ref().is_some_and(|manifest| manifest.jsonrpc.is_some()) + && (executable.is_empty() || (context.reuse_resources && context.reuse_resource)) +} + +#[allow(clippy::too_many_arguments)] +fn invoke_native( + resource: &DscResource, operation: &str, input: Option<&str>, context: &Context, + returns: Option<&ReturnKind>, schema_kind: Option<&str>, executable: &str, + fallback: impl FnOnce() -> Result<(i32, String, String), DscError>, +) -> Result<(i32, String, String), DscError> { + resource.manifest.as_ref().ok_or_else(|| DscError::MissingManifest(resource.type_name.to_string()))?.validate_transport()?; + if use_jsonrpc(resource, executable, context) { + super::jsonrpc::invoke(resource, operation, input, schema_kind, returns, context) + } else { + fallback() + } +} + +fn resource_environment(context: &Context, env: Option>) -> Option> { + if context.reuse_resources { return env; } + let mut env = env.unwrap_or_default(); + env.insert("DSC_RESOURCE_REUSE_FORBIDDEN".to_string(), "1".to_string()); + Some(env) +} + +fn native_input(resource: &DscResource, executable: &str, kind: Option<&InputKind>, input: &str, context: &Context) -> Result { + if use_jsonrpc(resource, executable, context) { + Ok(CommandInput { env: None, stdin: None }) + } else { + get_command_input(kind, input) + } +} + /// Invoke the get operation on a resource /// /// # Arguments @@ -49,14 +83,16 @@ pub fn invoke_get(resource: &DscResource, filter: &str, context: &Context) -> Re None => resource }; validate_security_context(context.target_resource.as_ref(), &get.require_security_context, &command_resource.type_name, &Operation::Get)?; - let args = process_get_args(get.args.as_ref(), filter, command_resource, context)?; + let args = if use_jsonrpc(resource, &get.executable, context) { None } + else { process_get_args(get.args.as_ref(), filter, command_resource, context)? }; if !filter.is_empty() { verify_json_from_manifest(resource, filter, context)?; - command_input = get_command_input(get.input.as_ref(), filter)?; + command_input = native_input(resource, &get.executable, get.input.as_ref(), filter, context)?; } info!("{}", t!("dscresources.commandResource.invokeGetUsing", resource = &resource.type_name, executable = &get.executable)); - let (_exit_code, stdout, stderr) = invoke_command(&get.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), command_input.env, manifest.exit_codes.as_ref())?; + let (_exit_code, stdout, stderr) = invoke_native(resource, "get", Some(filter), context, None, None, &get.executable, || + invoke_command(&get.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), resource_environment(context, command_input.env), manifest.exit_codes.as_ref()))?; if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.verifyOutputUsing", resource = &resource.type_name, executable = &get.executable)); verify_json_from_manifest(resource, &stdout, context)?; @@ -114,10 +150,8 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, contex operation_type = "whatif".to_string(); // Check if set supports native what-if let has_native_whatif = manifest.set.as_ref().is_some_and(|set| { - let Ok((_, supports_whatif)) = process_set_delete_args(set.args.as_ref(), "", command_resource, context) else { - return false; - }; - supports_whatif + set.supports_what_if == Some(true) || set.args.as_ref().is_some_and(|args| + args.iter().any(|arg| matches!(arg, SetDeleteArgKind::WhatIf { .. }))) }); if has_native_whatif { @@ -180,11 +214,13 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, contex None => resource, }; validate_security_context(context.target_resource.as_ref(), &get.require_security_context, &command_resource.type_name, &Operation::Get)?; - let args = process_get_args(get.args.as_ref(), desired, command_resource, context)?; - let command_input = get_command_input(get.input.as_ref(), desired)?; + let args = if use_jsonrpc(resource, &get.executable, context) { None } + else { process_get_args(get.args.as_ref(), desired, command_resource, context)? }; + let command_input = native_input(resource, &get.executable, get.input.as_ref(), desired, context)?; info!("{}", t!("dscresources.commandResource.setGetCurrent", resource = &command_resource.type_name, executable = &get.executable)); - let (exit_code, stdout, stderr) = invoke_command(&get.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), command_input.env, manifest.exit_codes.as_ref())?; + let (exit_code, stdout, stderr) = invoke_native(resource, "get", Some(desired), context, None, None, &get.executable, || + invoke_command(&get.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), resource_environment(context, command_input.env), manifest.exit_codes.as_ref()))?; if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.setVerifyGet", resource = &resource.type_name, executable = &get.executable)); @@ -212,8 +248,9 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, contex let mut env: Option> = None; let mut input_desired: Option<&str> = None; - let (args, _) = process_set_delete_args(set.args.as_ref(), desired, command_resource, context)?; - match &set.input { + let args = if use_jsonrpc(resource, &set.executable, context) { None } + else { process_set_delete_args(set.args.as_ref(), desired, command_resource, context)?.0 }; + match if use_jsonrpc(resource, &set.executable, context) { None } else { set.input.as_ref() } { Some(InputKind::Env) => { env = Some(json_to_hashmap(desired)?); }, @@ -225,14 +262,17 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, contex }, } - let (exit_code, stdout, stderr) = invoke_command(&set.executable, args, input_desired, resource.directory.as_deref(), env, manifest.exit_codes.as_ref())?; - let return_kind = if context.execution_type == ExecutionKind::WhatIf { set.what_if_returns.as_ref().or(set.returns.as_ref()) } else { set.returns.as_ref() }; + let native_operation = if context.execution_type == ExecutionKind::WhatIf + && std::ptr::eq(set_method, &manifest.what_if) { "whatIf" } else { "set" }; + let (exit_code, stdout, stderr) = invoke_native(resource, native_operation, Some(desired), context, return_kind, None, &set.executable, || + invoke_command(&set.executable, args, input_desired, resource.directory.as_deref(), resource_environment(context, env), manifest.exit_codes.as_ref()))?; + match return_kind { Some(ReturnKind::State) => { @@ -335,11 +375,13 @@ pub fn invoke_test(resource: &DscResource, expected: &str, context: &Context) -> None => resource, }; validate_security_context(context.target_resource.as_ref(), &test.require_security_context, &command_resource.type_name, &Operation::Test)?; - let args = process_get_args(test.args.as_ref(), expected, command_resource, context)?; - let command_input = get_command_input(test.input.as_ref(), expected)?; + let args = if use_jsonrpc(resource, &test.executable, context) { None } + else { process_get_args(test.args.as_ref(), expected, command_resource, context)? }; + let command_input = native_input(resource, &test.executable, test.input.as_ref(), expected, context)?; info!("{}", t!("dscresources.commandResource.invokeTestUsing", resource = &command_resource.type_name, executable = &test.executable)); - let (exit_code, stdout, stderr) = invoke_command(&test.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), command_input.env, manifest.exit_codes.as_ref())?; + let (exit_code, stdout, stderr) = invoke_native(resource, "test", Some(expected), context, test.returns.as_ref(), None, &test.executable, || + invoke_command(&test.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), resource_environment(context, command_input.env), manifest.exit_codes.as_ref()))?; if command_resource.kind == Kind::Importer { debug!("{}", t!("dscresources.commandResource.testGroupTestResponse")); @@ -453,21 +495,27 @@ fn invoke_synthetic_test(resource: &DscResource, expected: &str, context: &Conte }; let expected_value: Value = serde_json::from_str(expected)?; let cached_resource = context.target_resource.as_ref().unwrap_or(resource); - let schema: Option = get_resource_schema(&cached_resource.type_name, &cached_resource.version) - .or_else(|| { - // Cache miss: parse and use the schema returned by get_schema. This covers cases - // where get_schema returns early (e.g. target_resource.schema) without caching. - let schema_str = get_schema(resource, context).ok()?; - let schema_value: Value = serde_json::from_str(&schema_str).ok()?; - // Best-effort cache population for future callers. - locked_insert!( - RESOURCE_SCHEMAS, - cached_resource.type_name.clone(), - cached_resource.version.clone(), - schema_value.clone() - ); - Some(schema_value) - }); + let schema: Option = match get_resource_schema(&cached_resource.type_name, &cached_resource.version) { + Some(schema) => Some(schema), + None => { + let schema_str = match get_schema(resource, context) { + Ok(schema) => Some(schema), + Err(DscError::SchemaNotAvailable(_)) => None, + Err(err) if resource.manifest.as_ref().is_some_and(|manifest| manifest.jsonrpc.is_some()) => return Err(err), + Err(_) => None, + }; + let schema_value = schema_str.as_deref().map(serde_json::from_str::).transpose()?; + if let Some(schema_value) = &schema_value { + locked_insert!( + RESOURCE_SCHEMAS, + cached_resource.type_name.clone(), + cached_resource.version.clone(), + schema_value.clone() + ); + } + schema_value + }, + }; let diff_properties = get_diff_with_schema(&expected_value, &actual_state, schema.as_ref()); Ok(TestResult::Resource(ResourceTestResponse { desired_state: expected_value, @@ -502,16 +550,20 @@ pub fn invoke_delete(resource: &DscResource, filter: &str, context: &Context) -> let command_resource = context.target_resource.as_ref().unwrap_or(resource); validate_security_context(context.target_resource.as_ref(), &delete.require_security_context, &command_resource.type_name, &Operation::Delete)?; - let (args, supports_whatif) = process_set_delete_args(delete.args.as_ref(), filter, command_resource, context)?; + let (args, supports_whatif) = if use_jsonrpc(resource, &delete.executable, context) { + (None, delete.supports_what_if == Some(true) || delete.args.as_ref().is_some_and(|args| + args.iter().any(|arg| matches!(arg, SetDeleteArgKind::WhatIf { .. })))) + } else { process_set_delete_args(delete.args.as_ref(), filter, command_resource, context)? }; if context.execution_type == ExecutionKind::WhatIf && !supports_whatif { // perform a synthetic what-if by calling test and wrapping the TestResult in DeleteResultKind::SyntheticWhatIf let test_result = invoke_test(resource, filter, context)?; return Ok(DeleteResultKind::SyntheticWhatIf(test_result)); } - let command_input = get_command_input(delete.input.as_ref(), filter)?; + let command_input = native_input(resource, &delete.executable, delete.input.as_ref(), filter, context)?; info!("{}", t!("dscresources.commandResource.invokeDeleteUsing", resource = &command_resource.type_name, executable = &delete.executable)); - let (_exit_code, stdout, _stderr) = invoke_command(&delete.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), command_input.env, manifest.exit_codes.as_ref())?; + let (_exit_code, stdout, _stderr) = invoke_native(resource, "delete", Some(filter), context, None, None, &delete.executable, || + invoke_command(&delete.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), resource_environment(context, command_input.env), manifest.exit_codes.as_ref()))?; let result = if context.execution_type == ExecutionKind::WhatIf { let delete_result: DeleteResult = serde_json::from_str(&stdout)?; DeleteResultKind::ResourceWhatIf(delete_result) @@ -548,11 +600,13 @@ pub fn invoke_validate(resource: &DscResource, config: &str, context: &Context) }; let target_resource = context.target_resource.as_ref().unwrap_or(resource); - let args = process_get_args(validate.args.as_ref(), config, target_resource, context)?; - let command_input = get_command_input(validate.input.as_ref(), config)?; + let args = if use_jsonrpc(resource, &validate.executable, context) { None } + else { process_get_args(validate.args.as_ref(), config, target_resource, context)? }; + let command_input = native_input(resource, &validate.executable, validate.input.as_ref(), config, context)?; info!("{}", t!("dscresources.commandResource.invokeValidateUsing", resource = &resource.type_name, executable = &validate.executable)); - let (_exit_code, stdout, _stderr) = invoke_command(&validate.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), command_input.env, manifest.exit_codes.as_ref())?; + let (_exit_code, stdout, _stderr) = invoke_native(resource, "validate", Some(config), context, None, None, &validate.executable, || + invoke_command(&validate.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), resource_environment(context, command_input.env), manifest.exit_codes.as_ref()))?; let result: ValidateResult = serde_json::from_str(&stdout)?; Ok(result) } @@ -590,8 +644,10 @@ pub fn get_schema(resource: &DscResource, context: &Context) -> Result { - let args = process_schema_args(command.args.as_ref(), target_resource)?; - let (_exit_code, stdout, _stderr) = invoke_command(&command.executable, args, None, resource.directory.as_deref(), None, manifest.exit_codes.as_ref())?; + let (_exit_code, stdout, _stderr) = invoke_native(resource, "schema", None, context, None, Some("resource"), &command.executable, || { + let args = process_schema_args(command.args.as_ref(), target_resource)?; + invoke_command(&command.executable, args, None, resource.directory.as_deref(), resource_environment(context, None), manifest.exit_codes.as_ref()) + })?; let schema_value: Value = serde_json::from_str(&stdout)?; (stdout, schema_value) }, @@ -618,8 +674,10 @@ fn verify_with_export_schema(input: &str, resource: &DscResource, context: &Cont let schema = match export.schema_or_filtering { Some(ExportSchemaOrFiltering::Schema(ExportSchemaKind::Command(ref command))) => { - let args = process_schema_args(command.args.as_ref(), command_resource)?; - let (_exit_code, stdout, _stderr) = invoke_command(&command.executable, args, None, resource.directory.as_deref(), None, manifest.exit_codes.as_ref())?; + let (_exit_code, stdout, _stderr) = invoke_native(resource, "schema", None, context, None, Some("exportInput"), &command.executable, || { + let args = process_schema_args(command.args.as_ref(), command_resource)?; + invoke_command(&command.executable, args, None, resource.directory.as_deref(), resource_environment(context, None), manifest.exit_codes.as_ref()) + })?; stdout }, Some(ExportSchemaOrFiltering::Schema(ExportSchemaKind::Embedded(ref schema))) => { @@ -717,15 +775,18 @@ pub fn invoke_export(resource: &DscResource, input: Option<&str>, context: &Cont verify_with_export_schema(input, resource, context)?; - command_input = get_command_input(export.input.as_ref(), input)?; + command_input = native_input(resource, &export.executable, export.input.as_ref(), input, context)?; } - args = process_get_args(export.args.as_ref(), input, command_resource, context)?; + args = if use_jsonrpc(resource, &export.executable, context) { None } + else { process_get_args(export.args.as_ref(), input, command_resource, context)? }; } else { - args = process_get_args(export.args.as_ref(), "", command_resource, context)?; + args = if use_jsonrpc(resource, &export.executable, context) { None } + else { process_get_args(export.args.as_ref(), "", command_resource, context)? }; } - let (_exit_code, stdout, stderr) = invoke_command(&export.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), command_input.env, manifest.exit_codes.as_ref())?; + let (_exit_code, stdout, stderr) = invoke_native(resource, "export", input, context, None, None, &export.executable, || + invoke_command(&export.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), resource_environment(context, command_input.env), manifest.exit_codes.as_ref()))?; let mut instances: Vec = Vec::new(); for line in stdout.lines() { @@ -773,11 +834,13 @@ pub fn invoke_resolve(resource: &DscResource, input: &str, context: &Context) -> let mut command_resource = DscResource::new(); command_resource.type_name = resource.type_name.clone(); command_resource.path = resource.path.clone(); - let args = process_get_args(resolve.args.as_ref(), input, &command_resource, context)?; - let command_input = get_command_input(resolve.input.as_ref(), input)?; + let args = if use_jsonrpc(resource, &resolve.executable, context) { None } + else { process_get_args(resolve.args.as_ref(), input, &command_resource, context)? }; + let command_input = native_input(resource, &resolve.executable, resolve.input.as_ref(), input, context)?; info!("{}", t!("dscresources.commandResource.invokeResolveUsing", resource = &resource.type_name, executable = &resolve.executable)); - let (_exit_code, stdout, _stderr) = invoke_command(&resolve.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), command_input.env, manifest.exit_codes.as_ref())?; + let (_exit_code, stdout, _stderr) = invoke_native(resource, "resolve", Some(input), context, None, None, &resolve.executable, || + invoke_command(&resolve.executable, args, command_input.stdin.as_deref(), resource.directory.as_deref(), resource_environment(context, command_input.env), manifest.exit_codes.as_ref()))?; let result: ResolveResult = serde_json::from_str(&stdout)?; Ok(result) } diff --git a/lib/dsc-lib/src/dscresources/jsonrpc.rs b/lib/dsc-lib/src/dscresources/jsonrpc.rs new file mode 100644 index 000000000..4d70507ef --- /dev/null +++ b/lib/dsc-lib/src/dscresources/jsonrpc.rs @@ -0,0 +1,525 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{ + collections::HashMap, + io::{BufRead, BufReader, Write}, + process::{Child, Command, Stdio}, + sync::mpsc::{self, Receiver}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use rust_i18n::t; +use serde_json::{Value, json}; + +use crate::{ + DscError, + configure::{config_doc::ExecutionKind, context::Context}, + progress::ResourceProgress, + util::canonicalize_which, + settings::DscSettingsScope, +}; +use super::{ + command_resource::{log_stderr_line, verify_executable_is_invokable}, + dscresource::{DscResource, redact}, + resource_manifest::{JsonRpc, ReturnKind}, +}; + +#[derive(Debug, Default)] +pub(crate) struct ProcessPool { + processes: HashMap, + failed: bool, +} + +fn protocol_error() -> DscError { + DscError::Operation(t!("dscresources.jsonrpc.protocolError").to_string()) +} + +impl ProcessPool { + pub(crate) fn finish(&mut self) -> Result<(), DscError> { + let mut failure = None; + for (_, mut process) in self.processes.drain() { + if let Err(err) = process.shutdown() { + if failure.is_none() { + failure = Some(err); + } else { + tracing::warn!("{err}"); + } + } + } + self.failed = false; + failure.map_or(Ok(()), Err) + } + + fn invoke(&mut self, resource: &DscResource, operation: &str, input: Option<&str>, schema_kind: Option<&str>, context: &Context) -> Result { + if self.failed { + return Err(DscError::Operation(t!("dscresources.jsonrpc.runFailed").to_string())); + } + let result = self.invoke_inner(resource, operation, input, schema_kind, context); + if result.is_err() { + self.failed = true; + } + result + } + + fn invoke_inner(&mut self, resource: &DscResource, operation: &str, input: Option<&str>, schema_kind: Option<&str>, context: &Context) -> Result { + for process in self.processes.values_mut() { + process.check_alive()?; + } + let manifest = resource.manifest.as_ref().ok_or_else(|| DscError::MissingManifest(resource.type_name.to_string()))?; + let rpc = manifest.jsonrpc.as_ref().ok_or_else(protocol_error)?; + manifest.validate_transport()?; + let target = context.target_resource.as_ref(); + let resource_type = target.map_or(&resource.type_name, |target| &target.type_name); + let target_descriptor = target.map(|target| json!({ + "type": target.type_name, + "version": target.version, + "path": target.path, + "directory": target.directory, + "manifest": target.manifest, + "adaptedManifest": target.adapted_manifest, + "adaptedContent": target.adapted_content, + "schema": target.schema, + })); + let mut params = json!({ + "operation": operation, + "executionType": if matches!(operation, "set" | "whatIf" | "delete") + && context.execution_type == ExecutionKind::WhatIf { "whatIf" } else { "actual" }, + "resourceType": resource_type, + "context": { "targetResource": target_descriptor }, + }); + if let Some(input) = input.filter(|input| !input.is_empty()) { + let properties: Value = serde_json::from_str(input)?; + if !properties.is_object() { + return Err(protocol_error()); + } + params["properties"] = properties; + } + if let Some(kind) = schema_kind { + params["context"]["schemaKind"] = json!(kind); + } + if context.reuse_resources && context.reuse_resource { + let key = serde_json::to_string(&( + &resource.type_name, &resource.version, &resource.path, &resource.directory, + manifest, &context.security_context, + ))?; + let process = match self.processes.entry(key) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::hash_map::Entry::Vacant(entry) => entry.insert(RpcProcess::start(resource, rpc, context)?), + }; + process.request("dsc_invoke", Some(params), process.request_timeout, Some(context)) + } else { + let mut process = RpcProcess::start(resource, rpc, context)?; + let result = process.request("dsc_invoke", Some(params), process.request_timeout, Some(context)); + let cleanup = process.shutdown(); + match (result, cleanup) { + (Err(err), cleanup) => { + if let Err(cleanup) = cleanup { tracing::warn!("{cleanup}"); } + Err(err) + }, + (Ok(_), Err(err)) => Err(err), + (Ok(value), Ok(())) => Ok(value), + } + } + } +} + +impl Drop for ProcessPool { + fn drop(&mut self) { + if let Err(err) = self.finish() { + tracing::warn!("{err}"); + } + } +} + +#[derive(Debug)] +struct RpcProcess { + child: Child, + writer: Option>, + stdout: Receiver>>, + readers: Vec>, + next_id: u64, + initialized: bool, + terminated: bool, + request_timeout: u64, + shutdown_timeout: u64, + resource: DscResource, +} + +type WriteRequest = (Value, mpsc::SyncSender>); + +impl RpcProcess { + fn start(resource: &DscResource, rpc: &JsonRpc, context: &Context) -> Result { + let executable = canonicalize_which(&rpc.executable, resource.directory.as_deref())?; + verify_executable_is_invokable(std::path::Path::new(&executable))?; + let mut command = Command::new(&executable); + command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + if let Some(args) = &rpc.args { command.args(args); } + if let Some(directory) = &resource.directory { command.current_dir(directory); } + if !context.reuse_resources { command.env("DSC_RESOURCE_REUSE_FORBIDDEN", "1"); } + command.env_remove("DEBUG_DSC"); + let mut child = command.spawn().map_err(|err| DscError::CommandOperation(err.to_string(), executable))?; + let mut stdin = child.stdin.take().ok_or_else(protocol_error)?; + let stdout = child.stdout.take().ok_or_else(protocol_error)?; + let stderr = child.stderr.take().ok_or_else(protocol_error)?; + let (sender, receiver) = mpsc::sync_channel(32); + let (writer, writes) = mpsc::channel::(); + let stdin_writer = thread::spawn(move || { + for (value, completion) in writes { + let result = (|| { + serde_json::to_writer(&mut stdin, &value).map_err(std::io::Error::other)?; + stdin.write_all(b"\n")?; + stdin.flush() + })(); + let failed = result.is_err(); + if completion.send(result).is_err() || failed { break; } + } + }); + let stdout_reader = thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + let event = match reader.read_line(&mut line) { + Ok(0) => Ok(None), + Ok(_) if line.ends_with('\n') => Ok(Some(line)), + Ok(_) => Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)), + Err(err) => Err(err), + }; + let finished = !matches!(&event, Ok(Some(_))); + if sender.send(event).is_err() || finished { break; } + } + }); + let pid = child.id(); + let dispatcher = tracing::dispatcher::get_default(Clone::clone); + let stderr_reader = thread::spawn(move || tracing::dispatcher::with_default(&dispatcher, || { + for line in BufReader::new(stderr).lines() { + match line { + Ok(line) => { + let diagnostic = log_stderr_line(&pid, &line); + if !diagnostic.is_empty() { tracing::warn!(pid, "{diagnostic}"); } + }, + Err(err) => { tracing::warn!(pid, "{err}"); break; }, + } + } + })); + let settings = crate::settings::global::get_or_init().resolved().resource_timeouts.clone(); + let mut process = Self { + child, writer: Some(writer), stdout: receiver, readers: vec![stdin_writer, stdout_reader, stderr_reader], + next_id: 1, initialized: false, terminated: false, + request_timeout: if settings.request_timeout_ms.scope == DscSettingsScope::Default { + rpc.request_timeout_ms.unwrap_or(0) + } else { settings.request_timeout_ms.value }, + shutdown_timeout: if settings.shutdown_timeout_ms.scope == DscSettingsScope::Default { + rpc.shutdown_timeout_ms.unwrap_or(250) + } else { settings.shutdown_timeout_ms.value.get() }, + resource: resource.clone(), + }; + let tracing = crate::settings::global::get_or_init().resolved().tracing.clone(); + let mut params = json!({ + "dscVersion": context.dsc_version, + "protocolVersions": [1], + "traceLevel": tracing.level.value, + "traceFormat": tracing.format.value, + "securityContext": context.security_context, + }); + if let Ok(root) = std::env::var("DSC_CONFIG_ROOT") { params["configRoot"] = json!(root); } + let initialize_timeout = if settings.initialize_timeout_ms.scope == DscSettingsScope::Default { + rpc.initialize_timeout_ms.unwrap_or(250) + } else { settings.initialize_timeout_ms.value.get() }; + let result = process.request("initialize", Some(params), initialize_timeout, Some(context))?; + if result.get("protocolVersion").and_then(Value::as_u64) != Some(1) { + process.terminate(); + return Err(protocol_error()); + } + if let Some(operations) = result.get("operations") { + let Some(operations) = operations.as_array() else { return Err(protocol_error()); }; + if operations.iter().any(|operation| !operation.is_string()) { return Err(protocol_error()); } + let manifest = serde_json::to_value(resource.manifest.as_ref())?; + let declared: Vec<&str> = ["get", "set", "whatIf", "test", "delete", "export", "validate", "resolve"] + .into_iter().filter(|name| manifest.get(*name).is_some_and(|value| !value.is_null())).collect(); + if declared.iter().any(|name| !operations.contains(&json!(name))) + || operations.iter().any(|name| !declared.iter().any(|declared| name.as_str() == Some(declared)) + && !(name == "schema" && manifest.pointer("/schema/command").is_some())) + { + tracing::warn!("{}", t!("dscresources.jsonrpc.operationsMismatch", resource = resource.type_name)); + } + } + process.initialized = true; + Ok(process) + } + + fn check_alive(&mut self) -> Result<(), DscError> { + if let Some(status) = self.child.try_wait()? { + self.terminated = true; + return Err(DscError::Operation(t!("dscresources.jsonrpc.unexpectedExit", + resource = self.resource.type_name, status = status).to_string())); + } + Ok(()) + } + + fn send(&mut self, value: &Value, deadline: Option, method: &str, timeout_ms: u64) -> Result<(), DscError> { + let (completion, result) = mpsc::sync_channel(1); + self.writer.as_ref().ok_or_else(protocol_error)?.send((value.clone(), completion)).map_err(|_| protocol_error())?; + let write = if let Some(deadline) = deadline { + result.recv_timeout(deadline.saturating_duration_since(Instant::now())).map_err(|err| match err { + mpsc::RecvTimeoutError::Timeout => DscError::Operation(t!("dscresources.jsonrpc.timeout", + resource = self.resource.type_name, method = method, timeout = timeout_ms).to_string()), + mpsc::RecvTimeoutError::Disconnected => protocol_error(), + })? + } else { + result.recv().map_err(|_| protocol_error())? + }; + write?; + Ok(()) + } + + fn request(&mut self, method: &str, params: Option, timeout_ms: u64, context: Option<&Context>) -> Result { + let result = self.request_inner(method, params, timeout_ms, context); + if method == "dsc_invoke" + && let Some(progress) = context.and_then(|context| context.resource_progress.as_ref()) { + progress.write_resource_progress(None, None); + } + if result.is_err() && !matches!(&result, Err(DscError::JsonRpc { .. })) { + self.terminate(); + } + result + } + + fn request_inner(&mut self, method: &str, params: Option, timeout_ms: u64, context: Option<&Context>) -> Result { + self.check_alive()?; + match self.stdout.try_recv() { + Err(mpsc::TryRecvError::Empty) => {}, + _ => return Err(protocol_error()), + } + let id = self.next_id; + self.next_id = self.next_id.checked_add(1).ok_or_else(protocol_error)?; + let mut request = json!({"jsonrpc": "2.0", "id": id, "method": method}); + if let Some(params) = params { request["params"] = params; } + let start = Instant::now(); + let deadline = if timeout_ms == 0 { None } else { + Some(start.checked_add(Duration::from_millis(timeout_ms)).ok_or_else(|| DscError::Operation( + t!("dscresources.jsonrpc.timeoutOutOfRange", timeout = timeout_ms).to_string() + ))?) + }; + self.send(&request, deadline, method, timeout_ms)?; + let mut previous_completed = None; + let mut previous_total = None; + loop { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(DscError::Operation(t!("dscresources.jsonrpc.timeout", + resource = self.resource.type_name, method = method, timeout = timeout_ms).to_string())); + } + let event = if timeout_ms == 0 { + self.stdout.recv().map_err(|_| protocol_error())? + } else { + let remaining = Duration::from_millis(timeout_ms).saturating_sub(start.elapsed()); + self.stdout.recv_timeout(remaining).map_err(|err| match err { + mpsc::RecvTimeoutError::Timeout => DscError::Operation(t!("dscresources.jsonrpc.timeout", + resource = self.resource.type_name, method = method, timeout = timeout_ms).to_string()), + mpsc::RecvTimeoutError::Disconnected => protocol_error(), + })? + }; + let Some(line) = event.map_err(|_| protocol_error())? else { + self.check_alive()?; + return Err(protocol_error()); + }; + let value: Value = serde_json::from_str(&line).map_err(|_| protocol_error())?; + if !value.is_object() || value["jsonrpc"] != "2.0" { return Err(protocol_error()); } + if value.get("method").is_some() { + if method != "dsc_invoke" || value.get("id").is_some() + || value.get("result").is_some() || value.get("error").is_some() + || !value["params"].is_object() || value["params"]["requestId"].as_u64() != Some(id) + { + return Err(protocol_error()); + } + let params = &value["params"]; + let message = params["message"].as_str().ok_or_else(protocol_error)?; + match value["method"].as_str() { + Some("dsc_trace") => { + let pid = self.child.id(); + match params["level"].as_str() { + Some("error") => tracing::error!(pid, "{message}"), + Some("warn") => tracing::warn!(pid, "{message}"), + Some("info") => tracing::info!(pid, "{message}"), + Some("debug") => tracing::debug!(pid, "{message}"), + Some("trace") => tracing::trace!(pid, "{message}"), + _ => return Err(protocol_error()), + } + }, + Some("dsc_progress") => { + let number = |name| -> Result, DscError> { + params.get(name).map(|value| value.as_f64() + .filter(|number| number.is_finite()).ok_or_else(protocol_error)).transpose() + }; + let completed = number("completed")?; + let total = number("total")?; + if completed.is_some_and(|value| value < 0.0 || previous_completed.is_some_and(|previous| value < previous)) + || completed.is_some_and(|value| previous_total.is_some_and(|total| value > total)) + || total.is_some_and(|value| value <= 0.0 || completed.is_none_or(|completed| completed > value) + || previous_total.is_some_and(|previous| value != previous)) + { + return Err(protocol_error()); + } + if completed.is_some() { previous_completed = completed; } + if total.is_some() { previous_total = total; } + if let Some(progress) = context.and_then(|context| context.resource_progress.as_ref()) { + let numbers = (completed.is_some() || total.is_some()).then_some(ResourceProgress { completed, total }); + progress.write_resource_progress(Some(message), numbers); + } + }, + _ => return Err(protocol_error()), + } + continue; + } + if value.get("id").is_none() || value.get("result").is_some() == value.get("error").is_some() + || (value["id"].as_u64() != Some(id) && !(value["id"].is_null() && value.get("error").is_some())) + { + return Err(protocol_error()); + } + if let Some(error) = value.get("error") { + let code = error["code"].as_i64().and_then(|code| i32::try_from(code).ok()).ok_or_else(protocol_error)?; + if value["id"].is_null() && !matches!(code, -32700 | -32600) { return Err(protocol_error()); } + let message = error["message"].as_str().ok_or_else(protocol_error)?; + let mut message = message.to_string(); + if code != 0 && !(-32768..=-32000).contains(&code) + && let Some(description) = self.resource.manifest.as_ref().and_then(|manifest| manifest.exit_codes.get_code(code)) { + message.push_str(": "); + message.push_str(description); + } + return Err(DscError::JsonRpc { resource: self.resource.type_name.to_string(), code, + message, data: error.get("data").map(|data| Box::new(redact(data))) }); + } + if value["id"].as_u64() != Some(id) { return Err(protocol_error()); } + return Ok(value["result"].clone()); + } + } + + fn shutdown(&mut self) -> Result<(), DscError> { + if self.terminated { return Ok(()); } + self.check_alive()?; + if !self.initialized { self.terminate(); return Ok(()); } + let result = self.request("shutdown", None, self.shutdown_timeout, None)?; + if !result.is_null() { self.terminate(); return Err(protocol_error()); } + let deadline = Instant::now().checked_add(Duration::from_millis(self.shutdown_timeout)).ok_or_else(|| DscError::Operation( + t!("dscresources.jsonrpc.timeoutOutOfRange", timeout = self.shutdown_timeout).to_string() + ))?; + self.send(&json!({"jsonrpc": "2.0", "method": "exit"}), Some(deadline), "exit", self.shutdown_timeout)?; + loop { + if let Some(status) = self.child.try_wait()? { + self.terminated = true; + if !status.success() { return Err(protocol_error()); } + // Wait for the reader's EOF so scheduling cannot hide trailing output. + return match self.stdout.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Ok(None)) => Ok(()), + Err(mpsc::RecvTimeoutError::Timeout) => Err(DscError::Operation(t!("dscresources.jsonrpc.timeout", + resource = self.resource.type_name, method = "exit", timeout = self.shutdown_timeout).to_string())), + _ => Err(protocol_error()), + }; + } + if Instant::now() >= deadline { + self.terminate(); + return Err(DscError::Operation(t!("dscresources.jsonrpc.timeout", + resource = self.resource.type_name, method = "exit", timeout = self.shutdown_timeout).to_string())); + } + thread::sleep(Duration::from_millis(1)); + } + } + + fn terminate(&mut self) { + if !self.terminated { + if let Err(err) = self.child.kill() { + tracing::warn!("{err}"); + } + if let Err(err) = self.child.wait() { + tracing::warn!("{err}"); + } + self.terminated = true; + } + } +} + +impl Drop for RpcProcess { + fn drop(&mut self) { + self.terminate(); + self.writer.take(); + for reader in self.readers.drain(..) { + // Descendants may inherit a pipe; never wait indefinitely for its EOF. + if reader.is_finished() && reader.join().is_err() { + tracing::warn!("{}", t!("dscresources.jsonrpc.readerFailed")); + } + } + } +} + +/// Normalize the wire value into the command runner's semantic input format. +pub(crate) fn invoke(resource: &DscResource, operation: &str, input: Option<&str>, schema_kind: Option<&str>, + returns: Option<&ReturnKind>, context: &Context) -> Result<(i32, String, String), DscError> +{ + let mut pool = context.resource_processes.lock().unwrap(); + let value = pool.invoke(resource, operation, input, schema_kind, context)?; + let result = normalize_result(resource, operation, returns, context, value); + if result.is_err() { pool.failed = true; } + result +} + +fn normalize_result(resource: &DscResource, operation: &str, returns: Option<&ReturnKind>, context: &Context, + value: Value) -> Result<(i32, String, String), DscError> +{ + let group = match operation { + "set" | "whatIf" => serde_json::from_value::>(value.clone()).is_ok(), + "test" => serde_json::from_value::>(value.clone()).is_ok(), + _ => false, + }; + if group { return Ok((0, serde_json::to_string(&value)?, String::new())); } + let stdout = match operation { + "get" => { + if !value.is_object() { + let _: Vec = + serde_json::from_value(value.clone()).map_err(|_| protocol_error())?; + } + serde_json::to_string(&value)? + }, + "export" => { + let instances = value.as_array().ok_or_else(protocol_error)?; + if instances.iter().any(|value| !value.is_object()) { return Err(protocol_error()); } + let lines: Result, _> = instances.iter().map(serde_json::to_string).collect(); + lines?.join("\n") + }, + "set" | "whatIf" | "test" if returns == Some(&ReturnKind::StateAndDiff) => { + let state = value.get("actualState").ok_or_else(protocol_error)?; + if !state.is_object() && resource.kind == super::resource_manifest::Kind::Resource { return Err(protocol_error()); } + let diff = value.get("diff").ok_or_else(protocol_error)?; + let _: Vec = serde_json::from_value(diff.clone()).map_err(|_| protocol_error())?; + format!("{}\n{}", serde_json::to_string(state)?, serde_json::to_string(diff)?) + }, + "set" | "whatIf" | "test" if returns.is_none() && resource.kind == super::resource_manifest::Kind::Resource => { + if !value.is_null() { return Err(protocol_error()); } + String::new() + }, + "delete" if context.execution_type == ExecutionKind::Actual => { + if !value.is_null() { return Err(protocol_error()); } + String::new() + }, + "schema" => { + if !value.is_object() && !value.is_boolean() { return Err(protocol_error()); } + serde_json::to_string(&value)? + }, + "validate" => { + let _: crate::resources::invoke_result::ValidateResult = + serde_json::from_value(value.clone()).map_err(|_| protocol_error())?; + serde_json::to_string(&value)? + }, + "resolve" => { + let _: crate::resources::invoke_result::ResolveResult = + serde_json::from_value(value.clone()).map_err(|_| protocol_error())?; + serde_json::to_string(&value)? + }, + "set" | "whatIf" | "test" | "delete" => { + if !value.is_object() && resource.kind == super::resource_manifest::Kind::Resource { return Err(protocol_error()); } + serde_json::to_string(&value)? + }, + _ => serde_json::to_string(&value)?, + }; + Ok((0, stdout, String::new())) +} diff --git a/lib/dsc-lib/src/dscresources/mod.rs b/lib/dsc-lib/src/dscresources/mod.rs index 4a41d2bc7..02ea44a56 100644 --- a/lib/dsc-lib/src/dscresources/mod.rs +++ b/lib/dsc-lib/src/dscresources/mod.rs @@ -7,3 +7,4 @@ pub mod dscresource; pub mod resource_manifest; use super::dscerror; +pub(crate) mod jsonrpc; diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index 60c7f121e..a7a87b2bb 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -32,6 +32,7 @@ pub enum Kind { #[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[serde(deny_unknown_fields, rename_all = "camelCase")] #[schemars( + transform = ResourceManifest::transport_constraints, transform = ResourceManifest::transform_export_schema_uris, transform = ResourceManifest::transform_schema_docs, )] @@ -46,6 +47,9 @@ pub enum Kind { ) )] pub struct ResourceManifest { + /// Persistent JSON-RPC entry point. Operation commands are optional fallbacks. + #[serde(skip_serializing_if = "Option::is_none")] + pub jsonrpc: Option, /// The version of the resource manifest schema. #[serde(rename = "$schema")] #[schemars(schema_with = "ResourceManifest::recognized_schema_uris_subschema")] @@ -285,6 +289,7 @@ pub enum ExportSchemaKind { #[schemars(inline)] pub struct SchemaCommand { /// The command to run to get the schema. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command. pub args: Option>, @@ -314,6 +319,7 @@ pub enum ReturnKind { )] pub struct GetMethod { /// The command to run to get the state of the resource. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command to perform a Get. pub args: Option>, @@ -332,7 +338,11 @@ pub struct GetMethod { transform = SetMethod::transform_schema_docs, )] pub struct SetMethod { + /// Whether JSON-RPC set supports native what-if. + #[serde(rename = "supportsWhatIf", skip_serializing_if = "Option::is_none")] + pub supports_what_if: Option, /// The command to run to set the state of the resource. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command to perform a Set. pub args: Option>, @@ -363,6 +373,7 @@ pub struct SetMethod { )] pub struct TestMethod { /// The command to run to test the state of the resource. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command to perform a Test. pub args: Option>, @@ -383,7 +394,11 @@ pub struct TestMethod { transform = DeleteMethod::transform_schema_docs, )] pub struct DeleteMethod { + /// Whether JSON-RPC delete supports native what-if. + #[serde(rename = "supportsWhatIf", skip_serializing_if = "Option::is_none")] + pub supports_what_if: Option, /// The command to run to delete the state of the resource. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command to perform a Delete. pub args: Option>, @@ -402,6 +417,7 @@ pub struct DeleteMethod { )] pub struct ValidateMethod { // TODO: enable validation via schema or command /// The command to run to validate the state of the resource. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command to perform a Validate. pub args: Option>, @@ -425,6 +441,7 @@ pub enum ExportSchemaOrFiltering { )] pub struct ExportMethod { /// The command to run to enumerate instances of the resource. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command to perform a Export. pub args: Option>, @@ -433,10 +450,19 @@ pub struct ExportMethod { /// The security context required to run the Export method. Default if not specified is `current`. #[serde(rename = "requireSecurityContext", skip_serializing_if = "Option::is_none")] pub require_security_context: Option, - #[serde(flatten, skip_serializing_if = "Option::is_none")] + #[serde(flatten, deserialize_with = "deserialize_export_schema_or_filtering", skip_serializing_if = "Option::is_none")] pub schema_or_filtering: Option, } +fn deserialize_export_schema_or_filtering<'de, D: serde::Deserializer<'de>>(deserializer: D) + -> Result, D::Error> +{ + let mut fields = Map::::deserialize(deserializer)?; + fields.retain(|name, value| matches!(name.as_str(), "schema" | "supportsFiltering") && !value.is_null()); + if fields.is_empty() { return Ok(None); } + serde_json::from_value(Value::Object(fields)).map(Some).map_err(serde::de::Error::custom) +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[dsc_repo_schema(base_name = "manifest.resolve", folder_path = "resource")] #[schemars( @@ -445,6 +471,7 @@ pub struct ExportMethod { )] pub struct ResolveMethod { /// The command to run to enumerate instances of the resource. + #[serde(default, deserialize_with = "deserialize_executable", skip_serializing_if = "String::is_empty")] pub executable: String, /// The arguments to pass to the command to perform a Export. pub args: Option>, @@ -489,6 +516,143 @@ pub struct ListMethod { pub args: Option>, } +fn deserialize_executable<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let executable = String::deserialize(deserializer)?; + if executable.is_empty() { + return Err(serde::de::Error::custom(t!("dscresources.jsonrpc.emptyExecutable"))); + } + Ok(executable) +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[schemars(inline)] +pub struct JsonRpc { + #[schemars(length(min = 1))] + pub executable: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, + #[serde(rename = "initializeTimeoutMS", skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub initialize_timeout_ms: Option, + #[serde(rename = "requestTimeoutMS", skip_serializing_if = "Option::is_none")] + pub request_timeout_ms: Option, + #[serde(rename = "shutdownTimeoutMS", skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub shutdown_timeout_ms: Option, +} + +impl ResourceManifest { + fn transport_constraints(schema: &mut schemars::Schema) { + let fallback = serde_json::json!({"anyOf": [{"type": "null"}, {"required": ["executable"]}]}); + let mut properties = Map::new(); + let mut fallbacks = Map::new(); + let mut constraints = Vec::new(); + for operation in ["get", "set", "whatIf", "test", "delete", "export", "resolve", "validate"] { + properties.insert(operation.to_string(), serde_json::json!({ + "properties": {"executable": {"minLength": 1}}, + "if": {"not": {"required": ["executable"]}}, + "then": {"properties": {"args": {"type": "null"}, "input": {"type": "null"}}} + })); + fallbacks.insert(operation.to_string(), fallback.clone()); + } + fallbacks.insert("schema".to_string(), serde_json::json!({"properties": {"command": fallback}})); + fallbacks.get_mut("export").unwrap()["properties"] = + serde_json::json!({"schema": {"properties": {"command": fallback}}}); + constraints.push(serde_json::json!({ + "if": {"not": {"required": ["jsonrpc"], "properties": {"jsonrpc": {"type": "object"}}}}, + "then": {"properties": fallbacks} + })); + properties.insert("schema".to_string(), serde_json::json!({"properties": {"command": { + "properties": {"executable": {"minLength": 1}}, + "if": {"not": {"required": ["executable"]}}, + "then": {"properties": {"args": {"type": "null"}}} + }}})); + properties.get_mut("export").unwrap()["properties"] = serde_json::json!({"schema": {"properties": {"command": { + "properties": {"executable": {"minLength": 1}}, + "if": {"not": {"required": ["executable"]}}, + "then": {"properties": {"args": {"type": "null"}}} + }}}}); + constraints.push(serde_json::json!({"properties": properties})); + constraints.push(serde_json::json!({ + "if": {"required": ["jsonrpc"], "properties": {"jsonrpc": {"type": "object"}}}, + "then": {"properties": {"adapter": {"properties": {"inputKind": {"const": "single"}}}}} + })); + for operation in ["set", "delete"] { + constraints.push(serde_json::json!({ + "if": { + "required": [operation], + "properties": {(operation): { + "type": "object", "required": ["supportsWhatIf"], + "properties": {"supportsWhatIf": {"const": true}} + }} + }, + "then": { + "required": ["jsonrpc"], + "properties": { + "jsonrpc": {"type": "object"}, + (operation): { + "if": {"required": ["executable"]}, + "then": {"required": ["args"], "properties": {"args": {"type": "array", "contains": { + "type": "object", "required": ["whatIfArg"] + }}}} + } + } + } + })); + } + schema.insert("allOf".to_string(), Value::Array(constraints)); + } + + pub(crate) fn validate_transport(&self) -> Result<(), crate::DscError> { + let value = serde_json::to_value(self)?; + let invalid = || crate::DscError::InvalidManifest( + t!("dscresources.jsonrpc.invalidManifest", resource = self.resource_type).to_string() + ); + if let Some(rpc) = &self.jsonrpc + && (rpc.executable.is_empty() + || rpc.initialize_timeout_ms == Some(0) + || rpc.shutdown_timeout_ms == Some(0) + || self.adapter.as_ref().is_some_and(|adapter| adapter.input_kind != AdapterInputKind::Single)) + { + return Err(invalid()); + } + for operation in ["get", "set", "whatIf", "test", "delete", "export", "resolve", "validate"] { + let Some(method) = value.get(operation).filter(|v| !v.is_null()) else { continue }; + let executable = method.get("executable").and_then(Value::as_str); + if executable.is_none_or(str::is_empty) { + if self.jsonrpc.is_none() || method.get("args").is_some_and(|v| !v.is_null()) + || method.get("input").is_some_and(|v| !v.is_null()) + { + return Err(invalid()); + } + } else if method.get("supportsWhatIf") == Some(&Value::Bool(true)) + && !method.get("args").and_then(Value::as_array).is_some_and(|args| + args.iter().any(|arg| arg.get("whatIfArg").is_some())) + { + return Err(invalid()); + } + if method.get("supportsWhatIf") == Some(&Value::Bool(true)) && self.jsonrpc.is_none() { + return Err(invalid()); + } + } + let resource_schema = match &self.schema { + Some(SchemaKind::Command(command)) => Some(command), + _ => None, + }; + let export_schema = match self.export.as_ref().and_then(|export| export.schema_or_filtering.as_ref()) { + Some(ExportSchemaOrFiltering::Schema(ExportSchemaKind::Command(command))) => Some(command), + _ => None, + }; + for command in [resource_schema, export_schema].into_iter().flatten() { + if command.executable.is_empty() && (self.jsonrpc.is_none() || command.args.is_some()) { + return Err(invalid()); + } + } + Ok(()) + } +} + /// Validate a semantic version string. /// /// # Arguments @@ -513,6 +677,57 @@ mod test { use crate::dscresources::resource_manifest::ResourceManifest; + #[test] + fn jsonrpc_manifest_constraints() { + use serde_json::json; + let schema = crate::dsc_repo_schema_for!(ResourceManifest); + let validator = jsonschema::Validator::new(schema.as_value()).unwrap(); + let base = json!({ + "$schema": ResourceManifest::default_schema_id_uri(), + "type": "Test/JsonRpc", "version": "0.1.0", "get": {}, + "jsonrpc": {"executable": "dsctest"} + }); + for update in [ + json!({}), + json!({"set": {"return": "stateAndDiff", "supportsWhatIf": true}}), + json!({"schema": {"command": {}}}), + json!({"export": {"schema": {"command": {}}}}), + json!({"adapter": {"inputKind": "single"}}), + ] { + let mut manifest = base.clone(); + manifest.as_object_mut().unwrap().extend(update.as_object().unwrap().clone()); + assert!(serde_json::from_value::(manifest.clone()).unwrap().validate_transport().is_ok(), "{manifest}"); + assert!(validator.is_valid(&manifest), "{manifest}: {:?}", validator.iter_errors(&manifest).collect::>()); + } + for update in [ + json!({"get": {"executable": ""}}), + json!({"schema": {"command": {"executable": ""}}}), + json!({"export": {"schema": {"command": {"executable": ""}}}}), + ] { + let mut manifest = base.clone(); + manifest.as_object_mut().unwrap().extend(update.as_object().unwrap().clone()); + assert!(serde_json::from_value::(manifest.clone()).is_err(), "{manifest}"); + assert!(!validator.is_valid(&manifest), "{manifest}"); + } + for update in [ + json!({"jsonrpc": null}), + json!({"get": {"args": ["ignored"]}}), + json!({"get": {"input": "stdin"}}), + json!({"schema": {"command": {"args": []}}}), + json!({"export": {"schema": {"command": {"args": []}}}}), + json!({"adapter": {"inputKind": "full"}}), + json!({"adapter": {"inputKind": "sequence"}}), + json!({"jsonrpc": {"executable": "dsctest", "initializeTimeoutMS": 0}}), + json!({"jsonrpc": {"executable": "dsctest", "shutdownTimeoutMS": 0}}), + json!({"set": {"executable": "dsctest", "supportsWhatIf": true}}), + ] { + let mut manifest = base.clone(); + manifest.as_object_mut().unwrap().extend(update.as_object().unwrap().clone()); + assert!(serde_json::from_value::(manifest.clone()).unwrap().validate_transport().is_err(), "{manifest}"); + assert!(!validator.is_valid(&manifest), "{manifest}"); + } + } + #[test] fn test_validate_schema_uri_with_invalid_uri() { let invalid_uri = "https://invalid.schema.uri".to_string(); diff --git a/lib/dsc-lib/src/progress.rs b/lib/dsc-lib/src/progress.rs index 66857d01a..89fac163e 100644 --- a/lib/dsc-lib/src/progress.rs +++ b/lib/dsc-lib/src/progress.rs @@ -34,6 +34,8 @@ pub struct Failure { #[derive(Default, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Progress { + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_progress: Option, /// The unique identifier for the operation. pub id: String, /// The activity being performed. @@ -73,7 +75,9 @@ impl Progress { } } +#[derive(Clone, Debug)] pub struct ProgressBar { + shared: std::sync::Arc>, progress_value: Progress, console_bar: Span, format: ProgressFormat @@ -106,8 +110,10 @@ impl ProgressBar { let _guard = bar.enter(); } + let progress_value = Progress::new(total_items); Ok(ProgressBar { - progress_value: Progress::new(total_items), + shared: std::sync::Arc::new(std::sync::Mutex::new(progress_value.clone())), + progress_value, console_bar: bar, format }) @@ -120,6 +126,7 @@ impl ProgressBar { /// * `delta` - The amount to increment the progress bar by /// pub fn write_increment(&mut self, delta: u64) { + self.progress_value.resource_progress = None; if self.format == ProgressFormat::None { return; } @@ -172,6 +179,8 @@ impl ProgressBar { /// * `status` - The status of the operation /// pub fn write_activity(&mut self, activity: &str) { + self.progress_value.activity = Some(activity.to_string()); + *self.shared.lock().unwrap() = self.progress_value.clone(); match self.format { ProgressFormat::Json => { self.progress_value.activity = Some(activity.to_string()); @@ -203,10 +212,47 @@ impl ProgressBar { } fn write_json(&mut self) { + *self.shared.lock().unwrap() = self.progress_value.clone(); if let Ok(json) = serde_json::to_string(&self.progress_value) { eprintln!("{json}"); } else { trace!("{}", t!("progress.failedToSerialize", json = self.progress_value : {:?})); } + } + + pub(crate) fn write_resource_progress(&self, message: Option<&str>, numbers: Option) { + let mut value = self.shared.lock().unwrap().clone(); + if let Some(message) = message { + value.activity = Some(format!("{}: {message}", value.activity.as_deref().unwrap_or_default())); + } + value.resource_progress = numbers; + match self.format { + ProgressFormat::Json => match serde_json::to_string(&value) { + Ok(json) => eprintln!("{json}"), + Err(err) => tracing::error!("{err}"), + }, + ProgressFormat::Default => { + let mut activity = value.activity.unwrap_or_default(); + if let Some(numbers) = value.resource_progress + && let Some(completed) = numbers.completed { + activity.push_str(&format!(" ({completed}")); + if let Some(total) = numbers.total { + activity.push_str(&format!("/{total}")); + } + activity.push(')'); + } + self.console_bar.pb_set_message(&activity); + }, + ProgressFormat::None => {}, + } + } +} + +#[derive(Default, Debug, Clone, Serialize)] +pub struct ResourceProgress { + #[serde(skip_serializing_if = "Option::is_none")] + pub completed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, } diff --git a/lib/dsc-lib/src/settings/dsc_settings.rs b/lib/dsc-lib/src/settings/dsc_settings.rs index 9abce5f36..a2d79ad7d 100644 --- a/lib/dsc-lib/src/settings/dsc_settings.rs +++ b/lib/dsc-lib/src/settings/dsc_settings.rs @@ -1246,6 +1246,10 @@ impl DscSettings { }; let scope = DscSettingsScope::Environment; + if let Some(value) = env_data.dsc_resource_reuse + && resolving.resource_reuse.scope < scope { + resolving.resource_reuse = DscSettingsResolvedField::for_environment(value); + } if let Some(level) = env_data.dsc_trace_level.as_ref() && resolving.tracing.level.scope < scope { resolving.tracing.level = DscSettingsResolvedField::for_environment(level.clone()); @@ -1317,6 +1321,8 @@ impl DscSettings { // issues. macros::define_preference_resolver!(resolving, file_data, source, $); + resolve_field!(resource_reuse); + resolve_field!(resource_timeouts => [initialize_timeout_ms, request_timeout_ms, shutdown_timeout_ms]); resolve_field!(tracing => [level, format]); resolve_field!(resource_path => [append_env_path, directories, restricted]); } @@ -1369,6 +1375,8 @@ impl DscSettings { } // Resolve remaining fields as normal. + resolve_field!(resource_reuse); + resolve_field!(resource_timeouts => [initialize_timeout_ms, request_timeout_ms, shutdown_timeout_ms]); resolve_field!(tracing => [level, format]); resolve_field!(resource_path => [append_env_path, directories, restricted]); } diff --git a/lib/dsc-lib/src/settings/fields/mod.rs b/lib/dsc-lib/src/settings/fields/mod.rs index 66049f3d6..7af1d6d13 100644 --- a/lib/dsc-lib/src/settings/fields/mod.rs +++ b/lib/dsc-lib/src/settings/fields/mod.rs @@ -3,6 +3,9 @@ //! Defines the fields used in DSC settings. +mod resource_timeouts; +pub use resource_timeouts::*; + #[cfg(doc)] pub(super) mod _implementation_guidance { //! This documentation describes the structure and provides guidance for implementing new diff --git a/lib/dsc-lib/src/settings/fields/resource_timeouts.rs b/lib/dsc-lib/src/settings/fields/resource_timeouts.rs new file mode 100644 index 000000000..6d91d6ea8 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/resource_timeouts.rs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::num::NonZeroU64; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use crate::settings::DscSettingsResolvedField; + +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(inline)] +pub struct ResourceTimeoutsFileData { + #[serde(rename = "initializeTimeoutMS", skip_serializing_if = "Option::is_none")] + pub initialize_timeout_ms: Option, + #[serde(rename = "requestTimeoutMS", skip_serializing_if = "Option::is_none")] + pub request_timeout_ms: Option, + #[serde(rename = "shutdownTimeoutMS", skip_serializing_if = "Option::is_none")] + pub shutdown_timeout_ms: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(inline)] +pub struct ResourceTimeoutsResolvedSettings { + #[serde(rename = "initializeTimeoutMS")] + pub initialize_timeout_ms: DscSettingsResolvedField, + #[serde(rename = "requestTimeoutMS")] + pub request_timeout_ms: DscSettingsResolvedField, + #[serde(rename = "shutdownTimeoutMS")] + pub shutdown_timeout_ms: DscSettingsResolvedField, +} + +impl Default for ResourceTimeoutsResolvedSettings { + fn default() -> Self { + Self { + initialize_timeout_ms: DscSettingsResolvedField::for_code_default(NonZeroU64::new(250).unwrap()), + request_timeout_ms: DscSettingsResolvedField::for_code_default(0), + shutdown_timeout_ms: DscSettingsResolvedField::for_code_default(NonZeroU64::new(250).unwrap()), + } + } +} diff --git a/lib/dsc-lib/src/settings/resolved/settings.rs b/lib/dsc-lib/src/settings/resolved/settings.rs index 31aa1bb00..d8d54354f 100644 --- a/lib/dsc-lib/src/settings/resolved/settings.rs +++ b/lib/dsc-lib/src/settings/resolved/settings.rs @@ -65,6 +65,8 @@ pub(in crate::settings) mod _implementation_guidance { ) )] pub struct DscSettingsResolved { + pub resource_reuse: DscSettingsResolvedField, + pub resource_timeouts: ResourceTimeoutsResolvedSettings, /// Indicates whether to allow users to ignore settings files. /// /// This setting is only available in the [`Policy`] scope. The default @@ -202,6 +204,8 @@ pub struct DscSettingsResolved { impl Default for DscSettingsResolved { fn default() -> Self { Self { + resource_reuse: DscSettingsResolvedField::for_code_default(true), + resource_timeouts: ResourceTimeoutsResolvedSettings::default(), forbid_ignore_settings_file: DscSettingsResolvedField::for_code_default(CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE), ignore_settings_file: DscSettingsResolvedField::for_code_default(CODE_DEFAULT_IGNORE_SETTINGS_FILE), tracing: TracingResolvedSettings::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..6a7d20074 100644 --- a/lib/dsc-lib/src/settings/sources/code_defaults.rs +++ b/lib/dsc-lib/src/settings/sources/code_defaults.rs @@ -37,6 +37,8 @@ pub(in crate::settings) mod _implementation_guidance { #[derive(Clone, Debug, PartialEq, Eq, Serialize, DscRepoSchema)] #[dsc_repo_schema(base_name = "default", folder_path = "settings/sources")] pub struct DscSettingsCodeDefaults { + pub resource_reuse: bool, + pub resource_timeouts: ResourceTimeoutsFileData, /// Indicates whether to allow users to ignore settings files. /// /// The code default for this setting is `false`, which means that users @@ -163,6 +165,12 @@ impl JsonSchema for DscSettingsCodeDefaults { /// restricted: false /// ``` pub const DSC_SETTINGS_CODE_DEFAULTS: DscSettingsCodeDefaults = DscSettingsCodeDefaults { + resource_reuse: true, + resource_timeouts: ResourceTimeoutsFileData { + initialize_timeout_ms: std::num::NonZeroU64::new(250), + request_timeout_ms: Some(0), + shutdown_timeout_ms: std::num::NonZeroU64::new(250), + }, forbid_ignore_settings_file: CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE, ignore_settings_file: CODE_DEFAULT_IGNORE_SETTINGS_FILE, tracing: CODE_DEFAULT_TRACING, diff --git a/lib/dsc-lib/src/settings/sources/environment.rs b/lib/dsc-lib/src/settings/sources/environment.rs index eb03a0013..de1da856a 100644 --- a/lib/dsc-lib/src/settings/sources/environment.rs +++ b/lib/dsc-lib/src/settings/sources/environment.rs @@ -126,6 +126,8 @@ pub(in crate::settings) mod _implementation_guidance { ) )] pub struct EnvironmentData { + #[serde(skip_serializing_if = "Option::is_none")] + pub dsc_resource_reuse: Option, /// `DSC_TRACE_LEVEL` - Defines the trace level to use. /// /// This environment variable can be set to one of the following values @@ -287,6 +289,14 @@ pub struct EnvironmentData { } impl EnvironmentData { + pub fn get_env_resource_reuse() -> Result, DscSettingsError> { + let Ok(value) = get_env_var("DSC_RESOURCE_REUSE") else { return Ok(None); }; + Self::parse_boolean_env_var(&value).map(Some).map_err(|source| DscSettingsError::LoadEnvironmentError { + env_var: "DSC_RESOURCE_REUSE", + source: Box::new(source), + }) + } + /// The name of the environment variable for the `DSC_TRACE_LEVEL` setting. pub const DSC_TRACE_LEVEL_ENV_VAR: &str = "DSC_TRACE_LEVEL"; /// The name of the environment variable for the `DSC_TRACE_FORMAT` setting. @@ -518,6 +528,10 @@ impl EnvironmentData { /// [`try_from_env()`]: Self::try_from_env pub fn from_env() -> Self { Self { + dsc_resource_reuse: match Self::get_env_resource_reuse() { + Ok(value) => value, + Err(err) => { warn!("{err}"); None }, + }, dsc_trace_level: match Self::get_env_trace_level() { Ok(level) => level, Err(err) => { @@ -572,6 +586,10 @@ impl EnvironmentData { let mut errors = Vec::new(); let mut data = Self::default(); + match Self::get_env_resource_reuse() { + Ok(value) => data.dsc_resource_reuse = value, + Err(err) => errors.push(err), + } match Self::get_env_trace_level() { Ok(level) => data.dsc_trace_level = level, Err(err) => errors.push(err), @@ -652,6 +670,7 @@ impl EnvironmentData { /// `true` if any of the environment variable fields are populated, /// otherwise `false`. pub(crate) fn any_defined(&self) -> bool { + if self.dsc_resource_reuse.is_some() { return true; } self.dsc_trace_format.is_some() || self.dsc_trace_level.is_some() || self.dsc_resource_path.is_some() || diff --git a/lib/dsc-lib/src/settings/sources/policy_file.rs b/lib/dsc-lib/src/settings/sources/policy_file.rs index bcd5d47bb..a0b1e92b5 100644 --- a/lib/dsc-lib/src/settings/sources/policy_file.rs +++ b/lib/dsc-lib/src/settings/sources/policy_file.rs @@ -61,6 +61,10 @@ use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; ) )] pub struct PolicyFileData { + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_reuse: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_timeouts: Option, /// Indicates whether DSC should allow ignoring preference settings files /// when loading and resolving settings. /// @@ -207,6 +211,7 @@ impl PolicyFileData { /// /// `true` if any of the file data fields are populated, otherwise `false`. pub(crate) fn any_defined(&self) -> bool { + if self.resource_reuse.is_some() || self.resource_timeouts.is_some() { return true; } self.forbid_ignore_settings_file.is_some() || self.ignore_settings_file.is_some() || self.resource_path.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..cf3ff3cce 100644 --- a/lib/dsc-lib/src/settings/sources/preference_file.rs +++ b/lib/dsc-lib/src/settings/sources/preference_file.rs @@ -72,6 +72,10 @@ pub(in crate::settings) mod _implementation_guidance { ) )] pub struct PreferenceFileData { + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_reuse: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_timeouts: Option, /// Defines settings that affect how DSC emits trace messages. /// /// You can control the level of the messages DSC emits and the format that @@ -175,6 +179,7 @@ impl PreferenceFileData { /// /// `true` if any of the file data fields are populated, otherwise `false`. pub(crate) fn any_defined(&self) -> bool { + if self.resource_reuse.is_some() || self.resource_timeouts.is_some() { return true; } self.resource_path.is_some() || self.tracing.is_some() } diff --git a/lib/dsc-lib/tests/integration/settings/builders.rs b/lib/dsc-lib/tests/integration/settings/builders.rs index 8ea7781ac..7a78b6711 100644 --- a/lib/dsc-lib/tests/integration/settings/builders.rs +++ b/lib/dsc-lib/tests/integration/settings/builders.rs @@ -242,6 +242,7 @@ impl PolicyFileDataBuilder { ignore_settings_file: self.ignore_settings_file, tracing: self.tracing, resource_path: self.resource_path, + ..Default::default() } } } @@ -301,6 +302,7 @@ impl PreferenceFileDataBuilder { PreferenceFileData { tracing: self.tracing, resource_path: self.resource_path, + ..Default::default() } } } @@ -487,6 +489,7 @@ impl EnvironmentDataBuilder { dsc_resource_path: self.dsc_resource_path, dsc_restricted_path: self.dsc_restricted_path, dsc_ignore_settings_file: self.dsc_ignore_settings_file, + ..Default::default() } } } diff --git a/lib/dsc-lib/tests/integration/settings/jsonrpc.rs b/lib/dsc-lib/tests/integration/settings/jsonrpc.rs new file mode 100644 index 000000000..fbfac7843 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/jsonrpc.rs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use dsc_lib::settings::{DscSettings, DscSettingsScope}; +use serde_json::json; + +#[test] +fn jsonrpc_settings_resolve_each_leaf_and_preserve_policy() { + let mut settings = DscSettings::new(); + settings.machine = Some(serde_json::from_value(json!({ + "resourceReuse": false, "resourceTimeouts": {"initializeTimeoutMS": 500, "requestTimeoutMS": 1000} + })).unwrap()); + settings.workspace = Some(serde_json::from_value(json!({ + "resourceReuse": true, "resourceTimeouts": {"requestTimeoutMS": 25} + })).unwrap()); + settings.environment = Some(dsc_lib::settings::sources::EnvironmentData { + dsc_resource_reuse: Some(false), ..Default::default() + }); + settings.policy = Some(serde_json::from_value(json!({ + "resourceReuse": true, "resourceTimeouts": {"shutdownTimeoutMS": 750, "requestTimeoutMS": 0} + })).unwrap()); + let resolved = settings.resolved(); + assert!(resolved.resource_reuse.value); + assert_eq!(resolved.resource_reuse.scope, DscSettingsScope::Policy); + assert_eq!(resolved.resource_timeouts.initialize_timeout_ms.value.get(), 500); + assert_eq!(resolved.resource_timeouts.initialize_timeout_ms.scope, DscSettingsScope::Machine); + assert_eq!(resolved.resource_timeouts.request_timeout_ms.value, 0); + assert_eq!(resolved.resource_timeouts.request_timeout_ms.scope, DscSettingsScope::Policy); + assert_eq!(resolved.resource_timeouts.shutdown_timeout_ms.value.get(), 750); +} + +#[test] +fn jsonrpc_settings_environment_overrides_preferences() { + let mut settings = DscSettings::new(); + settings.workspace = Some(serde_json::from_value(json!({"resourceReuse": true})).unwrap()); + settings.environment = Some(dsc_lib::settings::sources::EnvironmentData { + dsc_resource_reuse: Some(false), ..Default::default() + }); + assert!(!settings.resolved().resource_reuse.value); + assert_eq!(settings.resolved().resource_reuse.scope, DscSettingsScope::Environment); +} + +#[test] +fn jsonrpc_settings_reject_zero_lifecycle_timeouts() { + use dsc_lib::settings::sources::PreferenceFileData; + for field in ["initializeTimeoutMS", "shutdownTimeoutMS"] { + assert!(serde_json::from_value::(json!({"resourceTimeouts": {(field): 0}})).is_err()); + } + assert!(serde_json::from_value::(json!({"resourceTimeouts": {"requestTimeoutMS": 0}})).is_ok()); +} diff --git a/lib/dsc-lib/tests/integration/settings/mod.rs b/lib/dsc-lib/tests/integration/settings/mod.rs index 17b3d7a32..e1eaf95ae 100644 --- a/lib/dsc-lib/tests/integration/settings/mod.rs +++ b/lib/dsc-lib/tests/integration/settings/mod.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. mod builders; +mod jsonrpc; mod fields { mod ignore_settings_file; diff --git a/lib/dsc-lib/tests/integration/settings/resolved/settings.rs b/lib/dsc-lib/tests/integration/settings/resolved/settings.rs index 11a78b4c1..b70b5ed12 100644 --- a/lib/dsc-lib/tests/integration/settings/resolved/settings.rs +++ b/lib/dsc-lib/tests/integration/settings/resolved/settings.rs @@ -32,6 +32,12 @@ crate::macros::test_dsc_repo_schema! { test_validation: { with_all_defined_is_valid => { input_json: json!({ + "resourceReuse": {"value": true, "scope": "default"}, + "resourceTimeouts": { + "initializeTimeoutMS": {"value": 250, "scope": "default"}, + "requestTimeoutMS": {"value": 0, "scope": "default"}, + "shutdownTimeoutMS": {"value": 250, "scope": "default"} + }, "forbidIgnoreSettingsFile": { "value": false, "scope": "policy" @@ -92,6 +98,7 @@ mod serde { directories: DscSettingsResolvedField::for_policy(vec!["/dsc/resources".into(), "/dsc/extensions".into()]), restricted: DscSettingsResolvedField::for_policy(true.into()), }, + ..Default::default() }; serde_json::to_value(&settings) 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..c913bcc60 100644 --- a/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs +++ b/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs @@ -98,7 +98,8 @@ mod serde { PathBuf::from("/dsc/extensions") ]), ..Default::default() - }) + }), + ..Default::default() }, json!({ "forbidIgnoreSettingsFile": true, 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..8fff276e0 100644 --- a/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs +++ b/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs @@ -72,7 +72,8 @@ mod serde { PathBuf::from("/dsc/extensions") ]), ..Default::default() - }) + }), + ..Default::default() }, json!({ "resourcePath": { diff --git a/tools/dsctest/README.md b/tools/dsctest/README.md index c03a1a7e8..4551ac4ee 100644 --- a/tools/dsctest/README.md +++ b/tools/dsctest/README.md @@ -1,5 +1,30 @@ # DSCTest Resource +## JSON-RPC + +`dsctest jsonrpc` serves the JSON-RPC 2.0 lifecycle (`initialize`, `dsc_invoke`, `shutdown`, and +`exit`) over newline-delimited stdin/stdout. The test manifests include: + +- `Test/JsonRpc`: native get, set, test, delete, export, and command-based schema, including native + set/delete what-if and state-and-diff results. +- `Test/JsonRpcValidate`: custom validation. +- `Test/JsonRpcFallback`: get through JSON-RPC or an executable fallback. +- `Test/JsonRpcSynthetic`: no-return set/test and synthetic export/what-if. +- `Test/JsonRpcAdapter`: a single-input adapter for `Adapted/JsonRpcOne` and + `Adapted/JsonRpcTwo`, with executable enumeration and command-based export-input schema. + +Returned state includes `processId`, `requestCount`, and `transport` for lifecycle assertions. +Set `DSC_TEST_JSONRPC_LOG` to a file path to append lifecycle events as JSON Lines. +`dsctest jsonrpc --operation get --input '{}'` exercises the executable fallback. + +Test properties can request `delayMS`, valid `notifications`, custom `progress` notifications, or +`notificationDelayMS` for deadline tests. `fault` accepts `error` (with optional `errorCode`), +`exit`, `malformed`, `wrongId`, `nullId`, `missingId`, `both`, `batch`, `duplicate`, or `invalidResult`. +Set `DSC_TEST_JSONRPC_FAULT` to `initializeError`, `initializeVersion`, `initializeTimeout`, +`shutdownError`, `shutdownTimeout`, `exitTimeout`, or `exitOutput` to exercise lifecycle failures. + +The engine's Pester coverage is in `dsc/tests/config/dsc_jsonrpc.tests.ps1`. + ## Sleep Example config: diff --git a/tools/dsctest/dsctest.dsc.manifests.json b/tools/dsctest/dsctest.dsc.manifests.json index 0bf980174..4348ce8ae 100644 --- a/tools/dsctest/dsctest.dsc.manifests.json +++ b/tools/dsctest/dsctest.dsc.manifests.json @@ -334,6 +334,71 @@ } ], "resources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/JsonRpcAdapter", + "kind": "adapter", + "version": "0.1.0", + "jsonrpc": {"executable": "dsctest", "args": ["jsonrpc"], "initializeTimeoutMS": 2000}, + "adapter": { + "inputKind": "single", + "list": {"executable": "dsctest", "args": ["jsonrpc", "--operation", "list"]} + }, + "get": {}, + "set": {"return": "stateAndDiff", "implementsPretest": true}, + "test": {"return": "stateAndDiff"}, + "export": {"schema": {"command": {}}}, + "schema": {"command": {}} + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/JsonRpcSynthetic", + "version": "0.1.0", + "jsonrpc": {"executable": "dsctest", "args": ["jsonrpc"], "initializeTimeoutMS": 2000}, + "get": {}, + "set": {}, + "test": {}, + "schema": {"command": {}} + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/JsonRpc", + "version": "0.1.0", + "jsonrpc": { + "executable": "dsctest", + "args": ["jsonrpc"], + "initializeTimeoutMS": 2000, + "requestTimeoutMS": 1000, + "shutdownTimeoutMS": 2000 + }, + "get": {}, + "set": {"return": "stateAndDiff", "implementsPretest": true, "supportsWhatIf": true}, + "test": {"return": "stateAndDiff"}, + "delete": {"supportsWhatIf": true}, + "export": {"schema": {"embedded": {"type": "object"}}}, + "schema": {"command": {}} + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/JsonRpcValidate", + "version": "0.1.0", + "jsonrpc": {"executable": "dsctest", "args": ["jsonrpc"], "initializeTimeoutMS": 2000}, + "get": {}, + "validate": {}, + "schema": {"embedded": {"type": "object"}} + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/JsonRpcFallback", + "version": "0.1.0", + "jsonrpc": {"executable": "dsctest", "args": ["jsonrpc"], "initializeTimeoutMS": 2000}, + "get": { + "executable": "dsctest", + "args": ["jsonrpc", "--operation", "get", {"jsonInputArg": "--input"}], + "input": "env" + }, + "schema": {"embedded": {"type": "object", "additionalProperties": true}} + }, { "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", "type": "Test/CopyResource", diff --git a/tools/dsctest/src/args.rs b/tools/dsctest/src/args.rs index 54f5f609d..7783f8607 100644 --- a/tools/dsctest/src/args.rs +++ b/tools/dsctest/src/args.rs @@ -61,6 +61,13 @@ pub enum RefreshEnvOperation { #[derive(Debug, PartialEq, Eq, Subcommand)] pub enum SubCommand { + #[clap(name = "jsonrpc", about = "Run the JSON-RPC test server or executable fallback")] + Jsonrpc { + #[clap(long)] + operation: Option, + #[clap(long)] + input: Option, + }, #[clap(name = "action", about = "Invoke action")] Action { #[clap(name = "input", short, long, help = "The input to the action command as JSON")] diff --git a/tools/dsctest/src/jsonrpc.rs b/tools/dsctest/src/jsonrpc.rs new file mode 100644 index 000000000..d7abbbc76 --- /dev/null +++ b/tools/dsctest/src/jsonrpc.rs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fs::OpenOptions, io::{self, BufRead, Write}, thread, time::Duration}; +use serde_json::{Value, json}; + +fn write_message(output: &mut impl Write, value: &Value) -> Result<(), Box> { + serde_json::to_writer(&mut *output, value)?; + writeln!(output)?; + output.flush()?; + Ok(()) +} + +fn record(event: &str, params: &Value) -> Result<(), Box> { + if let Some(path) = std::env::var_os("DSC_TEST_JSONRPC_LOG") { + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "{}", json!({"pid": std::process::id(), "event": event, "params": params}))?; + } + Ok(()) +} + +fn schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "value": {"type": "integer"}, + "processId": {"type": "integer", "readOnly": true}, + "requestCount": {"type": "integer", "readOnly": true}, + "transport": {"type": "string", "readOnly": true} + }, + "additionalProperties": true + }) +} + +fn invoke(params: &Value, state: &mut Value, count: u64, transport: &str) -> Value { + let operation = params["operation"].as_str().unwrap_or_default(); + if operation == "schema" { return schema(); } + if operation == "validate" { return json!({"valid": true}); } + if operation == "resolve" { + return json!({"configuration": params["properties"]["configuration"], "parameters": null}); + } + let mut actual = params.get("properties").cloned().unwrap_or_else(|| json!({})); + actual["value"] = state.get("value").cloned().unwrap_or(json!(0)); + actual["processId"] = json!(std::process::id()); + actual["requestCount"] = json!(count); + actual["transport"] = json!(transport); + let desired = params["properties"].get("value").cloned().unwrap_or(json!(0)); + let diff = if actual["value"] == desired { json!([]) } else { json!(["value"]) }; + let result = match operation { + "set" | "whatIf" => { + actual["value"] = desired; + if params["executionType"] != "whatIf" { *state = actual.clone(); } + json!({"actualState": actual, "diff": diff}) + }, + "test" => json!({"actualState": actual, "diff": diff}), + "delete" if params["executionType"] == "whatIf" => { + actual["_exist"] = json!(false); + actual + }, + "delete" => { *state = json!({}); Value::Null }, + "export" => json!([actual]), + _ => actual, + }; + if params["resourceType"] == "Test/JsonRpcSynthetic" && matches!(operation, "set" | "test") { + Value::Null + } else { + result + } +} + +pub fn fallback(operation: &str, input: Option<&str>) -> Result<(), Box> { + if operation == "list" { + let mut output = io::stdout().lock(); + for name in ["One", "Two"] { + write_message(&mut output, &json!({ + "type": format!("Adapted/JsonRpc{name}"), "kind": "resource", "version": "0.1.0", + "capabilities": ["get", "set", "test", "export"], "path": format!("jsonrpc/{name}"), + "directory": "jsonrpc", "implementedAs": "TestAdapted", "properties": ["value"], + "requireAdapter": "Test/JsonRpcAdapter" + }))?; + } + return Ok(()); + } + let properties = input.map(serde_json::from_str::).transpose()?.unwrap_or_else(|| json!({})); + record("fallback", &json!({"operation": operation}))?; + let result = invoke(&json!({"operation": operation, "properties": properties}), &mut json!({}), 1, "executable"); + let mut output = io::stdout().lock(); + if operation == "set" || operation == "test" { + write_message(&mut output, &result["actualState"])?; + write_message(&mut output, &result["diff"]) + } else { + write_message(&mut output, &result) + } +} + +pub fn serve() -> Result<(), Box> { + let mut output = io::stdout().lock(); + let mut initialized = false; + let mut shutting_down = false; + let mut state = json!({}); + let mut count = 0; + let lifecycle_fault = std::env::var("DSC_TEST_JSONRPC_FAULT").ok(); + record("start", &Value::Null)?; + for line in io::stdin().lock().lines() { + let request: Value = serde_json::from_str(&line?)?; + let method = request["method"].as_str().ok_or("Missing method")?; + let id = request.get("id").cloned().unwrap_or(Value::Null); + let params = &request["params"]; + record(method, params)?; + match (method, lifecycle_fault.as_deref()) { + ("initialize", Some("initializeTimeout")) | ("shutdown", Some("shutdownTimeout")) + | ("exit", Some("exitTimeout")) => thread::sleep(Duration::from_secs(10)), + ("initialize", Some("initializeError")) | ("shutdown", Some("shutdownError")) => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, + "error": {"code": -32000, "message": "Requested lifecycle error"}}))?; + continue; + }, + ("initialize", Some("initializeVersion")) => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, "result": {"protocolVersion": 99}}))?; + continue; + }, + ("exit", Some("exitOutput")) => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": 0, "result": null}))?; + return Ok(()); + }, + _ => {}, + } + let result = match method { + "initialize" if !initialized => { + initialized = true; + json!({"protocolVersion": 1}) + }, + "dsc_invoke" if initialized && !shutting_down => { + count += 1; + let properties = ¶ms["properties"]; + if let Some(milliseconds) = properties["delayMS"].as_u64() { + thread::sleep(Duration::from_millis(milliseconds)); + } + match properties["fault"].as_str() { + Some("exit") => return Ok(()), + Some("malformed") => { writeln!(output, "not json")?; output.flush()?; continue; }, + Some("wrongId") => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": 0, "result": {}}))?; + continue; + }, + Some("nullId") => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": null, "result": {}}))?; + continue; + }, + Some("missingId") => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "result": {}}))?; + continue; + }, + Some("both") => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, "result": {}, + "error": {"code": -32000, "message": "Invalid response"}}))?; + continue; + }, + Some("batch") => { + write_message(&mut output, &json!([{"jsonrpc": "2.0", "id": id, "result": {}}]))?; + continue; + }, + Some("invalidResult") => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, "result": null}))?; + continue; + }, + Some("error") => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, "error": { + "code": properties["errorCode"].as_i64().unwrap_or(-32001), + "message": "Requested test error", "data": {"details": "test diagnostic"} + }}))?; + continue; + }, + _ => {}, + } + if let Some(notifications) = properties["progress"].as_array() { + for notification in notifications { + let mut progress = json!({"requestId": id, "message": "JSON-RPC work"}); + progress.as_object_mut().ok_or("Invalid progress")? + .extend(notification.as_object().ok_or("Progress must be an object")?.clone()); + write_message(&mut output, &json!({"jsonrpc": "2.0", "method": "dsc_progress", "params": progress}))?; + } + } + if let Some(milliseconds) = properties["notificationDelayMS"].as_u64() { + for _ in 0..20 { + write_message(&mut output, &json!({"jsonrpc": "2.0", "method": "dsc_progress", + "params": {"requestId": id, "message": "Still working"}}))?; + thread::sleep(Duration::from_millis(milliseconds)); + } + } + if properties["notifications"] == true { + write_message(&mut output, &json!({"jsonrpc": "2.0", "method": "dsc_trace", + "params": {"requestId": id, "level": "info", "message": "JSON-RPC trace"}}))?; + for completed in [0.5, 1.0] { + write_message(&mut output, &json!({"jsonrpc": "2.0", "method": "dsc_progress", + "params": {"requestId": id, "message": "JSON-RPC work", "completed": completed, "total": 1.0}}))?; + } + } + let result = invoke(params, &mut state, count, "jsonrpc"); + if properties["fault"] == "duplicate" { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, "result": result}))?; + } + result + }, + "shutdown" if initialized => { shutting_down = true; Value::Null }, + "exit" if shutting_down => return Ok(()), + _ => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, + "error": {"code": -32600, "message": "Invalid lifecycle request"}}))?; + continue; + }, + }; + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, "result": result}))?; + } + Err("Unexpected end of input before exit".into()) +} diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index bac0576a7..f5a46e11b 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -13,6 +13,7 @@ mod exporter; mod expression_arg; mod get; mod in_desired_state; +mod jsonrpc; mod metadata; mod operation; mod adapter; @@ -69,6 +70,14 @@ fn main() -> ProcessExitCode { fn run() -> Result<(), u8> { let args = Args::parse(); let json = match args.subcommand { + SubCommand::Jsonrpc { operation, input } => { + let result = if let Some(operation) = operation { + jsonrpc::fallback(&operation, input.as_deref()) + } else { + jsonrpc::serve() + }; + return result.map_err(|err| { eprintln!("JSON-RPC test resource: {err}"); 1 }); + }, SubCommand::Action { input, expression } => { match action::invoke_action(&input, expression.as_deref()) { Ok(result) => result, From 69ab808a6662342353b5b6ae961f5174d48ed7d3 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Thu, 12 Nov 2026 06:38:16 -0800 Subject: [PATCH 2/5] Fix JSON-RPC review issues and deadline test flakiness Honor reuse restrictions in validation and adapter wrappers, preserve RPC diagnostics, and restart persistent hosts after Windows environment refresh. Refresh the rolling manifest schema and cover the review fixes with regression tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/concepts/resources/anatomy.md | 4 + .../schemas/resource/manifest/root.md | 5 + dsc/src/server/show_dsc_resource.rs | 15 + dsc/src/subcommand.rs | 6 +- dsc/tests/config/dsc_jsonrpc.tests.ps1 | 106 +- lib/dsc-lib/src/configure/context.rs | 6 + lib/dsc-lib/src/configure/mod.rs | 18 +- lib/dsc-lib/src/dscresources/dscresource.rs | 44 +- schemas/v3/bundled/resource/manifest.json | 2590 ++++++++++------- tools/dsctest/dsctest.dsc.manifests.json | 6 +- tools/dsctest/src/jsonrpc.rs | 8 +- 11 files changed, 1657 insertions(+), 1151 deletions(-) diff --git a/docs/concepts/resources/anatomy.md b/docs/concepts/resources/anatomy.md index a8a30c7b2..0cb008753 100644 --- a/docs/concepts/resources/anatomy.md +++ b/docs/concepts/resources/anatomy.md @@ -145,6 +145,10 @@ the `exit` notification, and waits for process termination. Standalone `dsc reso the same transport preference and clean up before returning. Malformed protocol messages, initialization failures, or timeouts terminate the process without retrying. +On Windows, a set result requesting `_metadata._refreshEnv` also shuts down cached resource +processes after refreshing the environment. Later operations start new processes so they receive +the updated environment. + #### Reuse and timeout settings `resourceReuse` defaults to `true`. Preference files and policy can set it, and diff --git a/docs/reference/schemas/resource/manifest/root.md b/docs/reference/schemas/resource/manifest/root.md index e155fc57b..3bb459b8a 100644 --- a/docs/reference/schemas/resource/manifest/root.md +++ b/docs/reference/schemas/resource/manifest/root.md @@ -288,6 +288,11 @@ ItemsPattern: ^\w+$ ### jsonrpc +Use the rolling `https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json` schema for JSON-RPC +manifests. This bundle is generated from the engine's Rust manifest types with +`dsc schema --type resource-manifest --output-format pretty-json`. The legacy YAML schema sources +in `schemas/src` target v3.1.0 and don't define this transport. + The optional `jsonrpc` object defines a persistent JSON-RPC 2.0 process over newline-delimited stdin and stdout. It requires a nonempty `executable` and accepts a static string array `args`. The optional `initializeTimeoutMS` and `shutdownTimeoutMS` fields are positive integers and default diff --git a/dsc/src/server/show_dsc_resource.rs b/dsc/src/server/show_dsc_resource.rs index e5811e8c6..667752888 100644 --- a/dsc/src/server/show_dsc_resource.rs +++ b/dsc/src/server/show_dsc_resource.rs @@ -14,6 +14,20 @@ fn nullable_json_object_schema(_: &mut schemars::SchemaGenerator) -> schemars::S json_schema!({"oneOf": [{"type": "null"}, {"type": "object"}]}) } +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn jsonrpc_resource_without_schema_can_be_shown() { + let Json(resource) = McpServer::new().show_dsc_resource(Parameters(ShowResourceRequest { + r#type: "Test/JsonRpcValidate".parse().unwrap(), + })).await.unwrap(); + assert!(resource.schema.is_none()); + assert!(resource.capabilities.contains(&ResourceCapability::Get)); + } +} + #[derive(Serialize, JsonSchema)] pub struct DscResource { /// The namespaced name of the resource. @@ -63,6 +77,7 @@ impl McpServer { let context = Context::new(); let schema = match context.complete_resource_operation(resource.schema(&context)) { Ok(schema_str) => serde_json::from_str(&schema_str).ok(), + Err(dsc_lib::dscerror::DscError::SchemaNotAvailable(_)) => None, Err(err) if resource.as_dsc_resource().and_then(|resource| resource.manifest.as_ref()).is_some_and(|manifest| manifest.jsonrpc.is_some()) => return Err(McpError::internal_error(err.to_string(), None)), Err(_) => None, diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index e24ce116a..0dc8d8453 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -530,7 +530,11 @@ pub fn validate_config(config: &Configuration, progress_format: ProgressFormat) return Err(DscError::Validation(format!("{}: '{type_name}'", t!("subcommand.resourceNotFound")))); }; - let context = Context::new(); + let mut context = Context::new(); + context.restrict_resource_reuse( + config.directives.as_ref().and_then(|directives| directives.reuse_resources), + resource_block["directives"]["reuseResource"].as_bool(), + ); context.complete_resource_operation(validate_properties(&*resource, &resource_block["properties"], &context))?; } diff --git a/dsc/tests/config/dsc_jsonrpc.tests.ps1 b/dsc/tests/config/dsc_jsonrpc.tests.ps1 index 6c5ca52f0..baac7de73 100644 --- a/dsc/tests/config/dsc_jsonrpc.tests.ps1 +++ b/dsc/tests/config/dsc_jsonrpc.tests.ps1 @@ -71,6 +71,35 @@ Describe 'JSON-RPC resource lifecycle' { ($events | Where-Object event -EQ dsc_invoke).params.operation | Should -Contain 'validate' } + It 'validates JSON-RPC manifests against the published bundle' { + $schemaPath = Join-Path $PSScriptRoot '../../../schemas/v3/bundled/resource/manifest.json' + $manifestPath = Join-Path $PSScriptRoot '../../../tools/dsctest/dsctest.dsc.manifests.json' + $manifests = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -AsHashtable + foreach ($manifest in $manifests.resources | Where-Object jsonrpc) { + $manifest | ConvertTo-Json -Depth 30 | Test-Json -SchemaFile $schemaPath | Should -BeTrue + } + $invalid = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'Test/MissingTransport' + version = '0.1.0' + get = @{} + } + $invalid | ConvertTo-Json -Depth 10 | Test-Json -SchemaFile $schemaPath -ErrorAction SilentlyContinue | + Should -BeFalse + } + + It 'continues without metadata rejected by custom validation' { + $configuration = New-RpcConfiguration -ResourceType 'Test/JsonRpcValidate' -Properties @{ rejectMetadata = $true } + $configuration.resources[0].metadata = @{ example = 'metadata probe' } + $result = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json -Depth 30 + $LASTEXITCODE | Should -Be 0 + $result.hadErrors | Should -BeFalse + $calls = @((Get-RpcEvent) | Where-Object { $_.event -eq 'dsc_invoke' -and $_.params.operation -eq 'get' }) + $calls | Should -HaveCount 2 + $calls[0].params.properties._metadata | Should -BeNullOrEmpty + @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 1 + } + It 'does not reuse a process across configuration runs' { $configuration = New-RpcConfiguration | ConvertTo-Json -Depth 20 $first = dsc config get -i $configuration | ConvertFrom-Json @@ -81,6 +110,77 @@ Describe 'JSON-RPC resource lifecycle' { @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 2 } + It 'applies reuse restrictions during config validate from ' -ForEach @( + @{ Scope = 'document' } + @{ Scope = 'instance' } + ) { + $configuration = New-RpcConfiguration -ResourceType 'Test/JsonRpcValidate' + if ($Scope -eq 'document') { + $configuration.directives.reuseResources = $false + } else { + foreach ($resource in $configuration.resources) { + $resource.directives = @{ reuseResource = $false } + } + } + $null = dsc config validate -i ($configuration | ConvertTo-Json -Depth 20) + $LASTEXITCODE | Should -Be 0 + @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 0 + @((Get-RpcEvent) | Where-Object event -EQ fallback) | Should -HaveCount 2 + } + + It 'preserves JSON-RPC errors while probing metadata for ' -ForEach @( + @{ ResourceType = 'Test/JsonRpc'; Fault = 'initializeError' } + @{ ResourceType = 'Test/JsonRpcValidate'; Fault = 'initializeError' } + @{ ResourceType = 'Test/JsonRpcValidate'; Fault = 'error' } + ) { + $configuration = New-RpcConfiguration -ResourceType $ResourceType -Properties @{ fault = $Fault } + $configuration.resources[0].metadata = @{ example = 'metadata probe' } + if ($Fault -eq 'initializeError') { + $env:DSC_TEST_JSONRPC_FAULT = $Fault + } + $output = dsc config get -i ($configuration | ConvertTo-Json -Depth 20) 2>&1 | Out-String + $LASTEXITCODE | Should -Not -Be 0 + $output | Should -Match 'Requested (lifecycle|test) error' + $output | Should -Not -Match 'scope has already failed' + @((Get-RpcEvent) | Where-Object { $_.event -eq 'dsc_invoke' -and $_.params.operation -eq 'get' }) | + Should -HaveCount 0 + } + + It 'includes JSON-RPC application failures in JSON progress' { + $configuration = New-RpcConfiguration -Properties @{ fault = 'error'; errorCode = -32001 } + $progressPath = Join-Path $TestDrive 'failure-progress.jsonl' + $null = dsc --progress-format json config get -i ($configuration | ConvertTo-Json -Depth 20) 2> $progressPath + $LASTEXITCODE | Should -Not -Be 0 + $records = @(Get-Content $progressPath | Where-Object { $_.StartsWith('{') } | ConvertFrom-Json) + $failures = @($records | Where-Object failure) + $failures.Count | Should -BeGreaterThan 0 + $failures[0].failure.exitCode | Should -Be -32001 + $failures[0].failure.message | Should -BeExactly 'Requested test error' + } + + It 'restarts persistent hosts after a Windows environment refresh' -Skip:(!$IsWindows) { + $variableName = "DSC_JSONRPC_$([guid]::NewGuid().ToString('N'))" + $configuration = New-RpcConfiguration -Properties @{ environmentName = $variableName; value = 5 } + $configuration.resources = @( + $configuration.resources[0] + @{ name = 'refresh'; type = 'Test/RefreshEnv'; properties = @{ name = $variableName; value = 'refreshed' } } + $configuration.resources[1] + ) + try { + $result = dsc config set -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json -Depth 30 + $LASTEXITCODE | Should -Be 0 + $before = $result.results[0].result.afterState + $after = $result.results[2].result.afterState + $before.environmentValue | Should -BeNullOrEmpty + $after.environmentValue | Should -BeExactly 'refreshed' + $before.processId | Should -Not -Be $after.processId + @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 2 + @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount 2 + } finally { + [System.Environment]::SetEnvironmentVariable($variableName, $null, [System.EnvironmentVariableTarget]::User) + } + } + It 'shares one adapter host across target resources during ' -ForEach @( @{ Operation = 'get' } @{ Operation = 'set' } @@ -375,20 +475,18 @@ Describe 'JSON-RPC resource lifecycle' { } It 'does not reset request deadlines when progress notifications arrive' { - $timer = [System.Diagnostics.Stopwatch]::StartNew() $output = dsc resource get -r Test/JsonRpc -i '{"notificationDelayMS":100}' 2>&1 $LASTEXITCODE | Should -Not -Be 0 - $timer.Elapsed.TotalSeconds | Should -BeLessThan 3 $output | Out-String | Should -Match 'timed out' + @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount 0 } It 'enforces the request deadline and terminates an unresponsive resource' { - $timer = [System.Diagnostics.Stopwatch]::StartNew() $output = dsc resource get -r Test/JsonRpc -i '{"delayMS":10000}' 2>&1 $LASTEXITCODE | Should -Not -Be 0 - $timer.Elapsed.TotalSeconds | Should -BeLessThan 5 $output | Out-String | Should -Match 'timed out' @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount 0 + @((Get-RpcEvent) | Where-Object event -EQ shutdown) | Should -HaveCount 0 } It 'applies explicit requestTimeoutMS instead of the manifest default' -ForEach @( diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index aa6301cda..6513cece1 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -59,6 +59,12 @@ pub struct Context { } impl Context { + /// Apply document and instance reuse restrictions without overriding an inherited prohibition. + pub fn restrict_resource_reuse(&mut self, document: Option, instance: Option) { + self.reuse_resources &= document.unwrap_or(true); + self.reuse_resource = instance.unwrap_or(true); + } + /// Finish an invocation scope, preserving its original failure if cleanup also fails. pub fn complete_resource_operation(&self, result: Result) -> Result { let cleanup = self.resource_processes.lock().unwrap().finish(); diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 218700d12..c263ad40b 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -330,9 +330,13 @@ fn add_metadata(dsc_resource: &dyn ResourceInvoke, mut properties: Option {}, + Err(DscError::Validation(_)) => { + info!("{}", t!("configure.mod.schemaExcludesMetadata")); + props.remove("_metadata"); + }, + Err(err) => return Err(err), } return Ok(serde_json::to_string(&props)?); } @@ -465,6 +469,10 @@ fn get_metadata_from_result(mut context: Option<&mut Context>, properties: &mut std::env::set_var(&key, &value); } } + // Persistent children retain their original environment, so retire them before reuse. + if let Some(context) = context.as_ref() { + context.complete_resource_operation(Ok(()))?; + } } } metadata.other.insert(key.clone(), value.clone()); @@ -1595,6 +1603,10 @@ pub fn validate_parameter_type(name: &str, value: &Value, parameter_type: &DataT fn get_failure_from_error(err: &DscError) -> Option { match err { + DscError::JsonRpc { code, message, .. } => Some(Failure { + message: message.clone(), + exit_code: *code, + }), DscError::CommandExit(_resource, exit_code, reason) => { Some(Failure { message: reason.to_string(), diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index bf5323e2e..23d9e96b3 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -137,7 +137,7 @@ impl DscResource { } } - fn create_config_for_adapter(self, adapter: &FullyQualifiedTypeName, input: &str) -> Result { + fn create_config_for_adapter(self, adapter: &FullyQualifiedTypeName, input: &str, context: &Context) -> Result { // create new configuration with adapter and use this as the resource let mut configuration = Configuration::new(); let mut property_map = Map::new(); @@ -160,11 +160,12 @@ impl DscResource { let mut configurator = Configurator::new(&config_json, crate::progress::ProgressFormat::None)?; // don't process expressions again as they would have already been processed before being passed to the adapter configurator.context.process_mode = ProcessMode::NoExpressionEvaluation; + configurator.context.reuse_resources &= context.reuse_resources; Ok(configurator) } fn invoke_get_with_adapter(&self, adapter: &FullyQualifiedTypeName, filter: &str, context: &Context) -> Result { - let mut configurator = self.clone().create_config_for_adapter(adapter, filter)?; + let mut configurator = self.clone().create_config_for_adapter(adapter, filter, context)?; let adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&*adapter)? == AdapterInputKind::Single { return adapter.get(filter, context); @@ -187,7 +188,7 @@ impl DscResource { } fn invoke_set_with_adapter(&self, adapter: &FullyQualifiedTypeName, desired: &str, skip_test: bool, context: &Context) -> Result { - let mut configurator = self.clone().create_config_for_adapter(adapter, desired)?; + let mut configurator = self.clone().create_config_for_adapter(adapter, desired, context)?; let adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&*adapter)? == AdapterInputKind::Single { return adapter.set(desired, skip_test, context); @@ -219,7 +220,7 @@ impl DscResource { } fn invoke_test_with_adapter(&self, adapter: &FullyQualifiedTypeName, expected: &str, context: &Context) -> Result { - let mut configurator = self.clone().create_config_for_adapter(adapter, expected)?; + let mut configurator = self.clone().create_config_for_adapter(adapter, expected, context)?; let adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&*adapter)? == AdapterInputKind::Single { return adapter.test(expected, context); @@ -252,7 +253,7 @@ impl DscResource { } fn invoke_delete_with_adapter(&self, adapter: &FullyQualifiedTypeName, filter: &str, context: &Context) -> Result { - let mut configurator = self.clone().create_config_for_adapter(adapter, filter)?; + let mut configurator = self.clone().create_config_for_adapter(adapter, filter, context)?; let adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&*adapter)? == AdapterInputKind::Single { if adapter.has_resource_capability(&ResourceCapability::Delete) { @@ -266,7 +267,7 @@ impl DscResource { } fn invoke_export_with_adapter(&self, adapter: &FullyQualifiedTypeName, input: &str, context: &Context) -> Result { - let mut configurator = self.clone().create_config_for_adapter(adapter, input)?; + let mut configurator = self.clone().create_config_for_adapter(adapter, input, context)?; let adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&*adapter)? == AdapterInputKind::Single { return adapter.export(input, context); @@ -295,7 +296,7 @@ impl DscResource { } fn invoke_schema_with_adapter(&self, adapter: &FullyQualifiedTypeName, context: &Context) -> Result { - let mut configurator = self.clone().create_config_for_adapter(adapter, "")?; + let mut configurator = self.clone().create_config_for_adapter(adapter, "", context)?; let adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&*adapter)? == AdapterInputKind::Single { return adapter.schema(context); @@ -853,8 +854,11 @@ pub fn validate_properties(resource: &dyn ResourceInvoke, properties: &Value, co } // use schema validation trace!("{}: {type_name}", t!("dscresources.dscresource.resourceDoesNotImplementValidate")); - let Ok(schema) = resource.schema(context) else { - return Err(DscError::Validation(format!("{}: {type_name}", t!("dscresources.dscresource.noSchemaOrValidate")))); + let schema = match resource.schema(context) { + Ok(schema) => schema, + Err(DscError::SchemaNotAvailable(_)) => + return Err(DscError::Validation(format!("{}: {type_name}", t!("dscresources.dscresource.noSchemaOrValidate")))), + Err(err) => return Err(err), }; let schema = serde_json::from_str(&schema)?; return validate_json(&resource.type_name(), &schema, properties) @@ -895,6 +899,28 @@ pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), D Ok(()) } +#[cfg(test)] +mod jsonrpc_tests { + use super::*; + + #[test] + fn adapter_wrapper_inherits_only_document_reuse_restriction() { + for document_reuse in [false, true] { + let mut context = Context::new(); + context.reuse_resources = document_reuse; + context.reuse_resource = false; + let mut resource = DscResource::new(); + resource.type_name = "Adapted/JsonRpcOne".parse().unwrap(); + let wrapper = resource.create_config_for_adapter( + &"Test/JsonRpcAdapter".parse().unwrap(), "{}", &context, + ).unwrap(); + assert_eq!(wrapper.context.reuse_resources, document_reuse); + assert!(wrapper.context.reuse_resource); + assert!(!std::sync::Arc::ptr_eq(&wrapper.context.resource_processes, &context.resource_processes)); + } + } +} + /// Compares two arrays independent of order fn is_same_array(expected: &Vec, actual: &Vec) -> bool { if expected.len() != actual.len() { diff --git a/schemas/v3/bundled/resource/manifest.json b/schemas/v3/bundled/resource/manifest.json index ff4a17dab..d0690e8ae 100644 --- a/schemas/v3/bundled/resource/manifest.json +++ b/schemas/v3/bundled/resource/manifest.json @@ -1,1449 +1,1777 @@ { + "$id": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.json", - "title": "DSC resource manifest", - "description": "Defines the information DSC and integrating require to process and call a DSC command resource.", + "title": "ResourceManifest", "type": "object", - "required": [ - "$schema", - "type", - "version" - ], "properties": { + "jsonrpc": { + "description": "Persistent JSON-RPC entry point. Operation commands are optional fallbacks.", + "type": [ + "object", + "null" + ], + "properties": { + "executable": { + "type": "string", + "minLength": 1 + }, + "args": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "initializeTimeoutMS": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1 + }, + "requestTimeoutMS": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "shutdownTimeoutMS": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1 + } + }, + "additionalProperties": false, + "required": [ + "executable" + ] + }, "$schema": { - "title": "Manifest Schema", - "description": "This property must be the canonical URL of the Command-based DSC resource Manifest schema that the manifest is implemented for.", + "title": "Resource manifest schema URI", + "description": "The version of the resource manifest schema.", "type": "string", "format": "uri", "enum": [ - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/bundled/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/bundled/resource/manifest.vscode.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/bundled/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/bundled/resource/manifest.vscode.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/bundled/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/bundled/resource/manifest.vscode.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/bundled/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/bundled/resource/manifest.vscode.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/bundled/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/bundled/resource/manifest.vscode.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/bundled/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/bundled/resource/manifest.vscode.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/bundled/resource/manifest.json", - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/bundled/resource/manifest.vscode.json", - "https://aka.ms/dsc/schemas/v3/resource/manifest.json", + "https://aka.ms/dsc/schemas/vNext/bundled/resource/manifest.json", "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.vscode.json", - "https://aka.ms/dsc/schemas/v3.1/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.3/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.3.0/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.3/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.2/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.1/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.0/bundled/resource/manifest.json", "https://aka.ms/dsc/schemas/v3.1/bundled/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3.1/bundled/resource/manifest.vscode.json", - "https://aka.ms/dsc/schemas/v3.1.0/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1.3/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1.2/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1.1/bundled/resource/manifest.json", "https://aka.ms/dsc/schemas/v3.1.0/bundled/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3.1.0/bundled/resource/manifest.vscode.json", - "https://aka.ms/dsc/schemas/v3.0/resource/manifest.json", "https://aka.ms/dsc/schemas/v3.0/bundled/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3.0/bundled/resource/manifest.vscode.json", - "https://aka.ms/dsc/schemas/v3.0.0/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3.0.0/bundled/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3.0.0/bundled/resource/manifest.vscode.json", - "https://aka.ms/dsc/schemas/v3.0.1/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.0.2/bundled/resource/manifest.json", "https://aka.ms/dsc/schemas/v3.0.1/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.0.0/bundled/resource/manifest.json", + "https://aka.ms/dsc/schemas/vNext/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.3/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.3.0/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.2/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.2.3/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.2.2/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.2.1/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.2.0/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.1/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.1.3/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.1.2/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.1.1/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.1.0/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.0/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.0.2/bundled/resource/manifest.vscode.json", "https://aka.ms/dsc/schemas/v3.0.1/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/v3.0.0/bundled/resource/manifest.vscode.json", + "https://aka.ms/dsc/schemas/vNext/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.3/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.3.0/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.3/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.2/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.1/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.2.0/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1.3/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1.2/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1.1/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.1.0/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.0/resource/manifest.json", "https://aka.ms/dsc/schemas/v3.0.2/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3.0.2/bundled/resource/manifest.json", - "https://aka.ms/dsc/schemas/v3.0.2/bundled/resource/manifest.vscode.json" + "https://aka.ms/dsc/schemas/v3.0.1/resource/manifest.json", + "https://aka.ms/dsc/schemas/v3.0.0/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/vNext/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.3/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.3.0/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.3/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.2/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.1/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.0/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.3/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.2/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.1/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/bundled/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/vNext/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.3/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.3.0/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.3/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.2/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.1/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.0/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.3/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.2/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.1/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/bundled/resource/manifest.vscode.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/vNext/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.3/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.3.0/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.3/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.2/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.1/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.2.0/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.3/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.2/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.1/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/resource/manifest.json", + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/resource/manifest.json" ] }, "type": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/resourceType.json" + "description": "The namespaced name of the resource.", + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceType.json" }, - "version": { - "title": "Resource Semantic Version", - "description": "The semantic version (semver) of the DSC resource. This version identifies the DSC resource, not the version of the application it manages.", - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/semver.json" + "condition": { + "description": "An optional condition for the resource to be active. If the condition evaluates to false, the resource is skipped.", + "type": [ + "string", + "null" + ] }, - "description": { - "title": "Resource Description", - "description": "A short synopsis of the DSC resource's purpose.", - "type": "string" + "deprecationMessage": { + "description": "An optional message indicating the resource is deprecated. If provided, the message will be shown when the resource is used.", + "type": [ + "string", + "null" + ] }, "kind": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/resourceKind.json" + "description": "The kind of resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceKind.json" + }, + { + "type": "null" + } + ] + }, + "version": { + "description": "The version of the resource using semantic versioning.", + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceVersion.json" + }, + "author": { + "description": "The author of the resource.", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "The description of the resource.", + "type": [ + "string", + "null" + ] }, "tags": { - "title": "Tags", - "description": "Defines a list of searchable terms for the resource.", - "type": "array", - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^\\w+$" - } + "description": "Tags for the resource.", + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/tags.json" }, "get": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.get.json" + "description": "Details how to call the Get method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.get.json" + }, + { + "type": "null" + } + ] }, "set": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.set.json" + "description": "Details how to call the Set method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.set.json" + }, + { + "type": "null" + } + ] }, "whatIf": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.whatIf.json" + "description": "Details how to call the `WhatIf` method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.set.json" + }, + { + "type": "null" + } + ] }, "test": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.test.json" + "description": "Details how to call the Test method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.test.json" + }, + { + "type": "null" + } + ] }, "delete": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.delete.json" + "description": "Details how to call the Delete method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.delete.json" + }, + { + "type": "null" + } + ] }, "export": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.export.json" - }, - "validate": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.validate.json" + "description": "Details how to call the Export method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.export.json" + }, + { + "type": "null" + } + ] }, "resolve": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.resolve.json" + "description": "Details how to call the Resolve method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.resolve.json" + }, + { + "type": "null" + } + ] + }, + "validate": { + "description": "Details how to call the Validate method of the resource.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.validate.json" + }, + { + "type": "null" + } + ] }, "adapter": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.adapter.json" + "description": "Indicates the resource is a adapter of other resources.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.adapter.json" + }, + { + "type": "null" + } + ] }, "exitCodes": { - "title": "Exit Codes", - "description": "This property defines a map of valid exit codes for the DSC resource. DSC always interprets exit code `0` as a successful operation and any other exit code as an error. Use this property to indicate human-readable semantic meanings for the DSC resource's exit codes.", - "type": "object", - "propertyNames": { - "pattern": "^-?[0-9]+$" - }, - "patternProperties": { - "^-?[0-9]+$": { - "type": "string" - } - }, - "unevaluatedProperties": false, - "default": { - "0": "Success", - "1": "Error" - }, - "examples": [ + "description": "Mapping of exit codes to descriptions. Zero is always success and non-zero is always failure.", + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/exitCodes.json" + }, + "schema": { + "description": "Details how to get the schema of the resource.", + "anyOf": [ { - "0": "Success", - "1": "Invalid parameter", - "2": "Invalid input", - "3": "Registry error", - "4": "JSON serialization failed" + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.schema.json" + }, + { + "type": "null" } ] }, - "schema": { - "$ref": "/PowerShell/DSC/main/schemas/v3/resource/manifest.schema.json" + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": true } }, + "additionalProperties": false, + "required": [ + "$schema", + "type", + "version" + ], "allOf": [ { "if": { - "properties": { - "kind": { - "const": "adapter" - } - }, - "required": [ - "kind" - ] + "not": { + "properties": { + "jsonrpc": { + "type": "object" + } + }, + "required": [ + "jsonrpc" + ] + } }, "then": { - "required": [ - "adapter", - "get", - "test", - "set" - ] + "properties": { + "get": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "set": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "whatIf": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "test": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "delete": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "export": { + "properties": { + "schema": { + "properties": { + "command": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + } + } + } + }, + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "resolve": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "validate": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + }, + "schema": { + "properties": { + "command": { + "anyOf": [ + { + "type": "null" + }, + { + "required": [ + "executable" + ] + } + ] + } + } + } + } } }, { - "if": { - "properties": { - "kind": { - "const": "importer" - } - }, - "required": [ - "kind" - ] - }, - "then": { - "required": [ - "resolve" - ], - "not": { - "anyOf": [ - { + "properties": { + "get": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { "required": [ - "adapter" + "executable" ] - }, - { + } + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "set": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { "required": [ - "delete" + "executable" ] - }, - { + } + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "whatIf": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { "required": [ - "get" + "executable" ] - }, - { + } + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "test": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { "required": [ - "export" + "executable" ] - }, - { + } + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "delete": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { "required": [ - "set" + "executable" ] - }, - { + } + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "export": { + "properties": { + "schema": { + "properties": { + "command": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { + "required": [ + "executable" + ] + } + }, + "then": { + "properties": { + "args": { + "type": "null" + } + } + } + } + } + } + }, + "if": { + "not": { "required": [ - "test" + "executable" ] - }, - { + } + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "resolve": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { "required": [ - "validate" + "executable" ] - }, - { + } + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "validate": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { "required": [ - "whatIf" + "executable" ] } - ] + }, + "then": { + "properties": { + "args": { + "type": "null" + }, + "input": { + "type": "null" + } + } + } + }, + "schema": { + "properties": { + "command": { + "properties": { + "executable": { + "minLength": 1 + } + }, + "if": { + "not": { + "required": [ + "executable" + ] + } + }, + "then": { + "properties": { + "args": { + "type": "null" + } + } + } + } + } } } }, { "if": { "properties": { - "kind": { - "const": "exporter" + "jsonrpc": { + "type": "object" } }, "required": [ - "kind" + "jsonrpc" ] }, "then": { - "required": [ - "export" - ], - "not": { - "anyOf": [ - { - "required": [ - "adapter" - ] - }, - { - "required": [ - "delete" - ] - }, - { - "required": [ - "get" - ] - }, - { - "required": [ - "resolve" - ] - }, - { - "required": [ - "set" - ] - }, - { - "required": [ - "test" - ] + "properties": { + "adapter": { + "properties": { + "inputKind": { + "const": "single" + } + } + } + } + } + }, + { + "if": { + "properties": { + "set": { + "type": "object", + "properties": { + "supportsWhatIf": { + "const": true + } }, - { + "required": [ + "supportsWhatIf" + ] + } + }, + "required": [ + "set" + ] + }, + "then": { + "properties": { + "jsonrpc": { + "type": "object" + }, + "set": { + "if": { "required": [ - "validate" + "executable" ] }, - { + "then": { + "properties": { + "args": { + "type": "array", + "contains": { + "type": "object", + "required": [ + "whatIfArg" + ] + } + } + }, "required": [ - "whatIf" + "args" ] - } - ] - } + } + } + }, + "required": [ + "jsonrpc" + ] } }, { "if": { "properties": { - "kind": { - "enum": [ - "resource", - "adapter", - "group" + "delete": { + "type": "object", + "properties": { + "supportsWhatIf": { + "const": true + } + }, + "required": [ + "supportsWhatIf" ] } }, "required": [ - "kind" + "delete" ] }, "then": { + "properties": { + "jsonrpc": { + "type": "object" + }, + "delete": { + "if": { + "required": [ + "executable" + ] + }, + "then": { + "properties": { + "args": { + "type": "array", + "contains": { + "type": "object", + "required": [ + "whatIfArg" + ] + } + } + }, + "required": [ + "args" + ] + } + } + }, "required": [ - "get" + "jsonrpc" ] } } ], "$defs": { "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceType.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceType.json", - "title": "DSC resource fully qualified type name", - "description": "The namespaced name of the DSC resource, using the syntax:\n\n[.][.][.]/\n\nFor example:\n\n- Microsoft/OSInfo\n- Microsoft.SqlServer/Database\n- Microsoft.SqlServer.Database/User\n", - "type": "string", - "pattern": "^\\w+(\\.\\w+){0,3}\\/\\w+$" - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/semver.json": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/semver.json", + "title": "Fully qualified type name", + "description": "Uniquely identifies a DSC resource or extension.", "type": "string", - "title": "Semantic Version", - "description": "A valid semantic version (semver) string.\n\nFor reference, see https://semver.org/\n", - "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-]+)*))?$", - "$comment": "A valid semantic version ([semver][01]) string.\n\nThis value uses the [suggested regular expression][02] to validate whether the string is valid\nsemver. This is the same pattern, made multi-line for easier readability:\n\n```regex\n^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\n(?:-(\n (?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)\n (?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))\n*))?\n(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$\n```\n\nThe first line matches the `major.minor.patch` components of the version. The middle lines match\nthe pre-release components. The last line matches the build metadata component.\n\n[01]: https://semver.org/\n[02]: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string\n" + "pattern": "^\\w+(\\.\\w+)*\\/\\w+$" }, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceKind.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceKind.json", - "title": "Resource kind", - "description": "Defines whether the resource is a normal DSC resource, a group resource, or an adapter resource. This property is only required for group resources.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", "enum": [ - "resource", "adapter", + "exporter", "group", "importer", - "exporter" + "resource" ] }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.get.json": { + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceVersion.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/resourceVersion.json", "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Resource version", + "description": "Defines the version of a DSC resource as a semantic version or arbitrary string.", + "anyOf": [ + { + "title": "Semantic resource version", + "description": "Defines the resource's version as a valid semantic version.", + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/semver.json" + }, + { + "title": "Date resource version", + "description": "Defines the resource's version as a date version.", + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/dateVersion.json", + "deprecated": true + } + ] + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/semver.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/semver.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Semantic version", + "description": "Defines a valid semantic version (semver) as a string.\n\nFor reference, see https://semver.org/", + "type": "string", + "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-]+)*))?$" + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/dateVersion.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/dateVersion.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Date version", + "description": "Defines the version for a resource as a date instead of a semantic version.", + "type": "string", + "pattern": "^(?[1-9][0-9]{3})-(?0[1-9]|1[0-2])-(?0[1-9]|[1-2][0-9]|3[0-1])(?:-(?[a-zA-Z]+))?$" + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/tags.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/tags.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Tag list", + "description": "Defines a set of short search terms for a DSC resource or extension.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/tag.json" + } + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/tag.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/tag.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Tag", + "description": "Defines a short search term for a DSC resource or extension.", + "type": "string", + "pattern": "^\\w+$" + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.get.json": { "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.get.json", - "title": "Get Method", - "description": "Defines how DSC must call the DSC resource to get the current state of an instance.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": [ - "executable" - ], "properties": { "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" + "description": "The command to run to get the state of the resource.", + "type": "string" }, "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" + "description": "The arguments to pass to the command to perform a Get.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.get.json" + } }, "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + "description": "How to pass optional input for a Get.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + }, + { + "type": "null" + } + ] + }, + "requireSecurityContext": { + "description": "The security context required to run the Get method. Default if not specified is `current`.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/executionInformation/securityContext.json" + }, + { + "type": "null" + } + ] } - }, - "oneOf": [ + } + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.get.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.get.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "description": "The argument is a string.", + "type": "string" + }, { + "type": "object", + "properties": { + "adaptedContentArg": { + "description": "The argument that accepts the JSON content from the manifest.", + "type": "string" + } + }, "required": [ - "input" - ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" - } - } + "adaptedContentArg" + ] + }, + { + "type": "object", + "properties": { + "arg": { + "description": "The argument that accepts the result of the expression.", + "type": [ + "string", + "null" + ] + }, + "expression": { + "description": "The expression to be evaluated. `null` result becomes an empty string.", + "type": "string" + }, + "mandatory": { + "description": "Indicates if the argument is mandatory. Default is false.", + "type": [ + "boolean", + "null" + ] } - } + }, + "required": [ + "expression" + ] }, { - "not": { - "required": [ - "input" - ] + "type": "object", + "properties": { + "jsonInputArg": { + "description": "The argument that accepts the JSON input object.", + "type": "string" + }, + "mandatory": { + "description": "Indicates if argument is mandatory which will pass an empty string if no JSON input is provided. Default is false.", + "type": [ + "boolean", + "null" + ] + } }, + "required": [ + "jsonInputArg" + ] + }, + { + "type": "object", "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "resourcePathArg": { + "description": "The argument that accepts the resource path.", + "type": "string" + }, + "includeQuotes": { + "description": "Indicates if the argument should be wrapped in quotes. Default is false.", + "type": "boolean", + "default": false } - } + }, + "required": [ + "resourcePathArg" + ] }, { + "type": "object", + "properties": { + "resourceTypeArg": { + "description": "The argument that accepts the resource type name.", + "type": "string" + } + }, "required": [ - "input" - ], + "resourceTypeArg" + ] + }, + { + "type": "object", "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "resourceVersionArg": { + "description": "The argument that accepts the resource version.", + "type": "string" } - } + }, + "required": [ + "resourceVersionArg" + ] } ] }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.set.json": { + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "enum": [ + "env", + "stdin" + ] + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/executionInformation/securityContext.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/executionInformation/securityContext.json", "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "enum": [ + "current", + "elevated", + "restricted" + ] + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.set.json": { "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.set.json", - "title": "Set Method", - "description": "Defines how DSC must call the DSC resource to set the desired state of an instance and how to process the output from the DSC resource.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": [ - "executable" - ], "properties": { + "supportsWhatIf": { + "description": "Whether JSON-RPC set supports native what-if.", + "type": [ + "boolean", + "null" + ] + }, "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" + "description": "The command to run to set the state of the resource.", + "type": "string" }, "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" + "description": "The arguments to pass to the command to perform a Set.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.setDelete.json" + } }, "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + "description": "How to pass required input for a Set.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + }, + { + "type": "null" + } + ] }, "implementsPretest": { - "title": "Resource performs pre-test", - "description": "Defines whether the DSC resource performs its own test to ensure idempotency when calling the `set` command. Set this value to `true` if the DSC resource tests input before modifying system state.", - "type": "boolean", - "default": false + "description": "Whether to run the Test method before the Set method. True means the resource will perform its own test before running the Set method.", + "type": [ + "boolean", + "null" + ] }, "handlesExist": { - "title": "Resource handles `_exist` property", - "description": "Defines whether the DSC resource has its own built-in handling for the `_exist` common property. Set this value to `true` if the DSC resource handles instance deletion internally when receiving a `set` command where the instance defines the `_exist` property as `false`.", - "type": "boolean", - "default": false + "description": "Indicates that the resource directly handles `_exist` as a property.", + "type": [ + "boolean", + "null" + ] }, "return": { - "description": "Defines whether the command returns a JSON blob of the DSC resource's state after the set operation or the state and an array of the properties the DSC resource modified.", - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json" - } - }, - "oneOf": [ - { - "required": [ - "input" - ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" - } - } + "description": "The type of return value expected from the Set method.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json" + }, + { + "type": "null" } - } + ] }, - { - "not": { - "required": [ - "input" - ] - }, - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "requireSecurityContext": { + "description": "The security context required to run the Set method. Default if not specified is `current`.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/executionInformation/securityContext.json" + }, + { + "type": "null" } - } + ] }, - { - "required": [ - "input" - ], - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "whatIfReturns": { + "description": "The type of return value expected from the Set method when running in what-if mode. When specified, this overrides the `return` property during what-if execution.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json" + }, + { + "type": "null" } - } + ] } - ] + } }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.whatIf.json": { + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.setDelete.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.setDelete.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.whatIf.json", - "title": "What-if method", - "description": "Defines how DSC must call the DSC resource to indicate whether and how the set command will modify an instance and how to process the output from the DSC resource.", - "type": "object", - "required": [ - "executable", - "return" - ], - "properties": { - "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" - }, - "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" - }, - "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" - }, - "implementsPretest": { - "title": "Resource performs pre-test", - "description": "Defines whether the DSC resource performs its own test to ensure idempotency when calling the `set --what-if` command. Set this value to `true` if the DSC resource tests input before processing how it will modify system state.", - "type": "boolean", - "default": false + "anyOf": [ + { + "description": "The argument is a string.", + "type": "string" }, - "handlesExist": { - "title": "Resource handles `_exist` property", - "description": "Defines whether the DSC resource has its own built-in handling for the `_exist` common property. Set this value to `true` if the DSC resource handles instance deletion internally when receiving a `set --what-if` command where the instance defines the `_exist` property as `false`.", - "type": "boolean", - "default": false + { + "type": "object", + "properties": { + "adaptedContentArg": { + "description": "The argument that accepts the JSON content from the manifest.", + "type": "string" + } + }, + "required": [ + "adaptedContentArg" + ] }, - "return": { - "description": "Defines whether the command returns a JSON blob of the DSC resource's expected state after a set operation in what-if mode or the state and an array of the properties the DSC resource would modify.", - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json" - } - }, - "oneOf": [ { + "type": "object", + "properties": { + "arg": { + "description": "The argument that accepts the result of the expression.", + "type": [ + "string", + "null" + ] + }, + "expression": { + "description": "The expression to be evaluated. `null` result becomes an empty string.", + "type": "string" + }, + "mandatory": { + "description": "Indicates if the argument is mandatory. Default is false.", + "type": [ + "boolean", + "null" + ] + } + }, "required": [ - "input" - ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" - } - } + "expression" + ] + }, + { + "type": "object", + "properties": { + "jsonInputArg": { + "description": "The argument that accepts the JSON input object.", + "type": "string" + }, + "mandatory": { + "description": "Indicates if argument is mandatory which will pass an empty string if no JSON input is provided. Default is false.", + "type": [ + "boolean", + "null" + ] } - } + }, + "required": [ + "jsonInputArg" + ] }, { - "not": { - "required": [ - "input" - ] + "type": "object", + "properties": { + "resourcePathArg": { + "description": "The argument that accepts the resource path.", + "type": "string" + }, + "includeQuotes": { + "description": "Indicates if the resource path should be passed with quotes. Default is false.", + "type": "boolean", + "default": false + } }, + "required": [ + "resourcePathArg" + ] + }, + { + "type": "object", "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "resourceTypeArg": { + "description": "The argument that accepts the resource type name.", + "type": "string" } - } + }, + "required": [ + "resourceTypeArg" + ] }, { + "type": "object", + "properties": { + "resourceVersionArg": { + "description": "The argument that accepts the resource version.", + "type": "string" + } + }, "required": [ - "input" - ], + "resourceVersionArg" + ] + }, + { + "description": "The argument is passed when the resource is invoked in what-if mode.", + "type": "object", "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "whatIfArg": { + "description": "The argument to pass when in what-if mode.", + "type": "string" } - } + }, + "required": [ + "whatIfArg" + ] } ] }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.test.json": { + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json", "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "enum": [ + "state", + "stateAndDiff" + ] + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.test.json": { "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.test.json", - "title": "Test Method", - "description": "Defines how DSC must call the DSC resource to test if an instance is in the desired state and how to process the output from the DSC resource.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": [ - "executable" - ], "properties": { "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" + "description": "The command to run to test the state of the resource.", + "type": "string" }, "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" + "description": "The arguments to pass to the command to perform a Test.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.get.json" + } }, "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" - }, - "return": { - "title": "Test Command Return Type", - "description": "Defines whether the command returns a JSON blob of the DSC resource's current state or the state and an array of the properties that are out of the desired state.", - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json", - "default": "state" - } - }, - "oneOf": [ - { - "required": [ - "input" - ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" - } - } + "description": "How to pass required input for a Test.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + }, + { + "type": "null" } - } + ] }, - { - "not": { - "required": [ - "input" - ] - }, - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "return": { + "description": "The type of return value expected from the Test method.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json" + }, + { + "type": "null" } - } + ] }, - { - "required": [ - "input" - ], - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "requireSecurityContext": { + "description": "The security context required to run the Test method. Default if not specified is `current`.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/executionInformation/securityContext.json" + }, + { + "type": "null" } - } + ] } - ] + } }, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.delete.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.delete.json", - "title": "Delete method", - "description": "Defines how DSC must call the DSC resource to delete an instance. Define this method for resources as an alternative to handling the `_exist` property in a `set` operation, which can lead to highly complex code. If the `set` operation for the resource is able to handle deleting an instance when `_exist` is `false`, set the `handlesExist` property of the set method definition to `true` instead.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": [ - "executable" - ], "properties": { + "supportsWhatIf": { + "description": "Whether JSON-RPC delete supports native what-if.", + "type": [ + "boolean", + "null" + ] + }, "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" + "description": "The command to run to delete the state of the resource.", + "type": "string" }, "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" - }, - "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" - } - }, - "oneOf": [ - { - "required": [ - "input" + "description": "The arguments to pass to the command to perform a Delete.", + "type": [ + "array", + "null" ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" - } - } - } + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.setDelete.json" } }, - { - "not": { - "required": [ - "input" - ] - }, - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "input": { + "description": "How to pass required input for a Delete.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + }, + { + "type": "null" } - } + ] }, - { - "required": [ - "input" - ], - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "requireSecurityContext": { + "description": "The security context required to run the Delete method. Default if not specified is `current`.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/executionInformation/securityContext.json" + }, + { + "type": "null" } - } + ] } - ] + } }, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.export.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.export.json", - "title": "Export Method", - "description": "Defines how DSC must call the DSC resource to get the current state of every instance.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": [ - "executable" - ], "properties": { "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" + "description": "The command to run to enumerate instances of the resource.", + "type": "string" }, "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" + "description": "The arguments to pass to the command to perform a Export.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.get.json" + } }, "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + "description": "How to pass input for a Export.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + }, + { + "type": "null" + } + ] + }, + "requireSecurityContext": { + "description": "The security context required to run the Export method. Default if not specified is `current`.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/executionInformation/securityContext.json" + }, + { + "type": "null" + } + ] } }, - "oneOf": [ + "anyOf": [ { - "required": [ - "input" - ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" + "oneOf": [ + { + "type": "object", + "properties": { + "schema": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/manifest.exportSchema.json" } - } + }, + "required": [ + "schema" + ] + }, + { + "type": "object", + "properties": { + "supportsFiltering": { + "type": "boolean" + } + }, + "required": [ + "supportsFiltering" + ] } - } + ] }, + {} + ] + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/manifest.exportSchema.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/manifest.exportSchema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ { - "not": { - "required": [ - "input" - ] - }, + "description": "The export schema is returned by running a command.", + "type": "object", "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "command": { + "type": "object", + "properties": { + "executable": { + "description": "The command to run to get the schema.", + "type": "string" + }, + "args": { + "description": "The arguments to pass to the command.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.schema.json" + } + } + } } - } + }, + "required": [ + "command" + ], + "additionalProperties": false }, { + "description": "The export schema is embedded in the manifest.", + "type": "object", + "properties": { + "embedded": true + }, "required": [ - "input" + "embedded" ], - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 - } - } + "additionalProperties": false } ] }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.validate.json": { + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.schema.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.validate.json", - "title": "Validate Method", - "description": "Defines how DSC must call the DSC resource to validate the state of an instance. This method is mandatory for DSC group resources. It's ignored for all other DSC resources.", - "type": "object", - "required": [ - "executable" - ], - "properties": { - "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" - }, - "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" - }, - "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" - } - }, - "oneOf": [ + "anyOf": [ { - "required": [ - "input" - ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" - } - } - } - } + "description": "The argument is a string.", + "type": "string" }, { - "not": { - "required": [ - "input" - ] - }, + "type": "object", "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "resourceTypeArg": { + "description": "The argument that accepts the resource type name.", + "type": "string" } - } + }, + "required": [ + "resourceTypeArg" + ] }, { - "required": [ - "input" - ], + "type": "object", "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "resourceVersionArg": { + "description": "The argument that accepts the resource version.", + "type": "string" } - } + }, + "required": [ + "resourceVersionArg" + ] } ] }, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.resolve.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.resolve.json", - "title": "Resolve method", - "description": "Defines how DSC must call the DSC resource to resolve a nested configuration document from an external source. Define this method for importer resources where the resource kind is set to `importer`.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": [ - "executable" - ], "properties": { "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" + "description": "The command to run to enumerate instances of the resource.", + "type": "string" }, "args": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json" - }, - "input": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" - } - }, - "oneOf": [ - { - "required": [ - "input" + "description": "The arguments to pass to the command to perform a Export.", + "type": [ + "array", + "null" ], - "not": { - "properties": { - "args": { - "contains": { - "type": "object" - } - } - } + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.get.json" } }, - { - "not": { - "required": [ - "input" - ] - }, - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 + "input": { + "description": "How to pass input for a Export.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + }, + { + "type": "null" } - } + ] + } + } + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.validate.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.validate.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "executable": { + "description": "The command to run to validate the state of the resource.", + "type": "string" }, - { - "required": [ - "input" + "args": { + "description": "The arguments to pass to the command to perform a Validate.", + "type": [ + "array", + "null" ], - "properties": { - "args": { - "contains": { - "type": "object" - }, - "minContains": 1, - "maxContains": 1 - } + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.get.json" } + }, + "input": { + "description": "How to pass required input for a Validate.", + "anyOf": [ + { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json" + }, + { + "type": "null" + } + ] } - ] + } }, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.adapter.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.adapter.json", - "title": "Adapter", - "description": "Defines the DSC resource as a DSC resource adapter. A DSC resource adapter enables users to manage resources that don't have their own manifests with DSC.", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": [ - "list", - "config" - ], "properties": { "list": { - "title": "List Command", - "description": "Defines how DSC must call the DSC resource adapter to list its supported DSC resources.", - "type": "object", - "required": [ - "executable" + "description": "The way to list adapter supported resources.", + "type": [ + "object", + "null" ], "properties": { "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" + "description": "The command to run to list resources supported by a group resource.", + "type": "string" }, "args": { - "type": "array", + "description": "The arguments to pass to the command to perform a List.", + "type": [ + "array", + "null" + ], "items": { "type": "string" - }, - "title": "Command arguments", - "description": "Defines the list of arguments to pass to the command to return the list of supported DSC resources." + } } - } + }, + "required": [ + "executable" + ] }, - "config": { - "title": "Expected Configuration", - "description": "Defines whether the adapter expects to receive a full and unprocessed configuration as a single JSON blob over stdin or a sequence of JSON Lines for each child resource's configurations.", + "inputKind": { + "description": "Defines how the adapter supports accepting configuration.", "type": "string", "enum": [ "full", - "sequence" + "sequence", + "single" ] } }, + "required": [ + "inputKind" + ] + }, + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/exitCodes.json": { + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/exitCodes.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Exit codes", + "description": "Defines a map of valid exit codes for operation commands.", + "type": "object", "examples": [ { - "config": "full", - "list": { - "executable": "pwsh", - "args": [ - "-NoLogo", - "-NonInteractive", - "-NoProfile", - "-Command", - "./powershellgroup.resource.ps1 List" - ] - } + "0": "Success", + "1": "Invalid parameter", + "2": "Invalid input", + "3": "Registry error", + "4": "JSON serialization failed" } - ] + ], + "minProperties": 1, + "propertyNames": { + "pattern": "^-?[0-9]+$" + }, + "patternProperties": { + "^-?[0-9]+$": { + "type": "string" + } + }, + "unevaluatedProperties": false, + "default": { + "1": "Error", + "0": "Success" + } }, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.schema.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/manifest.schema.json", - "title": "Instance Schema", - "description": "Defines how DSC must validate a JSON blob representing an instance of the DSC resource.", - "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { - "required": [ - "command" - ] - }, - { - "required": [ - "embedded" - ] - } - ], - "properties": { - "command": { - "title": "Instance Schema Command", - "description": "Defines how DSC must call the DSC resource to get the JSON Schema for validating a JSON blob representing an instance of the DSC resource.", - "type": "object", - "required": [ - "executable" - ], - "properties": { - "executable": { - "$ref": "/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json" - }, - "args": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Command arguments", - "description": "Defines the list of arguments to pass to the command to return the JSON Schema for the resource." - } - } - }, - "embedded": { - "title": "Embedded Instance Schema", - "description": "Defines the JSON Schema DSC must use to validate a JSON blob representing an instance of the DSC resource.", + "description": "The schema is returned by running a command.", "type": "object", - "required": [ - "$schema", - "type", - "properties" - ], "properties": { - "type": { - "title": "Instance Type", - "description": "Defines the JSON type for an instance of the DSC resource. DSC resource instances always have the `object` type.", - "const": "object" - }, - "$schema": { - "title": "DSC resource instance schema dialect", - "description": "Defines which dialect of JSON Schema the DSC resource is using to validate instances.", - "type": "string", - "format": "uri-reference", - "enum": [ - "https://json-schema.org/draft/2020-12/schema", - "https://json-schema.org/draft/2019-09/schema", - "http://json-schema.org/draft-07/schema#" - ] - }, - "$id": { - "title": "DSC resource instance schema ID", - "description": "Defines the unique ID for the DSC resource's instance schema. If the instance schema is published to its own public URI, set this keyword to that URI.", - "type": "string", - "format": "uri-reference" - }, - "properties": { - "title": "Instance Properties", - "description": "Defines the properties that DSC can retrieve and manage for the resource's instances. This keyword must define at least one property as a key-value pair. The key is the property's name. The value is a subschema that validates the property.", + "command": { "type": "object", - "minProperties": 1, - "unevaluatedProperties": { - "anyOf": [ - { - "$ref": "https://json-schema.org/draft/2020-12/schema" - }, - { - "$ref": "https://json-schema.org/draft/2019-09/schema" - }, - { - "$ref": "http://json-schema.org/draft-07/schema#" - } - ] - }, - "additionalProperties": {}, "properties": { - "_exist": { - "title": "Standard Property: _exist", - "description": "Indicates that the DSC resource uses the standard `_exist` property to specify whether an instance should exist as a boolean value that defaults to `true`.", - "enum": [ - { - "$ref": "https://aka.ms/dsc/schemas/v3/resource/properties/exist.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1/resource/properties/exist.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1.0/resource/properties/exist.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0/resource/properties/exist.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.0/resource/properties/exist.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.1/resource/properties/exist.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.2/resource/properties/exist.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/exist.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/resource/properties/exist.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/resource/properties/exist.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/resource/properties/exist.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/resource/properties/exist.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/resource/properties/exist.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/resource/properties/exist.json" - } - ] - }, - "_inDesiredState": { - "title": "Standard Property: _inDesiredState", - "description": "Indicates that the DSC resource returns this value for it's own `test` method. This read-only property is mandatory when the manifest defines the `test` property. It shouldn't be included if the DSC resource relies on DSC's synthetic testing.", - "enum": [ - { - "$ref": "https://aka.ms/dsc/schemas/v3/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1.0/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.0/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.1/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.2/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/resource/properties/inDesiredState.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/resource/properties/inDesiredState.json" - } - ] - }, - "_purge": { - "title": "Standard Property: _purge", - "description": "Indicates that the DSC resource uses the standard `_purge` property to specify whether the DSC resource should remove all non-specified members when it manages an array of members or values.", - "enum": [ - { - "$ref": "https://aka.ms/dsc/schemas/v3/resource/properties/purge.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1/resource/properties/purge.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1.0/resource/properties/purge.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0/resource/properties/purge.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.0/resource/properties/purge.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.1/resource/properties/purge.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.2/resource/properties/purge.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/purge.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/resource/properties/purge.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/resource/properties/purge.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/resource/properties/purge.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/resource/properties/purge.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/resource/properties/purge.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/resource/properties/purge.json" - } - ] + "executable": { + "description": "The command to run to get the schema.", + "type": "string" }, - "_rebootRequested": { - "title": "Standard property: _rebootRequested", - "description": "Indicates whether a resource instance requires a reboot after a set operation. To use DSC's built-in reboot notification processing, resources must define this property in their manifest.", - "enum": [ - { - "$ref": "https://aka.ms/dsc/schemas/v3/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.1.0/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.0/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.1/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://aka.ms/dsc/schemas/v3.0.2/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.0/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.1/resource/properties/rebootRequested.json" - }, - { - "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.0.2/resource/properties/rebootRequested.json" - } - ] + "args": { + "description": "The arguments to pass to the command.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.schema.json" + } } } } - } - } - }, - "examples": [ - { - "command": { - "executable": "registry", - "args": [ - "schema" - ] - } + }, + "required": [ + "command" + ], + "additionalProperties": false }, { - "embedded": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "OSInfo", - "type": "object", - "required": [], - "properties": { - "$id": { - "type": "string" - }, - "architecture": { - "type": [ - "string", - "null" - ] - }, - "bitness": { - "$ref": "#/definitions/Bitness" - }, - "codename": { - "type": [ - "string", - "null" - ] - }, - "edition": { - "type": [ - "string", - "null" - ] - }, - "family": { - "$ref": "#/definitions/Family" - }, - "version": { - "type": "string" - } - }, - "additionalProperties": false, - "definitions": { - "Bitness": { - "type": "string", - "enum": [ - "32", - "64", - "unknown" - ] - }, - "Family": { - "type": "string", - "enum": [ - "Linux", - "macOS", - "Windows" - ] - } - } - } - } - ] - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandExecutable.json", - "title": "Executable Command Name", - "description": "The name of the command to run.", - "type": "string" - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/commandArgs.json", - "title": "Executable Command Arguments", - "description": "The list of arguments to pass to the command. The arguments can be any number of strings. If you want to pass the JSON object representing the property bag for the resource to an argument, you can define a single item in the array as a JSON object, indicating the name of the argument with the `jsonInputArg` string property and whether the argument is mandatory for the command with the `mandatory` boolean property.", - "type": "array", - "items": { - "oneOf": [ - { - "type": "string", - "title": "String argument", - "description": "Any item in the argument array can be a string representing a static argument to pass to the command." + "description": "The schema is embedded in the manifest.", + "type": "object", + "properties": { + "embedded": true }, - { - "type": "object", - "title": "JSON input argument", - "description": "Defines an argument for the command that accepts the JSON input object as a string. DSC passes the JSON input to the named argument when available. You can define the `mandatory` property to indicate whether DSC should always pass the argument to the command, even when there's no JSON input for the command. In that case, DSC passes an empty string to the JSON input argument. You can only define one JSON input argument per arguments array.", - "required": [ - "jsonInputArg" - ], - "unevaluatedProperties": false, - "properties": { - "jsonInputArg": { - "title": "JSON input argument name", - "description": "Defines the argument that accepts the JSON property bag for the resource as input.", - "type": "string" - }, - "mandatory": { - "title": "Mandatory argument", - "description": "Defines whether the argument is mandatory. If this property is set to `true`, DSC passes an empty string when no JSON input is provided. The default value is `false`.", - "type": "boolean", - "default": false - } - } - } - ] - } - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/inputKind.json", - "title": "Executable Command Input Type", - "description": "Defines how DSC should pass input to the command, either as environment variables or JSON over stdin. When this value isn't defined, DSC doesn't send the resource any input.", - "type": "string", - "enum": [ - "env", - "stdin" - ] - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/definitions/returnKind.json", - "title": "Return Kind", - "type": "string", - "enum": [ - "state", - "stateAndDiff" - ], - "$comment": "While the enumeration for return kind is the same for the `set` and `test`\nmethod, the way it changes the behavior of the command isn't. The description\nkeyword isn't included here because the respective schemas for those methods\ndocument the behavior themselves." - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/exist.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/exist.json", - "title": "Instance should exist", - "description": "Indicates whether the DSC resource instance should exist.", - "type": "boolean", - "default": true, - "enum": [ - false, - true + "required": [ + "embedded" + ], + "additionalProperties": false + } ] - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json", - "title": "Instance is in the Desired State", - "description": "Indicates whether the instance is in the desired state. This property is only returned by the `test` method.", - "type": [ - "boolean", - "null" - ], - "readOnly": true - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/purge.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/purge.json", - "title": "Purge", - "description": "Indicates that only the components described in the DSC resource should exist. If other components exist, the DSC resource is out of the desired state. When enforcing desired state, the DSC resource removes unmanaged components.", - "type": [ - "boolean", - "null" - ], - "writeOnly": true - }, - "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/rebootRequested.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/rebootRequested.json", - "title": "Reboot Requested", - "description": "Indicates that the set operation requires a reboot before it's fully complete.", - "type": [ - "boolean", - "null" - ], - "readOnly": true } } } diff --git a/tools/dsctest/dsctest.dsc.manifests.json b/tools/dsctest/dsctest.dsc.manifests.json index 4348ce8ae..0b507dd82 100644 --- a/tools/dsctest/dsctest.dsc.manifests.json +++ b/tools/dsctest/dsctest.dsc.manifests.json @@ -384,8 +384,10 @@ "version": "0.1.0", "jsonrpc": {"executable": "dsctest", "args": ["jsonrpc"], "initializeTimeoutMS": 2000}, "get": {}, - "validate": {}, - "schema": {"embedded": {"type": "object"}} + "validate": { + "executable": "dsctest", + "args": ["jsonrpc", "--operation", "validate", {"jsonInputArg": "--input"}] + } }, { "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", diff --git a/tools/dsctest/src/jsonrpc.rs b/tools/dsctest/src/jsonrpc.rs index d7abbbc76..ee3e5ae12 100644 --- a/tools/dsctest/src/jsonrpc.rs +++ b/tools/dsctest/src/jsonrpc.rs @@ -36,7 +36,10 @@ fn schema() -> Value { fn invoke(params: &Value, state: &mut Value, count: u64, transport: &str) -> Value { let operation = params["operation"].as_str().unwrap_or_default(); if operation == "schema" { return schema(); } - if operation == "validate" { return json!({"valid": true}); } + if operation == "validate" { + return json!({"valid": !(params["properties"]["rejectMetadata"] == true + && params["properties"].get("_metadata").is_some())}); + } if operation == "resolve" { return json!({"configuration": params["properties"]["configuration"], "parameters": null}); } @@ -45,6 +48,9 @@ fn invoke(params: &Value, state: &mut Value, count: u64, transport: &str) -> Val actual["processId"] = json!(std::process::id()); actual["requestCount"] = json!(count); actual["transport"] = json!(transport); + if let Some(name) = actual["environmentName"].as_str() { + actual["environmentValue"] = json!(std::env::var(name).ok()); + } let desired = params["properties"].get("value").cloned().unwrap_or(json!(0)); let diff = if actual["value"] == desired { json!([]) } else { json!(["value"]) }; let result = match operation { From f43f5d5f3925386fbeb3add706698e5a449a6b5f Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Thu, 12 Nov 2026 10:07:56 -0800 Subject: [PATCH 3/5] Respect transport-specific what-if capabilities Require supportsWhatIf for native JSON-RPC set and delete, while retaining whatIfArg detection for executable fallbacks. Cover hybrid synthetic, separate, and executable what-if paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/concepts/resources/anatomy.md | 3 +- dsc/tests/config/dsc_jsonrpc.tests.ps1 | 63 +++++++++++++++++++ .../src/dscresources/command_resource.rs | 11 ++-- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/docs/concepts/resources/anatomy.md b/docs/concepts/resources/anatomy.md index 0cb008753..9d5918ec2 100644 --- a/docs/concepts/resources/anatomy.md +++ b/docs/concepts/resources/anatomy.md @@ -133,7 +133,8 @@ Return the existing logical operation output as a single `result` value. For `st without declared return output and actual delete return `null`. Schema commands return a JSON Schema value. Existing group results remain arrays. Native set or delete what-if requires `supportsWhatIf: true`; if that operation also has an executable fallback, it must retain a -`whatIfArg` in its fallback arguments. +`whatIfArg` in its fallback arguments. A fallback's `whatIfArg` only declares what-if support for +executable invocation; it doesn't enable native JSON-RPC what-if. During an invocation, the resource can send `dsc_trace` or `dsc_progress` notifications correlated by `params.requestId`. Progress accepts a message and optional fractional `completed` and `total`. diff --git a/dsc/tests/config/dsc_jsonrpc.tests.ps1 b/dsc/tests/config/dsc_jsonrpc.tests.ps1 index baac7de73..01b97eea0 100644 --- a/dsc/tests/config/dsc_jsonrpc.tests.ps1 +++ b/dsc/tests/config/dsc_jsonrpc.tests.ps1 @@ -349,6 +349,69 @@ Describe 'JSON-RPC resource lifecycle' { $export.resources[0].properties.transport | Should -BeExactly 'jsonrpc' } + It 'uses for hybrid what-if with reuse and separate what-if ' -ForEach @( + @{ Operation = 'set'; Reuse = $true; SeparateWhatIf = $false; ExpectedOperation = 'test' } + @{ Operation = 'set'; Reuse = $true; SeparateWhatIf = $true; ExpectedOperation = 'whatIf' } + @{ Operation = 'set'; Reuse = $false; SeparateWhatIf = $false; ExpectedOperation = 'fallback' } + @{ Operation = 'delete'; Reuse = $true; SeparateWhatIf = $false; ExpectedOperation = 'test' } + @{ Operation = 'delete'; Reuse = $false; SeparateWhatIf = $false; ExpectedOperation = 'fallback' } + ) { + $manifest = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'Test/JsonRpcHybridWhatIf' + version = '0.1.0' + jsonrpc = @{ executable = 'dsctest'; args = @('jsonrpc'); initializeTimeoutMS = 2000 } + get = @{} + test = @{ return = 'stateAndDiff' } + set = @{ + executable = 'dsctest' + args = @('whatif', '--state-and-diff', @{ whatIfArg = '-w' }) + return = 'stateAndDiff' + } + delete = @{ + executable = 'dsctest' + args = @('whatif-delete', @{ whatIfArg = '-w' }) + } + schema = @{ embedded = @{ type = 'object'; additionalProperties = $true } } + } + if ($SeparateWhatIf) { + $manifest.whatIf = @{ return = 'stateAndDiff' } + } + $manifestPath = Join-Path $TestDrive 'hybrid.dsc.resource.json' + $manifest | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $manifestPath + $properties = @{ value = 5 } + if ($Operation -eq 'delete') { + $properties._exist = $false + } + $configuration = New-RpcConfiguration -ResourceType $manifest.type -Properties $properties -Directives @{ reuseResources = $Reuse } + $previousResourcePath = $env:DSC_RESOURCE_PATH + try { + $env:DSC_RESOURCE_PATH = (@($TestDrive, $previousResourcePath) | Where-Object { $_ }) -join [IO.Path]::PathSeparator + $result = dsc config set -w -i ($configuration | ConvertTo-Json -Depth 20) | ConvertFrom-Json -Depth 30 + $LASTEXITCODE | Should -Be 0 + $result.hadErrors | Should -BeFalse + $calls = @((Get-RpcEvent) | Where-Object event -EQ dsc_invoke) + $calls.params.operation | Should -Not -Contain 'set' + $calls.params.operation | Should -Not -Contain 'delete' + if ($ExpectedOperation -eq 'fallback') { + if ($Operation -eq 'set') { + $result.results[0].result.afterState.executionType | Should -BeExactly 'WhatIf' + $result.results[0].result.afterState.fromResource | Should -BeExactly 'ResourceProvidedDiff' + } else { + $result.results[0].metadata.whatIf | Should -Contain 'Delete what-if message 1' + } + } else { + $selectedCalls = @($calls | Where-Object { $_.params.operation -eq $ExpectedOperation }) + $selectedCalls | Should -HaveCount 2 + if ($SeparateWhatIf) { + $selectedCalls.params.executionType | Select-Object -Unique | Should -BeExactly 'whatIf' + } + } + } finally { + $env:DSC_RESOURCE_PATH = $previousResourcePath + } + } + It 'preserves follow-up get, synthetic export, and synthetic what-if semantics' { $set = dsc resource set -r Test/JsonRpcSynthetic -i '{"value":5}' | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 10ad7e755..b14fce779 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -150,8 +150,12 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, contex operation_type = "whatif".to_string(); // Check if set supports native what-if let has_native_whatif = manifest.set.as_ref().is_some_and(|set| { - set.supports_what_if == Some(true) || set.args.as_ref().is_some_and(|args| - args.iter().any(|arg| matches!(arg, SetDeleteArgKind::WhatIf { .. }))) + if use_jsonrpc(resource, &set.executable, context) { + set.supports_what_if == Some(true) + } else { + set.args.as_ref().is_some_and(|args| + args.iter().any(|arg| matches!(arg, SetDeleteArgKind::WhatIf { .. }))) + } }); if has_native_whatif { @@ -551,8 +555,7 @@ pub fn invoke_delete(resource: &DscResource, filter: &str, context: &Context) -> let command_resource = context.target_resource.as_ref().unwrap_or(resource); validate_security_context(context.target_resource.as_ref(), &delete.require_security_context, &command_resource.type_name, &Operation::Delete)?; let (args, supports_whatif) = if use_jsonrpc(resource, &delete.executable, context) { - (None, delete.supports_what_if == Some(true) || delete.args.as_ref().is_some_and(|args| - args.iter().any(|arg| matches!(arg, SetDeleteArgKind::WhatIf { .. })))) + (None, delete.supports_what_if == Some(true)) } else { process_set_delete_args(delete.args.as_ref(), filter, command_resource, context)? }; if context.execution_type == ExecutionKind::WhatIf && !supports_whatif { // perform a synthetic what-if by calling test and wrapping the TestResult in DeleteResultKind::SyntheticWhatIf From 533c4cf818dbd2942172d21d7a9f352daf754adf Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Thu, 12 Nov 2026 12:41:45 -0800 Subject: [PATCH 4/5] Propagate synthetic test schema errors and preserve export constraints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dsc/tests/config/dsc_jsonrpc.tests.ps1 | 66 +++++++++++++++++++ lib/dsc-lib/src/dscresources/dscresource.rs | 9 ++- .../src/dscresources/resource_manifest.rs | 5 +- schemas/v3/bundled/resource/manifest.json | 3 + tools/dsctest/src/jsonrpc.rs | 11 ++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/dsc/tests/config/dsc_jsonrpc.tests.ps1 b/dsc/tests/config/dsc_jsonrpc.tests.ps1 index 01b97eea0..3f9e8daf4 100644 --- a/dsc/tests/config/dsc_jsonrpc.tests.ps1 +++ b/dsc/tests/config/dsc_jsonrpc.tests.ps1 @@ -100,6 +100,72 @@ Describe 'JSON-RPC resource lifecycle' { @((Get-RpcEvent) | Where-Object event -EQ initialize) | Should -HaveCount 1 } + It 'rejects an empty export executable in the published bundle with JSON-RPC ' -ForEach @( + @{ UseJsonRpc = $true } + @{ UseJsonRpc = $false } + ) { + $schemaPath = Join-Path $PSScriptRoot '../../../schemas/v3/bundled/resource/manifest.json' + $manifest = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'Test/EmptyExportExecutable' + version = '0.1.0' + get = @{ executable = 'dsctest' } + export = @{ executable = '' } + } + if ($UseJsonRpc) { + $manifest.jsonrpc = @{ executable = 'dsctest' } + } + $manifest | ConvertTo-Json -Depth 10 | Test-Json -SchemaFile $schemaPath -ErrorAction SilentlyContinue | + Should -BeFalse + } + + It 'handles during a synthetic test schema request' -ForEach @( + @{ Fault = 'schemaError'; ExpectedError = 'Requested schema error' } + @{ Fault = 'schemaTimeout'; ExpectedError = 'timed out' } + @{ Fault = 'noSchema'; ExpectedError = $null } + ) { + $manifest = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'Test/JsonRpcSyntheticSchema' + version = '0.1.0' + jsonrpc = @{ + executable = 'dsctest' + args = @('jsonrpc') + initializeTimeoutMS = 2000 + requestTimeoutMS = 200 + } + get = @{} + validate = @{} + } + if ($Fault -ne 'noSchema') { + $manifest.schema = @{ command = @{} } + } + $manifestPath = Join-Path $TestDrive 'synthetic-schema.dsc.resource.json' + $manifest | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $manifestPath + $previousResourcePath = $env:DSC_RESOURCE_PATH + $env:DSC_TEST_JSONRPC_FAULT = $Fault + try { + $env:DSC_RESOURCE_PATH = (@($TestDrive, $previousResourcePath) | Where-Object { $_ }) -join [IO.Path]::PathSeparator + $output = dsc resource test -r $manifest.type -i '{"value":0}' 2>&1 | Out-String + if ($ExpectedError) { + $LASTEXITCODE | Should -Not -Be 0 + $output | Should -Match $ExpectedError + $output | Should -Not -Match '"inDesiredState"' + } else { + $LASTEXITCODE | Should -Be 0 + ($output | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + $calls = @((Get-RpcEvent) | Where-Object event -EQ dsc_invoke) + $calls.params.operation | Should -Contain 'validate' + $calls.params.operation | Should -Contain 'get' + if ($ExpectedError) { + $calls[-1].params.operation | Should -BeExactly 'schema' + } + } finally { + $env:DSC_RESOURCE_PATH = $previousResourcePath + } + } + It 'does not reuse a process across configuration runs' { $configuration = New-RpcConfiguration | ConvertTo-Json -Depth 20 $first = dsc config get -i $configuration | ConvertFrom-Json diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 23d9e96b3..d77144a89 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -402,9 +402,14 @@ impl ResourceInvoke for DscResource { } }; let schema: Option = if let Some(s) = &self.schema { - serde_json::to_value(s).ok() + Some(serde_json::to_value(s)?) } else { - self.schema(context).ok().and_then(|s| serde_json::from_str(&s).ok()) + match self.schema(context) { + Ok(schema) => Some(serde_json::from_str(&schema)?), + Err(DscError::SchemaNotAvailable(_)) => None, + Err(err) if manifest.jsonrpc.is_some() => return Err(err), + Err(_) => None, + } }; let diff_properties = get_diff_with_schema( &desired_state, &actual_state, schema.as_ref()); desired_state = redact(&desired_state); diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index a7a87b2bb..7f473b0f3 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -568,11 +568,11 @@ impl ResourceManifest { "if": {"not": {"required": ["executable"]}}, "then": {"properties": {"args": {"type": "null"}}} }}})); - properties.get_mut("export").unwrap()["properties"] = serde_json::json!({"schema": {"properties": {"command": { + properties.get_mut("export").unwrap()["properties"]["schema"] = serde_json::json!({"properties": {"command": { "properties": {"executable": {"minLength": 1}}, "if": {"not": {"required": ["executable"]}}, "then": {"properties": {"args": {"type": "null"}}} - }}}}); + }}}); constraints.push(serde_json::json!({"properties": properties})); constraints.push(serde_json::json!({ "if": {"required": ["jsonrpc"], "properties": {"jsonrpc": {"type": "object"}}}, @@ -701,6 +701,7 @@ mod test { } for update in [ json!({"get": {"executable": ""}}), + json!({"export": {"executable": ""}}), json!({"schema": {"command": {"executable": ""}}}), json!({"export": {"schema": {"command": {"executable": ""}}}}), ] { diff --git a/schemas/v3/bundled/resource/manifest.json b/schemas/v3/bundled/resource/manifest.json index d0690e8ae..a142f10d7 100644 --- a/schemas/v3/bundled/resource/manifest.json +++ b/schemas/v3/bundled/resource/manifest.json @@ -622,6 +622,9 @@ }, "export": { "properties": { + "executable": { + "minLength": 1 + }, "schema": { "properties": { "command": { diff --git a/tools/dsctest/src/jsonrpc.rs b/tools/dsctest/src/jsonrpc.rs index ee3e5ae12..6ff58f576 100644 --- a/tools/dsctest/src/jsonrpc.rs +++ b/tools/dsctest/src/jsonrpc.rs @@ -139,6 +139,17 @@ pub fn serve() -> Result<(), Box> { }, "dsc_invoke" if initialized && !shutting_down => { count += 1; + if params["operation"] == "schema" { + match lifecycle_fault.as_deref() { + Some("schemaError") => { + write_message(&mut output, &json!({"jsonrpc": "2.0", "id": id, + "error": {"code": -32001, "message": "Requested schema error"}}))?; + continue; + }, + Some("schemaTimeout") => thread::sleep(Duration::from_secs(10)), + _ => {}, + } + } let properties = ¶ms["properties"]; if let Some(milliseconds) = properties["delayMS"].as_u64() { thread::sleep(Duration::from_millis(milliseconds)); From 670956437c7a3c63fc2c86a1ac216444f60e4791 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Thu, 12 Nov 2026 14:07:42 -0800 Subject: [PATCH 5/5] Stabilize synthetic schema tests on Windows CI Serialize lifecycle log entries before writing and allow normal requests five seconds before the injected ten-second schema stall times out. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dsc/tests/config/dsc_jsonrpc.tests.ps1 | 2 +- tools/dsctest/src/jsonrpc.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dsc/tests/config/dsc_jsonrpc.tests.ps1 b/dsc/tests/config/dsc_jsonrpc.tests.ps1 index 3f9e8daf4..fab0813ac 100644 --- a/dsc/tests/config/dsc_jsonrpc.tests.ps1 +++ b/dsc/tests/config/dsc_jsonrpc.tests.ps1 @@ -132,7 +132,7 @@ Describe 'JSON-RPC resource lifecycle' { executable = 'dsctest' args = @('jsonrpc') initializeTimeoutMS = 2000 - requestTimeoutMS = 200 + requestTimeoutMS = 5000 } get = @{} validate = @{} diff --git a/tools/dsctest/src/jsonrpc.rs b/tools/dsctest/src/jsonrpc.rs index 6ff58f576..7e7b934a7 100644 --- a/tools/dsctest/src/jsonrpc.rs +++ b/tools/dsctest/src/jsonrpc.rs @@ -14,7 +14,9 @@ fn write_message(output: &mut impl Write, value: &Value) -> Result<(), Box Result<(), Box> { if let Some(path) = std::env::var_os("DSC_TEST_JSONRPC_LOG") { let mut file = OpenOptions::new().create(true).append(true).open(path)?; - writeln!(file, "{}", json!({"pid": std::process::id(), "event": event, "params": params}))?; + let mut entry = serde_json::to_vec(&json!({"pid": std::process::id(), "event": event, "params": params}))?; + entry.push(b'\n'); + file.write_all(&entry)?; } Ok(()) }