diff --git a/src/compile/ir/tasks/azure_function_app.rs b/src/compile/ir/tasks/azure_function_app.rs new file mode 100644 index 00000000..c6eaf99e --- /dev/null +++ b/src/compile/ir/tasks/azure_function_app.rs @@ -0,0 +1,398 @@ +//! Typed builder for `AzureFunctionApp@2`. +//! +//! `AzureFunctionApp@2` deploys a function app package to Azure Functions on +//! Windows or Linux, including Flex Consumption plan apps. It supports zip +//! deploy, run-from-package, and auto-detect deployment modes, deployment slot +//! targeting, and per-app settings overrides. +//! +//! ADO task reference: +//! + +use super::common::{de_opt_bool_flex, push_bool, push_opt}; +use crate::compile::ir::step::TaskStep; +use serde::Deserialize; + +/// Hosting OS for the Azure Functions App (`appType` input). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum FunctionAppType { + /// Function App on Windows (`"functionApp"`). + #[serde(rename = "functionApp")] + Windows, + /// Function App on Linux (`"functionAppLinux"`). + #[serde(rename = "functionAppLinux")] + Linux, +} + +impl FunctionAppType { + /// Return the exact ADO token for this app type. + pub fn as_ado_str(self) -> &'static str { + match self { + FunctionAppType::Windows => "functionApp", + FunctionAppType::Linux => "functionAppLinux", + } + } +} + +/// Package deployment method for the function app (`deploymentMethod` input). +/// +/// Not applicable to Flex Consumption plan apps or WAR/JAR packages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum FunctionDeploymentMethod { + /// ADO auto-selects the best method (`"auto"`). ADO default. + #[serde(rename = "auto")] + Auto, + /// Deploy as a zip package (`"zipDeploy"`). + #[serde(rename = "zipDeploy")] + ZipDeploy, + /// Deploy as a run-from-package mount (`"runFromPackage"`). + #[serde(rename = "runFromPackage")] + RunFromPackage, +} + +impl FunctionDeploymentMethod { + /// Return the exact ADO token for this deployment method. + pub fn as_ado_str(self) -> &'static str { + match self { + FunctionDeploymentMethod::Auto => "auto", + FunctionDeploymentMethod::ZipDeploy => "zipDeploy", + FunctionDeploymentMethod::RunFromPackage => "runFromPackage", + } + } +} + +/// Builder for a [`TaskStep`] invoking `AzureFunctionApp@2`. +/// +/// Deploys a function app package to Azure Functions (Windows or Linux, +/// including Flex Consumption plan). The `azure_subscription` parameter must +/// name an Azure Resource Manager service connection configured in the ADO +/// project. Equivalent to the `AzureFunctionApp@2` Azure DevOps task. +/// +/// # Examples +/// +/// Deploy a Windows function app: +/// ```rust +/// use ado_aw::compile::ir::tasks::azure_function_app::{AzureFunctionApp, FunctionAppType}; +/// +/// let step = AzureFunctionApp::new( +/// "my-azure-sub", +/// FunctionAppType::Windows, +/// "my-func-app", +/// "$(System.DefaultWorkingDirectory)/**/*.zip", +/// ) +/// .into_step(); +/// ``` +/// +/// Deploy to a staging slot on a Linux function app: +/// ```rust +/// use ado_aw::compile::ir::tasks::azure_function_app::{AzureFunctionApp, FunctionAppType}; +/// +/// let step = AzureFunctionApp::new( +/// "my-azure-sub", +/// FunctionAppType::Linux, +/// "my-func-app", +/// "$(Build.ArtifactStagingDirectory)/app.zip", +/// ) +/// .deploy_to_slot_or_ase(true) +/// .resource_group_name("my-resource-group") +/// .slot_name("staging") +/// .runtime_stack("NODE|20") +/// .into_step(); +/// ``` +/// +/// ADO task reference: +/// +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AzureFunctionApp { + #[serde(rename = "azureSubscription")] + azure_subscription: String, + #[serde(rename = "appType")] + app_type: FunctionAppType, + #[serde(rename = "appName")] + app_name: String, + package: String, + #[serde( + rename = "isFlexConsumption", + default, + deserialize_with = "de_opt_bool_flex" + )] + is_flex_consumption: Option, + #[serde( + rename = "deployToSlotOrASE", + default, + deserialize_with = "de_opt_bool_flex" + )] + deploy_to_slot_or_ase: Option, + #[serde(rename = "resourceGroupName", default)] + resource_group_name: Option, + #[serde(rename = "slotName", default)] + slot_name: Option, + #[serde(rename = "runtimeStack", default)] + runtime_stack: Option, + #[serde(rename = "appSettings", default)] + app_settings: Option, + #[serde(rename = "deploymentMethod", default)] + deployment_method: Option, + #[serde(skip)] + display_name: Option, +} + +impl AzureFunctionApp { + /// Create a new `AzureFunctionApp@2` builder. + /// + /// # Parameters + /// - `azure_subscription` — the Azure Resource Manager service connection + /// name configured in the ADO project (maps to `connectedServiceNameARM` + /// / `azureSubscription`). + /// - `app_type` — [`FunctionAppType::Windows`] or + /// [`FunctionAppType::Linux`]. + /// - `app_name` — the name of the Azure Functions App to deploy to. + /// - `package` — path to the package or folder to deploy; supports ADO + /// variables and wildcards (e.g. + /// `"$(System.DefaultWorkingDirectory)/**/*.zip"`). + pub fn new( + azure_subscription: impl Into, + app_type: FunctionAppType, + app_name: impl Into, + package: impl Into, + ) -> Self { + Self { + azure_subscription: azure_subscription.into(), + app_type, + app_name: app_name.into(), + package: package.into(), + is_flex_consumption: None, + deploy_to_slot_or_ase: None, + resource_group_name: None, + slot_name: None, + runtime_stack: None, + app_settings: None, + deployment_method: None, + display_name: None, + } + } + + /// `isFlexConsumption` — set to `true` when the function app is hosted on + /// a [Flex Consumption plan](https://learn.microsoft.com/en-us/azure/azure-functions/flex-consumption-plan). + /// ADO default: `false`. + pub fn flex_consumption(mut self, value: bool) -> Self { + self.is_flex_consumption = Some(value); + self + } + + /// `deployToSlotOrASE` — when `true`, deploys to a specific deployment + /// slot or App Service Environment rather than the default production slot. + /// Requires [`resource_group_name`][Self::resource_group_name] and + /// [`slot_name`][Self::slot_name] to be set. Not applicable to Flex + /// Consumption plan apps. ADO default: `false`. + pub fn deploy_to_slot_or_ase(mut self, value: bool) -> Self { + self.deploy_to_slot_or_ase = Some(value); + self + } + /// `resourceGroupName` — the Azure resource group containing the Function + /// App. Required when [`deploy_to_slot_or_ase`][Self::deploy_to_slot_or_ase] + /// is `true`. + pub fn resource_group_name(mut self, value: impl Into) -> Self { + self.resource_group_name = Some(value.into()); + self + } + + /// `slotName` — the deployment slot to target (ADO default: `"production"`). + /// Required when [`deploy_to_slot_or_ase`][Self::deploy_to_slot_or_ase] + /// is `true`. + pub fn slot_name(mut self, value: impl Into) -> Self { + self.slot_name = Some(value.into()); + self + } + + /// `runtimeStack` — the runtime stack for Linux function apps (e.g. + /// `"NODE|20"`, `"PYTHON|3.12"`, `"DOTNET-ISOLATED|8.0"`). Only + /// applicable when `app_type` is [`FunctionAppType::Linux`] and the app + /// is not on a Flex Consumption plan. + pub fn runtime_stack(mut self, value: impl Into) -> Self { + self.runtime_stack = Some(value.into()); + self + } + + /// `appSettings` — application settings to configure on the Function App, + /// using `-key value` syntax (e.g. `"-Port 5000 -WEBSITE_TIME_ZONE \"Eastern Standard Time\""`). + pub fn app_settings(mut self, value: impl Into) -> Self { + self.app_settings = Some(value.into()); + self + } + + /// `deploymentMethod` — how ADO deploys the package to the Function App. + /// ADO default: [`FunctionDeploymentMethod::Auto`]. Not applicable to Flex + /// Consumption plan apps or WAR/JAR packages. + pub fn deployment_method(mut self, value: FunctionDeploymentMethod) -> Self { + self.deployment_method = Some(value); + self + } + + /// Override the default `displayName` (`"Azure Functions Deploy"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "AzureFunctionApp@2", + self.display_name + .unwrap_or_else(|| "Azure Functions Deploy".into()), + ) + .with_input("azureSubscription", self.azure_subscription) + .with_input("appType", self.app_type.as_ado_str()) + .with_input("appName", self.app_name) + .with_input("package", self.package); + push_bool(&mut t, "isFlexConsumption", self.is_flex_consumption); + push_bool(&mut t, "deployToSlotOrASE", self.deploy_to_slot_or_ase); + push_opt(&mut t, "resourceGroupName", self.resource_group_name); + push_opt(&mut t, "slotName", self.slot_name); + push_opt(&mut t, "runtimeStack", self.runtime_stack); + push_opt(&mut t, "appSettings", self.app_settings); + if let Some(m) = self.deployment_method { + t = t.with_input("deploymentMethod", m.as_ado_str()); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minimal_windows_deploy_emits_required_inputs() { + let t = AzureFunctionApp::new( + "my-sub", + FunctionAppType::Windows, + "my-func", + "$(System.DefaultWorkingDirectory)/**/*.zip", + ) + .into_step(); + assert_eq!(t.task, "AzureFunctionApp@2"); + assert_eq!(t.display_name, "Azure Functions Deploy"); + assert_eq!( + t.inputs.get("azureSubscription").map(String::as_str), + Some("my-sub") + ); + assert_eq!( + t.inputs.get("appType").map(String::as_str), + Some("functionApp") + ); + assert_eq!( + t.inputs.get("appName").map(String::as_str), + Some("my-func") + ); + assert_eq!( + t.inputs.get("package").map(String::as_str), + Some("$(System.DefaultWorkingDirectory)/**/*.zip") + ); + assert!(t.inputs.get("isFlexConsumption").is_none()); + assert!(t.inputs.get("deployToSlotOrASE").is_none()); + assert!(t.inputs.get("runtimeStack").is_none()); + assert!(t.inputs.get("deploymentMethod").is_none()); + } + + #[test] + fn linux_app_type_emits_correct_token() { + let t = AzureFunctionApp::new( + "sub", + FunctionAppType::Linux, + "func-app", + "app.zip", + ) + .into_step(); + assert_eq!( + t.inputs.get("appType").map(String::as_str), + Some("functionAppLinux") + ); + } + + #[test] + fn slot_deployment_sets_all_relevant_inputs() { + let t = AzureFunctionApp::new( + "sub", + FunctionAppType::Windows, + "func-app", + "$(Build.ArtifactStagingDirectory)/func.zip", + ) + .deploy_to_slot_or_ase(true) + .resource_group_name("my-rg") + .slot_name("staging") + .into_step(); + assert_eq!( + t.inputs.get("deployToSlotOrASE").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs.get("resourceGroupName").map(String::as_str), + Some("my-rg") + ); + assert_eq!( + t.inputs.get("slotName").map(String::as_str), + Some("staging") + ); + } + + #[test] + fn linux_runtime_stack_is_emitted() { + let t = AzureFunctionApp::new("sub", FunctionAppType::Linux, "app", "pkg.zip") + .runtime_stack("NODE|20") + .into_step(); + assert_eq!( + t.inputs.get("runtimeStack").map(String::as_str), + Some("NODE|20") + ); + } + + #[test] + fn deployment_method_enum_round_trips() { + for (method, expected) in [ + (FunctionDeploymentMethod::Auto, "auto"), + (FunctionDeploymentMethod::ZipDeploy, "zipDeploy"), + (FunctionDeploymentMethod::RunFromPackage, "runFromPackage"), + ] { + let t = AzureFunctionApp::new("sub", FunctionAppType::Windows, "app", "pkg.zip") + .deployment_method(method) + .into_step(); + assert_eq!( + t.inputs.get("deploymentMethod").map(String::as_str), + Some(expected), + "method {method:?}" + ); + } + } + + #[test] + fn flex_consumption_flag_is_emitted() { + let t = AzureFunctionApp::new("sub", FunctionAppType::Linux, "app", "pkg.zip") + .flex_consumption(true) + .into_step(); + assert_eq!( + t.inputs.get("isFlexConsumption").map(String::as_str), + Some("true") + ); + } + + #[test] + fn app_settings_are_forwarded() { + let t = AzureFunctionApp::new("sub", FunctionAppType::Windows, "app", "pkg.zip") + .app_settings("-MY_SETTING value") + .into_step(); + assert_eq!( + t.inputs.get("appSettings").map(String::as_str), + Some("-MY_SETTING value") + ); + } + + #[test] + fn display_name_override_is_respected() { + let t = AzureFunctionApp::new("sub", FunctionAppType::Windows, "app", "pkg.zip") + .with_display_name("Deploy to production") + .into_step(); + assert_eq!(t.display_name, "Deploy to production"); + } +} diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs index e66fc44f..a29ef43c 100644 --- a/src/compile/ir/tasks/mod.rs +++ b/src/compile/ir/tasks/mod.rs @@ -23,6 +23,7 @@ pub mod archive_files; pub mod azure_cli; pub mod azure_container_apps; pub mod azure_file_copy; +pub mod azure_function_app; pub mod azure_key_vault; pub mod azure_powershell; pub mod azure_web_app; diff --git a/src/compile/ir/tasks/parse.rs b/src/compile/ir/tasks/parse.rs index 17949af9..a667ea2c 100644 --- a/src/compile/ir/tasks/parse.rs +++ b/src/compile/ir/tasks/parse.rs @@ -30,16 +30,16 @@ use serde::de::DeserializeOwned; use serde_yaml::Value; use super::{ - archive_files, azure_cli, azure_container_apps, azure_file_copy, azure_key_vault, - azure_powershell, azure_web_app, cargo_authenticate, cmd_line, copy_files, delete_files, docker, - docker_installer, dotnet_core_cli, download_build_artifacts, download_package, - download_pipeline_artifact, download_secure_file, extract_files, github_release, go_tool, gradle, - helm_installer, java_tool_installer, manual_validation, maven, maven_authenticate, node_tool, - npm, npm_authenticate, nuget_authenticate, nuget_command, pip_authenticate, powershell, - publish_build_artifacts, publish_code_coverage_results, publish_pipeline_artifact, - publish_test_results, python_script, sonar_qube_analyze, sonar_qube_prepare, sonar_qube_publish, - twine_authenticate, universal_packages, use_dotnet, use_node, use_python_version, - use_ruby_version, vs_build, vstest, + archive_files, azure_cli, azure_container_apps, azure_file_copy, azure_function_app, + azure_key_vault, azure_powershell, azure_web_app, cargo_authenticate, cmd_line, copy_files, + delete_files, docker, docker_installer, dotnet_core_cli, download_build_artifacts, + download_package, download_pipeline_artifact, download_secure_file, extract_files, + github_release, go_tool, gradle, helm_installer, java_tool_installer, manual_validation, maven, + maven_authenticate, node_tool, npm, npm_authenticate, nuget_authenticate, nuget_command, + pip_authenticate, powershell, publish_build_artifacts, publish_code_coverage_results, + publish_pipeline_artifact, publish_test_results, python_script, sonar_qube_analyze, + sonar_qube_prepare, sonar_qube_publish, twine_authenticate, universal_packages, use_dotnet, + use_node, use_python_version, use_ruby_version, vs_build, vstest, }; /// Registry mapping an ADO task id (`"CopyFiles@2"`) to a validator that checks @@ -58,6 +58,7 @@ const VALIDATORS: &[(&str, fn(Value) -> Result<(), String>)] = &[ // ── Flat single-struct builders (validity == clean deserialization) ── ("ArchiveFiles@2", validate_by_deserialize::), ("AzureContainerApps@1", validate_by_deserialize::), + ("AzureFunctionApp@2", validate_by_deserialize::), ("AzureKeyVault@2", validate_by_deserialize::), ("AzureWebApp@1", validate_by_deserialize::), ("CargoAuthenticate@0", validate_by_deserialize::), @@ -810,6 +811,40 @@ mod tests { assert!(err.contains("SonarQube"), "got: {err}"); } + #[test] + fn roundtrip_azure_function_app() { + assert_roundtrips( + azure_function_app::AzureFunctionApp::new( + "arm-conn", + azure_function_app::FunctionAppType::Linux, + "my-func", + "$(Build.ArtifactStagingDirectory)/app.zip", + ) + .deploy_to_slot_or_ase(true) + .resource_group_name("rg") + .slot_name("staging") + .deployment_method(azure_function_app::FunctionDeploymentMethod::ZipDeploy) + .into_step(), + ); + } + + #[test] + fn azure_function_app_unknown_input_warns() { + let step = yaml( + r#" + task: AzureFunctionApp@2 + inputs: + azureSubscription: conn + appType: functionApp + appName: my-func + package: app.zip + bogusInput: nope + "#, + ); + let err = validate_task_step(&step).expect("recognized").unwrap_err(); + assert!(err.contains("AzureFunctionApp@2"), "got: {err}"); + } + #[test] fn registry_has_no_duplicate_task_ids() { let mut ids: Vec<&str> = VALIDATORS.iter().map(|(id, _)| *id).collect();