diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6fdfcc42c..b3db2be44 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,9 @@ Release Notes ============= +## 1.9.28 +* App Configuration: Add support for Azure App Configuration stores, key-value items, and feature flags. + ## 1.9.27 * Storage Accounts: Add `AccountKey` member to return just the storage account key and `ConnectionString` member to return the connection string. The existing `Key` member is now obsolete (it incorrectly returned a connection string instead of just the key). * Network Manager: Add support for Azure Network Manager with security admin configurations, rule collections, rules with IP ranges and service tags, and network groups. diff --git a/docs/content/api-overview/resources/app-configuration.md b/docs/content/api-overview/resources/app-configuration.md new file mode 100644 index 000000000..6f35d099f --- /dev/null +++ b/docs/content/api-overview/resources/app-configuration.md @@ -0,0 +1,111 @@ +--- +title: "App Configuration" +date: 2026-04-17T00:00:00+00:00 +chapter: false +weight: 1 +--- + +#### Overview +The App Configuration builder creates Azure App Configuration stores, which are a central repository for managing application settings and feature flags. + +* Configuration Store (`Microsoft.AppConfiguration/configurationStores`) +* Key Values (`Microsoft.AppConfiguration/configurationStores/keyValues`) + +#### Builder Keywords + +##### configurationStore + +| Keyword | Purpose | +|-|-| +| name | Sets the name of the App Configuration store. | +| sku | Sets the SKU of the store. Defaults to `Free`. Options: `Free`, `Developer`, `Standard`, `Premium`. | +| disable_local_auth | Disables local (access-key) authentication, requiring Azure AD / RBAC only. | +| enable_purge_protection | Enables purge protection (Standard SKU only). Once enabled, it cannot be disabled. | +| public_network_access | Sets public network access: `Enabled` or `Disabled`. | +| soft_delete_retention_in_days | Sets the soft-delete retention period in days (Standard SKU only, 1-7 days). | +| data_plane_authentication_mode | Sets the data plane authentication mode: `Local` or `Passthrough`. | +| add_feature_flag | Adds a single feature flag to the store. | +| add_feature_flags | Adds a list of feature flags to the store. | +| add_key_value | Adds a single key-value item to the store. | +| add_key_values | Adds a list of key-value items to the store. | +| add_tag | Adds an ARM tag to the store. | +| add_tags | Adds multiple ARM tags to the store. | +| depends_on | Adds a dependency to the store. | + +#### Key Value Fields + +| Field | Purpose | +|-|-| +| Key | The key name for the item. | +| Label | An optional label to distinguish between different environments or configurations. | +| Value | The value to store. | +| ContentType | Optional MIME content type (e.g. `application/json` for JSON values). | +| KeyValueTags | Optional App Configuration item tags (not ARM resource tags). | + +#### Feature Flag Fields + +| Field | Purpose | +|-|-| +| Name | The name of the feature flag. | +| Description | A human-readable description of the flag. | +| Label | An optional label (can be empty string for no label). | +| State | `true` if the flag is enabled, `false` if disabled. | + +#### Configuration Members + +| Member | Purpose | +|-|-| +| ResourceId | The ARM resource ID of the App Configuration store. | +| Endpoint | Returns an ARM expression for the data plane endpoint URL of the store. | + +#### Example + +```fsharp +open Farmer +open Farmer.Builders +open Farmer.ConfigurationStore + +let myConfig = configurationStore { + name "my-app-config" + sku Standard + disable_local_auth + add_tags [ "env", "prod"; "team", "platform" ] + + add_feature_flags [ + { + Name = "DarkMode" + Description = "Enable dark mode UI" + Label = "" + State = true + } + { + Name = "BetaFeature" + Description = "Beta feature available to select users" + Label = "beta" + State = false + } + ] + + add_key_values [ + { + Key = "Api:BaseUrl" + Label = Some "prod" + Value = "https://api.example.com" + ContentType = None + KeyValueTags = Map.empty + } + { + Key = "FeatureSettings" + Label = None + Value = """{"timeout":30,"retries":3}""" + ContentType = Some "application/json" + KeyValueTags = Map [ "environment", "production" ] + } + ] +} + +let deployment = arm { + location Location.NorthEurope + add_resource myConfig +} +``` diff --git a/src/Farmer/Arm/ConfigurationStore.fs b/src/Farmer/Arm/ConfigurationStore.fs new file mode 100644 index 000000000..2af0a46bb --- /dev/null +++ b/src/Farmer/Arm/ConfigurationStore.fs @@ -0,0 +1,116 @@ +[] +module Farmer.Arm.ConfigurationStore + +open Farmer +open Farmer.ConfigurationStore + +let configurationStores = + ResourceType("Microsoft.AppConfiguration/configurationStores", "2024-05-01") + +let keyValues = + ResourceType("Microsoft.AppConfiguration/configurationStores/keyValues", "2024-05-01") + +type ConfigFeatureFlag = { + Name: string + Description: string + Label: string + State: bool + ConfigurationStoreId: ResourceId +} with + + member this.ResourceName = + let labelSuffix = if this.Label = "" then "" else $"${this.Label}" + ResourceName $"{this.ConfigurationStoreId.Name.Value}/.appconfig.featureflag~2F{this.Name}{labelSuffix}" + + interface IArmResource with + member this.ResourceId = keyValues.resourceId this.ResourceName + + member this.JsonModel = + let enabled = this.State |> string |> _.ToLower() + + let featureFlagValue = + $"""{{"id":"{this.Name}","description":"{this.Description}","enabled":{enabled}}}""" + + {| + keyValues.Create(this.ResourceName, dependsOn = [ this.ConfigurationStoreId ]) with + properties = {| + value = featureFlagValue + contentType = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" + |} + |} + +type KeyValue = { + /// The name of the key value in the format `{storeName}/{key}` or `{storeName}/{key}$label`. + Name: ResourceName + Value: string + ContentType: string option + /// Tags applied to the App Configuration key-value item (not ARM resource tags). + KeyValueTags: Map + Dependencies: ResourceId Set +} with + + interface IArmResource with + member this.ResourceId = keyValues.resourceId this.Name + + member this.JsonModel = {| + keyValues.Create(this.Name, dependsOn = this.Dependencies) with + properties = {| + value = this.Value + contentType = this.ContentType |> Option.toObj + tags = + if this.KeyValueTags.IsEmpty then + null + else + box this.KeyValueTags + |} + |} + +type ConfigurationStore = { + Name: ResourceName + Location: Location + Sku: Sku + DisableLocalAuth: bool option + EnablePurgeProtection: bool option + PublicNetworkAccess: Farmer.FeatureFlag option + SoftDeleteRetentionInDays: int option + DataPlaneAuthenticationMode: DataPlaneAuthenticationMode option + Tags: Map + Dependencies: ResourceId Set +} with + + interface IArmResource with + member this.ResourceId = configurationStores.resourceId this.Name + + member this.JsonModel = {| + configurationStores.Create(this.Name, this.Location, this.Dependencies, this.Tags) with + sku = {| + name = + match this.Sku with + | Free -> "free" + | Developer -> "developer" + | Standard -> "standard" + | Premium -> "premium" + |} + properties = {| + disableLocalAuth = this.DisableLocalAuth |> Option.toNullable + enablePurgeProtection = this.EnablePurgeProtection |> Option.toNullable + publicNetworkAccess = + this.PublicNetworkAccess + |> Option.map (fun f -> + match f with + | Enabled -> "Enabled" + | Disabled -> "Disabled") + |> Option.toObj + softDeleteRetentionInDays = this.SoftDeleteRetentionInDays |> Option.toNullable + dataPlaneProxy = + this.DataPlaneAuthenticationMode + |> Option.map (fun mode -> + box {| + authenticationMode = + match mode with + | Local -> "Local" + | Passthrough -> "Pass-through" + |}) + |> Option.toObj + |} + |} \ No newline at end of file diff --git a/src/Farmer/Builders/Builders.ConfigurationStore.fs b/src/Farmer/Builders/Builders.ConfigurationStore.fs new file mode 100644 index 000000000..585becda3 --- /dev/null +++ b/src/Farmer/Builders/Builders.ConfigurationStore.fs @@ -0,0 +1,198 @@ +[] +module Farmer.Builders.ConfigurationStore + +open Farmer +open Farmer.ConfigurationStore +open Farmer.Arm.ConfigurationStore + +type FeatureFlagConfig = { + Name: string + Description: string + Label: string + State: bool +} + +type KeyValueConfig = { + Key: string + Label: string option + Value: string + ContentType: string option + /// Tags applied to the App Configuration key-value item (not ARM resource tags). + KeyValueTags: Map +} + +type ConfigurationStoreConfig = { + Name: ResourceName + Sku: Sku + DisableLocalAuth: bool option + EnablePurgeProtection: bool option + PublicNetworkAccess: FeatureFlag option + SoftDeleteRetentionInDays: int option + DataPlaneAuthenticationMode: DataPlaneAuthenticationMode option + FeatureFlags: FeatureFlagConfig list + KeyValues: KeyValueConfig list + Tags: Map + Dependencies: ResourceId Set +} with + + member this.ResourceId = configurationStores.resourceId this.Name + + /// Gets an ARM expression for the endpoint of this App Configuration store. + member this.Endpoint = + ArmExpression + .create($"reference({this.ResourceId.ArmExpression.Value}, '{configurationStores.ApiVersion}').endpoint") + .WithOwner(this.ResourceId) + + interface IBuilder with + member this.ResourceId = this.ResourceId + + member this.BuildResources location = [ + { + ConfigurationStore.Name = this.Name + Location = location + Sku = this.Sku + DisableLocalAuth = this.DisableLocalAuth + EnablePurgeProtection = this.EnablePurgeProtection + PublicNetworkAccess = this.PublicNetworkAccess + SoftDeleteRetentionInDays = this.SoftDeleteRetentionInDays + DataPlaneAuthenticationMode = this.DataPlaneAuthenticationMode + Tags = this.Tags + Dependencies = this.Dependencies + } + let storeId = configurationStores.resourceId this.Name + + for ff in this.FeatureFlags do + { + ConfigFeatureFlag.Name = ff.Name + Description = ff.Description + Label = ff.Label + State = ff.State + ConfigurationStoreId = storeId + } + + for kv in this.KeyValues do + let kvName = + match kv.Label with + | Some label -> ResourceName $"{this.Name.Value}/{kv.Key}${label}" + | None -> ResourceName $"{this.Name.Value}/{kv.Key}" + + { + KeyValue.Name = kvName + Value = kv.Value + ContentType = kv.ContentType + KeyValueTags = kv.KeyValueTags + Dependencies = Set.singleton storeId + } + ] + + 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 + } + +type ConfigurationStoreBuilder() = + member _.Yield _ = { + Name = ResourceName.Empty + Sku = Free + DisableLocalAuth = None + EnablePurgeProtection = None + PublicNetworkAccess = None + SoftDeleteRetentionInDays = None + DataPlaneAuthenticationMode = None + FeatureFlags = [] + KeyValues = [] + Tags = Map.empty + Dependencies = Set.empty + } + + /// Sets the name of the App Configuration store. + [] + member _.Name(state: ConfigurationStoreConfig, name) = { state with Name = ResourceName name } + + /// Sets the SKU of the App Configuration store. Defaults to Free. + [] + member _.Sku(state: ConfigurationStoreConfig, sku) = { state with Sku = sku } + + /// Disables local (access-key) authentication for the App Configuration store. + [] + member _.DisableLocalAuth(state: ConfigurationStoreConfig) = { + state with + DisableLocalAuth = Some true + } + + /// Enables purge protection for the App Configuration store (Standard SKU only). + [] + member _.EnablePurgeProtection(state: ConfigurationStoreConfig) = { + state with + EnablePurgeProtection = Some true + } + + /// Sets the public network access for the App Configuration store. + [] + member _.PublicNetworkAccess(state: ConfigurationStoreConfig, access) = { + state with + PublicNetworkAccess = Some access + } + + /// Sets the soft-delete retention period in days (Standard SKU only, 1-7 days). + [] + member _.SoftDeleteRetentionInDays(state: ConfigurationStoreConfig, days) = { + state with + SoftDeleteRetentionInDays = Some days + } + + /// Sets the data plane authentication mode for the App Configuration store. + [] + member _.DataPlaneAuthenticationMode(state: ConfigurationStoreConfig, mode) = { + state with + DataPlaneAuthenticationMode = Some mode + } + + /// Adds feature flags to the App Configuration store. + [] + member _.AddFeatureFlags(state: ConfigurationStoreConfig, featureFlags: FeatureFlagConfig list) = { + state with + FeatureFlags = state.FeatureFlags @ featureFlags + } + + /// Adds a single feature flag to the App Configuration store. + [] + member _.AddFeatureFlag(state: ConfigurationStoreConfig, featureFlag: FeatureFlagConfig) = { + state with + FeatureFlags = state.FeatureFlags @ [ featureFlag ] + } + + /// Adds key-value items to the App Configuration store. + [] + member _.AddKeyValues(state: ConfigurationStoreConfig, keyValues: KeyValueConfig list) = { + state with + KeyValues = state.KeyValues @ keyValues + } + + /// Adds a single key-value item to the App Configuration store. + [] + member _.AddKeyValue(state: ConfigurationStoreConfig, keyValue: KeyValueConfig) = { + state with + KeyValues = state.KeyValues @ [ keyValue ] + } + + 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 + } + +let configurationStore = ConfigurationStoreBuilder() \ No newline at end of file diff --git a/src/Farmer/Common.fs b/src/Farmer/Common.fs index d82be4ae8..4e5656e81 100644 --- a/src/Farmer/Common.fs +++ b/src/Farmer/Common.fs @@ -1903,6 +1903,19 @@ module CognitiveServices = | TextAnalytics | TextTranslation +module ConfigurationStore = + /// SKU for Azure App Configuration stores. + type Sku = + | Free + | Developer + | Standard + | Premium + + /// Data plane authentication mode for Azure App Configuration stores. + type DataPlaneAuthenticationMode = + | Local + | Passthrough + module BingSearch = /// Type of SKU. See https://www.microsoft.com/en-us/bing/apis/pricing type Sku = diff --git a/src/Farmer/Farmer.fsproj b/src/Farmer/Farmer.fsproj index 94b1cfca3..c6ca6224b 100644 --- a/src/Farmer/Farmer.fsproj +++ b/src/Farmer/Farmer.fsproj @@ -79,6 +79,7 @@ + @@ -127,6 +128,7 @@ + diff --git a/src/Tests/AllTests.fs b/src/Tests/AllTests.fs index 47163aa9b..45645fd2b 100644 --- a/src/Tests/AllTests.fs +++ b/src/Tests/AllTests.fs @@ -35,6 +35,7 @@ let allTests = Cdn.tests CognitiveServices.tests CommunicationServices.tests + ConfigurationStore.tests ContainerApps.tests ContainerGroup.tests ContainerRegistry.tests diff --git a/src/Tests/ConfigurationStore.fs b/src/Tests/ConfigurationStore.fs new file mode 100644 index 000000000..c5f62808c --- /dev/null +++ b/src/Tests/ConfigurationStore.fs @@ -0,0 +1,289 @@ +module ConfigurationStore + +open Expecto +open Farmer +open Farmer.Builders +open Farmer.ConfigurationStore +open Farmer.Arm +open TestHelpers + +let private asStoreJson (arm: IArmResource) = + arm.JsonModel + |> convertTo< + {| + sku: {| name: string |} + properties: + {| + disableLocalAuth: System.Nullable + enablePurgeProtection: System.Nullable + publicNetworkAccess: string + softDeleteRetentionInDays: System.Nullable + dataPlaneProxy: obj + |} + |} + > + +let private asKeyValueJson (arm: IArmResource) = + arm.JsonModel + |> convertTo< + {| + name: string + properties: + {| + value: string + contentType: string + tags: System.Collections.Generic.Dictionary + |} + |} + > + +let tests = + testList "App Configuration" [ + test "Can create a basic configuration store with Free SKU" { + let store = configurationStore { name "my-app-config" } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + Expect.equal resources.Length 1 "Should have exactly one resource" + + let arm = resources.[0] + Expect.equal arm.ResourceId.Name (ResourceName "my-app-config") "Name should match" + + let json = asStoreJson arm + Expect.equal json.sku.name "free" "SKU should be free by default" + Expect.isFalse json.properties.disableLocalAuth.HasValue "disableLocalAuth should not be set" + Expect.isFalse json.properties.enablePurgeProtection.HasValue "enablePurgeProtection should not be set" + Expect.isNull json.properties.publicNetworkAccess "publicNetworkAccess should not be set" + + Expect.isFalse + json.properties.softDeleteRetentionInDays.HasValue + "softDeleteRetentionInDays should not be set" + + Expect.isNull json.properties.dataPlaneProxy "dataPlaneProxy should not be set" + } + + test "Can create a configuration store with Standard SKU" { + let store = configurationStore { + name "my-app-config" + sku Standard + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let json = asStoreJson resources.[0] + Expect.equal json.sku.name "standard" "SKU should be standard" + } + + test "Can create a configuration store with Developer SKU" { + let store = configurationStore { + name "my-app-config" + sku Developer + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let json = asStoreJson resources.[0] + Expect.equal json.sku.name "developer" "SKU should be developer" + } + + test "Can disable local auth" { + let store = configurationStore { + name "my-app-config" + sku Standard + disable_local_auth + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let json = asStoreJson resources.[0] + Expect.isTrue json.properties.disableLocalAuth.HasValue "disableLocalAuth should be set" + Expect.isTrue json.properties.disableLocalAuth.Value "disableLocalAuth should be true" + } + + test "Can enable purge protection" { + let store = configurationStore { + name "my-app-config" + sku Standard + enable_purge_protection + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let json = asStoreJson resources.[0] + Expect.isTrue json.properties.enablePurgeProtection.HasValue "enablePurgeProtection should be set" + Expect.isTrue json.properties.enablePurgeProtection.Value "enablePurgeProtection should be true" + } + + test "Can set public network access to Disabled" { + let store = configurationStore { + name "my-app-config" + public_network_access Disabled + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let json = asStoreJson resources.[0] + Expect.equal json.properties.publicNetworkAccess "Disabled" "publicNetworkAccess should be Disabled" + } + + test "Can set soft delete retention in days" { + let store = configurationStore { + name "my-app-config" + sku Standard + soft_delete_retention_in_days 7 + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let json = asStoreJson resources.[0] + Expect.isTrue json.properties.softDeleteRetentionInDays.HasValue "softDeleteRetentionInDays should be set" + + Expect.equal json.properties.softDeleteRetentionInDays.Value 7 "softDeleteRetentionInDays should be 7" + } + + test "Can set data plane authentication mode" { + let store = configurationStore { + name "my-app-config" + data_plane_authentication_mode Passthrough + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let json = asStoreJson resources.[0] + Expect.isNotNull json.properties.dataPlaneProxy "dataPlaneProxy should be set" + // Verify authenticationMode via JSON string + let jsonStr = resources.[0].JsonModel |> Serialization.toJson + Expect.stringContains jsonStr "Pass-through" "authenticationMode should be Pass-through" + } + + test "Can add feature flags" { + let store = configurationStore { + name "my-app-config" + + add_feature_flags [ + { + Name = "MyFeature" + Description = "A test feature flag" + Label = "" + State = true + } + { + Name = "AnotherFeature" + Description = "Another feature flag" + Label = "prod" + State = false + } + ] + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + Expect.equal resources.Length 3 "Should have store + 2 feature flags" + + let ff1 = resources.[1] + let ff1Json = asKeyValueJson ff1 + Expect.stringContains ff1Json.name ".appconfig.featureflag~2FMyFeature" "Feature flag 1 name" + + let ff2 = resources.[2] + let ff2Json = asKeyValueJson ff2 + Expect.stringContains ff2Json.name "AnotherFeature$prod" "Feature flag 2 name with label" + + Expect.equal + ff1Json.properties.contentType + "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" + "Content type" + + Expect.stringContains ff1Json.properties.value "\"enabled\":true" "Flag state should be true" + } + + test "Can add a single feature flag" { + let store = configurationStore { + name "my-app-config" + + add_feature_flag { + Name = "SingleFeature" + Description = "Single feature flag" + Label = "" + State = false + } + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + Expect.equal resources.Length 2 "Should have store + 1 feature flag" + + let ffJson = asKeyValueJson resources.[1] + Expect.stringContains ffJson.properties.value "\"enabled\":false" "Flag state should be false" + } + + test "Can add key-value items" { + let store = configurationStore { + name "my-app-config" + + add_key_value { + Key = "my-key" + Label = None + Value = "my-value" + ContentType = None + KeyValueTags = Map.empty + } + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + Expect.equal resources.Length 2 "Should have store + key value" + + let kvArm = resources.[1] + Expect.equal kvArm.ResourceId.Name (ResourceName "my-app-config") "Key value parent store name" + + let kvJson = asKeyValueJson kvArm + Expect.equal kvJson.name "my-app-config/my-key" "Key value JSON name" + Expect.equal kvJson.properties.value "my-value" "Value should match" + Expect.isNull kvJson.properties.contentType "contentType should not be set" + } + + test "Can add key-value with label" { + let store = configurationStore { + name "my-app-config" + + add_key_value { + Key = "my-key" + Label = Some "prod" + Value = "my-value" + ContentType = None + KeyValueTags = Map.empty + } + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let kvArm = resources.[1] + let kvJson = asKeyValueJson kvArm + + Expect.equal kvJson.name "my-app-config/my-key$prod" "Key value name with label should be correct" + } + + test "Can add key-value with content type and tags" { + let store = configurationStore { + name "my-app-config" + + add_key_value { + Key = "my-json-key" + Label = None + Value = """{"enabled":true}""" + ContentType = Some "application/json" + KeyValueTags = Map [ "env", "prod" ] + } + } + + let resources = (store :> IBuilder).BuildResources Location.WestEurope + let kvJson = asKeyValueJson resources.[1] + Expect.equal kvJson.properties.contentType "application/json" "contentType should match" + Expect.isNotNull kvJson.properties.tags "tags should be set" + Expect.equal kvJson.properties.tags.["env"] "prod" "tag should match" + } + + test "Endpoint returns correct ARM expression" { + let store = configurationStore { name "my-app-config" } + let endpoint = store.Endpoint.Eval() + Expect.stringContains endpoint "my-app-config" "Endpoint should contain store name" + Expect.stringContains endpoint "endpoint" "Endpoint should reference 'endpoint'" + } + + test "Can add tags to store" { + let store = configurationStore { + name "my-app-config" + add_tags [ "env", "prod"; "team", "devops" ] + } + + Expect.equal store.Tags (Map [ "env", "prod"; "team", "devops" ]) "Tags should be set" + } + ] \ No newline at end of file diff --git a/src/Tests/Tests.fsproj b/src/Tests/Tests.fsproj index a1f9cab07..a696ebd56 100644 --- a/src/Tests/Tests.fsproj +++ b/src/Tests/Tests.fsproj @@ -28,6 +28,7 @@ +