diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 042d2d6d8..8e372ff80 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,13 @@ Release Notes ============= +## Unreleased +* Computation Expressions: Added `Zero`, `Combine`, `Delay`, `Run`, and `For` methods to `DeploymentBuilder` to enable control flow constructs in the `arm` builder computation expression. + * This allows for more flexible deployment composition using F#'s computation expression features + * The existing `output` method overloads for `Option` types (`string option` and `ArmExpression option`) can now be used more naturally + * Users can use copy-and-update patterns on deployment records for conditional composition + * Example: `output "vmIP" (myVm.PublicIpId |> Option.map (fun ip -> ip.ArmExpression))` - [link to PR] + ## 1.9.25 * Service Bus: Support for minimum TLS version of 1.3. * Storage Accounts: Support for requesting minimum TLS version of 1.3. The ARM resource itself currently falls back to 1.2. diff --git a/src/Farmer/Builders/Builders.ResourceGroup.fs b/src/Farmer/Builders/Builders.ResourceGroup.fs index 802d69553..5048608d8 100644 --- a/src/Farmer/Builders/Builders.ResourceGroup.fs +++ b/src/Farmer/Builders/Builders.ResourceGroup.fs @@ -130,7 +130,7 @@ type ResourceGroupConfig = { ] type DeploymentBuilder() = - member _.Yield _ = { + static member private EmptyState() = { TargetResourceGroup = None DeploymentName = AutoGeneratable.AutoGenerate() Dependencies = Set.empty @@ -144,6 +144,55 @@ type DeploymentBuilder() = Tags = Map.empty } + member _.Yield _ = DeploymentBuilder.EmptyState() + + /// Returns empty state for if-then expressions without else branches. + /// Note: Custom operations (marked with []) cannot be used inside control flow (if/match/for/while/try). + /// This is F# error FS3086. Use Option type overloads instead: output "key" (someOption |> Option.map ...) + member _.Zero() = DeploymentBuilder.EmptyState() + + /// Combines two deployment states together. + /// Note: This enables control flow, but custom operations (like 'output', 'add_resource') still cannot be used inside. + /// Unlike seq/async which use yield/let! (not custom ops), Farmer operations are custom operations (error FS3086). + member _.Combine(state1: ResourceGroupConfig, state2: ResourceGroupConfig) = { + TargetResourceGroup = state2.TargetResourceGroup |> Option.orElse state1.TargetResourceGroup + DeploymentName = + match state2.DeploymentName with + | AutoGeneratedValue { contents = Some _ } -> state2.DeploymentName + | AutoGeneratedValue { contents = None } -> state1.DeploymentName + | FixedValue _ -> state2.DeploymentName + Dependencies = state1.Dependencies + state2.Dependencies + Parameters = state1.Parameters + state2.Parameters + Outputs = Map.merge (Map.toList state1.Outputs) state2.Outputs + Resources = + state1.Resources @ state2.Resources + |> List.distinctBy (fun r -> r.ResourceId, r.GetType().Name) + ParameterValues = state1.ParameterValues @ state2.ParameterValues + SubscriptionId = state2.SubscriptionId |> Option.orElse state1.SubscriptionId + Location = + match state2.Location with + | LocationExpression _ when state2.Location = Location.ResourceGroup -> state1.Location + | _ -> state2.Location + Mode = state2.Mode + Tags = Map.merge (Map.toList state1.Tags) state2.Tags + } + + /// Delays computation for proper evaluation order. + member _.Delay(f: unit -> ResourceGroupConfig) = f + + /// Runs the delayed computation. + member _.Run(f: unit -> ResourceGroupConfig) = f () + + /// Enables for loops over sequences. + /// Note: Custom operations cannot be used inside the loop body (F# error FS3086 for custom operations in control flow). + member this.For(sequence: seq<'T>, body: 'T -> ResourceGroupConfig) = + let mutable state = this.Zero() + + for item in sequence do + state <- this.Combine(state, body item) + + state + /// Creates an output value that will be returned by the ARM template. [] member _.Output(state, outputName, outputValue) : ResourceGroupConfig = { diff --git a/src/Tests/Template.fs b/src/Tests/Template.fs index 323a0776e..f75958d6c 100644 --- a/src/Tests/Template.fs +++ b/src/Tests/Template.fs @@ -559,4 +559,80 @@ let tests = expected "Parameter 'vault-secret-name' is incorrect." } + + test "Can use output with string Option type (None case)" { + let optionalValue: string option = None + + let template = + arm { + location Location.NorthEurope + output "conditionalOutput" optionalValue + } + |> toTemplate + + Expect.isEmpty template.outputs "Should have no outputs when value is None" + } + + test "Can use output with string Option type (Some case)" { + let optionalValue = Some "test-value" + + let template = + arm { + location Location.NorthEurope + output "conditionalOutput" optionalValue + } + |> toTemplate + + Expect.equal template.outputs.Count 1 "Should have one output" + Expect.equal template.outputs.["conditionalOutput"].value "test-value" "" + } + + test "Can use output with ArmExpression Option type (None)" { + let optionalExpr: ArmExpression option = None + + let template = + arm { + location Location.NorthEurope + output "conditionalExpr" optionalExpr + } + |> toTemplate + + Expect.isEmpty template.outputs "Should have no outputs when expression is None" + } + + test "Can use output with ArmExpression Option type (Some)" { + let optionalExpr = Some(ArmExpression.create "resourceGroup().location") + + let template = + arm { + location Location.NorthEurope + output "conditionalExpr" optionalExpr + } + |> toTemplate + + Expect.equal template.outputs.Count 1 "Should have one output" + Expect.equal template.outputs.["conditionalExpr"].value "[resourceGroup().location]" "" + } + + test "Copy-and-update pattern enables conditional deployment composition" { + let includeOutput = true + + let baseDeployment = arm { + location Location.NorthEurope + add_resource (storageAccount { name "storage1" }) + } + + let deployment = + if includeOutput then + { + baseDeployment with + Outputs = baseDeployment.Outputs.Add("testOutput", "testValue") + } + else + baseDeployment + + if includeOutput then + Expect.equal deployment.Outputs.Count 1 "Should have one output" + Expect.isTrue (deployment.Outputs.ContainsKey "testOutput") "Should have testOutput" + } ] \ No newline at end of file