From a3a31e73e1e6a5a4b622a75ab22298451fbe6480 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:03:33 +0000 Subject: [PATCH] feat(ir): add typed builder for AzurePowerShell@5 Adds AzurePowerShell struct with file() and inline() entry points returning distinct typestate builders (AzurePowerShellFile / AzurePowerShellInline). ScriptArguments is only available on the file builder; the inline script body is only accepted by the inline builder, so mixing inputs with the wrong mode is unrepresentable. Shared optionals (errorActionPreference, FailOnStandardError, pwsh, workingDirectory, azurePowerShellVersion) are available on both builders via the shared_setters! macro. The AzurePowerShellVersion enum models the LatestVersion / OtherVersion + preferredAzurePowerShellVersion pair so callers express intent in one call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/ir/tasks/azure_powershell.rs | 315 +++++++++++++++++++++++ src/compile/ir/tasks/mod.rs | 1 + 2 files changed, 316 insertions(+) create mode 100644 src/compile/ir/tasks/azure_powershell.rs diff --git a/src/compile/ir/tasks/azure_powershell.rs b/src/compile/ir/tasks/azure_powershell.rs new file mode 100644 index 00000000..d0f4db79 --- /dev/null +++ b/src/compile/ir/tasks/azure_powershell.rs @@ -0,0 +1,315 @@ +//! Typed builder for `AzurePowerShell@5`. +//! +//! [`AzurePowerShell::file`] and [`AzurePowerShell::inline`] return **distinct +//! typestate builders** ([`AzurePowerShellFile`] / [`AzurePowerShellInline`]). +//! `script_path` and `script_arguments` exist only on the file builder, and +//! `script` (the inline body) only on the inline builder, so mixing inputs with +//! the wrong mode is unrepresentable. +//! +//! ADO task reference: +//! + +use super::common::{push_bool, push_opt}; +use crate::compile::ir::step::TaskStep; + +/// Non-terminating error behaviour (`errorActionPreference`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorActionPreference { + Stop, + Continue, + SilentlyContinue, +} + +impl ErrorActionPreference { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + ErrorActionPreference::Stop => "stop", + ErrorActionPreference::Continue => "continue", + ErrorActionPreference::SilentlyContinue => "silentlyContinue", + } + } +} + +/// Azure PowerShell module version selection (`azurePowerShellVersion`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AzurePowerShellVersion { + /// Use the latest Azure PowerShell version available on the agent + /// (`azurePowerShellVersion: LatestVersion`). + Latest, + /// Pin to a specific semantic version string, e.g. `"8.0.0"`. + /// Sets `azurePowerShellVersion: OtherVersion` and + /// `preferredAzurePowerShellVersion: `. + Preferred(String), +} + +/// Optionals shared by both `AzurePowerShell@5` modes. +#[derive(Debug, Clone, Default)] +struct Shared { + error_action_preference: Option, + fail_on_standard_error: Option, + pwsh: Option, + working_directory: Option, + azure_powershell_version: Option, +} + +impl Shared { + fn apply(self, t: &mut TaskStep) { + if let Some(v) = self.error_action_preference { + t.inputs + .insert("errorActionPreference".to_string(), v.as_ado_str().to_string()); + } + push_bool(t, "FailOnStandardError", self.fail_on_standard_error); + push_bool(t, "pwsh", self.pwsh); + push_opt(t, "workingDirectory", self.working_directory); + match self.azure_powershell_version { + None => {} + Some(AzurePowerShellVersion::Latest) => { + t.inputs + .insert("azurePowerShellVersion".to_string(), "LatestVersion".to_string()); + } + Some(AzurePowerShellVersion::Preferred(ver)) => { + t.inputs + .insert("azurePowerShellVersion".to_string(), "OtherVersion".to_string()); + t.inputs + .insert("preferredAzurePowerShellVersion".to_string(), ver); + } + } + } +} + +/// Emit the optional setters that are shared by both `AzurePowerShell@5` builders. +macro_rules! shared_setters { + () => { + /// `errorActionPreference` — non-terminating error behaviour (default `stop`). + pub fn error_action_preference(mut self, value: ErrorActionPreference) -> Self { + self.shared.error_action_preference = Some(value); + self + } + + /// `FailOnStandardError` — fail the step when anything is written to stderr. + pub fn fail_on_standard_error(mut self, value: bool) -> Self { + self.shared.fail_on_standard_error = Some(value); + self + } + + /// `pwsh` — use PowerShell Core (`pwsh`) instead of Windows PowerShell. + pub fn pwsh(mut self, value: bool) -> Self { + self.shared.pwsh = Some(value); + self + } + + /// `workingDirectory` — working directory for the script. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.shared.working_directory = Some(value.into()); + self + } + + /// Select the Azure PowerShell module version. + /// + /// [`AzurePowerShellVersion::Latest`] → `LatestVersion`. + /// [`AzurePowerShellVersion::Preferred`] → `OtherVersion` + + /// `preferredAzurePowerShellVersion`. + pub fn azure_powershell_version(mut self, value: AzurePowerShellVersion) -> Self { + self.shared.azure_powershell_version = Some(value); + self + } + + /// Override the default `displayName` (`"Azure PowerShell Script"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + }; +} + +/// Builder for `AzurePowerShell@5` in file-path mode (`ScriptType: FilePath`). +#[derive(Debug, Clone)] +pub struct AzurePowerShellFile { + azure_subscription: String, + script_path: String, + script_arguments: Option, + shared: Shared, + display_name: Option, +} + +impl AzurePowerShellFile { + /// `ScriptArguments` — arguments passed to the script file. + pub fn script_arguments(mut self, value: impl Into) -> Self { + self.script_arguments = Some(value.into()); + self + } + + shared_setters!(); + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "AzurePowerShell@5", + self.display_name + .unwrap_or_else(|| "Azure PowerShell Script".into()), + ) + .with_input("azureSubscription", self.azure_subscription) + .with_input("ScriptType", "FilePath") + .with_input("ScriptPath", self.script_path); + push_opt(&mut t, "ScriptArguments", self.script_arguments); + self.shared.apply(&mut t); + t + } +} + +/// Builder for `AzurePowerShell@5` in inline mode (`ScriptType: InlineScript`). +#[derive(Debug, Clone)] +pub struct AzurePowerShellInline { + azure_subscription: String, + script: String, + shared: Shared, + display_name: Option, +} + +impl AzurePowerShellInline { + shared_setters!(); + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "AzurePowerShell@5", + self.display_name + .unwrap_or_else(|| "Azure PowerShell Script".into()), + ) + .with_input("azureSubscription", self.azure_subscription) + .with_input("ScriptType", "InlineScript") + .with_input("Inline", self.script); + self.shared.apply(&mut t); + t + } +} + +/// Entry point for the `AzurePowerShell@5` builders. +/// +/// `file` and `inline` return distinct typestate builders so each mode only +/// exposes its valid inputs (`ScriptArguments` is only available on the file +/// builder; the inline body `script` is only accepted by the inline builder). +pub struct AzurePowerShell; + +impl AzurePowerShell { + /// File-path mode: run the PowerShell script at `script_path` using the + /// Azure service connection `azure_subscription`. + pub fn file( + azure_subscription: impl Into, + script_path: impl Into, + ) -> AzurePowerShellFile { + AzurePowerShellFile { + azure_subscription: azure_subscription.into(), + script_path: script_path.into(), + script_arguments: None, + shared: Shared::default(), + display_name: None, + } + } + + /// Inline mode: run `script` as an inline PowerShell block using the Azure + /// service connection `azure_subscription`. + pub fn inline( + azure_subscription: impl Into, + script: impl Into, + ) -> AzurePowerShellInline { + AzurePowerShellInline { + azure_subscription: azure_subscription.into(), + script: script.into(), + shared: Shared::default(), + display_name: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_mode_required_inputs() { + let t = AzurePowerShell::file("my-azure-sub", "scripts/deploy.ps1").into_step(); + assert_eq!(t.task, "AzurePowerShell@5"); + assert_eq!( + t.inputs.get("azureSubscription").map(String::as_str), + Some("my-azure-sub") + ); + assert_eq!(t.inputs.get("ScriptType").map(String::as_str), Some("FilePath")); + assert_eq!( + t.inputs.get("ScriptPath").map(String::as_str), + Some("scripts/deploy.ps1") + ); + assert!(!t.inputs.contains_key("ScriptArguments")); + assert!(!t.inputs.contains_key("Inline")); + } + + #[test] + fn file_mode_with_arguments_and_options() { + let t = AzurePowerShell::file("sub-conn", "deploy.ps1") + .script_arguments("-Env Prod") + .pwsh(true) + .error_action_preference(ErrorActionPreference::Continue) + .fail_on_standard_error(true) + .with_display_name("Deploy to Azure") + .into_step(); + assert_eq!(t.display_name, "Deploy to Azure"); + assert_eq!( + t.inputs.get("ScriptArguments").map(String::as_str), + Some("-Env Prod") + ); + assert_eq!(t.inputs.get("pwsh").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("errorActionPreference").map(String::as_str), + Some("continue") + ); + assert_eq!( + t.inputs.get("FailOnStandardError").map(String::as_str), + Some("true") + ); + } + + #[test] + fn inline_mode_sets_script() { + let t = AzurePowerShell::inline("my-sub", "Write-Host 'hello'").into_step(); + assert_eq!(t.inputs.get("ScriptType").map(String::as_str), Some("InlineScript")); + assert_eq!( + t.inputs.get("Inline").map(String::as_str), + Some("Write-Host 'hello'") + ); + assert!(!t.inputs.contains_key("ScriptPath")); + assert!(!t.inputs.contains_key("ScriptArguments")); + } + + #[test] + fn preferred_powershell_version() { + let t = AzurePowerShell::inline("sub", "Get-AzResource") + .azure_powershell_version(AzurePowerShellVersion::Preferred("8.0.0".into())) + .into_step(); + assert_eq!( + t.inputs.get("azurePowerShellVersion").map(String::as_str), + Some("OtherVersion") + ); + assert_eq!( + t.inputs.get("preferredAzurePowerShellVersion").map(String::as_str), + Some("8.0.0") + ); + } + + #[test] + fn latest_version_shorthand() { + let t = AzurePowerShell::file("sub", "script.ps1") + .azure_powershell_version(AzurePowerShellVersion::Latest) + .into_step(); + assert_eq!( + t.inputs.get("azurePowerShellVersion").map(String::as_str), + Some("LatestVersion") + ); + assert!(!t.inputs.contains_key("preferredAzurePowerShellVersion")); + } + + // `script_arguments` is not available on `AzurePowerShellInline` — the + // following would not compile: + // AzurePowerShell::inline("sub", "body").script_arguments("-Arg") + // That mismatch is unrepresentable rather than silently dropped. +} diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs index 4ca0acc3..d5b34fc5 100644 --- a/src/compile/ir/tasks/mod.rs +++ b/src/compile/ir/tasks/mod.rs @@ -20,6 +20,7 @@ mod common; pub mod archive_files; +pub mod azure_powershell; pub mod cmd_line; pub mod copy_files; pub mod delete_files;