diff --git a/docs/custom-images.md b/docs/custom-images.md index 6c313140b9..c9906f1808 100644 --- a/docs/custom-images.md +++ b/docs/custom-images.md @@ -1,7 +1,7 @@ # Fuzzing using Custom OS Images -In order to use custom OS images in OneFzuz, the image _must_ run the -[Azure VM Agent](https://docs.microsoft.com/en-us/azure/virtual-machines/extensions/overview). +In order to use custom OS images in OneFuzz, the image _must_ run the [Azure VM +Agent](https://docs.microsoft.com/en-us/azure/virtual-machines/extensions/overview). Building custom images can be automated using the [Linux](https://docs.microsoft.com/en-us/azure/virtual-machines/linux/image-builder) @@ -9,15 +9,25 @@ or [Windows](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/image-builder) image builders for Azure. -If you have a custom Windows VHD, you should follow the -[Guide to prepare a VHD for Azure](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/prepare-for-upload-vhd-image). +If you have a custom Windows VHD, you should follow the [Guide to prepare a VHD +for +Azure](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/prepare-for-upload-vhd-image). From there, rather than using Image SKUs such as -`Canonical:0001-com-ubuntu-server-focal:20_04-lts:latest`, use the full resource ID to the -shared image, such as -`/subscriptions/MYSUBSCRIPTION/resourceGroups/MYGROUP/providers/Microsoft.Compute/galleries/MYGALLERY/images/MYDEFINITION/versions/MYVERSION` +`Canonical:0001-com-ubuntu-server-focal:20_04-lts:latest`, use the full resource ID for the +shared image. Supported ID formats are: -The images must be hosted in a -[Shared Image Gallery](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/shared-image-galleries). -The Service Principal for the OneFuzz instance must have RBAC to the shared -image gallery sufficient to deploy the images. +- VM image:
+ `/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/images/{image}` +- gallery image (latest):
+ `/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/galleries/{gallery}/images/{image}` +- gallery image (specific version):
+ `/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/galleries/{gallery}/images/{image}/versions/{version}` +- shared gallery image (latest):
+ `/subscriptions/{subscription}/providers/Microsoft.Compute/locations/{location}/sharedGalleries/{gallery}/images/{image}`, +- shared gallery image (specific version):
+ `/subscriptions/{subscription}/providers/Microsoft.Compute/locations/{location}/sharedGalleries/{gallery}/images/{image}/versions/{version}` + +The Service Principal for the OneFuzz instance must have RBAC to the image +sufficient to read and deploy the images, and the image must be replicated into +the region of the scaleset. diff --git a/src/ApiService/ApiService/Functions/Scaleset.cs b/src/ApiService/ApiService/Functions/Scaleset.cs index 29de614e6d..94b7a2c400 100644 --- a/src/ApiService/ApiService/Functions/Scaleset.cs +++ b/src/ApiService/ApiService/Functions/Scaleset.cs @@ -76,13 +76,13 @@ private async Task Post(HttpRequestData req) { context: "ScalesetCreate"); } - string image; + ImageReference image; if (create.Image is null) { var config = await _context.ConfigOperations.Fetch(); if (pool.Os == Os.Windows) { - image = config.DefaultWindowsVmImage; + image = config.DefaultWindowsVmImage ?? DefaultImages.Windows; } else { - image = config.DefaultLinuxVmImage; + image = config.DefaultLinuxVmImage ?? DefaultImages.Linux; } } else { image = create.Image; diff --git a/src/ApiService/ApiService/OneFuzzTypes/Model.cs b/src/ApiService/ApiService/OneFuzzTypes/Model.cs index cb971ca8d1..bcc95c74c1 100644 --- a/src/ApiService/ApiService/OneFuzzTypes/Model.cs +++ b/src/ApiService/ApiService/OneFuzzTypes/Model.cs @@ -223,7 +223,7 @@ public record TaskDetails( public record TaskVm( Region Region, string Sku, - string Image, + ImageReference Image, bool? RebootAfterSetup, long Count = 1, bool SpotInstance = false @@ -345,9 +345,9 @@ public record InstanceConfig string[] AllowedAadTenants, [DefaultValue(InitMethod.DefaultConstructor)] NetworkConfig NetworkConfig, [DefaultValue(InitMethod.DefaultConstructor)] NetworkSecurityGroupConfig ProxyNsgConfig, - AzureVmExtensionConfig? Extensions, - string DefaultWindowsVmImage = "MicrosoftWindowsDesktop:Windows-10:win10-21h2-pro:latest", - string DefaultLinuxVmImage = "Canonical:0001-com-ubuntu-server-focal:20_04-lts:latest", + AzureVmExtensionConfig? Extensions = null, + ImageReference? DefaultWindowsVmImage = null, + ImageReference? DefaultLinuxVmImage = null, string ProxyVmSku = "Standard_B2s", bool RequireAdminPrivileges = false, IDictionary? ApiAccessRules = null, @@ -355,18 +355,13 @@ public record InstanceConfig IDictionary? VmTags = null, IDictionary? VmssTags = null ) : EntityBase() { + public InstanceConfig(string instanceName) : this( - instanceName, - null, - Array.Empty(), - new NetworkConfig(), - new NetworkSecurityGroupConfig(), - null, - "MicrosoftWindowsDesktop:Windows-10:win10-21h2-pro:latest", - "Canonical:0001-com-ubuntu-server-focal:20_04-lts:latest", - "Standard_B2s", - false - ) { } + InstanceName: instanceName, + Admins: null, + AllowedAadTenants: Array.Empty(), + NetworkConfig: new NetworkConfig(), + ProxyNsgConfig: new NetworkSecurityGroupConfig()) { } public static List? CheckAdmins(List? value) { if (value is not null && value.Count == 0) { @@ -378,8 +373,8 @@ public InstanceConfig(string instanceName) : this( public InstanceConfig() : this(String.Empty) { } - //# At the moment, this only checks allowed_aad_tenants, however adding - //# support for 3rd party JWT validation is anticipated in a future release. + // At the moment, this only checks allowed_aad_tenants, however adding + // support for 3rd party JWT validation is anticipated in a future release. public ResultVoid> CheckInstanceConfig() { List errors = new(); if (AllowedAadTenants.Length == 0) { @@ -415,7 +410,7 @@ public record Scaleset( [RowKey] Guid ScalesetId, ScalesetState State, string VmSku, - string Image, + ImageReference Image, Region Region, long Size, bool? SpotInstances, @@ -757,7 +752,7 @@ public record Vm( string Name, Region Region, string Sku, - string Image, + ImageReference Image, Authentication Auth, Nsg? Nsg, IDictionary? Tags diff --git a/src/ApiService/ApiService/OneFuzzTypes/Requests.cs b/src/ApiService/ApiService/OneFuzzTypes/Requests.cs index d201e37a02..508d7a9ea2 100644 --- a/src/ApiService/ApiService/OneFuzzTypes/Requests.cs +++ b/src/ApiService/ApiService/OneFuzzTypes/Requests.cs @@ -191,7 +191,7 @@ public record ProxyReset( public record ScalesetCreate( [property: Required] PoolName PoolName, [property: Required] string VmSku, - string? Image, + ImageReference? Image, Region? Region, [property: Range(1, long.MaxValue), Required] long Size, [property: Required] bool SpotInstances, diff --git a/src/ApiService/ApiService/OneFuzzTypes/Responses.cs b/src/ApiService/ApiService/OneFuzzTypes/Responses.cs index 0b2944dc6b..2c7909299f 100644 --- a/src/ApiService/ApiService/OneFuzzTypes/Responses.cs +++ b/src/ApiService/ApiService/OneFuzzTypes/Responses.cs @@ -127,7 +127,7 @@ public record ScalesetResponse( ScalesetState State, Authentication? Auth, string VmSku, - string Image, + ImageReference Image, Region Region, long Size, bool? SpotInstances, diff --git a/src/ApiService/ApiService/Program.cs b/src/ApiService/ApiService/Program.cs index 9ba6edbd3f..abbabd8311 100644 --- a/src/ApiService/ApiService/Program.cs +++ b/src/ApiService/ApiService/Program.cs @@ -123,7 +123,6 @@ public static async Async.Task Main() { .AddScoped() .AddScoped() .AddScoped() - .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/src/ApiService/ApiService/onefuzzlib/ImageOperations.cs b/src/ApiService/ApiService/onefuzzlib/ImageOperations.cs deleted file mode 100644 index 21c6618e55..0000000000 --- a/src/ApiService/ApiService/onefuzzlib/ImageOperations.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Threading.Tasks; -using Azure; -using Azure.ResourceManager.Compute; -using Microsoft.Extensions.Caching.Memory; - -namespace Microsoft.OneFuzz.Service; - -public record ImageInfo(string Publisher, string Offer, string Sku, string Version); - -public interface IImageOperations { - public Async.Task> GetOs(Region region, string image); - - public static ImageInfo GetImageInfo(string image) { - var imageParts = image.Split(":"); - // The python code would throw if more than 4 parts are found in the split - System.Diagnostics.Trace.Assert(imageParts.Length == 4, $"Expected 4 ':' separated parts in {image}"); - - var publisher = imageParts[0]; - var offer = imageParts[1]; - var sku = imageParts[2]; - var version = imageParts[3]; - - return new ImageInfo(Publisher: publisher, Offer: offer, Sku: sku, Version: version); - } -} - -public class ImageOperations : IImageOperations { - private IOnefuzzContext _context; - private ILogTracer _logTracer; - private readonly IMemoryCache _cache; - - const string SubscriptionsStr = "/subscriptions/"; - const string ProvidersStr = "/providers/"; - - - public ImageOperations(ILogTracer logTracer, IOnefuzzContext context, IMemoryCache cache) { - _logTracer = logTracer; - _context = context; - _cache = cache; - } - - sealed record class GetOsKey(Region Region, string Image); - - public Task> GetOs(Region region, string image) - => _cache.GetOrCreateAsync>(new GetOsKey(region, image), entry => GetOsInternal(region, image)); - - private async Task> GetOsInternal(Region region, string image) { - string? name = null; - if (image.StartsWith(SubscriptionsStr) || image.StartsWith(ProvidersStr)) { - var parsed = _context.Creds.ParseResourceId(image); - parsed = await _context.Creds.GetData(parsed); - if (string.Equals(parsed.Id.ResourceType, "galleries", StringComparison.OrdinalIgnoreCase)) { - try { - // This is not _exactly_ the same as the python code - // because in C# we don't have access to child_name_1 - var gallery = await _context.Creds.GetResourceGroupResource().GetGalleries().GetAsync( - parsed.Data.Name - ); - - var galleryImage = gallery.Value.GetGalleryImages() - .ToEnumerable() - .Where(galleryImage => string.Equals(galleryImage.Id, parsed.Id, StringComparison.OrdinalIgnoreCase)) - .First(); - - galleryImage = await galleryImage.GetAsync(); - - name = galleryImage.Data?.OSType?.ToString().ToLowerInvariant()!; - - } catch (Exception ex) when ( - ex is RequestFailedException || - ex is NullReferenceException - ) { - _logTracer.Exception(ex); - return OneFuzzResult.Error( - ErrorCode.INVALID_IMAGE, - ex.ToString() - ); - } - } else { - try { - name = (await _context.Creds.GetResourceGroupResource().GetImages().GetAsync( - parsed.Data.Name - )).Value.Data.StorageProfile.OSDisk.OSType.ToString().ToLowerInvariant(); - } catch (Exception ex) when ( - ex is RequestFailedException || - ex is NullReferenceException - ) { - _logTracer.Exception(ex); - return OneFuzzResult.Error( - ErrorCode.INVALID_IMAGE, - ex.ToString() - ); - } - } - } else { - var imageInfo = IImageOperations.GetImageInfo(image); - try { - var subscription = await _context.Creds.ArmClient.GetDefaultSubscriptionAsync(); - string version; - if (string.Equals(imageInfo.Version, "latest", StringComparison.Ordinal)) { - version = - (await subscription.GetVirtualMachineImagesAsync( - region.String, - imageInfo.Publisher, - imageInfo.Offer, - imageInfo.Sku, - top: 1 - ).FirstAsync()).Name; - } else { - version = imageInfo.Version; - } - - name = (await subscription.GetVirtualMachineImageAsync( - region.String, - imageInfo.Publisher, - imageInfo.Offer, - imageInfo.Sku - , version - )).Value.OSDiskImageOperatingSystem.ToString().ToLower(); - } catch (RequestFailedException ex) { - _logTracer.Exception(ex); - return OneFuzzResult.Error( - ErrorCode.INVALID_IMAGE, - ex.ToString() - ); - } - } - - if (name != null) { - name = string.Concat(name[0].ToString().ToUpper(), name.AsSpan(1)); - if (Enum.TryParse(name, out Os os)) { - return OneFuzzResult.Ok(os); - } - } - - return OneFuzzResult.Error( - ErrorCode.INVALID_IMAGE, - $"Unexpected image os type: {name}" - ); - } -} diff --git a/src/ApiService/ApiService/onefuzzlib/ImageReference.cs b/src/ApiService/ApiService/onefuzzlib/ImageReference.cs new file mode 100644 index 0000000000..a3de78ee03 --- /dev/null +++ b/src/ApiService/ApiService/onefuzzlib/ImageReference.cs @@ -0,0 +1,261 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Compute; +using Azure.ResourceManager.Compute.Models; +using Microsoft.Extensions.Caching.Memory; +using Compute = Azure.ResourceManager.Compute; + +namespace Microsoft.OneFuzz.Service; + +public static class DefaultImages { + public static readonly ImageReference Windows = ImageReference.MustParse("MicrosoftWindowsDesktop:Windows-10:win10-21h2-pro:latest"); + public static readonly ImageReference Linux = ImageReference.MustParse("Canonical:0001-com-ubuntu-server-focal:20_04-lts:latest"); +} + +[JsonConverter(typeof(Converter))] +public abstract record ImageReference { + public static ImageReference MustParse(string image) { + var result = TryParse(image); + if (!result.IsOk) { + var msg = string.Join(", ", result.ErrorV.Errors ?? Array.Empty()); + throw new ArgumentException(msg, nameof(image)); + } + + return result.OkV; + } + + public static OneFuzzResult TryParse(string image) { + ResourceIdentifier identifier; + ImageReference result; + try { + // see if it is a valid ARM resource identifier: + identifier = new ResourceIdentifier(image); + if (identifier.ResourceType == SharedGalleryImageResource.ResourceType) { + result = new LatestSharedGalleryImage(identifier); + } else if (identifier.ResourceType == SharedGalleryImageVersionResource.ResourceType) { + result = new SharedGalleryImage(identifier); + } else if (identifier.ResourceType == GalleryImageVersionResource.ResourceType) { + result = new GalleryImage(identifier); + } else if (identifier.ResourceType == GalleryImageResource.ResourceType) { + result = new LatestGalleryImage(identifier); + } else if (identifier.ResourceType == ImageResource.ResourceType) { + result = new Image(identifier); + } else { + return new Error( + ErrorCode.INVALID_IMAGE, + new[] { $"Unknown image resource type: {identifier.ResourceType}" }); + } + } catch (FormatException) { + // not an ARM identifier, try to parse a marketplace image: + var imageParts = image.Split(":"); + // The python code would throw if more than 4 parts are found in the split + if (imageParts.Length != 4) { + return new Error( + Code: ErrorCode.INVALID_IMAGE, + new[] { $"Expected 4 ':' separated parts in '{image}'" }); + } + + result = new Marketplace( + Publisher: imageParts[0], + Offer: imageParts[1], + Sku: imageParts[2], + Version: imageParts[3]); + } + + return OneFuzzResult.Ok(result); + } + + // region is not part of the key as it should not make a difference to the OS type + // it is only used for marketplace images + private sealed record CacheKey(string image); + public Task> GetOs(IMemoryCache cache, ArmClient armClient, Region region) { + return cache.GetOrCreateAsync(new CacheKey(ToString()), entry => { + // this should essentially never change + // the user would have to delete the image and recreate it with the same name but + // a different OS, which would be very unusual + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + return GetOsUncached(armClient, region); + }); + } + + protected abstract Task> GetOsUncached(ArmClient armClient, Region region); + + public abstract Compute.Models.ImageReference ToArm(); + + public abstract long MaximumVmCount { get; } + + // Documented here: https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-placement-groups#checklist-for-using-large-scale-sets + protected const long CustomImageMaximumVmCount = 600; + protected const long MarketplaceImageMaximumVmCount = 1000; + + public abstract override string ToString(); + + public abstract record ArmImageReference(ResourceIdentifier Identifier) : ImageReference { + public sealed override long MaximumVmCount => CustomImageMaximumVmCount; + + public sealed override Compute.Models.ImageReference ToArm() + => new() { Id = Identifier }; + + public sealed override string ToString() => Identifier.ToString(); + } + + [JsonConverter(typeof(Converter))] + public sealed record LatestGalleryImage(ResourceIdentifier Identifier) : ArmImageReference(Identifier) { + protected override async Task> GetOsUncached(ArmClient armClient, Region region) { + try { + var resource = await armClient.GetGalleryImageResource(Identifier).GetAsync(); + if (resource.Value.Data.OSType is OperatingSystemTypes os) { + return OneFuzzResult.Ok(Enum.Parse(os.ToString(), ignoreCase: true)); + } else { + return new Error(ErrorCode.INVALID_IMAGE, new[] { "Specified image had no OSType" }); + } + } catch (Exception ex) when (ex is RequestFailedException) { + return new Error(ErrorCode.INVALID_IMAGE, new[] { ex.ToString() }); + } + } + } + + [JsonConverter(typeof(Converter))] + public sealed record GalleryImage(ResourceIdentifier Identifier) : ArmImageReference(Identifier) { + protected override async Task> GetOsUncached(ArmClient armClient, Region region) { + try { + // need to access parent of versioned resource to get the OS data + var resource = await armClient.GetGalleryImageResource(Identifier.Parent!).GetAsync(); + if (resource.Value.Data.OSType is OperatingSystemTypes os) { + return OneFuzzResult.Ok(Enum.Parse(os.ToString(), ignoreCase: true)); + } else { + return new Error(ErrorCode.INVALID_IMAGE, new[] { "Specified image had no OSType" }); + } + } catch (Exception ex) when (ex is RequestFailedException) { + return new Error(ErrorCode.INVALID_IMAGE, new[] { ex.ToString() }); + } + } + } + + [JsonConverter(typeof(Converter))] + public sealed record LatestSharedGalleryImage(ResourceIdentifier Identifier) : ArmImageReference(Identifier) { + protected override async Task> GetOsUncached(ArmClient armClient, Region region) { + try { + var resource = await armClient.GetSharedGalleryImageResource(Identifier).GetAsync(); + if (resource.Value.Data.OSType is OperatingSystemTypes os) { + return OneFuzzResult.Ok(Enum.Parse(os.ToString(), ignoreCase: true)); + } else { + return new Error(ErrorCode.INVALID_IMAGE, new[] { "Specified image had no OSType" }); + } + } catch (Exception ex) when (ex is RequestFailedException) { + return new Error(ErrorCode.INVALID_IMAGE, new[] { ex.ToString() }); + } + } + } + + [JsonConverter(typeof(Converter))] + public sealed record SharedGalleryImage(ResourceIdentifier Identifier) : ArmImageReference(Identifier) { + protected override async Task> GetOsUncached(ArmClient armClient, Region region) { + try { + // need to access parent of versioned resource to get OS info + var resource = await armClient.GetSharedGalleryImageResource(Identifier.Parent!).GetAsync(); + if (resource.Value.Data.OSType is OperatingSystemTypes os) { + return OneFuzzResult.Ok(Enum.Parse(os.ToString(), ignoreCase: true)); + } else { + return new Error(ErrorCode.INVALID_IMAGE, new[] { "Specified image had no OSType" }); + } + } catch (Exception ex) when (ex is RequestFailedException) { + return new Error(ErrorCode.INVALID_IMAGE, new[] { ex.ToString() }); + } + } + } + + [JsonConverter(typeof(Converter))] + public sealed record Image(ResourceIdentifier Identifier) : ArmImageReference(Identifier) { + protected override async Task> GetOsUncached(ArmClient armClient, Region region) { + try { + var resource = await armClient.GetImageResource(Identifier).GetAsync(); + var os = resource.Value.Data.StorageProfile.OSDisk.OSType.ToString(); + return OneFuzzResult.Ok(Enum.Parse(os.ToString(), ignoreCase: true)); + } catch (Exception ex) when (ex is RequestFailedException) { + return new Error(ErrorCode.INVALID_IMAGE, new[] { ex.ToString() }); + } + } + } + + [JsonConverter(typeof(Converter))] + public sealed record Marketplace( + string Publisher, + string Offer, + string Sku, + string Version) : ImageReference { + public override long MaximumVmCount => MarketplaceImageMaximumVmCount; + + protected override async Task> GetOsUncached(ArmClient armClient, Region region) { + try { + var subscription = await armClient.GetDefaultSubscriptionAsync(); + string version; + if (string.Equals(Version, "latest", StringComparison.Ordinal)) { + version = + (await subscription.GetVirtualMachineImagesAsync( + region.String, + Publisher, + Offer, + Sku, + top: 1 + ).FirstAsync()).Name; + } else { + version = Version; + } + + var vm = await subscription.GetVirtualMachineImageAsync( + region.String, + Publisher, + Offer, + Sku, + version); + + var os = vm.Value.OSDiskImageOperatingSystem.ToString(); + return OneFuzzResult.Ok(Enum.Parse(os, ignoreCase: true)); + } catch (RequestFailedException ex) { + return OneFuzzResult.Error( + ErrorCode.INVALID_IMAGE, + ex.ToString() + ); + } + } + + public override Compute.Models.ImageReference ToArm() { + return new() { + Publisher = Publisher, + Offer = Offer, + Sku = Sku, + Version = Version + }; + } + + public override string ToString() => string.Join(":", Publisher, Offer, Sku, Version); + } + + // ImageReference serializes to and from JSON as a string. + public sealed class Converter : JsonConverter where T : ImageReference { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + Debug.Assert(typeToConvert.IsAssignableTo(typeof(ImageReference))); + + var value = reader.GetString(); + if (value is null) { + return null; + } + + var result = TryParse(value); + if (!result.IsOk) { + throw new JsonException(result.ErrorV.Errors?.First()); + } + + return (T)(object)result.OkV; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/ApiService/ApiService/onefuzzlib/OnefuzzContext.cs b/src/ApiService/ApiService/onefuzzlib/OnefuzzContext.cs index ffda56aaa2..ac463e6415 100644 --- a/src/ApiService/ApiService/onefuzzlib/OnefuzzContext.cs +++ b/src/ApiService/ApiService/onefuzzlib/OnefuzzContext.cs @@ -44,7 +44,6 @@ public interface IOnefuzzContext { IRequestHandling RequestHandling { get; } INsgOperations NsgOperations { get; } ISubnet Subnet { get; } - IImageOperations ImageOperations { get; } EntityConverter EntityConverter { get; } ITeams Teams { get; } IGithubIssues GithubIssues { get; } @@ -96,7 +95,6 @@ public OnefuzzContext(IServiceProvider serviceProvider) { public IRequestHandling RequestHandling => _serviceProvider.GetRequiredService(); public INsgOperations NsgOperations => _serviceProvider.GetRequiredService(); public ISubnet Subnet => _serviceProvider.GetRequiredService(); - public IImageOperations ImageOperations => _serviceProvider.GetRequiredService(); public EntityConverter EntityConverter => _serviceProvider.GetRequiredService(); public ITeams Teams => _serviceProvider.GetRequiredService(); public IGithubIssues GithubIssues => _serviceProvider.GetRequiredService(); diff --git a/src/ApiService/ApiService/onefuzzlib/ProxyOperations.cs b/src/ApiService/ApiService/onefuzzlib/ProxyOperations.cs index 5231564d84..fc3b67842e 100644 --- a/src/ApiService/ApiService/onefuzzlib/ProxyOperations.cs +++ b/src/ApiService/ApiService/onefuzzlib/ProxyOperations.cs @@ -242,19 +242,12 @@ private static IEnumerable GetErrors(Proxy proxy, VirtualMachineInstance public static Vm GetVm(Proxy proxy, InstanceConfig config) { var tags = config.VmssTags; - string proxyVmSku; - string proxyImage = config.DefaultLinuxVmImage; - if (config.ProxyVmSku is null) { - proxyVmSku = "Standard_B2s"; - } else { - proxyVmSku = config.ProxyVmSku; - } return new Vm( // name should be less than 40 chars otherwise it gets truncated by azure Name: $"proxy-{proxy.ProxyId:N}", Region: proxy.Region, - Sku: proxyVmSku, - Image: proxyImage, + Sku: config.ProxyVmSku ?? "Standard_B2s", + Image: config.DefaultLinuxVmImage ?? DefaultImages.Linux, Auth: proxy.Auth, Tags: tags, Nsg: null diff --git a/src/ApiService/ApiService/onefuzzlib/ReproOperations.cs b/src/ApiService/ApiService/onefuzzlib/ReproOperations.cs index 625c78fb6e..a840618ba3 100644 --- a/src/ApiService/ApiService/onefuzzlib/ReproOperations.cs +++ b/src/ApiService/ApiService/onefuzzlib/ReproOperations.cs @@ -31,7 +31,6 @@ public interface IReproOperations : IStatefulOrm { } public class ReproOperations : StatefulOrm, IReproOperations { - const string DEFAULT_SKU = "Standard_DS1_v2"; public ReproOperations(ILogTracer log, IOnefuzzContext context) @@ -51,10 +50,10 @@ public async Async.Task GetVm(Repro repro, InstanceConfig config) { throw new Exception($"previous existing task missing: {repro.TaskId}"); } - Dictionary default_os = new() + Dictionary default_os = new() { - { Os.Linux, config.DefaultLinuxVmImage }, - { Os.Windows, config.DefaultWindowsVmImage } + { Os.Linux, config.DefaultLinuxVmImage ?? DefaultImages.Linux }, + { Os.Windows, config.DefaultWindowsVmImage ?? DefaultImages.Windows }, }; var vmConfig = await taskOperations.GetReproVmConfig(task); diff --git a/src/ApiService/ApiService/onefuzzlib/ScalesetOperations.cs b/src/ApiService/ApiService/onefuzzlib/ScalesetOperations.cs index ed268c4b6e..83df5fc47b 100644 --- a/src/ApiService/ApiService/onefuzzlib/ScalesetOperations.cs +++ b/src/ApiService/ApiService/onefuzzlib/ScalesetOperations.cs @@ -4,6 +4,8 @@ using Azure.ResourceManager.Compute; using Azure.ResourceManager.Monitor; using Azure.ResourceManager.Monitor.Models; +using Microsoft.Extensions.Caching.Memory; + namespace Microsoft.OneFuzz.Service; public interface IScalesetOperations : IStatefulOrm { @@ -40,11 +42,12 @@ public interface IScalesetOperations : IStatefulOrm { public class ScalesetOperations : StatefulOrm, IScalesetOperations { private readonly ILogTracer _log; + private readonly IMemoryCache _cache; - public ScalesetOperations(ILogTracer log, IOnefuzzContext context) + public ScalesetOperations(ILogTracer log, IMemoryCache cache, IOnefuzzContext context) : base(log.WithTag("Component", "scalesets"), context) { _log = base._logTracer; - + _cache = cache; } public IAsyncEnumerable Search() { @@ -158,7 +161,7 @@ public async Async.Task Resize(Scaleset scaleset) { await shrinkQueue.Clear(); //# just in case, always ensure size is within max capacity - scaleset = scaleset with { Size = Math.Min(scaleset.Size, MaxSize(scaleset)) }; + scaleset = scaleset with { Size = Math.Min(scaleset.Size, scaleset.Image.MaximumVmCount) }; // # Treat Azure knowledge of the size of the scaleset as "ground truth" var vmssSize = await _context.VmssOperations.GetVmssSize(scaleset.ScalesetId); @@ -446,7 +449,7 @@ public async Async.Task Init(Scaleset scaleset) { if (pool.State == PoolState.Init) { _logTracer.Info($"waiting for pool {scaleset.PoolName:Tag:PoolName} - {scaleset.ScalesetId:Tag:ScalesetId}"); } else if (pool.State == PoolState.Running) { - var imageOsResult = await _context.ImageOperations.GetOs(scaleset.Region, scaleset.Image); + var imageOsResult = await scaleset.Image.GetOs(_cache, _context.Creds.ArmClient, scaleset.Region); if (!imageOsResult.IsOk) { _logTracer.Error($"failed to get OS with region: {scaleset.Region:Tag:Region} {scaleset.Image:Tag:Image} for scaleset: {scaleset.ScalesetId:Tag:ScalesetId} due to {imageOsResult.ErrorV:Tag:Error}"); return await SetFailed(scaleset, imageOsResult.ErrorV); @@ -817,7 +820,7 @@ public IAsyncEnumerable SearchStates(IEnumerable states => QueryAsync(Query.EqualAnyEnum("state", states)); public Async.Task SetSize(Scaleset scaleset, long size) { - var permittedSize = Math.Min(size, MaxSize(scaleset)); + var permittedSize = Math.Min(size, scaleset.Image.MaximumVmCount); if (permittedSize == scaleset.Size) { return Async.Task.FromResult(scaleset); // nothing to do } @@ -902,15 +905,6 @@ public async Async.Task Shutdown(Scaleset scaleset) { return scaleset; } - private static long MaxSize(Scaleset scaleset) { - // https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-placement-groups#checklist-for-using-large-scale-sets - if (scaleset.Image.StartsWith("/", StringComparison.Ordinal)) { - return 600; - } else { - return 1000; - } - } - public Task Running(Scaleset scaleset) { // nothing to do return Async.Task.FromResult(scaleset); diff --git a/src/ApiService/ApiService/onefuzzlib/Scheduler.cs b/src/ApiService/ApiService/onefuzzlib/Scheduler.cs index 71685c2b46..de9fa37e02 100644 --- a/src/ApiService/ApiService/onefuzzlib/Scheduler.cs +++ b/src/ApiService/ApiService/onefuzzlib/Scheduler.cs @@ -124,7 +124,7 @@ sealed record BucketConfig(long count, bool reboot, Container setupContainer, Co sealed record PoolKey( PoolName? poolName = null, - (string sku, string image)? vm = null); + (string sku, ImageReference image)? vm = null); private static PoolKey? GetPoolKey(Task task) { // the behaviour of this key should match the behaviour of TaskOperations.GetPool @@ -220,7 +220,7 @@ sealed record PoolKey( return OneFuzzResult<(BucketConfig, WorkUnit)>.Ok((bucketConfig, workUnit)); } - public record struct BucketId(Os os, Guid jobId, (string, string)? vm, PoolName? pool, Container setupContainer, bool? reboot, Guid? unique); + public record struct BucketId(Os os, Guid jobId, (string, ImageReference)? vm, PoolName? pool, Container setupContainer, bool? reboot, Guid? unique); public static ILookup BucketTasks(IEnumerable tasks) { @@ -237,7 +237,7 @@ public static ILookup BucketTasks(IEnumerable tasks) { Guid? unique = null; // check for multiple VMs for pre-1.0.0 tasks - (string, string)? vm = task.Config.Vm != null ? (task.Config.Vm.Sku, task.Config.Vm.Image) : null; + (string, ImageReference)? vm = task.Config.Vm != null ? (task.Config.Vm.Sku, task.Config.Vm.Image) : null; if ((task.Config.Vm?.Count ?? 0) > 1) { unique = Guid.NewGuid(); } diff --git a/src/ApiService/ApiService/onefuzzlib/TaskOperations.cs b/src/ApiService/ApiService/onefuzzlib/TaskOperations.cs index 0a836bb6b5..6be5d4bafa 100644 --- a/src/ApiService/ApiService/onefuzzlib/TaskOperations.cs +++ b/src/ApiService/ApiService/onefuzzlib/TaskOperations.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using ApiService.OneFuzzLib.Orm; +using Microsoft.Extensions.Caching.Memory; namespace Microsoft.OneFuzz.Service; @@ -39,11 +40,11 @@ public interface ITaskOperations : IStatefulOrm { } public class TaskOperations : StatefulOrm, ITaskOperations { + private readonly IMemoryCache _cache; - - public TaskOperations(ILogTracer log, IOnefuzzContext context) + public TaskOperations(ILogTracer log, IMemoryCache cache, IOnefuzzContext context) : base(log, context) { - + _cache = cache; } public async Async.Task GetByTaskId(Guid taskId) { @@ -189,14 +190,13 @@ public async Task> Create(TaskConfig config, Guid jobId, Use Os os; if (config.Vm != null) { - var osResult = await _context.ImageOperations.GetOs(config.Vm.Region, config.Vm.Image); + var osResult = await config.Vm.Image.GetOs(_cache, _context.Creds.ArmClient, config.Vm.Region); if (!osResult.IsOk) { return OneFuzzResult.Error(osResult.ErrorV); } os = osResult.OkV; } else if (config.Pool != null) { var pool = await _context.PoolOperations.GetByName(config.Pool.PoolName); - if (!pool.IsOk) { return OneFuzzResult.Error(pool.ErrorV); } diff --git a/src/ApiService/ApiService/onefuzzlib/VmOperations.cs b/src/ApiService/ApiService/onefuzzlib/VmOperations.cs index e5cb3130e8..704dafbafb 100644 --- a/src/ApiService/ApiService/onefuzzlib/VmOperations.cs +++ b/src/ApiService/ApiService/onefuzzlib/VmOperations.cs @@ -4,6 +4,7 @@ using Azure.Core; using Azure.ResourceManager.Compute; using Azure.ResourceManager.Compute.Models; +using Microsoft.Extensions.Caching.Memory; namespace Microsoft.OneFuzz.Service; @@ -29,11 +30,13 @@ public interface IVmOperations { } public class VmOperations : IVmOperations { - private ILogTracer _logTracer; - private IOnefuzzContext _context; + private readonly ILogTracer _logTracer; + private readonly IMemoryCache _cache; + private readonly IOnefuzzContext _context; - public VmOperations(ILogTracer log, IOnefuzzContext context) { + public VmOperations(ILogTracer log, IMemoryCache cache, IOnefuzzContext context) { _logTracer = log; + _cache = cache; _context = context; } @@ -266,7 +269,7 @@ async Task CreateVm( string name, Region location, string vmSku, - string image, + ImageReference image, string password, string sshPublicKey, Nsg? nsg, @@ -305,14 +308,14 @@ async Task CreateVm( VmSize = vmSku, }, StorageProfile = new StorageProfile { - ImageReference = GenerateImageReference(image), + ImageReference = image.ToArm(), }, NetworkProfile = new NetworkProfile(), }; vmParams.NetworkProfile.NetworkInterfaces.Add(new NetworkInterfaceReference { Id = nic.Id }); - var imageOs = await _context.ImageOperations.GetOs(location, image); + var imageOs = await image.GetOs(_cache, _context.Creds.ArmClient, location); if (!imageOs.IsOk) { return OneFuzzResultVoid.Error(imageOs.ErrorV); } @@ -368,20 +371,4 @@ async Task CreateVm( return OneFuzzResultVoid.Ok; } - - private static ImageReference GenerateImageReference(string image) { - var imageRef = new ImageReference(); - - if (image.StartsWith("/", StringComparison.Ordinal)) { - imageRef.Id = image; - } else { - var imageVal = image.Split(":", 4); - imageRef.Publisher = imageVal[0]; - imageRef.Offer = imageVal[1]; - imageRef.Sku = imageVal[2]; - imageRef.Version = imageVal[3]; - } - - return imageRef; - } } diff --git a/src/ApiService/ApiService/onefuzzlib/VmssOperations.cs b/src/ApiService/ApiService/onefuzzlib/VmssOperations.cs index e2d14fbfe9..b198352d43 100644 --- a/src/ApiService/ApiService/onefuzzlib/VmssOperations.cs +++ b/src/ApiService/ApiService/onefuzzlib/VmssOperations.cs @@ -32,7 +32,7 @@ Async.Task CreateVmss( Guid name, string vmSku, long vmCount, - string image, + ImageReference image, string networkId, bool? spotInstance, bool ephemeralOsDisks, @@ -49,7 +49,6 @@ Async.Task CreateVmss( public class VmssOperations : IVmssOperations { private readonly ILogTracer _log; private readonly ICreds _creds; - private readonly IImageOperations _imageOps; private readonly IServiceConfig _serviceConfig; private readonly IMemoryCache _cache; @@ -57,7 +56,6 @@ public class VmssOperations : IVmssOperations { public VmssOperations(ILogTracer log, IOnefuzzContext context, IMemoryCache cache) { _log = log.WithTag("Component", "vmss-operations"); _creds = context.Creds; - _imageOps = context.ImageOperations; _serviceConfig = context.ServiceConfiguration; _cache = cache; } @@ -270,7 +268,7 @@ public async Async.Task CreateVmss( Guid name, string vmSku, long vmCount, - string image, + ImageReference image, string networkId, bool? spotInstance, bool ephemeralOsDisks, @@ -282,24 +280,13 @@ public async Async.Task CreateVmss( if (vmss is not null) { return OneFuzzResultVoid.Ok; } - _log.Info($"creating VM name: {name:Tag:VmssName} {vmSku:Tag:VmSku} {vmCount:Tag:VmCount} {image:Tag:Image} {networkId:Tag:Subnet} {spotInstance:Tag:SpotInstance}"); - var getOsResult = await _imageOps.GetOs(location, image); + _log.Info($"creating VM name: {name:Tag:VmssName} {vmSku:Tag:VmSku} {vmCount:Tag:VmCount} {image:Tag:Image} {networkId:Tag:Subnet} {spotInstance:Tag:SpotInstance}"); + var getOsResult = await image.GetOs(_cache, _creds.ArmClient, location); if (!getOsResult.IsOk) { return getOsResult.ErrorV; } - var imageRef = new ImageReference(); - if (image.StartsWith('/')) { - imageRef.Id = image; - } else { - var info = IImageOperations.GetImageInfo(image); - imageRef.Publisher = info.Publisher; - imageRef.Offer = info.Offer; - imageRef.Sku = info.Sku; - imageRef.Version = info.Version; - } - var vmssData = new VirtualMachineScaleSetData(location) { DoNotRunExtensionsOnOverprovisionedVms = false, UpgradePolicy = new() { @@ -320,7 +307,7 @@ public async Async.Task CreateVmss( VirtualMachineProfile = new() { Priority = VirtualMachinePriorityTypes.Regular, StorageProfile = new() { - ImageReference = imageRef, + ImageReference = image.ToArm(), }, OSProfile = new() { ComputerNamePrefix = "node", diff --git a/src/ApiService/ApiService/onefuzzlib/notifications/JinjaTemplateAdapter.cs b/src/ApiService/ApiService/onefuzzlib/notifications/JinjaTemplateAdapter.cs index 60acac4b28..ef51e409f8 100644 --- a/src/ApiService/ApiService/onefuzzlib/notifications/JinjaTemplateAdapter.cs +++ b/src/ApiService/ApiService/onefuzzlib/notifications/JinjaTemplateAdapter.cs @@ -173,7 +173,7 @@ public static async Async.Task ValidateScribanTempla new TaskVm( Region.Parse("westus3"), "some sku", - "some image", + DefaultImages.Linux, true, 1, true diff --git a/src/ApiService/IntegrationTests/Fakes/TestContext.cs b/src/ApiService/IntegrationTests/Fakes/TestContext.cs index e69ae1955a..cbff1c14b9 100644 --- a/src/ApiService/IntegrationTests/Fakes/TestContext.cs +++ b/src/ApiService/IntegrationTests/Fakes/TestContext.cs @@ -27,7 +27,7 @@ public TestContext(ILogTracer logTracer, IStorage storage, ICreds creds, string Containers = new Containers(logTracer, Storage, ServiceConfiguration); Queue = new Queue(Storage, logTracer); RequestHandling = new RequestHandling(logTracer); - TaskOperations = new TaskOperations(logTracer, this); + TaskOperations = new TaskOperations(logTracer, cache, this); NodeOperations = new NodeOperations(logTracer, this); JobOperations = new JobOperations(logTracer, this); NodeTasksOperations = new NodeTasksOperations(logTracer, this); @@ -35,7 +35,7 @@ public TestContext(ILogTracer logTracer, IStorage storage, ICreds creds, string NodeMessageOperations = new NodeMessageOperations(logTracer, this); ConfigOperations = new ConfigOperations(logTracer, this, cache); PoolOperations = new PoolOperations(logTracer, this); - ScalesetOperations = new ScalesetOperations(logTracer, this); + ScalesetOperations = new ScalesetOperations(logTracer, cache, this); ReproOperations = new ReproOperations(logTracer, this); Reports = new Reports(logTracer, Containers); UserCredentials = new UserCredentials(logTracer, ConfigOperations); @@ -129,7 +129,6 @@ public Async.Task InsertAll(params EntityBase[] objs) public ISubnet Subnet => throw new NotImplementedException(); - public IImageOperations ImageOperations => throw new NotImplementedException(); public ITeams Teams => throw new NotImplementedException(); public IGithubIssues GithubIssues => throw new NotImplementedException(); public IAdo Ado => throw new NotImplementedException(); diff --git a/src/ApiService/IntegrationTests/Fakes/TestVmssOperations.cs b/src/ApiService/IntegrationTests/Fakes/TestVmssOperations.cs index 2b6a297f47..9de603d60d 100644 --- a/src/ApiService/IntegrationTests/Fakes/TestVmssOperations.cs +++ b/src/ApiService/IntegrationTests/Fakes/TestVmssOperations.cs @@ -16,10 +16,11 @@ public Task> ListAvailableSkus(Region region) public static IReadOnlyList TestSkus = new[] { TestSku }; public const string TestSku = "Test_Sku"; + public static readonly ImageReference TestImage = ImageReference.MustParse("Canonical:UbuntuServer:18.04-LTS:latest"); /* below not implemented */ - public Task CreateVmss(Region location, Guid name, string vmSku, long vmCount, string image, string networkId, bool? spotInstance, bool ephemeralOsDisks, IList? extensions, string password, string sshPublicKey, IDictionary tags) { + public Task CreateVmss(Region location, Guid name, string vmSku, long vmCount, ImageReference image, string networkId, bool? spotInstance, bool ephemeralOsDisks, IList? extensions, string password, string sshPublicKey, IDictionary tags) { throw new NotImplementedException(); } diff --git a/src/ApiService/IntegrationTests/ScalesetTests.cs b/src/ApiService/IntegrationTests/ScalesetTests.cs index f0e5e3bae7..8e7f760615 100644 --- a/src/ApiService/IntegrationTests/ScalesetTests.cs +++ b/src/ApiService/IntegrationTests/ScalesetTests.cs @@ -85,7 +85,7 @@ await Context.InsertAll( var req = new ScalesetCreate( poolName, TestVmssOperations.TestSku, - "Image", + TestVmssOperations.TestImage, Region: null, Size: 1, SpotInstances: false, @@ -121,7 +121,7 @@ await Context.InsertAll( var req = new ScalesetCreate( poolName, TestVmssOperations.TestSku, - "Image", + TestVmssOperations.TestImage, Region: null, Size: 1, SpotInstances: false, diff --git a/src/ApiService/Tests/ImageReferenceTests.cs b/src/ApiService/Tests/ImageReferenceTests.cs new file mode 100644 index 0000000000..8ac5f32845 --- /dev/null +++ b/src/ApiService/Tests/ImageReferenceTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Text.Json; +using Azure.ResourceManager.Compute; +using Microsoft.OneFuzz.Service; +using Xunit; + +namespace Tests; + +public class ImageReferenceTests { + + [Fact] + public void CanParseImageGalleryReference() { + var subId = Guid.NewGuid(); + var fakeId = GalleryImageResource.CreateResourceIdentifier( + subId.ToString(), "resource-group", "gallery", "imageName"); + + var result = ImageReference.MustParse(fakeId.ToString()); + var galleryImage = Assert.IsType(result); + Assert.Equal("imageName", galleryImage.Identifier.Name); + Assert.Equal("gallery", galleryImage.Identifier.Parent?.Name); + Assert.Equal("resource-group", galleryImage.Identifier.ResourceGroupName); + } + + [Fact] + public void CanParseImageReference() { + var subId = Guid.NewGuid(); + var fakeId = ImageResource.CreateResourceIdentifier( + subId.ToString(), "resource-group", "imageName"); + + var result = ImageReference.MustParse(fakeId.ToString()); + var image = Assert.IsType(result); + Assert.Equal("imageName", image.Identifier.Name); + Assert.Equal("resource-group", image.Identifier.ResourceGroupName); + } + + [Fact] + public void CanParseMarketplaceReference() { + var input = "Canonical:UbuntuServer:18.04-LTS:latest"; + var result = ImageReference.MustParse(input); + var marketplace = Assert.IsType(result); + + Assert.Equal("Canonical", marketplace.Publisher); + Assert.Equal("UbuntuServer", marketplace.Offer); + Assert.Equal("18.04-LTS", marketplace.Sku); + Assert.Equal("latest", marketplace.Version); + } + + [Fact] + public void CanParseSpecificVersionGalleryImage() { + var subId = Guid.NewGuid(); + var fakeId = GalleryImageVersionResource.CreateResourceIdentifier( + subId.ToString(), "resource-group", "gallery", "imageName", "latest"); + + var result = ImageReference.MustParse(fakeId.ToString()); + var galleryImage = Assert.IsType(result); + Assert.Equal("latest", galleryImage.Identifier.Name); + Assert.Equal("imageName", galleryImage.Identifier.Parent?.Name); + Assert.Equal("gallery", galleryImage.Identifier.Parent?.Parent?.Name); + Assert.Equal("resource-group", galleryImage.Identifier.ResourceGroupName); + } + + [Fact] + public void CanParseSharedGalleryImage() { + var subId = Guid.NewGuid(); + var fakeId = SharedGalleryImageResource.CreateResourceIdentifier( + subId.ToString(), "location", "gallery", "imageName"); + + var result = ImageReference.MustParse(fakeId.ToString()); + var galleryImage = Assert.IsType(result); + Assert.Equal("imageName", galleryImage.Identifier.Name); + Assert.Equal("gallery", galleryImage.Identifier.Parent?.Name); + Assert.Null(galleryImage.Identifier.ResourceGroupName); + } + + [Fact] + public void CanParseSpecificVersionSharedGalleryImage() { + var subId = Guid.NewGuid(); + var fakeId = SharedGalleryImageVersionResource.CreateResourceIdentifier( + subId.ToString(), "location", "gallery", "imageName", "latest"); + + var result = ImageReference.MustParse(fakeId.ToString()); + var galleryImage = Assert.IsType(result); + Assert.Equal("latest", galleryImage.Identifier.Name); + Assert.Equal("imageName", galleryImage.Identifier.Parent?.Name); + Assert.Equal("gallery", galleryImage.Identifier.Parent?.Parent?.Name); + Assert.Null(galleryImage.Identifier.ResourceGroupName); + } + + [Fact] + public void UnknownResourceTypeGeneratesError() { + var subId = Guid.NewGuid(); + var fakeId = VirtualMachineResource.CreateResourceIdentifier( + subId.ToString(), "resource-group", "vmName"); + + var ex = Assert.Throws(() => ImageReference.MustParse(fakeId.ToString())); + Assert.Equal("Unknown image resource type: Microsoft.Compute/virtualMachines (Parameter 'image')", ex.Message); + } + + static readonly string _expected = @$"{{ + ""latestGalleryId"": ""/subscriptions/{Guid.Empty}/resourceGroups/resource-group/providers/Microsoft.Compute/galleries/gallery/images/imageName"", + ""imageId"": ""/subscriptions/{Guid.Empty}/resourceGroups/resource-group/providers/Microsoft.Compute/images/imageName"", + ""marketplaceId"": ""Canonical:UbuntuServer:18.04-LTS:latest"", + ""galleryId"": ""/subscriptions/{Guid.Empty}/resourceGroups/resource-group/providers/Microsoft.Compute/galleries/gallery/images/imageName/versions/latest"", + ""latestSharedGalleryId"": ""/subscriptions/{Guid.Empty}/providers/Microsoft.Compute/locations/location/sharedGalleries/gallery/images/imageName"", + ""sharedGalleryId"": ""/subscriptions/{Guid.Empty}/providers/Microsoft.Compute/locations/location/sharedGalleries/gallery/images/imageName/versions/latest"" +}}"; + + private sealed record Holder( + ImageReference latestGalleryId, + ImageReference imageId, + ImageReference marketplaceId, + ImageReference galleryId, + ImageReference.LatestSharedGalleryImage latestSharedGalleryId, + ImageReference.SharedGalleryImage sharedGalleryId); + + [Fact] + public void SerializesToStringAndDeserializesFromString() { + var subId = Guid.Empty; + + var galleryId = new ImageReference.GalleryImage( + GalleryImageVersionResource.CreateResourceIdentifier( + subId.ToString(), "resource-group", "gallery", "imageName", "latest")); + + var latestGalleryId = new ImageReference.LatestGalleryImage( + GalleryImageResource.CreateResourceIdentifier( + subId.ToString(), "resource-group", "gallery", "imageName")); + + var imageId = new ImageReference.Image( + ImageResource.CreateResourceIdentifier( + subId.ToString(), "resource-group", "imageName")); + + var marketplaceId = new ImageReference.Marketplace( + "Canonical", "UbuntuServer", "18.04-LTS", "latest"); + + var latestSharedGalleryId = new ImageReference.LatestSharedGalleryImage( + SharedGalleryImageResource.CreateResourceIdentifier( + subId.ToString(), "location", "gallery", "imageName")); + + var sharedGalleryId = new ImageReference.SharedGalleryImage( + SharedGalleryImageVersionResource.CreateResourceIdentifier( + subId.ToString(), "location", "gallery", "imageName", "latest")); + + var result = JsonSerializer.Serialize( + new Holder( + latestGalleryId, + imageId, + marketplaceId, + galleryId, + latestSharedGalleryId, + sharedGalleryId), + new JsonSerializerOptions { WriteIndented = true }); + + Assert.Equal(_expected, result); + + var deserialized = JsonSerializer.Deserialize(result); + Assert.NotNull(deserialized); + Assert.Equal(latestGalleryId, deserialized!.latestGalleryId); + Assert.Equal(galleryId, deserialized.galleryId); + Assert.Equal(imageId, deserialized.imageId); + Assert.Equal(marketplaceId, deserialized.marketplaceId); + Assert.Equal(latestSharedGalleryId, deserialized.latestSharedGalleryId); + Assert.Equal(sharedGalleryId, deserialized.sharedGalleryId); + } +} diff --git a/src/ApiService/Tests/OrmModelsTest.cs b/src/ApiService/Tests/OrmModelsTest.cs index c8c19edcd1..334581375f 100644 --- a/src/ApiService/Tests/OrmModelsTest.cs +++ b/src/ApiService/Tests/OrmModelsTest.cs @@ -231,20 +231,28 @@ public static Gen Task() { ) ); } + + public static Gen ImageReferenceGen { get; } = + Gen.Elements( + ImageReference.MustParse("Canonical:UbuntuServer:18.04-LTS:latest"), + ImageReference.MustParse($"/subscriptions/{Guid.Empty}/resourceGroups/resource-group/providers/Microsoft.Compute/galleries/gallery/images/imageName"), + ImageReference.MustParse($"/subscriptions/{Guid.Empty}/resourceGroups/resource-group/providers/Microsoft.Compute/images/imageName")); + public static Gen Scaleset { get; } = from arg in Arb.Generate, + Tuple, Tuple, Tuple>>>() from poolName in PoolNameGen from region in RegionGen + from image in ImageReferenceGen select new Scaleset( PoolName: poolName, ScalesetId: arg.Item1.Item1, State: arg.Item1.Item2, Auth: arg.Item1.Item3, VmSku: arg.Item1.Item4, - Image: arg.Item1.Item5, + Image: image, Region: region, Size: arg.Item2.Item1, @@ -540,6 +548,9 @@ public static Arbitrary Task() { return Arb.From(OrmGenerators.Task()); } + public static Arbitrary ImageReference() + => Arb.From(OrmGenerators.ImageReferenceGen); + public static Arbitrary Scaleset() => Arb.From(OrmGenerators.Scaleset);