Extend use of validated string types (#2357)

In the Python code there were more validated string types that haven't been properly ported for use in the C# code (such as Region). Add that type back in and improve some others:

- Use `Region` type to represent regions (implicitly convertible to/from the `AzureLocation` SDK type)
- Improve validation of `Container` type to match Azure specs and use it in more places
- Restore/fix validation of `PoolName` type which was previously removed (#2080) due to being too strict: now allows 1-64 ASCII alphanumeric/hyphen/dash
  - We want to restrict pool names so that we can use them as disambiguating prefixes for scaleset names (see #2189). Note that underscore is not actually permitted in scaleset names so we will probably end up mapping it to hyphen.

Note that once C#7 lands we will be able to simplify the usage of `ValidatedString` a lot (using static abstract methods).

----

Open questions:

For deserializing from "known-good" places such as table storage or from Azure SDK APIs, should we have an `T.UnsafeAssumeValid(string input)` method which does no validation, to protect us from breakage?
This commit is contained in:
George Pollard
2022-09-12 10:06:51 +12:00
committed by GitHub
parent 140638f684
commit 2a2c07ed35
47 changed files with 324 additions and 264 deletions

View File

@ -42,7 +42,7 @@ public class ContainersFunction {
new Error(
Code: ErrorCode.INVALID_REQUEST,
Errors: new[] { "invalid container" }),
context: get.Name.ContainerName);
context: get.Name.String);
}
var metadata = (await container.GetPropertiesAsync()).Value.Metadata;
@ -63,7 +63,7 @@ public class ContainersFunction {
// otherwise list all containers
var containers = await _context.Containers.GetContainers(StorageType.Corpus);
var result = containers.Select(c => new ContainerInfoBase(new Container(c.Key), c.Value));
var result = containers.Select(c => new ContainerInfoBase(c.Key, c.Value));
return await RequestHandling.Ok(req, result);
}
@ -104,7 +104,7 @@ public class ContainersFunction {
new Error(
Code: ErrorCode.INVALID_REQUEST,
Errors: new[] { "invalid container" }),
context: post.Name.ContainerName);
context: post.Name.String);
}
return await RequestHandling.Ok(

View File

@ -21,13 +21,13 @@ public class Download {
private async Async.Task<HttpResponseData> Get(HttpRequestData req) {
var query = HttpUtility.ParseQueryString(req.Url.Query);
var container = query["container"];
if (container is null) {
var queryContainer = query["container"];
if (queryContainer is null || !Container.TryParse(queryContainer, out var container)) {
return await _context.RequestHandling.NotOk(
req,
new Error(
ErrorCode.INVALID_REQUEST,
new string[] { "'container' query parameter must be provided" }),
new string[] { "'container' query parameter must be provided and valid" }),
"download");
}
@ -42,7 +42,7 @@ public class Download {
}
var sasUri = await _context.Containers.GetFileSasUrl(
new Container(container),
container,
filename,
StorageType.Corpus,
BlobSasPermissions.Read,

View File

@ -52,7 +52,7 @@ public class Jobs {
var metadata = new Dictionary<string, string>{
{ "container_type", "logs" }, // TODO: use ContainerType.Logs enum somehow; needs snake case name
};
var containerName = new Container($"logs-{job.JobId}");
var containerName = Container.Parse($"logs-{job.JobId}");
var containerSas = await _context.Containers.CreateContainer(containerName, StorageType.Corpus, metadata);
if (containerSas is null) {
return await _context.RequestHandling.NotOk(

View File

@ -22,8 +22,9 @@ public class Notifications {
return await _context.RequestHandling.NotOk(req, request.ErrorV, "notification search");
}
var entries = request.OkV switch { { Container: null } => _context.NotificationOperations.SearchAll(), { Container: var c } => _context.NotificationOperations.SearchByRowKeys(c.Select(x => x.ContainerName))
var entries = request.OkV switch { { Container: null } => _context.NotificationOperations.SearchAll(), { Container: var c } => _context.NotificationOperations.SearchByRowKeys(c.Select(x => x.String))
};
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(entries);
return response;

View File

@ -56,6 +56,6 @@ public class QueueFileChanges {
var path = string.Join('/', parts.Skip(1));
log.Info($"file added container: {container} - path: {path}");
await _notificationOperations.NewFiles(new Container(container), path, failTaskOnTransientError);
await _notificationOperations.NewFiles(Container.Parse(container), path, failTaskOnTransientError);
}
}

View File

@ -75,7 +75,7 @@ public class Scaleset {
context: "ScalesetCreate");
}
string region;
Region region;
if (create.Region is null) {
region = await _context.Creds.GetBaseRegion();
} else {

View File

@ -62,15 +62,16 @@ public class TimerProxy {
// nsg enabled OneFuzz this will overwrite existing NSG
// assignment though. This behavior is acceptable at this point
// since we do not support bring your own NSG
var nsgName = Nsg.NameFromRegion(region);
if (await nsgOpertions.GetNsg(region) != null) {
if (await nsgOpertions.GetNsg(nsgName) != null) {
var network = await Network.Init(region, _context);
var subnet = await network.GetSubnet();
if (subnet != null) {
var vnet = await network.GetVnet();
if (vnet != null) {
var result = await nsgOpertions.AssociateSubnet(region, vnet, subnet);
var result = await nsgOpertions.AssociateSubnet(nsgName, vnet, subnet);
if (!result.OkV) {
_logger.Error($"Failed to associate NSG and subnet due to {result.ErrorV} in region {region}");
}

View File

@ -2,7 +2,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
using Region = System.String;
namespace Microsoft.OneFuzz.Service;

View File

@ -5,7 +5,6 @@ using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
using Endpoint = System.String;
using GroupId = System.Guid;
using PrincipalId = System.Guid;
using Region = System.String;
namespace Microsoft.OneFuzz.Service;
@ -406,25 +405,6 @@ public record Scaleset(
// 'Nodes' removed when porting from Python: only used in search response
) : StatefulEntityBase<ScalesetState>(State);
[JsonConverter(typeof(ContainerConverter))]
public record Container(string ContainerName) {
public string ContainerName { get; } = ContainerName.All(c => char.IsLetterOrDigit(c) || c == '-') ? ContainerName : throw new ArgumentException("Container name must have only numbers, letters or dashes");
public override string ToString() {
return ContainerName;
}
}
public class ContainerConverter : JsonConverter<Container> {
public override Container? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
var containerName = reader.GetString();
return containerName == null ? null : new Container(containerName);
}
public override void Write(Utf8JsonWriter writer, Container value, JsonSerializerOptions options) {
writer.WriteStringValue(value.ContainerName);
}
}
public record Notification(
[PartitionKey] Guid NotificationId,
[RowKey] Container Container,
@ -732,7 +712,14 @@ public record Job(
public UserInfo? UserInfo { get; set; }
}
public record Nsg(string Name, Region Region);
public record Nsg(string Name, Region Region) {
public static Nsg ForRegion(Region region)
=> new(NameFromRegion(region), region);
// Currently, the name of a NSG is the same as the region it is in.
public static string NameFromRegion(Region region)
=> region.String;
};
public record WorkUnit(
Guid JobId,

View File

@ -191,7 +191,7 @@ public record ScalesetCreate(
[property: Required] PoolName PoolName,
[property: Required] string VmSku,
[property: Required] string Image,
string? Region,
Region? Region,
[property: Range(1, long.MaxValue), Required] long Size,
[property: Required] bool SpotInstances,
[property: Required] Dictionary<string, string> Tags,

View File

@ -56,7 +56,7 @@ public record BoolResult(
public record InfoResponse(
string ResourceGroup,
string Region,
Region Region,
string Subscription,
IReadOnlyDictionary<string, InfoVersion> Versions,
Guid? InstanceId,
@ -127,7 +127,7 @@ public record ScalesetResponse(
Authentication? Auth,
string VmSku,
string Image,
string Region,
Region Region,
long Size,
bool? SpotInstances,
bool EmphemeralOsDisks,
@ -175,7 +175,7 @@ public record ProxyGetResult(
);
public record ProxyInfo(
string Region,
Region Region,
Guid ProxyId,
VmState State
);

View File

@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Azure.Core;
namespace Microsoft.OneFuzz.Service;
@ -11,6 +13,10 @@ static class Check {
private static readonly Regex _isAlnumDash = new(@"\A[a-zA-Z0-9\-]+\z", RegexOptions.Compiled);
public static bool IsAlnumDash(string input) => _isAlnumDash.IsMatch(input);
// Permits 1-64 characters: alphanumeric, underscore, or dash.
private static readonly Regex _isNameLike = new(@"\A[_a-zA-Z0-9\-]{1,64}\z", RegexOptions.Compiled);
public static bool IsNameLike(string input) => _isNameLike.IsMatch(input);
}
// Base class for types that are wrappers around a validated string.
@ -47,9 +53,11 @@ public abstract class ValidatedStringConverter<T> : JsonConverter<T> where T : V
}
[JsonConverter(typeof(Converter))]
public record PoolName : ValidatedString {
public PoolName(string value) : base(value) {
// Debug.Assert(Check.IsAlnumDash(value));
public sealed record PoolName : ValidatedString {
private static bool IsValid(string input) => Check.IsNameLike(input);
private PoolName(string value) : base(value) {
Debug.Assert(IsValid(value));
}
public static PoolName Parse(string input) {
@ -61,14 +69,10 @@ public record PoolName : ValidatedString {
}
public static bool TryParse(string input, [NotNullWhen(returnValue: true)] out PoolName? result) {
// bypassing the validation because this code has a stricter validation than the python equivalent
// see (issue #2080)
// if (!Check.IsAlnumDash(input)) {
// result = default;
// return false;
// }
if (!IsValid(input)) {
result = default;
return false;
}
result = new PoolName(input);
return true;
@ -80,12 +84,12 @@ public record PoolName : ValidatedString {
}
}
/* TODO: to be enabled in a separate PR
[JsonConverter(typeof(Converter))]
public record Region : ValidatedString {
private Region(string value) : base(value) {
Debug.Assert(Check.IsAlnum(value));
private static bool IsValid(string input) => Check.IsAlnum(input);
private Region(string value) : base(value.ToLowerInvariant()) {
Debug.Assert(IsValid(value));
}
public static Region Parse(string input) {
@ -93,11 +97,11 @@ public record Region : ValidatedString {
return result;
}
throw new ArgumentException("Region name must have only numbers, letters or dashes");
throw new ArgumentException("Region name must have only numbers or letters");
}
public static bool TryParse(string input, [NotNullWhen(returnValue: true)] out Region? result) {
if (!Check.IsAlnum(input)) {
if (!IsValid(input)) {
result = default;
return false;
}
@ -106,6 +110,9 @@ public record Region : ValidatedString {
return true;
}
public static implicit operator AzureLocation(Region me) => new(me.String);
public static implicit operator Region(AzureLocation it) => new(it.Name);
public sealed class Converter : ValidatedStringConverter<Region> {
protected override bool TryParse(string input, out Region? output)
=> Region.TryParse(input, out output);
@ -114,8 +121,16 @@ public record Region : ValidatedString {
[JsonConverter(typeof(Converter))]
public record Container : ValidatedString {
// See: https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftstorage
// - 3-63
// - Lowercase letters, numbers, and hyphens.
// - Start with lowercase letter or number. Can't use consecutive hyphens.
private static readonly Regex _containerRegex = new(@"\A(?!-)(?!.*--)[a-z0-9\-]{3,63}\z", RegexOptions.Compiled);
private static bool IsValid(string input) => _containerRegex.IsMatch(input);
private Container(string value) : base(value) {
Debug.Assert(Check.IsAlnumDash(value));
Debug.Assert(IsValid(value));
}
public static Container Parse(string input) {
@ -127,7 +142,7 @@ public record Container : ValidatedString {
}
public static bool TryParse(string input, [NotNullWhen(returnValue: true)] out Container? result) {
if (!Check.IsAlnumDash(input)) {
if (!IsValid(input)) {
result = default;
return false;
}
@ -141,4 +156,3 @@ public record Container : ValidatedString {
=> Container.TryParse(input, out output);
}
}
*/

View File

@ -49,7 +49,7 @@ namespace ApiService.TestHooks {
_log.Info("Get base region");
var resp = req.CreateResponse(HttpStatusCode.OK);
var region = await _creds.GetBaseRegion();
await resp.WriteStringAsync(region);
await resp.WriteStringAsync(region.String);
return resp;
}

View File

@ -30,7 +30,7 @@ namespace ApiService.TestHooks {
var fileName = query["fileName"];
var failTaskOnTransientError = UriExtension.GetBool("failTaskOnTransientError", query, true);
await _notificationOps.NewFiles(new Container(container), fileName, failTaskOnTransientError);
await _notificationOps.NewFiles(Container.Parse(container), fileName, failTaskOnTransientError);
var resp = req.CreateResponse(HttpStatusCode.OK);
return resp;
}
@ -43,7 +43,7 @@ namespace ApiService.TestHooks {
var query = UriExtension.GetQueryComponents(req.Url);
var container = query["container"];
var notifications = _notificationOps.GetNotifications(new Container(container));
var notifications = _notificationOps.GetNotifications(Container.Parse(container));
var json = JsonSerializer.Serialize(await notifications.ToListAsync(), EntityConverter.GetJsonSerializerOptions());
var resp = req.CreateResponse(HttpStatusCode.OK);

View File

@ -28,7 +28,7 @@ namespace ApiService.TestHooks {
var poolRes = _proxyForward.SearchForward(
UriExtension.GetGuid("scaleSetId", query),
UriExtension.GetString("region", query),
UriExtension.GetString("region", query) is string region ? Region.Parse(region) : null,
UriExtension.GetGuid("machineId", query),
UriExtension.GetGuid("proxyId", query),
UriExtension.GetInt("dstPort", query));

View File

@ -105,7 +105,7 @@ public class AutoScaleOperations : Orm<AutoScale>, IAutoScaleOperations {
return OneFuzzResultVoid.Ok;
}
private async Async.Task<OneFuzzResult<AutoscaleSettingResource>> CreateAutoScaleResourceFor(Guid resourceId, string location, AutoscaleProfile profile) {
private async Async.Task<OneFuzzResult<AutoscaleSettingResource>> CreateAutoScaleResourceFor(Guid resourceId, Region location, AutoscaleProfile profile) {
_logTracer.Info($"Creating auto-scale resource for: {resourceId}");
var resourceGroup = _context.Creds.GetBaseResourceGroup();

View File

@ -458,21 +458,24 @@ public class Config : IConfig {
return ResultVoid<TaskConfigError>.Ok();
}
var exist = new HashSet<string>();
var exist = new HashSet<Container>();
var containers = new Dictionary<ContainerType, List<Container>>();
foreach (var container in config.Containers) {
if (exist.Contains(container.Name.ContainerName)) {
if (exist.Contains(container.Name)) {
continue;
}
if (await _containers.FindContainer(container.Name, StorageType.Corpus) == null) {
return ResultVoid<TaskConfigError>.Error(new TaskConfigError($"missing container: {container.Name}"));
}
exist.Add(container.Name.ContainerName);
exist.Add(container.Name);
if (!containers.ContainsKey(container.Type)) {
containers.Add(container.Type, new List<Container>());
}
containers[container.Type].Add(container.Name);
}

View File

@ -28,7 +28,7 @@ public interface IContainers {
public Async.Task<bool> BlobExists(Container container, string name, StorageType storageType);
public Async.Task<Uri> AddContainerSasUrl(Uri uri, TimeSpan? duration = null);
public Async.Task<Dictionary<string, IDictionary<string, string>>> GetContainers(StorageType corpus);
public Async.Task<Dictionary<Container, IDictionary<string, string>>> GetContainers(StorageType corpus);
}
public class Containers : IContainers {
@ -36,7 +36,7 @@ public class Containers : IContainers {
private readonly IStorage _storage;
private readonly IServiceConfig _config;
static TimeSpan CONTAINER_SAS_DEFAULT_DURATION = TimeSpan.FromDays(30);
static readonly TimeSpan CONTAINER_SAS_DEFAULT_DURATION = TimeSpan.FromDays(30);
public Containers(ILogTracer log, IStorage storage, IServiceConfig config) {
_log = log;
@ -44,7 +44,7 @@ public class Containers : IContainers {
_config = config;
_getInstanceId = new Lazy<Async.Task<Guid>>(async () => {
var blob = await GetBlob(new Container("base-config"), "instance_id", StorageType.Config);
var blob = await GetBlob(WellKnownContainers.BaseConfig, "instance_id", StorageType.Config);
if (blob == null) {
throw new Exception("Blob Not Found");
}
@ -99,14 +99,14 @@ public class Containers : IContainers {
var account = _storage.ChooseAccount(storageType);
var client = await _storage.GetBlobServiceClientForAccount(account);
var containerName = _config.OneFuzzStoragePrefix + container.ContainerName;
var containerName = _config.OneFuzzStoragePrefix + container;
var cc = client.GetBlobContainerClient(containerName);
try {
await cc.CreateAsync(metadata: metadata);
} catch (RequestFailedException ex) when (ex.ErrorCode == "ContainerAlreadyExists") {
// note: resource exists error happens during creation if the container
// is being deleted
_log.Error($"unable to create container. account: {account} container: {container.ContainerName} metadata: {metadata} - {ex.Message}");
_log.Error($"unable to create container. account: {account} container: {container} metadata: {metadata} - {ex.Message}");
return null;
}
@ -123,7 +123,7 @@ public class Containers : IContainers {
// # Secondary accounts, if they exist, are preferred for containers and have
// # increased IOP rates, this should be a slight optimization
var containerName = _config.OneFuzzStoragePrefix + container.ContainerName;
var containerName = _config.OneFuzzStoragePrefix + container;
foreach (var account in _storage.GetAccounts(storageType).Reverse()) {
var accountClient = await _storage.GetBlobServiceClientForAccount(account);
@ -137,7 +137,7 @@ public class Containers : IContainers {
}
public async Async.Task<Uri> GetFileSasUrl(Container container, string name, StorageType storageType, BlobSasPermissions permissions, TimeSpan? duration = null) {
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container.ContainerName} - {storageType}");
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container} - {storageType}");
var blobClient = client.GetBlobClient(name);
var timeWindow = SasTimeWindow(duration ?? TimeSpan.FromDays(30));
return _storage.GenerateBlobSasUri(permissions, blobClient, timeWindow);
@ -160,7 +160,7 @@ public class Containers : IContainers {
}
public async Async.Task SaveBlob(Container container, string name, string data, StorageType storageType) {
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container.ContainerName} - {storageType}");
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container} - {storageType}");
await client.GetBlobClient(name).UploadAsync(new BinaryData(data), overwrite: true);
}
@ -192,23 +192,25 @@ public class Containers : IContainers {
}
public async Task<Uri> GetContainerSasUrl(Container container, StorageType storageType, BlobContainerSasPermissions permissions, TimeSpan? duration = null) {
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container.ContainerName} - {storageType}");
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container} - {storageType}");
var timeWindow = SasTimeWindow(duration ?? CONTAINER_SAS_DEFAULT_DURATION);
return _storage.GenerateBlobContainerSasUri(permissions, client, timeWindow);
}
public async Async.Task<bool> BlobExists(Container container, string name, StorageType storageType) {
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container.ContainerName} - {storageType}");
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container} - {storageType}");
return await client.GetBlobClient(name).ExistsAsync();
}
public async Task<Dictionary<string, IDictionary<string, string>>> GetContainers(StorageType corpus) {
public async Task<Dictionary<Container, IDictionary<string, string>>> GetContainers(StorageType corpus) {
var accounts = _storage.GetAccounts(corpus);
IEnumerable<IEnumerable<KeyValuePair<string, IDictionary<string, string>>>> data =
IEnumerable<IEnumerable<KeyValuePair<Container, IDictionary<string, string>>>> data =
await Async.Task.WhenAll(accounts.Select(async acc => {
var service = await _storage.GetBlobServiceClientForAccount(acc);
return await service.GetBlobContainersAsync(BlobContainerTraits.Metadata).Select(container =>
KeyValuePair.Create(container.Name, container.Properties.Metadata)).ToListAsync();
return await service
.GetBlobContainersAsync(BlobContainerTraits.Metadata)
.Select(container => KeyValuePair.Create(Container.Parse(container.Name), container.Properties.Metadata))
.ToListAsync();
}));
return new(data.SelectMany(x => x));

View File

@ -25,14 +25,14 @@ public interface ICreds {
public SubscriptionResource GetSubscriptionResource();
public Async.Task<string> GetBaseRegion();
public Async.Task<Region> GetBaseRegion();
public Async.Task<IReadOnlyList<Region>> GetRegions();
public Uri GetInstanceUrl();
public Async.Task<Guid> GetScalesetPrincipalId();
public GenericResource ParseResourceId(string resourceId);
public GenericResource ParseResourceId(ResourceIdentifier resourceId);
public Async.Task<GenericResource> GetData(GenericResource resource);
Async.Task<IReadOnlyList<string>> GetRegions();
public ResourceIdentifier GetScalesetIdentityResourcePath();
}
@ -95,13 +95,13 @@ public sealed class Creds : ICreds {
return ArmClient.GetSubscriptionResource(id);
}
public Async.Task<string> GetBaseRegion() {
public Async.Task<Region> GetBaseRegion() {
return _cache.GetOrCreateAsync(nameof(GetBaseRegion), async _ => {
var rg = await ArmClient.GetResourceGroupResource(GetResourceGroupResourceIdentifier()).GetAsync();
if (rg.GetRawResponse().IsError) {
throw new Exception($"Failed to get base region due to [{rg.GetRawResponse().Status}] {rg.GetRawResponse().ReasonPhrase}");
}
return rg.Value.Data.Location.Name;
return Region.Parse(rg.Value.Data.Location.Name);
});
}
@ -144,8 +144,8 @@ public sealed class Creds : ICreds {
return resource;
}
public Task<IReadOnlyList<string>> GetRegions()
=> _cache.GetOrCreateAsync<IReadOnlyList<string>>(
public Task<IReadOnlyList<Region>> GetRegions()
=> _cache.GetOrCreateAsync<IReadOnlyList<Region>>(
nameof(Creds) + "." + nameof(GetRegions),
async entry => {
// cache for one day
@ -153,7 +153,7 @@ public sealed class Creds : ICreds {
var subscriptionId = SubscriptionResource.CreateResourceIdentifier(GetSubscription());
return await ArmClient.GetSubscriptionResource(subscriptionId)
.GetLocationsAsync()
.Select(x => x.Name)
.Select(x => Region.Parse(x.Name))
.ToListAsync();
});

View File

@ -12,15 +12,13 @@ public interface IExtensions {
Async.Task<IList<VirtualMachineScaleSetExtensionData>> FuzzExtensions(Pool pool, Scaleset scaleset);
Async.Task<Dictionary<string, VirtualMachineExtensionData>> ReproExtensions(AzureLocation region, Os reproOs, Guid reproId, ReproConfig reproConfig, Container? setupContainer);
Task<IList<VMExtensionWrapper>> ProxyManagerExtensions(string region, Guid proxyId);
Task<IList<VMExtensionWrapper>> ProxyManagerExtensions(Region region, Guid proxyId);
}
public class Extensions : IExtensions {
IOnefuzzContext _context;
private readonly IOnefuzzContext _context;
private static readonly JsonSerializerOptions _extensionSerializerOptions = new JsonSerializerOptions {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private static readonly JsonSerializerOptions _extensionSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public Extensions(IOnefuzzContext context) {
_context = context;
@ -227,11 +225,12 @@ public class Extensions : IExtensions {
var fileName = $"{pool.Name}/config.json";
var configJson = JsonSerializer.Serialize(config, EntityConverter.GetJsonSerializerOptions());
await _context.Containers.SaveBlob(new Container("vm-scripts"), fileName, configJson, StorageType.Config);
return await ConfigUrl(new Container("vm-scripts"), fileName, false);
await _context.Containers.SaveBlob(WellKnownContainers.VmScripts, fileName, configJson, StorageType.Config);
return await ConfigUrl(WellKnownContainers.VmScripts, fileName, false);
}
public async Async.Task<Uri?> BuildScaleSetScript(Pool pool, Scaleset scaleSet) {
List<string> commands = new();
var extension = pool.Os == Os.Windows ? "ps1" : "sh";
@ -244,21 +243,23 @@ public class Extensions : IExtensions {
commands.Add($"Set-Content -Path {sshPath} -Value \"{sshKey}\"");
}
await _context.Containers.SaveBlob(new Container("vm-scripts"), fileName, string.Join(sep, commands) + sep, StorageType.Config);
return await _context.Containers.GetFileUrl(new Container("vm-scripts"), fileName, StorageType.Config);
await _context.Containers.SaveBlob(WellKnownContainers.VmScripts, fileName, string.Join(sep, commands) + sep, StorageType.Config);
return await _context.Containers.GetFileUrl(WellKnownContainers.VmScripts, fileName, StorageType.Config);
}
public async Async.Task UpdateManagedScripts() {
var instanceSpecificSetupSas = await _context.Containers.GetContainerSasUrl(new Container("instance-specific-setup"), StorageType.Config, BlobContainerSasPermissions.List | BlobContainerSasPermissions.Read);
var toolsSas = await _context.Containers.GetContainerSasUrl(new Container("tools"), StorageType.Config, BlobContainerSasPermissions.List | BlobContainerSasPermissions.Read);
var listAndRead = BlobContainerSasPermissions.List | BlobContainerSasPermissions.Read;
var instanceSpecificSetupSas = await _context.Containers.GetContainerSasUrl(WellKnownContainers.InstanceSpecificSetup, StorageType.Config, listAndRead);
var toolsSas = await _context.Containers.GetContainerSasUrl(WellKnownContainers.Tools, StorageType.Config, listAndRead);
string[] commands = {
$"azcopy sync '{instanceSpecificSetupSas}' instance-specific-setup",
$"azcopy sync '{toolsSas}' tools"
};
await _context.Containers.SaveBlob(new Container("vm-scripts"), "managed.ps1", string.Join("\r\n", commands) + "\r\n", StorageType.Config);
await _context.Containers.SaveBlob(new Container("vm-scripts"), "managed.sh", string.Join("\n", commands) + "\n", StorageType.Config);
await _context.Containers.SaveBlob(WellKnownContainers.VmScripts, "managed.ps1", string.Join("\r\n", commands) + "\r\n", StorageType.Config);
await _context.Containers.SaveBlob(WellKnownContainers.VmScripts, "managed.sh", string.Join("\n", commands) + "\n", StorageType.Config);
}
public async Async.Task<VMExtensionWrapper> AgentConfig(AzureLocation region, Os vmOs, AgentMode mode, List<Uri>? urls = null, bool withSas = false) {
@ -267,10 +268,10 @@ public class Extensions : IExtensions {
var managedIdentity = JsonSerializer.Serialize(new { ManagedIdentity = new Dictionary<string, string>() }, _extensionSerializerOptions);
if (vmOs == Os.Windows) {
var vmScripts = await ConfigUrl(new Container("vm-scripts"), "managed.ps1", withSas) ?? throw new Exception("failed to get VmScripts config url");
var toolsAzCopy = await ConfigUrl(new Container("tools"), "win64/azcopy.exe", withSas) ?? throw new Exception("failed to get toolsAzCopy config url");
var toolsSetup = await ConfigUrl(new Container("tools"), "win64/setup.ps1", withSas) ?? throw new Exception("failed to get toolsSetup config url");
var toolsOneFuzz = await ConfigUrl(new Container("tools"), "win64/onefuzz.ps1", withSas) ?? throw new Exception("failed to get toolsOneFuzz config url");
var vmScripts = await ConfigUrl(WellKnownContainers.VmScripts, "managed.ps1", withSas) ?? throw new Exception("failed to get VmScripts config url");
var toolsAzCopy = await ConfigUrl(WellKnownContainers.Tools, "win64/azcopy.exe", withSas) ?? throw new Exception("failed to get toolsAzCopy config url");
var toolsSetup = await ConfigUrl(WellKnownContainers.Tools, "win64/setup.ps1", withSas) ?? throw new Exception("failed to get toolsSetup config url");
var toolsOneFuzz = await ConfigUrl(WellKnownContainers.Tools, "win64/onefuzz.ps1", withSas) ?? throw new Exception("failed to get toolsOneFuzz config url");
urlsUpdated.Add(vmScripts);
urlsUpdated.Add(toolsAzCopy);
@ -293,9 +294,9 @@ public class Extensions : IExtensions {
return extension;
} else if (vmOs == Os.Linux) {
var vmScripts = await ConfigUrl(new Container("vm-scripts"), "managed.sh", withSas) ?? throw new Exception("failed to get VmScripts config url");
var toolsAzCopy = await ConfigUrl(new Container("tools"), "linux/azcopy", withSas) ?? throw new Exception("failed to get toolsAzCopy config url");
var toolsSetup = await ConfigUrl(new Container("tools"), "linux/setup.sh", withSas) ?? throw new Exception("failed to get toolsSetup config url");
var vmScripts = await ConfigUrl(WellKnownContainers.VmScripts, "managed.sh", withSas) ?? throw new Exception("failed to get VmScripts config url");
var toolsAzCopy = await ConfigUrl(WellKnownContainers.Tools, "linux/azcopy", withSas) ?? throw new Exception("failed to get toolsAzCopy config url");
var toolsSetup = await ConfigUrl(WellKnownContainers.Tools, "linux/setup.sh", withSas) ?? throw new Exception("failed to get toolsSetup config url");
urlsUpdated.Add(vmScripts);
urlsUpdated.Add(toolsAzCopy);
@ -423,7 +424,7 @@ public class Extensions : IExtensions {
}
await _context.Containers.SaveBlob(
new Container("task-configs"),
WellKnownContainers.TaskConfigs,
$"{reproId}/{scriptName}",
taskScript,
StorageType.Config
@ -433,13 +434,13 @@ public class Extensions : IExtensions {
urls.AddRange(new List<Uri>()
{
await _context.Containers.GetFileSasUrl(
new Container("repro-scripts"),
WellKnownContainers.ReproScripts,
reproFile,
StorageType.Config,
BlobSasPermissions.Read
),
await _context.Containers.GetFileSasUrl(
new Container("task-configs"),
WellKnownContainers.TaskConfigs,
$"{reproId}/{scriptName}",
StorageType.Config,
BlobSasPermissions.Read
@ -460,13 +461,18 @@ public class Extensions : IExtensions {
return extensionsDict;
}
public async Task<IList<VMExtensionWrapper>> ProxyManagerExtensions(string region, Guid proxyId) {
var config = await _context.Containers.GetFileSasUrl(new Container("proxy-configs"),
$"{region}/{proxyId}/config.json", StorageType.Config, BlobSasPermissions.Read);
var proxyManager = await _context.Containers.GetFileSasUrl(new Container("tools"),
$"linux/onefuzz-proxy-manager", StorageType.Config, BlobSasPermissions.Read);
public async Task<IList<VMExtensionWrapper>> ProxyManagerExtensions(Region region, Guid proxyId) {
var config = await _context.Containers.GetFileSasUrl(
WellKnownContainers.ProxyConfigs,
$"{region}/{proxyId}/config.json",
StorageType.Config,
BlobSasPermissions.Read);
var proxyManager = await _context.Containers.GetFileSasUrl(
WellKnownContainers.Tools,
$"linux/onefuzz-proxy-manager",
StorageType.Config,
BlobSasPermissions.Read);
var baseExtension =
await AgentConfig(region, Os.Linux, AgentMode.Proxy, new List<Uri> { config, proxyManager }, true);

View File

@ -7,7 +7,7 @@ namespace Microsoft.OneFuzz.Service;
public record ImageInfo(string Publisher, string Offer, string Sku, string Version);
public interface IImageOperations {
public Async.Task<OneFuzzResult<Os>> GetOs(string region, string image);
public Async.Task<OneFuzzResult<Os>> GetOs(Region region, string image);
public static ImageInfo GetImageInfo(string image) {
var imageParts = image.Split(":");
@ -32,7 +32,7 @@ public class ImageOperations : IImageOperations {
_context = context;
}
public async Task<OneFuzzResult<Os>> GetOs(string region, string image) {
public async Task<OneFuzzResult<Os>> GetOs(Region region, string image) {
string? name = null;
try {
var parsed = _context.Creds.ParseResourceId(image);
@ -86,7 +86,7 @@ public class ImageOperations : IImageOperations {
if (string.Equals(imageInfo.Version, "latest", StringComparison.Ordinal)) {
version =
(await subscription.GetVirtualMachineImagesAsync(
region,
region.String,
imageInfo.Publisher,
imageInfo.Offer,
imageInfo.Sku,
@ -97,7 +97,7 @@ public class ImageOperations : IImageOperations {
}
name = (await subscription.GetVirtualMachineImageAsync(
region,
region.String,
imageInfo.Publisher,
imageInfo.Offer,
imageInfo.Sku

View File

@ -12,7 +12,7 @@ namespace Microsoft.OneFuzz.Service;
public interface IIpOperations {
public Async.Task<NetworkInterfaceResource?> GetPublicNic(string resourceGroup, string name);
public Async.Task<OneFuzzResultVoid> CreatePublicNic(string resourceGroup, string name, string region, Nsg? nsg);
public Async.Task<OneFuzzResultVoid> CreatePublicNic(string resourceGroup, string name, Region region, Nsg? nsg);
public Async.Task<string?> GetPublicIp(ResourceIdentifier resourceId);
@ -26,7 +26,7 @@ public interface IIpOperations {
public Async.Task<string?> GetScalesetInstanceIp(Guid scalesetId, Guid machineId);
public Async.Task CreateIp(string resourceGroup, string name, string region);
public Async.Task CreateIp(string resourceGroup, string name, Region region);
}
@ -120,7 +120,7 @@ public class IpOperations : IIpOperations {
}
}
public async Task<OneFuzzResultVoid> CreatePublicNic(string resourceGroup, string name, string region, Nsg? nsg) {
public async Task<OneFuzzResultVoid> CreatePublicNic(string resourceGroup, string name, Region region, Nsg? nsg) {
_logTracer.Info($"creating nic for {resourceGroup}:{name} in {region}");
var network = await Network.Init(region, _context);
@ -190,7 +190,7 @@ public class IpOperations : IIpOperations {
return OneFuzzResultVoid.Ok;
}
public async Async.Task CreateIp(string resourceGroup, string name, string region) {
public async Async.Task CreateIp(string resourceGroup, string name, Region region) {
var ipParams = new PublicIPAddressData() {
Location = region,
PublicIPAllocationMethod = NetworkIPAllocationMethod.Dynamic
@ -259,5 +259,3 @@ public class IpOperations : IIpOperations {
}
}
}

View File

@ -6,7 +6,7 @@ namespace Microsoft.OneFuzz.Service;
public class Network {
private readonly string _name;
private readonly string _group;
private readonly string _region;
private readonly Region _region;
private readonly IOnefuzzContext _context;
private readonly NetworkConfig _networkConfig;
@ -14,7 +14,7 @@ public class Network {
// This was generated randomly and should be preserved moving forwards
static Guid NETWORK_GUID_NAMESPACE = Guid.Parse("372977ad-b533-416a-b1b4-f770898e0b11");
public Network(string region, string group, string name, IOnefuzzContext context, NetworkConfig networkConfig) {
public Network(Region region, string group, string name, IOnefuzzContext context, NetworkConfig networkConfig) {
_region = region;
_group = group;
_name = name;
@ -22,7 +22,7 @@ public class Network {
_networkConfig = networkConfig;
}
public static async Async.Task<Network> Init(string region, IOnefuzzContext context) {
public static async Async.Task<Network> Init(Region region, IOnefuzzContext context) {
var group = context.Creds.GetBaseResourceGroup();
var instanceConfig = await context.ConfigOperations.Fetch();
var networkConfig = instanceConfig.NetworkConfig;
@ -33,16 +33,14 @@ public class Network {
// configs.
string name;
if (networkConfig.AddressSpace == NetworkConfig.Default.AddressSpace && networkConfig.Subnet == NetworkConfig.Default.Subnet) {
name = region;
name = region.String;
} else {
//TODO: Remove dependency on "Faithlife"
var networkId = Faithlife.Utility.GuidUtility.Create(NETWORK_GUID_NAMESPACE, string.Join("|", networkConfig.AddressSpace, networkConfig.Subnet), 5);
name = $"{region}-{networkId}";
}
return new Network(region, group, name, context, networkConfig);
}

View File

@ -7,7 +7,7 @@ namespace Microsoft.OneFuzz.Service;
public interface INotificationOperations : IOrm<Notification> {
Async.Task NewFiles(Container container, string filename, bool failTaskOnTransientError);
IAsyncEnumerable<Notification> GetNotifications(Container container);
IAsyncEnumerable<(Task, IEnumerable<string>)> GetQueueTasks();
IAsyncEnumerable<(Task, IEnumerable<Container>)> GetQueueTasks();
Async.Task<OneFuzzResult<Notification>> Create(Container container, NotificationTemplate config, bool replaceExisting);
}
@ -52,8 +52,8 @@ public class NotificationOperations : Orm<Notification>, INotificationOperations
}
await foreach (var (task, containers) in GetQueueTasks()) {
if (containers.Contains(container.ContainerName)) {
_logTracer.Info($"queuing input {container.ContainerName} {filename} {task.TaskId}");
if (containers.Contains(container)) {
_logTracer.Info($"queuing input {container} {filename} {task.TaskId}");
var url = _context.Containers.GetFileSasUrl(container, filename, StorageType.Corpus, BlobSasPermissions.Read | BlobSasPermissions.Delete);
await _context.Queue.SendMessage(task.TaskId.ToString(), url?.ToString() ?? "", StorageType.Corpus);
}
@ -77,10 +77,10 @@ public class NotificationOperations : Orm<Notification>, INotificationOperations
}
public IAsyncEnumerable<Notification> GetNotifications(Container container) {
return QueryAsync(filter: $"container eq '{container.ContainerName}'");
return QueryAsync(filter: $"container eq '{container}'");
}
public IAsyncEnumerable<(Task, IEnumerable<string>)> GetQueueTasks() {
public IAsyncEnumerable<(Task, IEnumerable<Container>)> GetQueueTasks() {
// Nullability mismatch: We filter tuples where the containers are null
return _context.TaskOperations.SearchStates(states: TaskStateHelper.AvailableStates)
.Select(task => (task, _context.TaskOperations.GetInputContainerQueues(task.Config)))
@ -93,7 +93,7 @@ public class NotificationOperations : Orm<Notification>, INotificationOperations
}
if (replaceExisting) {
var existing = this.SearchByRowKeys(new[] { container.ContainerName });
var existing = this.SearchByRowKeys(new[] { container.String });
await foreach (var existingEntry in existing) {
_logTracer.Info($"replacing existing notification: {existingEntry.NotificationId} - {container}");
await this.Delete(existingEntry);

View File

@ -8,7 +8,7 @@ namespace Microsoft.OneFuzz.Service {
Async.Task<NetworkSecurityGroupResource?> GetNsg(string name);
public Async.Task<OneFuzzResult<bool>> AssociateSubnet(string name, VirtualNetworkResource vnet, SubnetResource subnet);
IAsyncEnumerable<NetworkSecurityGroupResource> ListNsgs();
bool OkToDelete(HashSet<string> active_regions, string nsg_region, string nsg_name);
bool OkToDelete(IReadOnlySet<Region> active_regions, Region nsg_region, string nsg_name);
Async.Task<bool> StartDeleteNsg(string name);
Async.Task<OneFuzzResultVoid> DissociateNic(Nsg nsg, NetworkInterfaceResource nic);
@ -128,8 +128,8 @@ namespace Microsoft.OneFuzz.Service {
return _context.Creds.GetResourceGroupResource().GetNetworkSecurityGroups().GetAllAsync();
}
public bool OkToDelete(HashSet<string> active_regions, string nsg_region, string nsg_name) {
return !active_regions.Contains(nsg_region) && nsg_region == nsg_name;
public bool OkToDelete(IReadOnlySet<Region> active_regions, Region nsg_region, string nsg_name) {
return !active_regions.Contains(nsg_region) && Nsg.NameFromRegion(nsg_region) == nsg_name;
}
/// <summary>
@ -164,7 +164,7 @@ namespace Microsoft.OneFuzz.Service {
return await CreateNsg(nsg.Name, nsg.Region);
}
private async Task<OneFuzzResultVoid> CreateNsg(string name, string location) {
private async Task<OneFuzzResultVoid> CreateNsg(string name, Region location) {
var resourceGroup = _context.Creds.GetBaseResourceGroup();
_logTracer.Info($"creating nsg {resourceGroup}:{location}:{name}");

View File

@ -5,10 +5,10 @@ namespace Microsoft.OneFuzz.Service;
public interface IProxyForwardOperations : IOrm<ProxyForward> {
IAsyncEnumerable<ProxyForward> SearchForward(Guid? scalesetId = null, string? region = null, Guid? machineId = null, Guid? proxyId = null, int? dstPort = null);
IAsyncEnumerable<ProxyForward> SearchForward(Guid? scalesetId = null, Region? region = null, Guid? machineId = null, Guid? proxyId = null, int? dstPort = null);
Forward ToForward(ProxyForward proxyForward);
Task<OneFuzzResult<ProxyForward>> UpdateOrCreate(string region, Guid scalesetId, Guid machineId, int dstPort, int duration);
Task<HashSet<string>> RemoveForward(Guid scalesetId, Guid? machineId = null, int? dstPort = null, Guid? proxyId = null);
Task<OneFuzzResult<ProxyForward>> UpdateOrCreate(Region region, Guid scalesetId, Guid machineId, int dstPort, int duration);
Task<HashSet<Region>> RemoveForward(Guid scalesetId, Guid? machineId = null, int? dstPort = null, Guid? proxyId = null);
}
@ -20,7 +20,7 @@ public class ProxyForwardOperations : Orm<ProxyForward>, IProxyForwardOperations
}
public IAsyncEnumerable<ProxyForward> SearchForward(Guid? scalesetId = null, string? region = null, Guid? machineId = null, Guid? proxyId = null, int? dstPort = null) {
public IAsyncEnumerable<ProxyForward> SearchForward(Guid? scalesetId = null, Region? region = null, Guid? machineId = null, Guid? proxyId = null, int? dstPort = null) {
var conditions =
new[] {
@ -40,7 +40,7 @@ public class ProxyForwardOperations : Orm<ProxyForward>, IProxyForwardOperations
return new Forward(proxyForward.Port, proxyForward.DstPort, proxyForward.DstIp);
}
public async Task<OneFuzzResult<ProxyForward>> UpdateOrCreate(string region, Guid scalesetId, Guid machineId, int dstPort, int duration) {
public async Task<OneFuzzResult<ProxyForward>> UpdateOrCreate(Region region, Guid scalesetId, Guid machineId, int dstPort, int duration) {
var privateIp = await _context.IpOperations.GetScalesetInstanceIp(scalesetId, machineId);
if (privateIp == null) {
@ -88,10 +88,10 @@ public class ProxyForwardOperations : Orm<ProxyForward>, IProxyForwardOperations
}
public async Task<HashSet<string>> RemoveForward(Guid scalesetId, Guid? machineId, int? dstPort, Guid? proxyId) {
public async Task<HashSet<Region>> RemoveForward(Guid scalesetId, Guid? machineId, int? dstPort, Guid? proxyId) {
var entries = await SearchForward(scalesetId: scalesetId, machineId: machineId, proxyId: proxyId, dstPort: dstPort).ToListAsync();
var regions = new HashSet<string>();
var regions = new HashSet<Region>();
foreach (var entry in entries) {
regions.Add(entry.Region);
await Delete(entry);

View File

@ -14,7 +14,7 @@ public interface IProxyOperations : IStatefulOrm<Proxy, VmState> {
bool IsAlive(Proxy proxy);
Async.Task SaveProxyConfig(Proxy proxy);
bool IsOutdated(Proxy proxy);
Async.Task<Proxy?> GetOrCreate(string region);
Async.Task<Proxy?> GetOrCreate(Region region);
Task<bool> IsUsed(Proxy proxy);
// state transitions:
@ -27,13 +27,10 @@ public interface IProxyOperations : IStatefulOrm<Proxy, VmState> {
Async.Task<Proxy> Stopped(Proxy proxy);
}
public class ProxyOperations : StatefulOrm<Proxy, VmState, ProxyOperations>, IProxyOperations {
static TimeSpan PROXY_LIFESPAN = TimeSpan.FromDays(7);
static readonly TimeSpan PROXY_LIFESPAN = TimeSpan.FromDays(7);
public ProxyOperations(ILogTracer log, IOnefuzzContext context)
: base(log.WithTag("Component", "scaleset-proxy"), context) {
}
@ -44,7 +41,7 @@ public class ProxyOperations : StatefulOrm<Proxy, VmState, ProxyOperations>, IPr
return await data.FirstOrDefaultAsync();
}
public async Async.Task<Proxy?> GetOrCreate(string region) {
public async Async.Task<Proxy?> GetOrCreate(Region region) {
var proxyList = QueryAsync(filter: $"region eq '{region}' and outdated eq false");
await foreach (var proxy in proxyList) {
@ -117,7 +114,7 @@ public class ProxyOperations : StatefulOrm<Proxy, VmState, ProxyOperations>, IPr
public async Async.Task SaveProxyConfig(Proxy proxy) {
var forwards = await GetForwards(proxy);
var url = (await _context.Containers.GetFileSasUrl(new Container("proxy-configs"), $"{proxy.Region}/{proxy.ProxyId}/config.json", StorageType.Config, BlobSasPermissions.Read)).EnsureNotNull("Can't generate file sas");
var url = (await _context.Containers.GetFileSasUrl(WellKnownContainers.ProxyConfigs, $"{proxy.Region}/{proxy.ProxyId}/config.json", StorageType.Config, BlobSasPermissions.Read)).EnsureNotNull("Can't generate file sas");
var queueSas = await _context.Queue.GetQueueSas("proxy", StorageType.Config, QueueSasPermissions.Add).EnsureNotNull("can't generate queue sas") ?? throw new Exception("Queue sas is null");
var proxyConfig = new ProxyConfig(
@ -130,7 +127,7 @@ public class ProxyOperations : StatefulOrm<Proxy, VmState, ProxyOperations>, IPr
MicrosoftTelemetryKey: _context.ServiceConfiguration.OneFuzzTelemetry.EnsureNotNull("missing Telemetry"),
InstanceId: await _context.Containers.GetInstanceId());
await _context.Containers.SaveBlob(new Container("proxy-configs"), $"{proxy.Region}/{proxy.ProxyId}/config.json", EntityConverter.ToJsonString(proxyConfig), StorageType.Config);
await _context.Containers.SaveBlob(WellKnownContainers.ProxyConfigs, $"{proxy.Region}/{proxy.ProxyId}/config.json", EntityConverter.ToJsonString(proxyConfig), StorageType.Config);
}
@ -175,7 +172,7 @@ public class ProxyOperations : StatefulOrm<Proxy, VmState, ProxyOperations>, IPr
return await SetState(proxy, VmState.ExtensionsLaunch);
}
} else {
var nsg = new Nsg(proxy.Region, proxy.Region);
var nsg = Nsg.ForRegion(proxy.Region);
var result = await _context.NsgOperations.Create(nsg);
if (!result.IsOk) {
return await SetFailed(proxy, result.ErrorV);

View File

@ -27,7 +27,7 @@ public class Reports : IReports {
}
public async Async.Task<IReport?> GetReportOrRegression(Container container, string fileName, bool expectReports = false, params string[] args) {
var filePath = String.Join("/", new[] { container.ContainerName, fileName });
var filePath = string.Join("/", new[] { container.String, fileName });
if (!fileName.EndsWith(".json", StringComparison.Ordinal)) {
if (expectReports) {
_log.Error($"get_report invalid extension: {filePath}");

View File

@ -131,7 +131,7 @@ public class ReproOperations : StatefulOrm<Repro, VmState, ReproOperations>, IRe
repro = repro with { State = VmState.ExtensionsLaunch };
}
} else {
var nsg = new Nsg(vm.Region, vm.Region);
var nsg = Nsg.ForRegion(vm.Region);
var result = await _context.NsgOperations.Create(nsg);
if (!result.IsOk) {
return await _context.ReproOperations.SetError(repro, result.ErrorV);
@ -260,7 +260,7 @@ public class ReproOperations : StatefulOrm<Repro, VmState, ReproOperations>, IRe
foreach (var (fileName, fileContents) in files) {
await _context.Containers.SaveBlob(
new Container("repro-scripts"),
WellKnownContainers.ReproScripts,
$"{repro.VmId}/{fileName}",
fileContents,
StorageType.Config

View File

@ -184,7 +184,7 @@ public class Scheduler : IScheduler {
return (bucketConfig, workUnit);
}
public record struct BucketId(Os os, Guid jobId, (string, string)? vm, PoolName? pool, string setupContainer, bool? reboot, Guid? unique);
public record struct BucketId(Os os, Guid jobId, (string, string)? vm, PoolName? pool, Container setupContainer, bool? reboot, Guid? unique);
public static ILookup<BucketId, Task> BucketTasks(IEnumerable<Task> tasks) {
@ -221,11 +221,11 @@ public class Scheduler : IScheduler {
});
}
static string GetSetupContainer(TaskConfig config) {
static Container GetSetupContainer(TaskConfig config) {
foreach (var container in config.Containers ?? throw new Exception("Missing containers")) {
if (container.Type == ContainerType.Setup) {
return container.Name.ContainerName;
return container.Name;
}
}

View File

@ -11,7 +11,7 @@ public interface ISubnet {
Async.Task<SubnetResource?> GetSubnet(string vnetName, string subnetName);
Async.Task<OneFuzzResultVoid> CreateVirtualNetwork(string resourceGroup, string name, string region, NetworkConfig networkConfig);
Async.Task<OneFuzzResultVoid> CreateVirtualNetwork(string resourceGroup, string name, Region region, NetworkConfig networkConfig);
Async.Task<ResourceIdentifier?> GetSubnetId(string name, string subnetName);
}
@ -29,7 +29,7 @@ public class Subnet : ISubnet {
_context = context;
}
public async Task<OneFuzzResultVoid> CreateVirtualNetwork(string resourceGroup, string name, string region, NetworkConfig networkConfig) {
public async Task<OneFuzzResultVoid> CreateVirtualNetwork(string resourceGroup, string name, Region region, NetworkConfig networkConfig) {
_logTracer.Info($"creating subnet - resource group:{resourceGroup} name:{name} region: {region}");
var virtualNetParam = new VirtualNetworkData {

View File

@ -15,7 +15,7 @@ public interface ITaskOperations : IStatefulOrm<Task, TaskState> {
IAsyncEnumerable<Task> SearchStates(Guid? jobId = null, IEnumerable<TaskState>? states = null);
IEnumerable<string>? GetInputContainerQueues(TaskConfig config);
IEnumerable<Container>? GetInputContainerQueues(TaskConfig config);
IAsyncEnumerable<Task> SearchExpired();
Async.Task MarkStopping(Task task);
@ -75,7 +75,7 @@ public class TaskOperations : StatefulOrm<Task, TaskState, TaskOperations>, ITas
return QueryAsync(filter: queryString);
}
public IEnumerable<string>? GetInputContainerQueues(TaskConfig config) {
public IEnumerable<Container>? GetInputContainerQueues(TaskConfig config) {
throw new NotImplementedException();
}

View File

@ -226,7 +226,7 @@ public class VmOperations : IVmOperations {
async Task<OneFuzzResultVoid> CreateVm(
string name,
string location,
Region location,
string vmSku,
string image,
string password,

View File

@ -16,7 +16,7 @@ public interface IVmssOperations {
Async.Task<OneFuzzResultVoid> UpdateExtensions(Guid name, IList<VirtualMachineScaleSetExtensionData> extensions);
Async.Task<VirtualMachineScaleSetData?> GetVmss(Guid name);
Async.Task<IReadOnlyList<string>> ListAvailableSkus(string region);
Async.Task<IReadOnlyList<string>> ListAvailableSkus(Region region);
Async.Task<bool> DeleteVmss(Guid name, bool? forceDeletion = null);
@ -27,7 +27,7 @@ public interface IVmssOperations {
Async.Task<OneFuzzResultVoid> ResizeVmss(Guid name, long capacity);
Async.Task<OneFuzzResultVoid> CreateVmss(
string location,
Region location,
Guid name,
string vmSku,
long vmCount,
@ -236,7 +236,7 @@ public class VmssOperations : IVmssOperations {
}
public async Async.Task<OneFuzzResultVoid> CreateVmss(
string location,
Region location,
Guid name,
string vmSku,
long vmCount,
@ -394,7 +394,7 @@ public class VmssOperations : IVmssOperations {
return null;
}
public Async.Task<IReadOnlyList<string>> ListAvailableSkus(string region)
public Async.Task<IReadOnlyList<string>> ListAvailableSkus(Region region)
=> _cache.GetOrCreateAsync<IReadOnlyList<string>>($"compute-skus-{region}", async entry => {
entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
@ -407,7 +407,7 @@ public class VmssOperations : IVmssOperations {
if (sku.Restrictions is not null) {
foreach (var restriction in sku.Restrictions) {
if (restriction.RestrictionsType == ResourceSkuRestrictionsType.Location &&
restriction.Values.Contains(region, StringComparer.OrdinalIgnoreCase)) {
restriction.Values.Contains(region.String, StringComparer.OrdinalIgnoreCase)) {
available = false;
break;
}

View File

@ -0,0 +1,12 @@

namespace Microsoft.OneFuzz.Service;
public static class WellKnownContainers {
public static readonly Container BaseConfig = Container.Parse("base-config");
public static readonly Container VmScripts = Container.Parse("vm-scripts");
public static readonly Container InstanceSpecificSetup = Container.Parse("instance-specific-setup");
public static readonly Container Tools = Container.Parse("tools");
public static readonly Container ReproScripts = Container.Parse("repro-scripts");
public static readonly Container TaskConfigs = Container.Parse("task-configs");
public static readonly Container ProxyConfigs = Container.Parse("proxy-configs");
}

View File

@ -225,17 +225,23 @@ public class EntityConverter {
private object? GetFieldValue(EntityInfo info, string name, TableEntity entity) {
var ef = info.properties[name].First();
if (ef.kind == EntityPropertyKind.PartitionKey || ef.kind == EntityPropertyKind.RowKey) {
if (ef.type == typeof(string))
return entity.GetString(ef.kind.ToString());
else if (ef.type == typeof(Guid))
return Guid.Parse(entity.GetString(ef.kind.ToString()));
else if (ef.type == typeof(int))
return int.Parse(entity.GetString(ef.kind.ToString()));
else if (ef.type == typeof(long))
return long.Parse(entity.GetString(ef.kind.ToString()));
else if (ef.type.IsClass)
return ef.type.GetConstructor(new[] { typeof(string) })!.Invoke(new[] { entity.GetString(ef.kind.ToString()) });
else {
// partition & row keys must always be strings
var stringValue = entity.GetString(ef.kind.ToString());
if (ef.type == typeof(string)) {
return stringValue;
} else if (ef.type == typeof(Guid)) {
return Guid.Parse(stringValue);
} else if (ef.type == typeof(int)) {
return int.Parse(stringValue);
} else if (ef.type == typeof(long)) {
return long.Parse(stringValue);
} else if (ef.type.IsClass) {
if (ef.type.IsAssignableTo(typeof(ValidatedString))) {
return ef.type.GetMethod("Parse")!.Invoke(null, new[] { stringValue });
}
return Activator.CreateInstance(ef.type, new[] { stringValue });
} else {
throw new Exception($"invalid partition or row key type of {info.type} property {name}: {ef.type}");
}
}

View File

@ -48,11 +48,11 @@ public abstract class ContainersTestBase : FunctionTestBase {
[Fact]
public async Async.Task CanDelete() {
var containerName = "test";
var containerName = Container.Parse("test");
var client = GetContainerClient(containerName);
await client.CreateIfNotExistsAsync();
var msg = TestHttpRequestData.FromJson("DELETE", new ContainerDelete(new Container(containerName)));
var msg = TestHttpRequestData.FromJson("DELETE", new ContainerDelete(containerName));
var auth = new TestEndpointAuthorization(RequestType.User, Logger, Context);
var func = new ContainersFunction(Logger, auth, Context);
@ -67,8 +67,8 @@ public abstract class ContainersTestBase : FunctionTestBase {
[Fact]
public async Async.Task CanPost_New() {
var meta = new Dictionary<string, string> { { "some", "value" } };
var containerName = "test";
var msg = TestHttpRequestData.FromJson("POST", new ContainerCreate(new Container(containerName), meta));
var containerName = Container.Parse("test");
var msg = TestHttpRequestData.FromJson("POST", new ContainerCreate(containerName, meta));
var auth = new TestEndpointAuthorization(RequestType.User, Logger, Context);
var func = new ContainersFunction(Logger, auth, Context);
@ -87,12 +87,12 @@ public abstract class ContainersTestBase : FunctionTestBase {
[Fact]
public async Async.Task CanPost_Existing() {
var containerName = "test";
var containerName = Container.Parse("test");
var client = GetContainerClient(containerName);
await client.CreateIfNotExistsAsync();
var metadata = new Dictionary<string, string> { { "some", "value" } };
var msg = TestHttpRequestData.FromJson("POST", new ContainerCreate(new Container(containerName), metadata));
var msg = TestHttpRequestData.FromJson("POST", new ContainerCreate(containerName, metadata));
var auth = new TestEndpointAuthorization(RequestType.User, Logger, Context);
var func = new ContainersFunction(Logger, auth, Context);
@ -110,13 +110,13 @@ public abstract class ContainersTestBase : FunctionTestBase {
[Fact]
public async Async.Task Get_Existing() {
var containerName = "test";
var containerName = Container.Parse("test");
{
var client = GetContainerClient(containerName);
await client.CreateIfNotExistsAsync();
}
var msg = TestHttpRequestData.FromJson("GET", new ContainerGet(new Container(containerName)));
var msg = TestHttpRequestData.FromJson("GET", new ContainerGet(containerName));
var auth = new TestEndpointAuthorization(RequestType.User, Logger, Context);
var func = new ContainersFunction(Logger, auth, Context);
@ -130,7 +130,8 @@ public abstract class ContainersTestBase : FunctionTestBase {
[Fact]
public async Async.Task Get_Missing_Fails() {
var msg = TestHttpRequestData.FromJson("GET", new ContainerGet(new Container("container")));
var container = Container.Parse("container");
var msg = TestHttpRequestData.FromJson("GET", new ContainerGet(container));
var auth = new TestEndpointAuthorization(RequestType.User, Logger, Context);
var func = new ContainersFunction(Logger, auth, Context);
@ -142,8 +143,8 @@ public abstract class ContainersTestBase : FunctionTestBase {
public async Async.Task List_Existing() {
var meta1 = new Dictionary<string, string> { { "key1", "value1" } };
var meta2 = new Dictionary<string, string> { { "key2", "value2" } };
await GetContainerClient("one").CreateIfNotExistsAsync(metadata: meta1);
await GetContainerClient("two").CreateIfNotExistsAsync(metadata: meta2);
await GetContainerClient(Container.Parse("one")).CreateIfNotExistsAsync(metadata: meta1);
await GetContainerClient(Container.Parse("two")).CreateIfNotExistsAsync(metadata: meta2);
var msg = TestHttpRequestData.Empty("GET"); // this means list all
@ -154,7 +155,7 @@ public abstract class ContainersTestBase : FunctionTestBase {
var list = BodyAs<ContainerInfoBase[]>(result);
// other tests can run in parallel, so filter to just our containers:
var cs = list.Where(ci => ci.Name.ContainerName.StartsWith(Context.ServiceConfiguration.OneFuzzStoragePrefix)).ToList();
var cs = list.Where(ci => ci.Name.String.StartsWith(Context.ServiceConfiguration.OneFuzzStoragePrefix)).ToList();
Assert.Equal(2, cs.Count);
// ensure correct metadata was returned.

View File

@ -72,7 +72,8 @@ public abstract class DownloadTestBase : FunctionTestBase {
[Fact]
public async Async.Task Download_RedirectsToResult_WithLocationHeader() {
// set up a file to download
var container = GetContainerClient("xxx");
var containerName = Container.Parse("xxx");
var container = GetContainerClient(containerName);
await container.CreateAsync();
await container.UploadBlobAsync("yyy", new BinaryData("content"));

View File

@ -15,9 +15,9 @@ class TestCreds : ICreds {
private readonly Guid _subscriptionId;
private readonly string _resourceGroup;
private readonly string _region;
private readonly Region _region;
public TestCreds(Guid subscriptionId, string resourceGroup, string region) {
public TestCreds(Guid subscriptionId, string resourceGroup, Region region) {
_subscriptionId = subscriptionId;
_resourceGroup = resourceGroup;
_region = region;
@ -26,8 +26,8 @@ class TestCreds : ICreds {
public ArmClient ArmClient => null!;
// we have to return something in some test cases, even if it isnt used
public Task<string> GetBaseRegion() => Task.FromResult(_region);
public Task<IReadOnlyList<string>> GetRegions() => Task.FromResult<IReadOnlyList<string>>(new[] { _region });
public Task<Region> GetBaseRegion() => Task.FromResult(_region);
public Task<IReadOnlyList<Region>> GetRegions() => Task.FromResult<IReadOnlyList<Region>>(new[] { _region });
public string GetBaseResourceGroup() => _resourceGroup;

View File

@ -48,7 +48,8 @@ public abstract class InfoTestBase : FunctionTestBase {
// store the instance ID in the expected location:
// for production this is done by the deploy script
var instanceId = Guid.NewGuid().ToString();
var containerClient = GetContainerClient("base-config");
var baseConfigContainer = WellKnownContainers.BaseConfig;
var containerClient = GetContainerClient(baseConfigContainer);
await containerClient.CreateAsync();
await containerClient.GetBlobClient("instance_id").UploadAsync(new BinaryData(instanceId));

View File

@ -169,7 +169,7 @@ public abstract class JobsTestBase : FunctionTestBase {
Assert.NotNull(job.Config.Logs);
Assert.Empty(new Uri(job.Config.Logs!).Query);
var container = Assert.Single(await Context.Containers.GetContainers(StorageType.Corpus), c => c.Key.Contains(job.JobId.ToString()));
var container = Assert.Single(await Context.Containers.GetContainers(StorageType.Corpus), c => c.Key.String.Contains(job.JobId.ToString()));
var metadata = Assert.Single(container.Value);
Assert.Equal(new KeyValuePair<string, string>("container_type", "logs"), metadata);
}

View File

@ -35,15 +35,15 @@ public abstract class FunctionTestBase : IAsyncLifetime {
private readonly Guid _subscriptionId = Guid.NewGuid();
private readonly string _resourceGroup = "FakeResourceGroup";
private readonly string _region = "fakeregion";
private readonly Region _region = Region.Parse("fakeregion");
protected ILogTracer Logger { get; }
protected TestContext Context { get; }
private readonly BlobServiceClient _blobClient;
protected BlobContainerClient GetContainerClient(string container)
=> _blobClient.GetBlobContainerClient(_storagePrefix + container);
protected BlobContainerClient GetContainerClient(Container container)
=> _blobClient.GetBlobContainerClient(_storagePrefix + container.String);
public FunctionTestBase(ITestOutputHelper output, IStorage storage) {
Logger = new TestLogTracer(output);

View File

@ -91,6 +91,11 @@ namespace Tests {
where PoolName.TryParse(name.Get, out _)
select PoolName.Parse(name.Get);
public static Gen<Region> RegionGen { get; }
= from name in Arb.Generate<NonEmptyString>()
where Region.TryParse(name.Get, out _)
select Region.Parse(name.Get);
public static Gen<Node> Node { get; }
= from arg in Arb.Generate<Tuple<Tuple<DateTimeOffset?, Guid?, Guid, NodeState>, Tuple<Guid?, DateTimeOffset, string, bool, bool, bool>>>()
from poolName in PoolNameGen
@ -107,39 +112,47 @@ namespace Tests {
DeleteRequested: arg.Item2.Item5,
DebugKeepNode: arg.Item2.Item6);
public static Gen<ProxyForward> ProxyForward() {
return Arb.Generate<Tuple<Tuple<string, long, Guid, Guid, Guid?, long>, Tuple<IPv4Address, DateTimeOffset>>>().Select(
arg =>
new ProxyForward(
Region: arg.Item1.Item1,
Port: arg.Item1.Item2,
ScalesetId: arg.Item1.Item3,
MachineId: arg.Item1.Item4,
ProxyId: arg.Item1.Item5,
DstPort: arg.Item1.Item6,
DstIp: arg.Item2.Item1.ToString(),
EndTime: arg.Item2.Item2
)
);
}
public static Gen<ProxyForward> ProxyForward { get; } =
from region in RegionGen
from port in Gen.Choose(0, ushort.MaxValue)
from scalesetId in Arb.Generate<Guid>()
from machineId in Arb.Generate<Guid>()
from proxyId in Arb.Generate<Guid?>()
from dstPort in Gen.Choose(0, ushort.MaxValue)
from dstIp in Arb.Generate<IPv4Address>()
from endTime in Arb.Generate<DateTimeOffset>()
select new ProxyForward(
Region: region,
Port: port,
ScalesetId: scalesetId,
MachineId: machineId,
ProxyId: proxyId,
DstPort: dstPort,
DstIp: dstIp.ToString(),
EndTime: endTime);
public static Gen<Proxy> Proxy() {
return Arb.Generate<Tuple<Tuple<string, Guid, DateTimeOffset?, VmState, Authentication, string?, Error?>, Tuple<string, ProxyHeartbeat?, bool>>>().Select(
arg =>
new Proxy(
Region: arg.Item1.Item1,
ProxyId: arg.Item1.Item2,
CreatedTimestamp: arg.Item1.Item3,
State: arg.Item1.Item4,
Auth: arg.Item1.Item5,
Ip: arg.Item1.Item6,
Error: arg.Item1.Item7,
Version: arg.Item2.Item1,
Heartbeat: arg.Item2.Item2,
Outdated: arg.Item2.Item3
)
);
}
public static Gen<Proxy> Proxy { get; } =
from region in RegionGen
from proxyId in Arb.Generate<Guid>()
from createdTimestamp in Arb.Generate<DateTimeOffset?>()
from state in Arb.Generate<VmState>()
from auth in Arb.Generate<Authentication>()
from ip in Arb.Generate<string>()
from error in Arb.Generate<Error?>()
from version in Arb.Generate<string>()
from heartbeat in Arb.Generate<ProxyHeartbeat?>()
from outdated in Arb.Generate<bool>()
select new Proxy(
Region: region,
ProxyId: proxyId,
CreatedTimestamp: createdTimestamp,
State: state,
Auth: auth,
Ip: ip,
Error: error,
Version: version,
Heartbeat: heartbeat,
Outdated: outdated);
public static Gen<EventMessage> EventMessage() {
return Arb.Generate<Tuple<Guid, BaseEvent, Guid, string>>().Select(
@ -219,10 +232,11 @@ namespace Tests {
}
public static Gen<Scaleset> Scaleset { get; }
= from arg in Arb.Generate<Tuple<
Tuple<Guid, ScalesetState, Authentication?, string, string, string>,
Tuple<Guid, ScalesetState, Authentication?, string, string>,
Tuple<int, bool, bool, bool, Error?, Guid?>,
Tuple<Guid?, Dictionary<string, string>>>>()
from poolName in PoolNameGen
from region in RegionGen
select new Scaleset(
PoolName: poolName,
ScalesetId: arg.Item1.Item1,
@ -230,7 +244,7 @@ namespace Tests {
Auth: arg.Item1.Item3,
VmSku: arg.Item1.Item4,
Image: arg.Item1.Item5,
Region: arg.Item1.Item6,
Region: region,
Size: arg.Item2.Item1,
SpotInstances: arg.Item2.Item2,
@ -346,11 +360,12 @@ namespace Tests {
);
}
public static Gen<Container> Container() {
return Arb.Generate<Tuple<NonNull<string>>>().Select(
arg => new Container(string.Join("", arg.Item1.Get.Where(c => char.IsLetterOrDigit(c) || c == '-'))!)
);
}
public static Gen<Container> ContainerGen { get; } =
from len in Gen.Choose(3, 63)
from name in Gen.ArrayOf(len, Gen.Elements<char>("abcdefghijklmnopqrstuvwxyz0123456789-"))
let nameString = new string(name)
where Container.TryParse(nameString, out var _)
select Container.Parse(nameString);
public static Gen<NotificationTemplate> NotificationTemplate() {
return Gen.OneOf(new[] {
@ -411,11 +426,11 @@ namespace Tests {
}
public static Arbitrary<ProxyForward> ProxyForward() {
return Arb.From(OrmGenerators.ProxyForward());
return Arb.From(OrmGenerators.ProxyForward);
}
public static Arbitrary<Proxy> Proxy() {
return Arb.From(OrmGenerators.Proxy());
return Arb.From(OrmGenerators.Proxy);
}
public static Arbitrary<EventMessage> EventMessage() {
@ -458,9 +473,12 @@ namespace Tests {
}
public static Arbitrary<Container> Container() {
return Arb.From(OrmGenerators.Container());
return Arb.From(OrmGenerators.ContainerGen);
}
public static Arbitrary<Region> Region() {
return Arb.From(OrmGenerators.RegionGen);
}
public static Arbitrary<NotificationTemplate> NotificationTemplate() {
return Arb.From(OrmGenerators.NotificationTemplate());

View File

@ -265,15 +265,15 @@ namespace Tests {
[Fact]
public void TestContainerSerialization() {
var container = new Container("abc-123");
var container = Container.Parse("abc-123");
var expected = new Entity3(123, "abc", container);
var converter = new EntityConverter();
var tableEntity = converter.ToTableEntity(expected);
var actual = converter.ToRecord<Entity3>(tableEntity);
Assert.Equal(expected.Container.ContainerName, actual.Container.ContainerName);
Assert.Equal(expected.Container.ContainerName, tableEntity.GetString("container"));
Assert.Equal(expected.Container, actual.Container);
Assert.Equal(expected.Container.String, tableEntity.GetString("container"));
}
[Fact]
@ -288,7 +288,7 @@ namespace Tests {
Assert.Equal(123, entity?.Id);
Assert.Equal("abc", entity?.TheName);
Assert.Equal("abc-123", entity?.Container.ContainerName);
Assert.Equal("abc-123", entity?.Container.String);
}
@ -300,7 +300,7 @@ namespace Tests {
[Fact]
public void TestPartitionKeyIsRowKey() {
var container = new Container("abc-123");
var container = Container.Parse("abc-123");
var expected = new Entity4(123, "abc", container);
var converter = new EntityConverter();
@ -310,8 +310,8 @@ namespace Tests {
var actual = converter.ToRecord<Entity4>(tableEntity);
Assert.Equal(expected.Container.ContainerName, actual.Container.ContainerName);
Assert.Equal(expected.Container.ContainerName, tableEntity.GetString("container"));
Assert.Equal(expected.Container, actual.Container);
Assert.Equal(expected.Container.String, tableEntity.GetString("container"));
}

View File

@ -25,7 +25,9 @@ public class SchedulerTests {
TargetEnv: new Dictionary<string, string>(),
TargetOptions: new List<string>()),
Pool: new TaskPool(1, PoolName.Parse("pool")),
Containers: new List<TaskContainers> { new TaskContainers(ContainerType.Setup, new Container("setup")) },
Containers: new List<TaskContainers> {
new TaskContainers(ContainerType.Setup, Container.Parse("setup"))
},
Colocate: true
),
@ -105,7 +107,7 @@ public class SchedulerTests {
var tasks = BuildTasks(100).Select((task, i) => {
var containers = new List<TaskContainers>(task.Config.Containers!);
if (i % 4 == 0) {
containers[0] = containers[0] with { Name = new Container("setup2") };
containers[0] = containers[0] with { Name = Container.Parse("setup2") };
}
return task with {
JobId = i % 2 == 0 ? jobId : task.JobId,

View File

@ -101,10 +101,9 @@ public class TimerReproTests {
Guid.NewGuid(),
Guid.Empty,
new ReproConfig(
new Container(String.Empty),
String.Empty,
0
),
Container.Parse("container"),
"",
0),
null,
Os.Linux,
VmState.Init,

View File

@ -19,4 +19,18 @@ public class ValidatedStringTests {
var result = JsonSerializer.Serialize(new ThingContainingPoolName(PoolName.Parse("is-a-pool")));
Assert.Equal("{\"PoolName\":\"is-a-pool\"}", result);
}
[Theory]
[InlineData("x", false)] // too short
[InlineData("xy", false)] // too short
[InlineData("xyz", true)]
[InlineData("-container", false)] // can't start with hyphen
[InlineData("container-", true)] // can end with hyphen
[InlineData("container-name", true)] // can have middle hyphen
[InlineData("container--name", false)] // can't have two consecutive hyphens
[InlineData("container-Name", false)] // can't have capitals
[InlineData("container-name-09", true)] // can have numbers
public void ContainerNames(string name, bool valid) {
Assert.Equal(valid, Container.TryParse(name, out var _));
}
}