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);