diff --git a/Farmer.sln b/Farmer.sln index bfe6f246d..928278680 100644 --- a/Farmer.sln +++ b/Farmer.sln @@ -1,7 +1,6 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29201.188 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.32002.185 MinimumVisualStudioVersion = 10.0.40219.1 Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Farmer", "src\Farmer\Farmer.fsproj", "{CB0287CC-AD12-427C-866B-5F236C29B0A2}" EndProject @@ -18,6 +17,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C399 samples\scripts\appinsights.fsx = samples\scripts\appinsights.fsx samples\scripts\bastion.fsx = samples\scripts\bastion.fsx samples\scripts\cdn.fsx = samples\scripts\cdn.fsx + samples\scripts\container-app.fsx = samples\scripts\container-app.fsx samples\scripts\container-group.fsx = samples\scripts\container-group.fsx samples\scripts\container-instance-gpu.fsx = samples\scripts\container-instance-gpu.fsx samples\scripts\container-instance.fsx = samples\scripts\container-instance.fsx diff --git a/optimiser.fsx b/optimiser.fsx new file mode 100644 index 000000000..96b6eb29f --- /dev/null +++ b/optimiser.fsx @@ -0,0 +1,136 @@ +#r "nuget:FsCheck" + +open FsCheck +open System + +type [] Gb +type [] VCores + +let resourceMinimums = + [ + 0.25, 0.5 + 0.5, 1.0 + 0.75, 1.5 + 1.0, 2.0 + 1.25, 2.5 + 1.5, 3.0 + 1.75, 3.5 + 2.0, 4. + ] + + +let cores = 1.75 +let containers = 5. + + +let optimise containers (cores:float, memory:float) = + let containers = float containers + let minCores = resourceMinimums |> List.tryFind (fun (cores, _) -> float cores > containers * 0.05) |> Option.map fst + let minRam = resourceMinimums |> List.tryFind (fun (_, ram) -> float ram > containers * 0.01) |> Option.map snd + match minCores, minRam with + | Some minCores, Some minRam -> + if minCores > cores then Error $"Insufficient cores (minimum is {minCores}VCores)." + elif minRam > memory then Error $"Insufficient memory (minimum is {minRam}Gb)." + else + let cores = float cores + let memory = float memory + + let vcoresPerContainer = Math.Truncate ((cores / containers) * 20.) / 20. + let remainingCores = cores - (vcoresPerContainer * containers) + + let gbPerContainer = Math.Truncate ((memory / containers) * 100.) / 100. + let remainingGb = memory - (gbPerContainer * containers) + + Ok [ + for container in 1. .. containers do + if container = 1. then + (vcoresPerContainer + remainingCores) * 1., (gbPerContainer + remainingGb) * 1. + else + vcoresPerContainer * 1., gbPerContainer * 1. + ] + | None, _ -> + Error "Insufficient cores" + | _, None -> + Error "Insufficient memory" + +// Usage +optimise 4 (1.0, 4.) + + + + + + + + + + + + + +type Inputs = PositiveInt * float * float +type ValidInput = ValidInput of Inputs +type InvalidInput = InvalidInput of Inputs + +type Tests = + static member totalsAlwaysEqualInput (ValidInput(PositiveInt containers, cores, memory)) = + let split = optimise containers (cores, memory) + match split with + | Ok split -> + let correctCores = split |> List.sumBy fst |> decimal = decimal cores + let correctRam = split |> List.sumBy snd |> decimal = decimal memory + correctCores && correctRam + | Error msg -> + failwith msg + + static member givesBackCorrectNumberOfConfigs (ValidInput(PositiveInt containers, cores, memory)) = + let split = optimise containers (cores, memory) + match split with + | Ok split -> split.Length = containers + | Error msg -> failwith msg + + static member neverReturnsLessThanMinimum (ValidInput(PositiveInt containers, cores, memory)) = + let split = optimise containers (cores, memory) + match split with + | Ok split -> split |> List.forall(fun (c, m) -> c >= 0.05 && m >= 0.01) + | Error msg -> failwith msg + + static member failsIfInputsInvalid (InvalidInput(PositiveInt containers, cores, memory)) = + let split = optimise containers (cores, memory) + match split with + | Ok _ -> failwith "Should have failed." + | Error _ -> true + +let basicGen = gen { + let! cores, gb = Gen.elements resourceMinimums + let! containers = Arb.Default.PositiveInt () |> Arb.filter(fun (PositiveInt s) -> s < 20) |> Arb.toGen + return containers, cores, gb +} + +let shrinker checker (con:PositiveInt, cor, mem) = + [ + if con.Get > 1 then PositiveInt (con.Get - 1), cor, mem + if cor > 0.25 then con, cor - 0.25, mem - 0.5 + ] + |> List.filter checker + +type ResourceArb = + static member IsValid (PositiveInt con, cor, mem) = + let cores = resourceMinimums |> Seq.find(fun (cores, _) -> float cores > float con * 0.05) |> fst <= cor + let memory = resourceMinimums |> Seq.find(fun (_, mem) -> float mem > float con * 0.01) |> snd <= mem + cores && memory + + static member ValidInputs () = + { new Arbitrary () with + override _.Generator = basicGen |> Gen.filter ResourceArb.IsValid |> Gen.map ValidInput + override _.Shrinker (ValidInput inputs) = inputs |> shrinker ResourceArb.IsValid |> Seq.map ValidInput + } + static member InvalidInputs () = + { new Arbitrary () with + override _.Generator = basicGen |> Gen.filter (ResourceArb.IsValid >> not) |> Gen.map InvalidInput + override _.Shrinker (InvalidInput inputs) = inputs |> shrinker (ResourceArb.IsValid >> not) |> Seq.map InvalidInput + } + +let config = { Config.Default with Arbitrary = [ typeof ] } + +Check.All config \ No newline at end of file diff --git a/samples/scripts/container-app.fsx b/samples/scripts/container-app.fsx index 48901c29a..996e59cbe 100644 --- a/samples/scripts/container-app.fsx +++ b/samples/scripts/container-app.fsx @@ -1,12 +1,11 @@ -#r @"..\..\src\Farmer\bin\Debug\net5.0\Farmer.dll" +#r @"..\..\src\Farmer\bin\Debug\netstandard2.0\Farmer.dll" open Farmer open Farmer.Builders open Farmer.ContainerApp -open System let queueName = "myqueue" -let storageName = $"{Guid.NewGuid().ToString().[0..5]}containerqueue" +let storageName = $"isaaccontainerqueue" let myStorageAccount = storageAccount { name storageName add_queue queueName @@ -14,26 +13,26 @@ let myStorageAccount = storageAccount { let env = containerEnvironment { - name $"containerenv{Guid.NewGuid().ToString().[0..5]}" + name $"containerenvisaac" add_containers [ - containerApp { - name "aspnetsample" - add_simple_container "mcr.microsoft.com/dotnet/samples" "aspnetapp" - ingress_target_port 80us - ingress_transport Auto - add_http_scale_rule "http-scaler" { ConcurrentRequests = 10 } - add_cpu_scale_rule "cpu-scaler" { Utilisation = 50 } - } containerApp { name "queuereaderapp" + allocate_resources ResourceLevels.``CPU = 1.75, RAM = 3.5`` add_containers [ + container { + name "aspnetsample" + public_docker_image "mcr.microsoft.com/dotnet/samples" "aspnetapp" + } container { name "queuereaderapp" - public_docker_image "mcr.microsoft.com/azuredocs/containerapps-queuereader" "" - cpu_cores 1.0 - memory 1.0 + public_docker_image "mcr.microsoft.com/azuredocs/containerapps-queuereader" null } ] + ingress_target_port 80us + ingress_transport Auto + active_revision_mode ActiveRevisionsMode.Single + add_http_scale_rule "http-scaler" { ConcurrentRequests = 10 } + add_cpu_scale_rule "cpu-scaler" { Utilisation = 50 } replicas 1 10 add_env_variable "QueueName" queueName add_secret_expression "queueconnectionstring" myStorageAccount.Key @@ -42,6 +41,8 @@ let env = ] } +env.ContainerApps.[0].Containers + let template = arm { location Location.NorthEurope diff --git a/src/Farmer/Builders/Builders.ContainerApps.fs b/src/Farmer/Builders/Builders.ContainerApps.fs index b07154747..c3fd24659 100644 --- a/src/Farmer/Builders/Builders.ContainerApps.fs +++ b/src/Farmer/Builders/Builders.ContainerApps.fs @@ -32,7 +32,8 @@ type ContainerAppConfig = /// Credentials for image registries used by containers in this environment. ImageRegistryCredentials : ImageRegistryAuthentication list Containers : ContainerConfig list - Dependencies : Set } + Dependencies : Set + ResourceAllocation : ContainerAppResourceLevel option } type ContainerEnvironmentConfig = { Name : ResourceName @@ -119,20 +120,48 @@ type ContainerEnvironmentBuilder() = /// Support for adding dependencies to this Container App Environment. interface IDependable with member _.Add state newDeps = { state with Dependencies = state.Dependencies + newDeps } -let private supportedResourceCombinations = - Set [ - 0.25, 0.5 - 0.5, 1.0 - 0.75, 1.5 - 1.0, 2.0 - 1.25, 2.5 - 1.5, 3.0 - 1.75, 3.5 - 2.0, 4. - ] - let private defaultResources = {| CPU = 0.25; Memory = 0.5 |} +[] +module ResourceOptimisation = + open System + let supportedResourceCombinations = ResourceLevels.AllLevels |> Set.toList + let MIN_CORE_SIZE = 0.05 + let MIN_RAM_SIZE = 0.01 + + let optimise (containers:int) (cores:float, memory:float) = + let containers = float containers + let requiredMinCores = supportedResourceCombinations |> List.map (fun (ContainerAppResourceLevel (cores, _)) -> cores) |> List.tryFind (fun cores -> float cores > containers * MIN_CORE_SIZE) + let requiredMinRam = supportedResourceCombinations |> List.map (fun (ContainerAppResourceLevel (_, ram)) -> ram) |> List.tryFind (fun ram -> float ram > containers * MIN_RAM_SIZE) + + match requiredMinCores, requiredMinRam with + | Some minCores, Some minRam -> + if minCores > cores then Error $"Insufficient cores (minimum is {minCores}VCores)." + elif minRam > memory then Error $"Insufficient memory (minimum is {minRam}Gb)." + else + let cores = float cores + let memory = float memory + + let vcoresPerContainer = Math.Truncate ((cores / containers) * 20.) / 20. + let remainingCores = cores - (vcoresPerContainer * containers) + + let gbPerContainer = Math.Truncate ((memory / containers) * 100.) / 100. + let remainingGb = memory - (gbPerContainer * containers) + + Ok [ + for container in 1. .. containers do + if container = 1. then + {| CPU = (vcoresPerContainer + remainingCores) * 1. + Memory = (gbPerContainer + remainingGb) * 1. |} + else + {| CPU = vcoresPerContainer * 1. + Memory = gbPerContainer * 1. |} + ] + | None, _ -> + Error "Insufficient cores" + | _, None -> + Error "Insufficient memory" + type ContainerAppBuilder () = member _.Yield _ = { Name = ResourceName.Empty @@ -145,27 +174,50 @@ type ContainerAppBuilder () = IngressMode = None EnvironmentVariables = Map.empty DaprConfig = None - Dependencies = Set.empty } - + Dependencies = Set.empty + ResourceAllocation = None } member _.Run (state:ContainerAppConfig) = + let state = + match state.ResourceAllocation with + | Some (ContainerAppResourceLevel (cores, memory)) -> + if state.Containers |> List.exists (fun r -> r.Resources <> defaultResources) then + raiseFarmer "You have set resource allocation at the Container App level, but also set the resource levels of some individual containers. If you are using Container App Resource Allocation, you cannot set resources of individual containers." + + let split = ResourceOptimisation.optimise state.Containers.Length (cores, memory) + match split with + | Ok resources -> + let containersAndResources = List.zip state.Containers resources + + { state with + Containers = [ + for (container, resources) in containersAndResources do + { container with Resources = resources } + ] + } + | Error msg -> + raiseFarmer msg + | None -> + state + let resourceTotals = state.Containers |> List.fold (fun (cpu, ram) container -> cpu + container.Resources.CPU, ram + container.Resources.Memory ) (0., 0.) + |> ContainerAppResourceLevel - let describe (cpu, ram) = $"({cpu}VCores, {ram}Gb)" - if not (supportedResourceCombinations.Contains resourceTotals) then - let supported = Set.toList supportedResourceCombinations |> List.map describe |> String.concat "; " + let describe (ContainerAppResourceLevel (cpu, ram)) = $"({cpu}VCores, {ram}Gb)" + if not (ResourceLevels.AllLevels.Contains resourceTotals) then + let supported = Set.toList ResourceLevels.AllLevels |> List.map describe |> String.concat "; " raiseFarmer $"The container app '{state.Name.Value}' has an invalid combination of CPU and Memory {describe resourceTotals}. All the containers within a container app must have a combined CPU & RAM combination that matches one of the following: [ {supported} ]." state /// Sets the name of the Azure Container App. [] - member _.ResourceName (state:ContainerAppConfig, name:string) = { state with Name = ResourceName name } - + member _.ResourceName (state:ContainerAppConfig, name:string) = + { state with Name = ResourceName name } /// Adds a scale rule to the Azure Container App. [] member _.AddHttpScaleRule (state:ContainerAppConfig, name, rule:HttpScaleRule) = @@ -307,14 +359,21 @@ type ContainerAppBuilder () = } this.AddContainers(state, [ container ]) + [] + /// Allocates resources equally to all containers in the container app. + member _.ShareResources (state:ContainerAppConfig, resourceLevel:ContainerAppResourceLevel) = + { state with ResourceAllocation = Some resourceLevel } + /// Support for adding dependencies to this Container App. - interface IDependable with member _.Add state newDeps = { state with Dependencies = state.Dependencies + newDeps } + interface IDependable with + member _.Add state newDeps = { state with Dependencies = state.Dependencies + newDeps } type ContainerBuilder () = member _.Yield _ = { ContainerName = "" DockerImage = None Resources = defaultResources } + /// Set docker credentials [] member _.ContainerName (state:ContainerConfig, name) = @@ -345,6 +404,6 @@ type ContainerBuilder () = let roundedMemory = System.Math.Round(memory, 2) * 1. { state with Resources = {| state.Resources with Memory = roundedMemory |} } -let containerEnvironment = ContainerEnvironmentBuilder() -let containerApp = ContainerAppBuilder() -let container = ContainerBuilder() \ No newline at end of file +let containerEnvironment = ContainerEnvironmentBuilder () +let containerApp = ContainerAppBuilder () +let container = ContainerBuilder () \ No newline at end of file diff --git a/src/Farmer/Common.fs b/src/Farmer/Common.fs index 3416144de..d46a13ba3 100644 --- a/src/Farmer/Common.fs +++ b/src/Farmer/Common.fs @@ -2129,6 +2129,27 @@ module ContainerApp = | PublicImage (container, version) -> let version = version |> Option.defaultValue "latest" $"{container}:{version}" + type ContainerAppResourceLevel = ContainerAppResourceLevel of cores:float * memory:float + module ResourceLevels = + let ``CPUs = 0.25, RAM = 0.5`` = ContainerAppResourceLevel (0.25, 0.5) + let ``CPUs = 0.5, RAM = 1.0`` = ContainerAppResourceLevel (0.5, 1.0) + let ``CPUs = 0.75, RAM = 1.5`` = ContainerAppResourceLevel (0.75, 1.5) + let ``CPUs = 1.0, RAM = 2.0`` = ContainerAppResourceLevel (1.0, 2.0) + let ``CPUs = 1.25, RAM = 2.5`` = ContainerAppResourceLevel (1.25, 2.5) + let ``CPUs = 1.5, RAM = 3.0`` = ContainerAppResourceLevel (1.5, 3.0) + let ``CPUs = 1.75, RAM = 3.5`` = ContainerAppResourceLevel (1.75, 3.5) + let ``CPUs = 2.0, RAM = 4.0`` = ContainerAppResourceLevel (2.0, 4.) + + let AllLevels = Set [ + ``CPUs = 0.25, RAM = 0.5`` + ``CPUs = 0.5, RAM = 1.0`` + ``CPUs = 0.75, RAM = 1.5`` + ``CPUs = 1.0, RAM = 2.0`` + ``CPUs = 1.25, RAM = 2.5`` + ``CPUs = 1.5, RAM = 3.0`` + ``CPUs = 1.75, RAM = 3.5`` + ``CPUs = 2.0, RAM = 4.0`` + ] namespace Farmer.DiagnosticSettings diff --git a/src/Tests/AllTests.fs b/src/Tests/AllTests.fs index 7a7d2efd1..c72335242 100644 --- a/src/Tests/AllTests.fs +++ b/src/Tests/AllTests.fs @@ -73,4 +73,4 @@ let allTests = [] let main _ = printfn "Running tests!" - runTests { defaultConfig with verbosity = Logging.Info } allTests + runTests { defaultConfig with verbosity = Logging.LogLevel.Verbose } allTests diff --git a/src/Tests/ContainerApps.fs b/src/Tests/ContainerApps.fs index e665a70cd..9fcc25a5c 100644 --- a/src/Tests/ContainerApps.fs +++ b/src/Tests/ContainerApps.fs @@ -1,6 +1,7 @@ module ContainerApps open Expecto +open FsCheck open Farmer open Farmer.Builders open Newtonsoft.Json.Linq @@ -65,7 +66,7 @@ let fullContainerAppDeployment = ] } -let tests = testList "Container Apps" [ +let standardTests = testList "Standard Tests" [ let jsonTemplate = fullContainerAppDeployment.Template |> Writer.toJson let jobj = JObject.Parse jsonTemplate @@ -124,3 +125,74 @@ let tests = testList "Container Apps" [ Expect.equal (scale.["maxReplicas"] |> int) 5 "Incorrect max replicas" } ] + +type Inputs = PositiveInt * float * float +type ValidInput = ValidInput of Inputs +type InvalidInput = InvalidInput of Inputs + +let basicGen = gen { + let! (ContainerAppResourceLevel (cores,gb)) = Gen.elements ResourceLevels.AllLevels + let! containers = Arb.Default.PositiveInt () |> Arb.filter(fun (PositiveInt s) -> s < 20) |> Arb.toGen + return containers, cores, gb +} + +let shrinker checker (con:PositiveInt, cor, mem) = + [ + if con.Get > 1 then PositiveInt (con.Get - 1), cor, mem + if cor > 0.25 then con, cor - 0.25, mem - 0.5 + ] + |> List.filter checker + +type ResourceArb = + static member IsValid (PositiveInt con, cor, mem) = + let cores = ResourceLevels.AllLevels |> Seq.map(fun (ContainerAppResourceLevel (cores, _)) -> cores) |> Seq.find (fun cores -> float cores > float con * 0.05) <= cor + let memory = ResourceLevels.AllLevels |> Seq.map(fun (ContainerAppResourceLevel (_, mem)) -> mem) |> Seq.find (fun mem -> float mem > float con * 0.01) <= mem + cores && memory + + static member ValidInputs () = + { new Arbitrary () with + override _.Generator = basicGen |> Gen.filter ResourceArb.IsValid |> Gen.map ValidInput + override _.Shrinker (ValidInput inputs) = inputs |> shrinker ResourceArb.IsValid |> Seq.map ValidInput + } + static member InvalidInputs () = + { new Arbitrary () with + override _.Generator = basicGen |> Gen.filter (ResourceArb.IsValid >> not) |> Gen.map InvalidInput + override _.Shrinker (InvalidInput inputs) = inputs |> shrinker (ResourceArb.IsValid >> not) |> Seq.map InvalidInput + } + +let config = { FsCheckConfig.defaultConfig with arbitrary = [ typeof ] } + +let pbTests = testList "Property Based Tests" [ + testPropertyWithConfig config "totals always equal input" <| fun (ValidInput(PositiveInt containers, cores, memory)) -> + let split = ResourceOptimisation.optimise containers (cores, memory) + match split with + | Ok split -> + let correctCores = split |> List.sumBy (fun s -> s.CPU) |> decimal = decimal cores + let correctRam = split |> List.sumBy (fun s -> s.Memory) |> decimal = decimal memory + correctCores && correctRam + | Error msg -> + failwith msg + + testPropertyWithConfig config "gives back correct number of resource allocations" <| fun (ValidInput(PositiveInt containers, cores, memory)) -> + let split = ResourceOptimisation.optimise containers (cores, memory) + match split with + | Ok split -> split.Length = containers + | Error msg -> failwith msg + + testPropertyWithConfig config "never generates an invalid resource allocation" <| fun (ValidInput(PositiveInt containers, cores, memory)) -> + let split = ResourceOptimisation.optimise containers (cores, memory) + match split with + | Ok split -> split |> List.forall(fun s -> s.CPU >= 0.05 && s.Memory >= 0.01) + | Error msg -> failwith msg + + testPropertyWithConfig config "fails if the inputs are invalid" <| fun (InvalidInput(PositiveInt containers, cores, memory)) -> + let split = ResourceOptimisation.optimise containers (cores, memory) + match split with + | Ok _ -> failwith "Should have failed." + | Error _ -> true +] + +let tests = testList "Container Apps" [ + standardTests + pbTests +] \ No newline at end of file diff --git a/src/Tests/Tests.fsproj b/src/Tests/Tests.fsproj index ebfd8e9bf..324ab55ec 100644 --- a/src/Tests/Tests.fsproj +++ b/src/Tests/Tests.fsproj @@ -63,7 +63,8 @@ - + + @@ -88,7 +89,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - +