diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9b56478cf..1bd1cad72 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,11 +1,9 @@ Release Notes ============= - -## vNext -* WebApps/Functions: Specify connection string types - ## 1.6.30 +* WebApps/Functions: Specify connection string types * WebApps/Functions: Allow adding IP restriction string with CIDR +* Application Insights: Support for Workspace-enabled instances. ## 1.6.29 * CLI: include `--only-show-error` option when executing Azure CLI commands. diff --git a/docs/content/api-overview/resources/app-insights.md b/docs/content/api-overview/resources/app-insights.md index 61ef6d4dd..949e2a6b9 100644 --- a/docs/content/api-overview/resources/app-insights.md +++ b/docs/content/api-overview/resources/app-insights.md @@ -10,6 +10,8 @@ The App Insights builder is used to create Application Insights accounts. Use th * Application Insights (`Microsoft.Insights/components`) +> This builder supports both "Classic" (standalone) and "Workspace Enabled" (Log Analytics-backed) instances of App Insights. See the `log_analytics_workspace` keyword to see how to create the latter type of instance. + #### Builder Keywords | Keyword | Purpose | @@ -17,6 +19,7 @@ The App Insights builder is used to create Application Insights accounts. Use th | name | Sets the name of the App Insights instance. | | disable_ip_masking | Disable IP masking. | | sampling_percentage | Define sampling percentage (0-100) | +| log_analytics_workspace | Use a Log Analytics workspace as the backing store for this AI instance. You can supply either a Farmer-generate Log Analytics`WorkspaceConfig` instance that exists in the same resource group, or a fully-qualified Resource ID path to that instance. This will also switch the AI instance over to creating a "workspace enabled" AI instance. | #### Configuration Members @@ -32,5 +35,6 @@ open Farmer.Builders let ai = appInsights { name "myAI" + log_analytics_workspace myWorkspace // use to activate workspace-enabled AI instances. } ``` \ No newline at end of file diff --git a/samples/scripts/appinsights-loganalytics.fsx b/samples/scripts/appinsights-loganalytics.fsx new file mode 100644 index 000000000..16b3bc6f6 --- /dev/null +++ b/samples/scripts/appinsights-loganalytics.fsx @@ -0,0 +1,26 @@ +#r @"nuget:Farmer" + +open Farmer +open Farmer.Builders + +let workspace = logAnalytics { + name "loganalytics-workspace" +} + +let myAppInsights = appInsights { + name "appInsights" + log_analytics_workspace workspace +} + +let myFunctions = functions { + name "functions-app" + link_to_app_insights myAppInsights.Name +} + +let template = arm { + location Location.NorthEurope + add_resources [ workspace; myAppInsights; myFunctions ] +} + +template +|> Deploy.execute "deleteme" Deploy.NoParameters \ No newline at end of file diff --git a/src/Farmer/Arm/Insights.fs b/src/Farmer/Arm/Insights.fs index 0ee64a418..3a48ce2fb 100644 --- a/src/Farmer/Arm/Insights.fs +++ b/src/Farmer/Arm/Insights.fs @@ -3,7 +3,20 @@ module Farmer.Arm.Insights open Farmer -let components = ResourceType("Microsoft.Insights/components", "2014-04-01") +let private createComponents version = ResourceType("Microsoft.Insights/components", version) +/// Classic AI instance +let components = createComponents "2014-04-01" +/// Workspace-enabled AI instance +let componentsWorkspace = createComponents "2020-02-02" + +/// The type of AI instance to create. +type InstanceKind = + | Classic + | Workspace of ResourceId + member this.ResourceType = + match this with + | Classic -> components + | Workspace _ -> componentsWorkspace type Components = { Name : ResourceName @@ -11,7 +24,9 @@ type Components = LinkedWebsite : ResourceName option DisableIpMasking : bool SamplingPercentage : int - Tags: Map } + InstanceKind : InstanceKind + Tags: Map + Dependencies : ResourceId Set } interface IArmResource with member this.ResourceId = components.resourceId this.Name member this.JsonModel = @@ -19,16 +34,24 @@ type Components = match this.LinkedWebsite with | Some linkedWebsite -> this.Tags.Add($"[concat('hidden-link:', resourceGroup().id, '/providers/Microsoft.Web/sites/', '{linkedWebsite.Value}')]", "Resource") | None -> this.Tags - - {| components.Create(this.Name, this.Location, tags = tags) with - kind = "web" - properties = - {| name = this.Name.Value - Application_Type = "web" - ApplicationId = - match this.LinkedWebsite with - | Some linkedWebsite -> linkedWebsite.Value - | None -> null - DisableIpMasking = this.DisableIpMasking - SamplingPercentage = this.SamplingPercentage |} + {| this.InstanceKind.ResourceType.Create(this.Name, this.Location, this.Dependencies, tags) with + kind = "web" + properties = + {| + name = this.Name.Value + Application_Type = "web" + ApplicationId = + match this.LinkedWebsite with + | Some linkedWebsite -> linkedWebsite.Value + | None -> null + DisableIpMasking = this.DisableIpMasking + SamplingPercentage = this.SamplingPercentage + IngestionMode = + match this.InstanceKind with + | Workspace _ -> "LogAnalytics" + | Classic -> null + WorkspaceResourceId = + match this.InstanceKind with + | Workspace resourceId -> resourceId.Eval() + | Classic -> null |} |} \ No newline at end of file diff --git a/src/Farmer/Builders/Builders.AppInsights.fs b/src/Farmer/Builders/Builders.AppInsights.fs index ddd7d87a1..fd5ddf62d 100644 --- a/src/Farmer/Builders/Builders.AppInsights.fs +++ b/src/Farmer/Builders/Builders.AppInsights.fs @@ -3,23 +3,27 @@ module Farmer.Builders.AppInsights open Farmer open Farmer.Arm.Insights +open Farmer.Arm.LogAnalytics type AppInsights = static member getInstrumentationKey (resourceId:ResourceId) = ArmExpression - .reference(components, resourceId) + .reference(resourceId) .Map(fun r -> r + ".InstrumentationKey") .WithOwner(resourceId) - static member getInstrumentationKey (name:ResourceName, ?resourceGroup) = - AppInsights.getInstrumentationKey(ResourceId.create (components, name, ?group = resourceGroup)) + static member getInstrumentationKey (name:ResourceName, ?resourceGroup, ?resourceType) = + let resourceType = resourceType |> Option.defaultValue components + AppInsights.getInstrumentationKey(ResourceId.create (resourceType, name, ?group = resourceGroup)) type AppInsightsConfig = { Name : ResourceName DisableIpMasking : bool SamplingPercentage : int + InstanceKind : InstanceKind + Dependencies : ResourceId Set Tags : Map } /// Gets the ARM expression path to the instrumentation key of this App Insights instance. - member this.InstrumentationKey = AppInsights.getInstrumentationKey this.Name + member this.InstrumentationKey = AppInsights.getInstrumentationKey(this.Name, resourceType = this.InstanceKind.ResourceType) interface IBuilder with member this.ResourceId = components.resourceId this.Name member this.BuildResources location = [ @@ -28,6 +32,8 @@ type AppInsightsConfig = LinkedWebsite = None DisableIpMasking = this.DisableIpMasking SamplingPercentage = this.SamplingPercentage + Dependencies = this.Dependencies + InstanceKind = this.InstanceKind Tags = this.Tags } ] @@ -36,7 +42,10 @@ type AppInsightsBuilder() = { Name = ResourceName.Empty DisableIpMasking = false SamplingPercentage = 100 - Tags = Map.empty } + Tags = Map.empty + Dependencies = Set.empty + InstanceKind = Classic } + [] /// Sets the name of the App Insights instance. member _.Name(state:AppInsightsConfig, name) = { state with Name = ResourceName name } @@ -49,10 +58,22 @@ type AppInsightsBuilder() = /// Sets the name of the App Insights instance. member _.SamplingPercentage(state:AppInsightsConfig, samplingPercentage) = { state with SamplingPercentage = samplingPercentage } + /// Links this AI instance to a Log Analytics workspace, using the newer 2020-02-02-preview App Insights version. + [] + member _.Workspace(state:AppInsightsConfig, workspace:ResourceId) = + { state with + InstanceKind = Workspace workspace + Dependencies = state.Dependencies.Add workspace + } + member this.Workspace(state:AppInsightsConfig, workspace:WorkspaceConfig) = + this.Workspace(state, workspaces.resourceId workspace.Name) + member _.Run (state:AppInsightsConfig) = if state.SamplingPercentage > 100 then raiseFarmer "Sampling Percentage cannot be higher than 100%" elif state.SamplingPercentage <= 0 then raiseFarmer "Sampling Percentage cannot be lower than or equal to 0%" state + interface ITaggable with member _.Add state tags = { state with Tags = state.Tags |> Map.merge tags } + interface IDependable with member _.Add state resources = { state with Dependencies = state.Dependencies + resources } let appInsights = AppInsightsBuilder() \ No newline at end of file diff --git a/src/Farmer/Builders/Builders.Functions.fs b/src/Farmer/Builders/Builders.Functions.fs index f77d82ef4..923881af6 100644 --- a/src/Farmer/Builders/Builders.Functions.fs +++ b/src/Farmer/Builders/Builders.Functions.fs @@ -15,7 +15,7 @@ open System type FunctionsRuntime = DotNet | DotNetIsolated | Node | Java | Python type VersionedFunctionsRuntime = FunctionsRuntime * string option type FunctionsRuntime with - // These values are defined on FunctionsRuntime to reduce the need for users to be aware of the distinction + // These values are defined on FunctionsRuntime to reduce the need for users to be aware of the distinction // between FunctionsRuntime and VersionedFunctionsRuntime as well as to provide parity with WebApp runtime static member DotNetCore31 = DotNet, Some "3.1" static member DotNet50 = DotNet, Some "5.0" @@ -246,12 +246,12 @@ type FunctionsConfig = HTTP20Enabled = None ClientAffinityEnabled = None WebSocketsEnabled = None - LinuxFxVersion = + LinuxFxVersion = match this.CommonWebConfig.OperatingSystem with | Windows -> None | Linux -> match this.VersionedRuntime with - | DotNet, Some version -> + | DotNet, Some version -> match Double.TryParse(version) with | true, versionNo when versionNo < 4.0 -> Some $"DOTNETCORE|{version}" | _ -> Some $"DOTNET|{version}" @@ -310,6 +310,8 @@ type FunctionsConfig = Location = location DisableIpMasking = false SamplingPercentage = 100 + Dependencies = Set.empty + InstanceKind = Classic LinkedWebsite = match this.CommonWebConfig.OperatingSystem with | Windows -> Some this.Name.ResourceName @@ -346,7 +348,7 @@ type FunctionsBuilder() = Slots = Map.empty WorkerProcess = None ZipDeployPath = None - HealthCheckPath = None + HealthCheckPath = None IpSecurityRestrictions = [] } StorageAccount = derived (fun config -> let storage = config.Name.ResourceName.Map (sprintf "%sstorage") |> sanitiseStorage |> ResourceName diff --git a/src/Farmer/Builders/Builders.WebApp.fs b/src/Farmer/Builders/Builders.WebApp.fs index c18fe988a..492cf2823 100644 --- a/src/Farmer/Builders/Builders.WebApp.fs +++ b/src/Farmer/Builders/Builders.WebApp.fs @@ -160,27 +160,27 @@ type SlotBuilder() = member this.AddConnectionStrings(state, connectionStrings:string list) :SlotConfig = connectionStrings |> List.fold (fun state key -> this.AddConnectionString(state, key)) state - + /// Add Allowed ip for ip security restrictions - [] - member _.AllowIp(state, name, cidr:IPAddressCidr) : SlotConfig = + [] + member _.AllowIp(state, name, cidr:IPAddressCidr) : SlotConfig = { state with IpSecurityRestrictions = state.IpSecurityRestrictions @ [IpSecurityRestriction.Create name cidr Allow] } - member this.AllowIp(state, name, ip:Net.IPAddress) : SlotConfig = + member this.AllowIp(state, name, ip:Net.IPAddress) : SlotConfig = let cidr = { Address = ip; Prefix = 32 } this.AllowIp(state, name, cidr) - member this.AllowIp(state, name, ip:string) : SlotConfig = + member this.AllowIp(state, name, ip:string) : SlotConfig = let cidr = IPAddressCidr.parse ip this.AllowIp(state, name, cidr) /// Add Denied ip for ip security restrictions - [] - member _.DenyIp(state, name, cidr:IPAddressCidr) : SlotConfig = + [] + member _.DenyIp(state, name, cidr:IPAddressCidr) : SlotConfig = { state with IpSecurityRestrictions = state.IpSecurityRestrictions @ [IpSecurityRestriction.Create name cidr Deny] } - member this.DenyIp(state, name, ip:Net.IPAddress) : SlotConfig = + member this.DenyIp(state, name, ip:Net.IPAddress) : SlotConfig = let cidr = { Address = ip; Prefix = 32 } this.DenyIp(state, name, cidr) - member this.DenyIp(state, name, ip:string) : SlotConfig = + member this.DenyIp(state, name, ip:string) : SlotConfig = let cidr = IPAddressCidr.parse ip - this.DenyIp(state, name, cidr) + this.DenyIp(state, name, cidr) interface ITaggable with member _.Add state tags = { state with Tags = state.Tags |> Map.merge tags } interface IDependable with member _.Add state newDeps = { state with Dependencies = state.Dependencies + newDeps } @@ -228,7 +228,7 @@ type WebAppConfig = AutomaticLoggingExtension : bool SiteExtensions : ExtensionName Set PrivateEndpoints: (LinkedResource * string option) Set - CustomDomains : Map + CustomDomains : Map DockerPort: int option ZoneRedundant : FeatureFlag option } member this.Name = this.CommonWebConfig.Name @@ -311,9 +311,9 @@ type WebAppConfig = | Linux, Some _ | _ , None -> () - + yield! this.DockerPort |> Option.mapList AppSettings.WebsitesPort - + if this.DockerCi then "DOCKER_ENABLE_CI", "true" ] @@ -480,6 +480,8 @@ type WebAppConfig = Location = location DisableIpMasking = false SamplingPercentage = 100 + InstanceKind = Classic + Dependencies = Set.empty LinkedWebsite = match this.CommonWebConfig.OperatingSystem with | Windows -> Some this.Name.ResourceName @@ -499,7 +501,7 @@ type WebAppConfig = MaximumElasticWorkerCount = this.MaximumElasticWorkerCount OperatingSystem = this.CommonWebConfig.OperatingSystem ZoneRedundant = this.ZoneRedundant - Tags = this.Tags} + Tags = this.Tags } | _ -> () @@ -514,13 +516,13 @@ type WebAppConfig = { site with AppSettings = None; ConnectionStrings = None } // Don't deploy production slot settings as they could cause an app restart for (_,slot) in this.CommonWebConfig.Slots |> Map.toSeq do slot.ToSite site - + // Host Name Bindings must be deployed sequentially to avoid an error, as the site cannot be modified concurrently. // To do so we add a dependency to the previous binding. let mutable previousHostNameBinding = None for customDomain in this.CustomDomains |> Map.toSeq |> Seq.map snd do - let dependsOn = - match previousHostNameBinding with + let dependsOn = + match previousHostNameBinding with | Some previous -> Set.singleton previous | None -> Set.empty @@ -544,16 +546,16 @@ type WebAppConfig = hostNameBinding // Get the resource group which contains the app service plan - let aspRgName = + let aspRgName = match this.CommonWebConfig.ServicePlan with | LinkedResource linked -> linked.ResourceId.ResourceGroup | _ -> None // Create a nested resource group deployment for the certificate - this isn't strictly necessary when the app & app service plan are in the same resource group // however, when they are in different resource groups this is required to make the deployment succeed (there is an ARM bug which causes a Not Found / Conflict otherwise) // To keep the code simple, I opted to always nest the certificate deployment. - TheRSP 2021-12-14 - let certRg = resourceGroup { + let certRg = resourceGroup { name (aspRgName |> Option.defaultValue "[resourceGroup().name]") - add_resource + add_resource { cert with SiteId = Unmanaged cert.SiteId.ResourceId ServicePlanId = Unmanaged cert.ServicePlanId.ResourceId } @@ -841,7 +843,7 @@ module Extensions = let current = this.Get state connectionStrings |> List.fold (fun (state:CommonWebConfig) (key, value:ArmExpression) -> - { state with ConnectionStrings = state.ConnectionStrings.Add(key, (ExpressionSetting value, Custom)) }) current + { state with ConnectionStrings = state.ConnectionStrings.Add(key, (ExpressionSetting value, Custom)) }) current |> this.Wrap state /// Sets an app setting of the web app in the form "key" "value". [] @@ -959,16 +961,16 @@ module Extensions = /// Specifies the path Azure load balancers will ping to check for unhealthy instances. member this.HealthCheckPath(state:'T, healthCheckPath:string) = this.Map state (fun x -> {x with HealthCheckPath = Some(healthCheckPath)}) /// Add Allowed ip for ip security restrictions - [] - member this.AllowIp(state:'T, name, ip:IPAddressCidr) = + [] + member this.AllowIp(state:'T, name, ip:IPAddressCidr) = this.Map state (fun x -> { x with IpSecurityRestrictions = IpSecurityRestriction.Create name ip Allow :: x.IpSecurityRestrictions }) - member this.AllowIp(state:'T, name, ip:string) = + member this.AllowIp(state:'T, name, ip:string) = let ip = IPAddressCidr.parse ip this.Map state (fun x -> { x with IpSecurityRestrictions = IpSecurityRestriction.Create name ip Allow :: x.IpSecurityRestrictions }) /// Add Denied ip for ip security restrictions - [] - member this.DenyIp(state:'T, name, ip) = + [] + member this.DenyIp(state:'T, name, ip) = this.Map state (fun x -> { x with IpSecurityRestrictions = IpSecurityRestriction.Create name ip Deny :: x.IpSecurityRestrictions }) - member this.DenyIp(state:'T, name, ip:string) = + member this.DenyIp(state:'T, name, ip:string) = let ip = IPAddressCidr.parse ip this.Map state (fun x -> { x with IpSecurityRestrictions = IpSecurityRestriction.Create name ip Deny :: x.IpSecurityRestrictions }) diff --git a/src/Farmer/Types.fs b/src/Farmer/Types.fs index f46bdc679..2350f4779 100644 --- a/src/Farmer/Types.fs +++ b/src/Farmer/Types.fs @@ -244,8 +244,8 @@ type CertificateOptions = type DomainConfig = | SecureDomain of domain:string * cert:CertificateOptions | InsecureDomain of domain:string - member this.DomainName = - match this with + member this.DomainName = + match this with | SecureDomain (domainName,_) | InsecureDomain (domainName) -> domainName diff --git a/src/Tests/AppInsights.fs b/src/Tests/AppInsights.fs index 28772bcfe..6fa51462f 100644 --- a/src/Tests/AppInsights.fs +++ b/src/Tests/AppInsights.fs @@ -3,6 +3,8 @@ module AppInsights open Expecto open Farmer open Farmer.Builders.AppInsights +open Farmer.Builders.LogAnalytics +open Newtonsoft.Json.Linq let tests = testList "AppInsights" [ test "Creates keys on an AI instance correctly" { @@ -11,8 +13,31 @@ let tests = testList "AppInsights" [ Expect.equal ai.InstrumentationKey.Value ("reference(resourceId('Microsoft.Insights/components', 'foo'), '2014-04-01').InstrumentationKey") "Incorrect Value" } + test "Creates with classic version by default" { + let deployment = arm { add_resource (appInsights { name "foo" }) } + let json = deployment.Template |> Writer.toJson |> JObject.Parse + let version = json.SelectToken("resources[?(@.name=='foo')].apiVersion").ToString() + Expect.equal version "2014-04-01" "Incorrect API version" + } + test "Create generated keys correctly" { let generatedKey = AppInsights.getInstrumentationKey(ResourceId.create(Arm.Insights.components, ResourceName "foo", "group")) Expect.equal generatedKey.Value "reference(resourceId('group', 'Microsoft.Insights/components', 'foo'), '2014-04-01').InstrumentationKey" "Incorrect generated key" } + + test "Creates LA-enabled workspace" { + let workspace = logAnalytics { name "la" } + let ai = appInsights { name "ai"; log_analytics_workspace workspace } + let deployment = arm { + add_resources [ workspace; ai ] + } + + let json = deployment.Template |> Writer.toJson |> JObject.Parse + let select query = json.SelectToken(query).ToString() + + Expect.equal (select "resources[?(@.name=='ai')].properties.WorkspaceResourceId") "[resourceId('Microsoft.OperationalInsights/workspaces', 'la')]" "Incorrect workspace id" + Expect.equal (select "resources[?(@.name=='ai')].apiVersion") "2020-02-02" "Incorrect API version" + Expect.equal ai.InstrumentationKey.Value ("reference(resourceId('Microsoft.Insights/components', 'ai'), '2020-02-02').InstrumentationKey") "Incorrect Instrumentation Key reference" + Expect.sequenceEqual (json.SelectToken("resources[?(@.name=='ai')].dependsOn").Children() |> Seq.map string |> Seq.toArray) [ "[resourceId('Microsoft.OperationalInsights/workspaces', 'la')]" ] "Incorrect dependencies" + } ] \ No newline at end of file diff --git a/src/Tests/test-data/lots-of-resources.json b/src/Tests/test-data/lots-of-resources.json index 061382fbf..1c7556329 100644 --- a/src/Tests/test-data/lots-of-resources.json +++ b/src/Tests/test-data/lots-of-resources.json @@ -97,6 +97,7 @@ }, { "apiVersion": "2014-04-01", + "dependsOn": [], "kind": "web", "location": "northeurope", "name": "farmerwebapp1979-ai", @@ -236,6 +237,7 @@ }, { "apiVersion": "2014-04-01", + "dependsOn": [], "kind": "web", "location": "northeurope", "name": "farmerfuncs1979-ai",