Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ea43ae9
Add support for `Actions`
SteveL-MSFT Sep 8, 2026
d478e5b
Implement action discovery
SteveL-MSFT Sep 11, 2026
46d07e1
implement support for actions in configure get
SteveL-MSFT Sep 16, 2026
2b5d5fe
integrate actions into engine
SteveL-MSFT Sep 19, 2026
f2cad19
fix loc and clippy
SteveL-MSFT Sep 25, 2026
66aeef7
Refactor DSC action descriptions and input validation
SteveL-MSFT Sep 28, 2026
14c6a8d
address copilot feedback
SteveL-MSFT Sep 28, 2026
d65df71
refactor code to abstract actions and adapters as resources
SteveL-MSFT Oct 22, 2026
b0a1e2a
Fix loc issues
SteveL-MSFT Oct 22, 2026
f9091e9
fix regression in detecting supporting filtering
SteveL-MSFT Oct 23, 2026
2b84ccf
Fix issue where adapter wasn't being found
SteveL-MSFT Oct 23, 2026
83de1eb
fix tests
SteveL-MSFT Oct 24, 2026
fa0d286
Address PR feedback: action error handling, schema validation, locale…
SteveL-MSFT Oct 24, 2026
45fdbc2
Don't write action input to stdin when passed via JSON arg
SteveL-MSFT Oct 24, 2026
78fec92
Allow discover extensions to return action manifests; scan action man…
SteveL-MSFT Oct 24, 2026
20d218a
Reuse already-loaded action schemas in list_dsc_actions
SteveL-MSFT Oct 24, 2026
78e6e50
Add ActionManifest and DscAction imports
SteveL-MSFT Oct 24, 2026
a8f8a7a
fix mishandling of path by making it an option
SteveL-MSFT Oct 24, 2026
687a354
Address review: inline action directories, manifest lists, adapter re…
SteveL-MSFT Oct 24, 2026
be1de15
Merge branch 'actions' of https://github.com/PowerShell/DSC into actions
SteveL-MSFT Oct 24, 2026
29a6301
make action manifest directory option
SteveL-MSFT Oct 24, 2026
b8eb5f2
fix dscaction path allowed ot be Option
SteveL-MSFT Oct 25, 2026
05ca910
fix so dscresource path/directory is option
SteveL-MSFT Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# CFS/ADO crate feed is currently only working with unstable features.
[unstable]
registry-auth = true

[registries]
DSCCargoMirror = { index = "sparse+https://pkgs.dev.azure.com/msazure/One/_packaging/DSCCargoMirror/Cargo/index/" }

Expand Down
9 changes: 2 additions & 7 deletions dsc-bicep-ext/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,9 @@

use clap::Parser;
use dsc_lib::{
configure::config_doc::ExecutionKind,
discovery::discovery_trait::DiscoveryFilter,
dscresources::{
dscresource::Invoke,
invoke_result::{GetResult, SetResult},
},
DscManager, configure::config_doc::ExecutionKind, discovery::{discovery_trait::DiscoveryFilter},
resources::invoke_result::{GetResult, SetResult},
types::{FullyQualifiedTypeName, ResourceVersionReq},
DscManager,
};
use rust_i18n::{i18n, t};
use std::{env, io, process};
Expand Down
21 changes: 20 additions & 1 deletion dsc/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ configAbout = "Apply a configuration document"
parameters = "Parameters to pass to the configuration as JSON or YAML"
parametersFile = "Parameters to pass to the configuration as a JSON or YAML file"
systemRoot = "Specify the operating system root path if not targeting the current running OS"
actionAbout = "Operations on DSC actions"
extensionAbout = "Operations on DSC extensions"
resourceAbout = "Invoke a specific DSC resource"
schemaAbout = "Get the JSON schema for a DSC type"
Expand Down Expand Up @@ -81,7 +82,7 @@ noParameters = "No parameters specified"

[resource_command]
implementedAs = "implemented as"
invalidOperationOnAdapter = "Can not perform this operation on the adapter itself"
invalidOperation = "Can not perform this operation on %{resource}"
setInputEmpty = "Desired input is empty"
testInputEmpty = "Expected input is required"
jsonError = "JSON: %{err}"
Expand Down Expand Up @@ -115,6 +116,7 @@ functionInvocationFailed = "Function '%{function}' invocation failed: %{error}"
[server.invoke_dsc_resource]
resourceNotFound = "Resource type '%{resource}' does not exist"
whatIfNotSupported = "what_if is only supported for the 'set' and 'delete' operations"
actionNotSupported = "The type '%{resource}' is an action or adapter and cannot be invoked as a resource"

[server.list_dsc_functions]
invalidNamePattern = "Invalid function name pattern '%{pattern}'"
Expand All @@ -124,8 +126,18 @@ invalidDescriptionPattern = "Invalid function description pattern '%{pattern}'"
resourceNotAdapter = "The resource '%{adapter}' is not a valid adapter"
adapterNotFound = "Adapter '%{adapter}' does not exist"

[server.invoke_dsc_action]
actionNotFound = "Action type '%{resource}' does not exist"
resourceNotSupported = "The type '%{resource}' is not an action"

[server.show_dsc_action]
actionNotFound = "Action type '%{type_name}' does not exist"
resourceNotSupported = "The type '%{type_name}' is not an action"

[server.show_dsc_resource]
resourceNotFound = "Resource type '%{type_name}' does not exist"
resourceManifestNotFound = "Resource manifest for '%{type_name}' not found"
Comment thread
SteveL-MSFT marked this conversation as resolved.
resourceNotSupported = "The type '%{type_name}' is an action or adapter and cannot be shown as a resource"

[subcommand]
actualStateNotObject = "actual_state is not an object"
Expand All @@ -148,6 +160,10 @@ noResources = "Resources not specified"
resourceTypeNotSpecified = "Resource type not specified"
validatingResource = "Validating resource named"
resourceNotFound = "Resource type not found"
tableHeader_action_operations = "Operations"
tableHeader_action_hasInput = "Input"
tableHeader_action_hasOutput = "Output"
tableHeader_action_requireSecurityContext = "Security"
tableHeader_type = "Type"
tableHeader_kind = "Kind"
tableHeader_version = "Version"
Expand All @@ -162,6 +178,9 @@ invalidFunctionDescriptionFilter = "Invalid function description filter"
maxInt = "maxInt"
invalidManifest = "Error in manifest for"
jsonArrayNotSupported = "JSON array output format is only supported for `--all'"
yes = "Yes"
no = "No"
none = "None"

[util]
failedToConvertJsonToString = "Failed to convert JSON to string"
Expand Down
17 changes: 17 additions & 0 deletions dsc/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ pub enum SubCommand {
#[clap(long, hide = true)]
as_include: bool,
},
#[clap(name = "action", about = t!("args.actionAbout").to_string())]
Action {
#[clap(subcommand)]
subcommand: ActionSubCommand,
},
#[clap(name = "extension", about = t!("args.extensionAbout").to_string())]
Extension {
#[clap(subcommand)]
Expand Down Expand Up @@ -178,6 +183,18 @@ pub enum ConfigSubCommand {
}
}

#[derive(Debug, PartialEq, Eq, Subcommand)]
pub enum ActionSubCommand {
#[clap(name = "list", about = t!("args.listActionAbout").to_string())]
List {
/// Optional action name to filter the list
#[clap(default_value_t)]
action_name: TypeNameFilter,
#[clap(short = 'o', long, help = t!("args.outputFormat").to_string())]
output_format: Option<ListOutputFormat>,
},
}

#[derive(Debug, PartialEq, Eq, Subcommand)]
pub enum ExtensionSubCommand {
#[clap(name = "list", about = t!("args.listExtensionAbout").to_string())]
Expand Down
3 changes: 3 additions & 0 deletions dsc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ fn dsc_main() -> Result<(), ExitCode> {

subcommand::config(&subcommand, &merged_parameters, system_root.as_ref(), &as_group, &as_assert, &as_include, progress_format)?;
},
SubCommand::Action { subcommand } => {
subcommand::action(&subcommand, progress_format)?;
},
SubCommand::Extension { subcommand } => {
subcommand::extension(&subcommand, progress_format)?;
},
Expand Down
52 changes: 23 additions & 29 deletions dsc/src/resource_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,27 @@ use crate::args::{GetOutputFormat, OutputFormat};
use crate::util::{EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_JSON_ERROR, EXIT_DSC_RESOURCE_NOT_FOUND, write_object};
use dsc_lib::configure::config_doc::{Configuration, ExecutionKind};
use dsc_lib::configure::add_resource_export_results_to_configuration;
use dsc_lib::discovery::ResourceKind;
use dsc_lib::discovery::discovery_trait::DiscoveryFilter;
use dsc_lib::dscresources::{resource_manifest::Kind, invoke_result::{DeleteResultKind, GetResult, ResourceGetResponse, ResourceSetResponse, SetResult}};
use dsc_lib::dscresources::dscresource::{Capability, get_diff};
use dsc_lib::DscManager;
use dsc_lib::dscresources::dscresource::get_diff;
use dsc_lib::dscerror::DscError;
use dsc_lib::resources::{invoke_result::{DeleteResultKind, GetResult, ResourceGetResponse, SetResult, ResourceSetResponse}, resource_invoke::{Capability, ResourceInvoke}};
use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq};
use rust_i18n::t;
use serde_json::Value;
use std::process::ExitCode;
use tracing::{debug, error, info};

use dsc_lib::{
dscresources::dscresource::{Invoke, DscResource},
DscManager
};

pub fn get(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version: Option<&ResourceVersionReq>, input: &str, format: Option<&GetOutputFormat>) -> Result<(), ExitCode> {
let Some(resource) = get_resource(dsc, resource_type, version) else {
error!("{}", DscError::ResourceNotFound(resource_type.to_string(), version.map_or(String::new(), |v| v.to_string())));
return Err(ExitCode::from(EXIT_DSC_RESOURCE_NOT_FOUND));
};

debug!("{} {} {:?}", resource.type_name, t!("resource_command.implementedAs"), resource.implemented_as);
if resource.kind == Kind::Adapter {
error!("{}: {}", t!("resource_command.invalidOperationOnAdapter"), resource.type_name);
if resource.kind() == ResourceKind::Action || resource.kind() == ResourceKind::Adapter {
error!("{}", t!("resource_command.invalidOperation", resource = resource.type_name()));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}

Expand Down Expand Up @@ -78,9 +75,8 @@ pub fn get_all(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, ver
return Err(ExitCode::from(EXIT_DSC_RESOURCE_NOT_FOUND));
};

debug!("{} {} {:?}", resource.type_name, t!("resource_command.implementedAs"), resource.implemented_as);
if resource.kind == Kind::Adapter {
error!("{}: {}", t!("resource_command.invalidOperationOnAdapter"), resource.type_name);
if resource.kind() == ResourceKind::Action || resource.kind() == ResourceKind::Adapter {
error!("{}", t!("resource_command.invalidOperation", resource = resource.type_name()));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}

Expand Down Expand Up @@ -141,9 +137,8 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version
return Err(ExitCode::from(EXIT_DSC_RESOURCE_NOT_FOUND));
};

debug!("{} {} {:?}", resource.type_name, t!("resource_command.implementedAs"), resource.implemented_as);
if resource.kind == Kind::Adapter {
error!("{}: {}", t!("resource_command.invalidOperationOnAdapter"), resource.type_name);
if resource.kind() == ResourceKind::Action || resource.kind() == ResourceKind::Adapter {
error!("{}", t!("resource_command.invalidOperation", resource = resource.type_name()));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}

Expand All @@ -160,7 +155,7 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version
Err(_) => true,
};

if !exist && resource.capabilities.contains(&Capability::Delete) && !resource.capabilities.contains(&Capability::SetHandlesExist) {
if !exist && resource.capabilities().contains(&Capability::Delete) && !resource.capabilities().contains(&Capability::SetHandlesExist) {
debug!("{}", t!("resource_command.routingToDelete"));

let before_state = match resource.get(input) {
Expand Down Expand Up @@ -236,9 +231,8 @@ pub fn test(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, versio
return Err(ExitCode::from(EXIT_DSC_RESOURCE_NOT_FOUND));
};

debug!("{} {} {:?}", resource.type_name, t!("resource_command.implementedAs"), resource.implemented_as);
if resource.kind == Kind::Adapter {
error!("{}: {}", t!("resource_command.invalidOperationOnAdapter"), resource.type_name);
if resource.kind() == ResourceKind::Action || resource.kind() == ResourceKind::Adapter {
error!("{}", t!("resource_command.invalidOperation", resource = resource.type_name()));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}

Expand Down Expand Up @@ -268,9 +262,8 @@ pub fn delete(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
return Err(ExitCode::from(EXIT_DSC_RESOURCE_NOT_FOUND));
};

debug!("{} {} {:?}", resource.type_name, t!("resource_command.implementedAs"), resource.implemented_as);
if resource.kind == Kind::Adapter {
error!("{}: {}", t!("resource_command.invalidOperationOnAdapter"), resource.type_name);
if resource.kind() == ResourceKind::Action || resource.kind() == ResourceKind::Adapter {
error!("{}", t!("resource_command.invalidOperation", resource = resource.type_name()));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}

Expand All @@ -291,7 +284,7 @@ pub fn delete(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
}
},
DeleteResultKind::SyntheticWhatIf(_) => {
info!("{} {}", resource.type_name, t!("resource_command.syntheticWhatIf"));
info!("{} {}", resource.type_name(), t!("resource_command.syntheticWhatIf"));
}
}
},
Expand All @@ -308,8 +301,9 @@ pub fn schema(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
error!("{}", DscError::ResourceNotFound(resource_type.to_string(), version.map_or(String::new(), |v| v.to_string())));
return Err(ExitCode::from(EXIT_DSC_RESOURCE_NOT_FOUND));
};
if resource.kind == Kind::Adapter {
error!("{}: {}", t!("resource_command.invalidOperationOnAdapter"), resource.type_name);

if resource.kind() == ResourceKind::Action || resource.kind() == ResourceKind::Adapter {
error!("{}", t!("resource_command.invalidOperation", resource = resource.type_name()));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}

Expand Down Expand Up @@ -339,13 +333,13 @@ pub fn export(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
return Err(ExitCode::from(EXIT_DSC_RESOURCE_NOT_FOUND));
};

if dsc_resource.kind == Kind::Adapter {
error!("{}: {}", t!("resource_command.invalidOperationOnAdapter"), dsc_resource.type_name);
if dsc_resource.kind() == ResourceKind::Action || dsc_resource.kind() == ResourceKind::Adapter {
error!("{}", t!("resource_command.invalidOperation", resource = dsc_resource.type_name()));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}

let mut conf = Configuration::new();
if let Err(err) = add_resource_export_results_to_configuration(dsc_resource, &mut conf, input) {
if let Err(err) = add_resource_export_results_to_configuration(&*dsc_resource, &mut conf, input) {
error!("{err}");
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
Expand All @@ -362,7 +356,7 @@ pub fn export(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
}

#[must_use]
pub fn get_resource<'a>(dsc: &'a mut DscManager, resource: &FullyQualifiedTypeName, version: Option<&ResourceVersionReq>) -> Option<&'a DscResource> {
pub fn get_resource(dsc: & mut DscManager, resource: &FullyQualifiedTypeName, version: Option<&ResourceVersionReq>) -> Option<Box<dyn ResourceInvoke>> {
//TODO: add dynamically generated resource to dsc
dsc.find_resource(&DiscoveryFilter::new(resource, version.cloned(), None)).unwrap_or(None)
}
73 changes: 73 additions & 0 deletions dsc/src/server/invoke_dsc_action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use crate::server::mcp_server::McpServer;
use dsc_lib::{
DscManager, configure::config_doc::ExecutionKind,
discovery::{
discovery_trait::DiscoveryFilter,
ResourceKind,
},
types::FullyQualifiedTypeName
};
use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters};
use rust_i18n::t;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::task;

#[derive(Serialize, JsonSchema)]
pub struct InvokeDscActionResponse {
pub result: Value,
}

#[derive(Deserialize, JsonSchema)]
pub struct InvokeDscActionRequest {
#[schemars(description = "The type name of the DSC action to invoke")]
pub r#type: FullyQualifiedTypeName,
#[schemars(description = "The properties to pass to the DSC action as JSON. Must match the input schema from `show_dsc_action` tool.")]
pub properties_json: Option<String>,
#[schemars(description = "When true, request what-if (dry-run) execution. Only valid for actions that declare what-if support; otherwise the invocation fails.")]
#[serde(default)]
pub what_if: Option<bool>,
}

#[tool_router(router = invoke_dsc_action_router, vis = "pub")]
impl McpServer {
#[tool(
description = "Invoke a DSC action with specified properties in JSON format. Set 'what_if' to true to request what-if execution when supported.",
annotations(
title = "Invoke a DSC action with specified properties and optional what-if execution",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = true,
)
)]
pub async fn invoke_dsc_action(&self, Parameters(InvokeDscActionRequest { r#type: type_name, properties_json, what_if }): Parameters<InvokeDscActionRequest>) -> Result<Json<InvokeDscActionResponse>, McpError> {
let result = task::spawn_blocking(move || {
let execution_kind = match what_if {
Some(true) => ExecutionKind::WhatIf,
_ => ExecutionKind::Actual,
};
let mut dsc = DscManager::new();
let Some(resource) = dsc.find_resource(&DiscoveryFilter::new(&type_name, None, None)).unwrap_or(None) else {
return Err(McpError::invalid_request(t!("server.invoke_dsc_action.actionNotFound", resource = type_name), None));
};
if resource.kind() != ResourceKind::Action {
return Err(McpError::invalid_request(t!("server.invoke_dsc_action.resourceNotSupported", resource = type_name), None));
}
let input = match properties_json {
Some(json) => json,
None => "".to_string(),
};
match resource.invoke(&input, &execution_kind) {
Ok(result) => Ok(result),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))??;

Ok(Json(InvokeDscActionResponse { result }))
}
}
20 changes: 13 additions & 7 deletions dsc/src/server/invoke_dsc_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
use crate::server::mcp_server::McpServer;
use dsc_lib::{
DscManager, configure::config_doc::ExecutionKind,
discovery::discovery_trait::DiscoveryFilter,
dscresources::{
dscresource::Invoke,
discovery::{
discovery_trait::DiscoveryFilter,
ResourceKind,
},
resources::{
invoke_result::{
DeleteResult,
DeleteResultKind,
Expand Down Expand Up @@ -54,7 +56,8 @@ pub struct InvokeDscResourceRequest {
#[schemars(description = "The operation to perform on the DSC resource")]
pub operation: DscOperation,
#[schemars(description = "The type name of the DSC resource to invoke")]
pub resource_type: FullyQualifiedTypeName,
#[serde(alias="resource_type")]
pub r#type: FullyQualifiedTypeName,
#[schemars(description = "The properties to pass to the DSC resource as JSON. Must match the resource JSON schema from `show_dsc_resource` tool.")]
pub properties_json: String,
#[schemars(description = "When true and operation is 'set' or 'delete', simulate the change (what-if / dry-run) instead of applying it. Resources without native what-if support return a synthetic result derived from 'test'. Only valid with the 'set' and 'delete' operations.")]
Expand All @@ -74,7 +77,7 @@ impl McpServer {
open_world_hint = true,
)
)]
pub async fn invoke_dsc_resource(&self, Parameters(InvokeDscResourceRequest { operation, resource_type, properties_json, what_if }): Parameters<InvokeDscResourceRequest>) -> Result<Json<InvokeDscResourceResponse>, McpError> {
pub async fn invoke_dsc_resource(&self, Parameters(InvokeDscResourceRequest { operation, r#type, properties_json, what_if }): Parameters<InvokeDscResourceRequest>) -> Result<Json<InvokeDscResourceResponse>, McpError> {
let result = task::spawn_blocking(move || {
let execution_kind = if what_if.unwrap_or(false) {
if !matches!(operation, DscOperation::Set | DscOperation::Delete) {
Expand All @@ -85,9 +88,12 @@ impl McpServer {
ExecutionKind::Actual
};
let mut dsc = DscManager::new();
let Some(resource) = dsc.find_resource(&DiscoveryFilter::new(&resource_type, None, None)).unwrap_or(None) else {
return Err(McpError::invalid_request(t!("server.invoke_dsc_resource.resourceNotFound", resource = resource_type), None));
let Some(resource) = dsc.find_resource(&DiscoveryFilter::new(&r#type, None, None)).unwrap_or(None) else {
return Err(McpError::invalid_request(t!("server.invoke_dsc_resource.resourceNotFound", resource = r#type), None));
};
if matches!(resource.kind(), ResourceKind::Action | ResourceKind::Adapter) {
return Err(McpError::invalid_request(t!("server.invoke_dsc_resource.actionNotSupported", resource = r#type), None));
}
match operation {
DscOperation::Get => {
let result = match resource.get(&properties_json) {
Expand Down
Loading
Loading