diff --git a/src/Achieve.Aspire.AzureProvisioning/Batch.cs b/src/Achieve.Aspire.AzureProvisioning/Batch.cs new file mode 100644 index 0000000..2fdf4c4 --- /dev/null +++ b/src/Achieve.Aspire.AzureProvisioning/Batch.cs @@ -0,0 +1,61 @@ + +using Achieve.Aspire.AzureProvisioning.Bicep.Batch; +using Achieve.Aspire.AzureProvisioning.Bicep.Internal; +using Achieve.Aspire.AzureProvisioning.Resources; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; + +namespace Achieve.Aspire.AzureProvisioning; + +public static class Batch +{ + public static IResourceBuilder AddAzureBatchAccount(this IDistributedApplicationBuilder builder, string name, Action configure) + { + builder.AddAzureProvisioning(); + + var accountResource = new BatchAccountResource(name); + var options = new BatchAccountOptions(accountResource); + configure(options); + + var fileOutput = BicepFileOutput.GetAspireFileOutput(); + fileOutput.AddResource(accountResource); + foreach (var certificate in options.Certificates) + { + fileOutput.AddResource(certificate.Value.Resource); + } + + var resource = new AzureBatchResource(name, fileOutput); + var resourceBuilder = builder.AddResource(resource); + + return resourceBuilder.WithManifestPublishingCallback(resource.WriteToManifest); + } + + public class AzureBatchResource(string name, BicepFileOutput bicepFileOutput) + : AchieveResource(name, bicepFileOutput) + { + public const string BatchResourceName = "resourceName"; + + public BicepOutputReference AccountEndpoint => new(BatchResourceName, this); + } +} + +public class BatchAccountOptions(BatchAccountResource resource) +{ + public BatchAccountResource Resource { get; set; } = resource; + + public Dictionary Certificates { get; set; } = []; + + public BatchCertificateOptions AddCertificate(string name, Action? configure = null) + { + var certificate = new BatchCertificateResource(Resource, name); + configure?.Invoke(certificate); + Certificates.Add(name, new BatchCertificateOptions(this, certificate)); + return Certificates[name]; + } +} + +public class BatchCertificateOptions(BatchAccountOptions parent, BatchCertificateResource certificate) +{ + public BatchCertificateResource Resource { get; set; } = certificate; +} \ No newline at end of file diff --git a/src/Achieve.Aspire.AzureProvisioning/Bicep/Batch/BatchAccountResource.cs b/src/Achieve.Aspire.AzureProvisioning/Bicep/Batch/BatchAccountResource.cs new file mode 100644 index 0000000..58a568c --- /dev/null +++ b/src/Achieve.Aspire.AzureProvisioning/Bicep/Batch/BatchAccountResource.cs @@ -0,0 +1,391 @@ +using System.Runtime.Serialization; +using Achieve.Aspire.AzureProvisioning.Bicep.Internal; +using Achieve.Aspire.AzureProvisioning.Extensions; + +namespace Achieve.Aspire.AzureProvisioning.Bicep.Batch; + +public sealed class BatchAccountResource : BicepResource +{ + private const string resourceType = "Microsoft.Batch/batchAccounts@2023-11-01"; + private const string propertyAllowedAuthenticationModes = "allowedAuthenticationModes"; + private const string propertyAutoStorage = "autoStorage"; + private const string propertyEncryption = "encryption"; + private const string propertyKeyVaultReference = "keyVaultReference"; + private const string propertyNetworkProfile = "networkProfile"; + private const string propertyPoolAllocationMode = "poolAllocationMode"; + private const string propertyPublicNetworkAccess = "publicNetworkAccess"; + + public BatchAccountResource(string name) : base(resourceType) + { + AccountName = name; + Name = "batchAccount"; + } + + /// + /// The name of the account. + /// + /// + /// 3-24 characters, accepts lowercase letters and numbers. + /// + public string AccountName { get; set; } + + /// + /// The user-specified tags associated with the account. + /// + public Dictionary Tags { get; set; } = []; + + /// + /// The identity of the Batch account. + /// + public BatchAccountIdentity Identity { get; set; } + + /// + /// The properties of the account. + /// + public BatchAccountCreatePropertiesOrBatchAccountProperties Properties { get; set; } + + protected override void ValidateResourceType() + { + if (Name.MatchesConstraints(3, 24, StringExtensions.CharacterClass.LowercaseLetter)) + { + throw new InvalidOperationException( + "Name must be between 3-24 characters and must contain only lowercase letters and numbers"); + } + + if (Identity is {Type: BatchAccountIdentityType.UserAssigned, UserAssignedIdentityResourceIds.Count: 0}) + { + throw new InvalidOperationException( + "If the resource uses User-Assigned Managed Identities, they must be assigned to the resource"); + } + + if (Properties.Encryption.KeySource == EncryptionKeySource.KeyVault && + string.IsNullOrWhiteSpace(Properties.Encryption.KeyVaultProperties?.KeyIdentifier)) + { + throw new InvalidOperationException( + "If a Key Vault is being used as the encryption key source, it must be specified"); + } + + if (Properties is {PublicNetworkAccess: PublicNetworkAccess.Enabled, NetworkProfile: null}) + { + throw new InvalidOperationException( + "If public network access is enabled, the network profile must be provided"); + } + } + + public override void Construct() + { + Body.Add(new BicepResourceProperty(BicepResourceProperties.Name, + new BicepInterpolatedString() + .Str(AccountName.ToLowerInvariant()) + .Exp(new BicepFunctionCallValue("uniqueString", + new BicepPropertyAccessValue(new BicepFunctionCallValue("resourceGroup"), "id"))))); + Body.Add(new BicepResourceProperty("location", new BicepVariableValue("location"))); + + var propertyBag = new BicepResourcePropertyBag(BicepResourceProperties.Properties, 1); + AddAllowedAuthenticationModes(propertyBag); + AddAutoStorage(propertyBag); + AddEncryption(propertyBag); + AddKeyVaultReference(propertyBag); + if (Properties.PublicNetworkAccess == PublicNetworkAccess.Enabled) AddNetworkProfile(propertyBag); + propertyBag.AddProperty(propertyPoolAllocationMode, + new BicepStringValue(Properties.PoolAllocationMode.GetValueFromEnumMember())); + propertyBag.AddProperty(propertyPublicNetworkAccess, + new BicepStringValue(Properties.PublicNetworkAccess.GetValueFromEnumMember())); + + Body.Add(propertyBag); + } + + private void AddAllowedAuthenticationModes(BicepResourcePropertyBag bag) + { + var authModes = new BicepResourcePropertyArray(propertyAllowedAuthenticationModes, 2); + foreach (var authMode in Properties.AllowedAuthenticationModes) + { + authModes.AddValue(new BicepStringValue(authMode.GetValueFromEnumMember())); + } + bag.AddProperty(authModes); + } + + private void AddAutoStorage(BicepResourcePropertyBag bag) + { + var autoStorageBag = new BicepResourcePropertyBag(propertyAutoStorage, 2); + autoStorageBag.AddProperty("authenticationMode", + new BicepStringValue(Properties.AutoStorage.AuthenticationMode.GetValueFromEnumMember())); + + var identityReferenceBag = new BicepResourcePropertyBag("nodeIdentityReference", 3) + .AddProperty("resourceId", new BicepStringValue(Properties.AutoStorage.NodeIdentityReference.ResourceId)); + autoStorageBag.AddProperty(identityReferenceBag); + + autoStorageBag.AddProperty("storageAccountId", new BicepStringValue(Properties.AutoStorage.StorageAccountId)); + } + + private void AddEncryption(BicepResourcePropertyBag bag) + { + var encryptionBag = new BicepResourcePropertyBag(propertyEncryption, 2); + encryptionBag.AddProperty("keySource", + new BicepStringValue(Properties.Encryption.KeySource.GetValueFromEnumMember())); + if (Properties.Encryption is {KeySource: EncryptionKeySource.KeyVault, KeyVaultProperties: not null}) + { + var keyVaultPropertiesBag = new BicepResourcePropertyBag("keyVaultProperties", 3); + keyVaultPropertiesBag.AddProperty("keyIdentifier", + new BicepStringValue(Properties.Encryption.KeyVaultProperties.KeyIdentifier)); + } + } + + private void AddKeyVaultReference(BicepResourcePropertyBag bag) + { + var keyVaultBag = new BicepResourcePropertyBag(propertyKeyVaultReference, 2); + keyVaultBag.AddProperty("id", new BicepStringValue(Properties.KeyVaultReference.Id)); + keyVaultBag.AddProperty("url", new BicepStringValue(Properties.KeyVaultReference.Url)); + } + + private void AddNetworkProfile(BicepResourcePropertyBag bag) + { + var networkProfileBag = new BicepResourcePropertyBag(propertyNetworkProfile, 2); + if (Properties.NetworkProfile != null) + { + //Account access + var accountAccessBag = new BicepResourcePropertyBag("accountAccess", 3) + .AddProperty("defaultAccess", + new BicepStringValue(Properties.NetworkProfile.AccountAccess.DefaultAction + .GetValueFromEnumMember())); + + var accountIpRules = new BicepResourcePropertyArray("ipRules", 4); + foreach (var rule in Properties.NetworkProfile.AccountAccess.IpRules) + { + var rulePropertyBag = new BicepResourcePropertyBag("ip"); + rulePropertyBag.AddProperty("action", new BicepStringValue(rule.Action)); + rulePropertyBag.AddProperty("value", new BicepStringValue(rule.Value)); + accountIpRules.AddValue(rulePropertyBag); + } + networkProfileBag.AddProperty(accountAccessBag); + + + //NOde management access + var nodeManagementAccessBag = new BicepResourcePropertyBag("nodeManagementAccess", 3) + .AddProperty("defaultAccess", + new BicepStringValue( + Properties.NetworkProfile.NodeManagementAccess.DefaultAction.GetValueFromEnumMember())); + + var nodeIpRules = new BicepResourcePropertyArray("ipRules", 4); + foreach (var rule in Properties.NetworkProfile.NodeManagementAccess.IpRules) + { + var rulePropertyBag = new BicepResourcePropertyBag("ip"); + rulePropertyBag.AddProperty("action", new BicepStringValue(rule.Action)); + rulePropertyBag.AddProperty("value", new BicepStringValue(rule.Value)); + nodeIpRules.AddValue(rulePropertyBag); + } + networkProfileBag.AddProperty(accountAccessBag); + } + } +} + +public class BatchAccountIdentity +{ + /// + /// The type of identity used for the Batch account. + /// + public BatchAccountIdentityType Type { get; set; } + + public List UserAssignedIdentityResourceIds { internal get; set; } = []; + + /// + /// The list of user identities associated with the Batch account. + /// + public Dictionary UserAssignedIdentities => + UserAssignedIdentityResourceIds.ToDictionary(resourceId => resourceId, resourceId => ""); +} + +public class BatchAccountCreatePropertiesOrBatchAccountProperties +{ + /// + /// List of allowed authentication modes for the Batch account that can be used to authenticate + /// with the data plane. This does not affect authentication with the control plane. + /// + public List AllowedAuthenticationModes { get; set; } = []; + /// + /// The properties related to the auto-storage account. + /// + public AutoStorageBasePropertiesOrAutoStorageProperties AutoStorage { get; set; } + /// + /// Configures how customer data is encrypted inside the Batch account. By default, accounts + /// are encrypted using a Microsoft managed key. + /// + public EncryptionProperties Encryption { get; set; } + /// + /// A reference to the Azure key vault associated with the Batch account. + /// + public KeyVaultReference KeyVaultReference { get; set; } + /// + /// The network profile only takes effect when publicNetworkAccess is enabled. + /// + public NetworkProfile? NetworkProfile { get; set; } + /// + /// The pool allocation mode also affects how clients may authenticate to the Batch Service + /// API. If the mode is BatchService, clients may authenticate using access keys or + /// Microsoft Entra ID. If the mode is UserSubscription, clients must use Microsoft Entra ID. + /// The default is BatchService. + /// + public PoolAllocationMode PoolAllocationMode { get; set; } = PoolAllocationMode.BatchService; + /// + /// The default is enabled. + /// + public PublicNetworkAccess PublicNetworkAccess { get; set; } = PublicNetworkAccess.Enabled; +} + +public class AutoStorageBasePropertiesOrAutoStorageProperties +{ + /// + /// The authentication mode which the Batch service will use to manage the auto-storage + /// account. + /// + public AutoStorageAuthenticationMode AuthenticationMode { get; set; } + + /// + /// The identity referenced here must be assigned to pools which have compute + /// nodes that need access to auto-storage. + /// + public ComputeNodeIdentityReference NodeIdentityReference { get; set; } + + /// + /// The resource ID of the storage account to be used for auto-storage account. + /// + public string StorageAccountId { get; set;} +} + +public class ComputeNodeIdentityReference +{ + /// + /// The ARM resource ID of the user assigned identity. + /// + public string ResourceId { get; set; } +} + +public class EncryptionProperties +{ + /// + /// Type of the key source. + /// + public EncryptionKeySource KeySource { get; set; } + + /// + /// Additional details when using a Key Vault. + /// + public KeyVaultProperties? KeyVaultProperties { get; set; } +} + +public class KeyVaultProperties +{ + /// + /// Full path to the secret with or without version. + /// + /// + /// https://mykeyvault.vault.azure.net/keys/testkey/6e34a81fef704045975661e297a4c053 + /// + /// + /// To be usable, the following prerequisites must be met: + /// + /// - The Batch account has a System Assigned identity + /// - The account identity has been granted Key/Get, Key/Unwrap and Key/Wrap permissions + /// - The key vault has soft-delete and purge protection enabled + /// + public string KeyIdentifier { get; set; } +} + +public class KeyVaultReference +{ + /// + /// The resource ID of the Azure key vault associated with the Batch account. + /// + public string Id { get; set; } + + /// + /// The URL of the Azure Key vault associated with the Batch account. + /// + public string Url { get; set; } +} + +public class NetworkProfile +{ + /// + /// Network access profile for batchAccount endpoint (Batch account data plane API). + /// + public EndpointAccessProfile AccountAccess { get; set; } + /// + /// Network access profile for nodeManagement endpoint (Batch service managing compute + /// nodes for Batch pools. + /// + public EndpointAccessProfile NodeManagementAccess { get; set; } +} + +public class EndpointAccessProfile +{ + /// + /// Default action for endpoint access. It is only applicable when publicNetworkAccess is enabled. + /// + public required EndpointAccessProfileDefaultAction DefaultAction { get; set; } + /// + /// Array of IP ranges to filter client IP address. + /// + public List IpRules { get; set; } = []; +} + +public class IpRule +{ + /// + /// Action when client IP address is matched. + /// + public string Action => "action"; + /// + /// IPv4 address, or IPv4 address range in CIDR format. + /// + public string Value { get; set; } +} + +public enum BatchAccountIdentityType +{ + None, + SystemAssigned, + UserAssigned +} + +public enum EndpointAccessProfileDefaultAction +{ + Allow, + Deny +} + +public enum AllowedAuthenticationMode +{ + [EnumMember(Value="AAD")] + Entra, + [EnumMember(Value="SharedKey")] + SharedKey, + [EnumMember(Value="TaskAuthenticationToken")] + TaskAuthenticationToken +} + +public enum PoolAllocationMode +{ + BatchService, + UserSubscription +} + +public enum PublicNetworkAccess +{ + Enabled, + Disabled +} + +public enum AutoStorageAuthenticationMode +{ + BatchAccountManagedIdentity, + StorageKeys +} + +public enum EncryptionKeySource +{ + [EnumMember(Value="Microsoft.Batch")] + Batch, + [EnumMember(Value="Microsoft.KeyVault")] + KeyVault +} \ No newline at end of file diff --git a/src/Achieve.Aspire.AzureProvisioning/Bicep/Batch/BatchCertificateResource.cs b/src/Achieve.Aspire.AzureProvisioning/Bicep/Batch/BatchCertificateResource.cs new file mode 100644 index 0000000..1c8df39 --- /dev/null +++ b/src/Achieve.Aspire.AzureProvisioning/Bicep/Batch/BatchCertificateResource.cs @@ -0,0 +1,76 @@ +using Achieve.Aspire.AzureProvisioning.Bicep.Internal; +using Achieve.Aspire.AzureProvisioning.Extensions; + +namespace Achieve.Aspire.AzureProvisioning.Bicep.Batch; + +public sealed class BatchCertificateResource : BicepResource +{ + private const string resourceType = "Microsoft.Batch/batchAccounts/certificates@2023-11-01"; + + public BatchAccountResource Parent { get; set; } + + public string Data { get; set; } + + /// + /// The format of the certificate. + /// + public CertificateFormat Format { get; set; } = CertificateFormat.Pfx; + + public string? Password { get; set; } + + public string Thumbprint { get; set; } + + public string ThumbprintAlgorithm => "SHA1"; + + + public BatchCertificateResource(BatchAccountResource parent, string name) : base(resourceType) + { + Parent = parent; + Name = name; + } + + protected override void ValidateResourceType() + { + if (!Name.MatchesConstraints(3, 45, + StringExtensions.CharacterClass.Alphanumeric | StringExtensions.CharacterClass.Underscore | + StringExtensions.CharacterClass.Hyphen)) + { + throw new InvalidOperationException( + "Name must be between 5 and 45 characters long and can only contain alphanumerics, underscores and hyphens"); + } + + if (Format == CertificateFormat.Cer && Password is not null) + { + throw new InvalidOperationException("The password must not be specified if the certificate format is Cer"); + } + + if (string.IsNullOrWhiteSpace(Data) || Data.Length > 10000) //Unclear if this is a metric or binary limit + { + throw new InvalidOperationException("The maximum length of the certificate is 10KB"); + } + } + + public override void Construct() + { + Body.Add(new BicepResourceProperty("parent", new BicepVariableValue(Parent.Name))); + Body.Add(new BicepResourceProperty("name", new BicepStringValue(Name))); + + var propertyBag = new BicepResourcePropertyBag(BicepResourceProperties.Properties); + propertyBag.AddProperty("data", new BicepStringValue(Data)); + propertyBag.AddProperty("format", new BicepStringValue(Format.GetValueFromEnumMember())); + if (Format == CertificateFormat.Pfx) + { + propertyBag.AddProperty("password", new BicepStringValue(Password ?? "")); + } + + propertyBag.AddProperty("thumbprint", new BicepStringValue(Thumbprint)); + propertyBag.AddProperty("thumbprintAlgorithm", new BicepStringValue(ThumbprintAlgorithm)); + Body.Add(propertyBag); + } +} + +public enum CertificateFormat +{ + Cer, + Pfx +} diff --git a/src/Achieve.Aspire.AzureProvisioning/Bicep/Internal/BicepOutput.cs b/src/Achieve.Aspire.AzureProvisioning/Bicep/Internal/BicepOutput.cs index 4363f1b..9dbae09 100644 --- a/src/Achieve.Aspire.AzureProvisioning/Bicep/Internal/BicepOutput.cs +++ b/src/Achieve.Aspire.AzureProvisioning/Bicep/Internal/BicepOutput.cs @@ -27,12 +27,15 @@ private PropertyAccessSyntax GetOutputPathSyntax() // This generates depending on the output path, super hacky for now // There will be a better way, but for now... var splitPath = Path.Split('.'); + var first = splitPath[0]; + var second = splitPath.Length > 1 ? splitPath[1] : splitPath[0]; + var propertyAccess = new PropertyAccessSyntax( - new VariableAccessSyntax(SyntaxFactory.CreateIdentifier(splitPath[0])), + new VariableAccessSyntax(SyntaxFactory.CreateIdentifier(first)), SyntaxFactory.DotToken, null, - SyntaxFactory.CreateIdentifier(splitPath[1])); + SyntaxFactory.CreateIdentifier(second)); if (splitPath.Length > 2) { for (var i = 2; i < splitPath.Length; i++) diff --git a/src/Achieve.Aspire.AzureProvisioning/Extensions/EnumExtensions.cs b/src/Achieve.Aspire.AzureProvisioning/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..ce59f03 --- /dev/null +++ b/src/Achieve.Aspire.AzureProvisioning/Extensions/EnumExtensions.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using System.Runtime.Serialization; + +namespace Achieve.Aspire.AzureProvisioning.Extensions; + +/// +/// Provides extensions for use with enums. +/// +internal static class EnumExtensions +{ + /// + /// Reads the value of an enum out of the attached attribute. + /// + /// The enum. + /// The value of the enum to pull the value for. + /// + internal static string GetValueFromEnumMember(this T value) where T : Enum + { + var memberInfo = typeof(T).GetMember(value.ToString(), + BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly); + if (memberInfo.Length <= 0) + { + return value.ToString(); + } + + var attributes = memberInfo[0].GetCustomAttributes(typeof(EnumMemberAttribute), false); + if (attributes.Length > 0) + { + var targetAttribute = (EnumMemberAttribute) attributes[0]; + if (targetAttribute is {Value: not null}) + { + return targetAttribute.Value; + } + } + + return value.ToString(); + } +} diff --git a/src/Achieve.Aspire.AzureProvisioning/Extensions/StringExtensions.cs b/src/Achieve.Aspire.AzureProvisioning/Extensions/StringExtensions.cs new file mode 100644 index 0000000..c1a350d --- /dev/null +++ b/src/Achieve.Aspire.AzureProvisioning/Extensions/StringExtensions.cs @@ -0,0 +1,64 @@ + + +namespace Achieve.Aspire.AzureProvisioning.Extensions; + +/// +/// Provides extensions for use with strings. +/// +internal static class StringExtensions +{ + /// + /// Denotes classes of character types. + /// + [Flags] + internal enum CharacterClass + { + UppercaseLetter = 0b_000001, + LowercaseLetter = 0b_000010, + Underscore = 0b_000100, + Hyphen = 0b_001000, + Whitespace = 0b_010000, + Number = 0b_100000, + Alphabetic = UppercaseLetter | LowercaseLetter, + Alphanumeric = Alphabetic | Number, + Any = UppercaseLetter | LowercaseLetter | Number | Underscore | Hyphen | Whitespace + } + + /// + /// Validates that a given string matches the specified naming constraints. + /// + /// The string to evaluate. + /// The minimum allowed length of the string. + /// The maximum allowed length of the string. + /// The types of characters allowed in the string. + /// True if the string matches the provided constraints; otherwise false. + public static bool MatchesConstraints(this string str, int? minLength, int? maxLength, CharacterClass characterClasses) + { + if ((minLength != null && str.Length < minLength) || (maxLength != null && str.Length > maxLength)) + return false; + + if (characterClasses.HasFlag(CharacterClass.Any)) + return true; + + //Create a set of allowed characters based on the flags + var allowedChars = new HashSet(); + + if (characterClasses.HasFlag(CharacterClass.UppercaseLetter)) + allowedChars.UnionWith("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); + + if (characterClasses.HasFlag(CharacterClass.LowercaseLetter)) + allowedChars.UnionWith("abcdefghijklmnopqrstuvwxyz"); + + if (characterClasses.HasFlag(CharacterClass.Number)) + allowedChars.UnionWith("0123456789"); + + if (characterClasses.HasFlag(CharacterClass.Underscore)) + allowedChars.Add('_'); + + if (characterClasses.HasFlag(CharacterClass.Hyphen)) + allowedChars.Add('-'); + + // Check each character in the input string + return str.All(c => allowedChars.Contains(c)); + } +} diff --git a/tests/Achieve.Aspire.AzureProvisioning.Tests/BatchTests.cs b/tests/Achieve.Aspire.AzureProvisioning.Tests/BatchTests.cs new file mode 100644 index 0000000..54bb98c --- /dev/null +++ b/tests/Achieve.Aspire.AzureProvisioning.Tests/BatchTests.cs @@ -0,0 +1,110 @@ +using Achieve.Aspire.AzureProvisioning.Bicep.Batch; +using Achieve.Aspire.AzureProvisioning.Tests.Utils; +using Aspire.Hosting; +using Aspire.Hosting.Azure; +using Xunit.Abstractions; + +namespace Achieve.Aspire.AzureProvisioning.Tests; + +public class BatchTests(ITestOutputHelper output) +{ + [Fact] + public void AzureProvisionerIsAdded() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var cosmos = builder.AddAzureBatchAccount("batch", acc => { }); + Assert.Contains(builder.Services, + m => m.ServiceKey != null && m.ServiceKey as Type == typeof(AzureBicepResource)); + } + + [Fact] + public async Task BasicBatchGeneratesCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + //var id = builder.AddManagedIdentity("testid"); + var batch = builder.AddAzureBatchAccount("batch", acc => + { + acc.Resource.Identity = new BatchAccountIdentity + { + Type = BatchAccountIdentityType.SystemAssigned + }; + acc.Resource.Properties = new BatchAccountCreatePropertiesOrBatchAccountProperties + { + Encryption = new EncryptionProperties + { + KeySource = EncryptionKeySource.Batch + }, + AutoStorage = new AutoStorageBasePropertiesOrAutoStorageProperties + { + AuthenticationMode = AutoStorageAuthenticationMode.BatchAccountManagedIdentity, + NodeIdentityReference = new ComputeNodeIdentityReference + { + ResourceId = "uai" + }, + StorageAccountId = "storage" + }, + AllowedAuthenticationModes = [AllowedAuthenticationMode.Entra], + PoolAllocationMode = PoolAllocationMode.BatchService, + PublicNetworkAccess = PublicNetworkAccess.Disabled, + KeyVaultReference = new KeyVaultReference + { + Id = "kv", + Url = "https://kv" + } + }; + acc.AddCertificate("cert", certificate => + { + certificate.Data = "abc123"; + certificate.Format = CertificateFormat.Pfx; + certificate.Password = "password"; + certificate.Thumbprint = "aaa"; + }); + }); + + var batchManifestBicep = await ManifestUtils.GetManifestWithBicep(batch.Resource); + + const string expectedManifest = """ + { + "type": "azure.bicep.v0", + "path": "batch.achieve.bicep" + } + """; + // var batchManifestNode = batchManifestBicep.ManifestNode.ToString(); + // Assert.Equal(expectedManifest, batchManifestNode); + + Assert.Equal(expectedManifest, batchManifestBicep.ManifestNode.ToString()); + + const string expectedBicep = """ + targetScope = 'resourceGroup' + + @description('The location of the resource group.') + param location string = resourceGroup().location + + resource batchAccount 'Microsoft.Batch/batchAccounts@2023-11-01' = { + name: 'batch${uniqueString(resourceGroup().id)}' + location: location + properties: { + allowedAuthenticationModes: [ + 'AAD' + ] + poolAllocationMode: 'BatchService' + publicNetworkAccess: 'Disabled' + } + } + + resource cert 'Microsoft.Batch/batchAccounts/certificates@2023-11-01' = { + parent: batchAccount + name: 'cert' + properties: { + data: 'abc123' + format: 'Pfx' + password: 'password' + thumbprint: 'aaa' + thumbprintAlgorithm: 'SHA1' + } + } + """; + var manifestBicep = batchManifestBicep.BicepText; + Assert.Equal(expectedBicep.Trim(), manifestBicep.Trim()); + } +} diff --git a/tests/Achieve.Aspire.AzureProvisioning.Tests/Extensions/EnumExtensionTests.cs b/tests/Achieve.Aspire.AzureProvisioning.Tests/Extensions/EnumExtensionTests.cs new file mode 100644 index 0000000..0939202 --- /dev/null +++ b/tests/Achieve.Aspire.AzureProvisioning.Tests/Extensions/EnumExtensionTests.cs @@ -0,0 +1,37 @@ + + +using System.Runtime.Serialization; +using Achieve.Aspire.AzureProvisioning.Extensions; + +namespace Achieve.Aspire.AzureProvisioning.Tests.Extensions; + +public class EnumExtensionTests +{ + [Fact] + public void CanReadValueFromEnumWithoutEnumMemberAttribute() + { + var sut = Shapes.Circle.GetValueFromEnumMember(); + Assert.Equal("Circle", sut); + } + + [Fact] + public void CanReadValueFromEnumWithEnumMemberAttribute() + { + var sut = Colors.Blue.GetValueFromEnumMember(); + Assert.Equal("azul", sut); + } + + public enum Shapes + { + Circle, + Triangle + } + + private enum Colors + { + [EnumMember(Value="verde")] + Green, + [EnumMember(Value="azul")] + Blue + } +} diff --git a/tests/Achieve.Aspire.AzureProvisioning.Tests/Extensions/StringExtensionTests.cs b/tests/Achieve.Aspire.AzureProvisioning.Tests/Extensions/StringExtensionTests.cs new file mode 100644 index 0000000..532bb4e --- /dev/null +++ b/tests/Achieve.Aspire.AzureProvisioning.Tests/Extensions/StringExtensionTests.cs @@ -0,0 +1,112 @@ +using Achieve.Aspire.AzureProvisioning.Extensions; + +namespace Achieve.Aspire.AzureProvisioning.Tests.Extensions; + +public class StringExtensionTests +{ + [Fact] + public void ShouldFailWhenLengthIsTooShort() + { + const string test = "a"; + var result = test.MatchesConstraints(3, 12, StringExtensions.CharacterClass.Any); + Assert.False(result); + } + + [Fact] + public void ShouldFailWhenLengthIsTooLong() + { + const string test = "ThisIsATest"; + var result = test.MatchesConstraints(3, 5, StringExtensions.CharacterClass.Any); + Assert.False(result); + } + + [Fact] + public void ShoudlAcceptValidLength() + { + const string test = "ThisIsATest"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.Any); + Assert.True(result); + } + + [Fact] + public void ShouldPassWithOnlyAlphabetCharacters() + { + const string test = "ThisIsATest"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.LowercaseLetter | StringExtensions.CharacterClass.UppercaseLetter); + Assert.True(result); + } + + [Fact] + public void ShouldAcceptAlphabeticCharacters() + { + const string test = "ThisIsATest"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.Alphabetic); + Assert.True(result); + } + + [Fact] + public void ShouldNotAcceptNonAlphabeticCharacters() + { + const string test = "ThisI_sAnother4Test-"; + var result = test.MatchesConstraints(2, 25, StringExtensions.CharacterClass.Alphabetic); + Assert.False(result); + } + + [Fact] + public void ShouldAcceptAlphanumericCharacters() + { + const string test = "Test3"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.Alphanumeric); + Assert.True(result); + } + + [Fact] + public void ShouldFailWhenExpectingOnlyAlphaCharacters() + { + const string test = "This is a big-test"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.LowercaseLetter | StringExtensions.CharacterClass.UppercaseLetter); + Assert.False(result); + } + + [Fact] + public void ShouldOnlyAllowAlphaNumeric() + { + const string test = "Test007"; + var result = test.MatchesConstraints(3, 25, + StringExtensions.CharacterClass.LowercaseLetter | StringExtensions.CharacterClass.UppercaseLetter | StringExtensions.CharacterClass.Number); + Assert.True(result); + } + + [Fact] + public void ShouldFailWhenExpectingOnlyAlphaNumeric() + { + const string test = "Test#007-"; + var result = test.MatchesConstraints(3, 25, + StringExtensions.CharacterClass.LowercaseLetter | StringExtensions.CharacterClass.UppercaseLetter | StringExtensions.CharacterClass.Number); + Assert.False(result); + } + + [Fact] + public void ShouldOnlyAllowLowerAlphaNumeric() + { + const string test = "test007"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.LowercaseLetter | StringExtensions.CharacterClass.Number); + Assert.True(result); + } + + [Fact] + public void ShouldAllowUnderscores() + { + const string test = "test_993"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.Underscore | StringExtensions.CharacterClass.LowercaseLetter | StringExtensions.CharacterClass.Number); + Assert.True(result); + } + + [Fact] + public void ShouldAllowHyphens() + { + const string test = "test--993"; + var result = test.MatchesConstraints(3, 25, StringExtensions.CharacterClass.LowercaseLetter | StringExtensions.CharacterClass.Hyphen | StringExtensions.CharacterClass.Number); + Assert.True(result); + } +}