diff --git a/DockerSdk.Tests/VolumeAccessTests.cs b/DockerSdk.Tests/VolumeAccessTests.cs new file mode 100644 index 0000000..93b414b --- /dev/null +++ b/DockerSdk.Tests/VolumeAccessTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using DockerSdk.Volumes; +using FluentAssertions; +using Xunit; + +namespace DockerSdk.Tests +{ + [Collection("Common")] + public class VolumeAccessTests + { + [Fact] + public async Task GetDetailsAsync_LocalVolumeExists_GetsExpectedDetails() + { + using var client = await DockerClient.StartAsync(); + + var volume = await client.Volumes.GetDetailsAsync("ddnt-general"); + + volume.Should().NotBeNull(); + volume.CreationTime.Should().BeAfter(DateTimeOffset.Parse("2020-1-1")); + volume.Driver.Should().Be("local"); + volume.Labels["ddnt1"].Should().Be("alef"); + volume.Labels["ddnt2"].Should().Be("beth"); + volume.Mountpoint.Should().NotBeNullOrWhiteSpace(); + volume.Name.ToString().Should().Be("ddnt-general"); + volume.Scope.Should().Be(VolumeScope.Local); + } + + [Fact] + public async Task GetDetailsAsync_NoSuchVolume_ThrowsVolumeNotFoundException() + { + using var client = await DockerClient.StartAsync(); + + await Assert.ThrowsAnyAsync( + () => client.Volumes.GetDetailsAsync("ddnt-no-such-volume-366ad733152b70e53ddd7fd59defe9fa2e055ed2090f5f3a8839b2797388d0b4")); + } + + [Fact] + public async Task ListAsync_NoFilters_FindsPremadeVolumes() + { + using var client = await DockerClient.StartAsync(); + + var volumes = await client.Volumes.ListAsync(); + + volumes.Should().ContainSingle(v => v.Name.ToString() == "ddnt-general"); + } + } +} diff --git a/DockerSdk.Tests/scripts/clean.ps1 b/DockerSdk.Tests/scripts/clean.ps1 index 89b294a..c50f775 100644 --- a/DockerSdk.Tests/scripts/clean.ps1 +++ b/DockerSdk.Tests/scripts/clean.ps1 @@ -25,6 +25,12 @@ foreach ($id in docker network ls --filter="name=ddnt" --quiet --no-trunc) docker network rm $id } +# Remove volumes. +foreach ($id in docker volume ls --filter="name=ddnt" --quiet) +{ + docker volume rm $id +} + # Remove images in reverse order of how they were created. $tags = $imageDefinitions.Name $tags = [System.Linq.Enumerable]::Reverse([string[]] $tags) diff --git a/DockerSdk.Tests/scripts/definitions.ps1 b/DockerSdk.Tests/scripts/definitions.ps1 index 279a2c8..255033c 100644 --- a/DockerSdk.Tests/scripts/definitions.ps1 +++ b/DockerSdk.Tests/scripts/definitions.ps1 @@ -39,6 +39,14 @@ $networkDefinitions = @( } ) +# Defines the volumes to build. +$volumeDefinitions = @( + @{ + name = 'general' + args = '--label ddnt1=alef --label ddnt2=beth' + } +) + # Defines the containers to start. If the image is not specified, it defaults to the name. # It's intentional that error-out immediately fails and that some other containers immediately run to completion. $containerDefinitions = @( diff --git a/DockerSdk.Tests/scripts/up.ps1 b/DockerSdk.Tests/scripts/up.ps1 index b7746ee..f37b223 100644 --- a/DockerSdk.Tests/scripts/up.ps1 +++ b/DockerSdk.Tests/scripts/up.ps1 @@ -25,6 +25,14 @@ function VerifySetup() } } + foreach ($name in $volumeDefinitions.Name) + { + if (!(VerifyVolume $name)) + { + return $false + } + } + foreach ($name in $imageDefinitions.Name) { if (!(VerifyImage $name)) @@ -88,6 +96,18 @@ function VerifyNetwork($name) return $true } +function VerifyVolume($name) +{ + $dockerId = docker volume ls --filter=name=ddnt-$name --quiet + if (!$dockerId) + { + # Setup hasn't been run, or someone has deleted the volume. Clean and rebuild. + return $false + } + + return $true +} + function VerifyImage($name) { $path = "$name/image.id" @@ -135,6 +155,14 @@ foreach ($entry in $imageDefinitions) Pop-Location } +# Create the volumes. +foreach ($entry in $volumeDefinitions) +{ + $name = $entry.Name + $args = $entry['Args'] ?? '' + Invoke-Expression "docker volume create $args ddnt-$name" +} + # Create the networks. foreach ($entry in $networkDefinitions) { diff --git a/DockerSdk/DockerClient.cs b/DockerSdk/DockerClient.cs index 40ae15a..b923593 100644 --- a/DockerSdk/DockerClient.cs +++ b/DockerSdk/DockerClient.cs @@ -9,6 +9,7 @@ using DockerSdk.Events; using DockerSdk.Images; using DockerSdk.Registries; +using DockerSdk.Volumes; using NetworkAccess = DockerSdk.Networks.NetworkAccess; using Version = System.Version; @@ -30,6 +31,7 @@ private DockerClient(Comm core, ClientOptions options, Version negotiatedApiVers Images = new ImageAccess(this); Networks = new NetworkAccess(this); Registries = new RegistryAccess(this); + Volumes = new VolumeAccess(this); } /// @@ -58,6 +60,11 @@ private DockerClient(Comm core, ClientOptions options, Version negotiatedApiVers /// public RegistryAccess Registries { get; } + /// + /// Provides access to functionality related to Docker volumes. + /// + public VolumeAccess Volumes { get; } + /// /// Gets the core client, which is what does all the heavy lifting for communicating with the Docker daemon. /// diff --git a/DockerSdk/JsonConverters/VolumeScopeConverter.cs b/DockerSdk/JsonConverters/VolumeScopeConverter.cs new file mode 100644 index 0000000..8d0cb8c --- /dev/null +++ b/DockerSdk/JsonConverters/VolumeScopeConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using DockerSdk.Volumes; + +namespace DockerSdk.JsonConverters +{ + internal class VolumeScopeConverter : JsonConverter + { + public override VolumeScope Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var name = reader.GetString(); + return name switch + { + "local" => VolumeScope.Local, + "global" => VolumeScope.Global, + _ => throw new JsonException($"Unexpected volume scope \"{name}\".") + }; + } + + public override void Write(Utf8JsonWriter writer, VolumeScope value, JsonSerializerOptions options) + { + var name = value switch + { + VolumeScope.Global => "global", + VolumeScope.Local => "local", + _ => throw new JsonException($"Unexpected volume scope \"{value}\".") + }; + writer.WriteStringValue(name); + } + } +} diff --git a/DockerSdk/RequestBuilder.cs b/DockerSdk/RequestBuilder.cs index d6580f9..c199917 100644 --- a/DockerSdk/RequestBuilder.cs +++ b/DockerSdk/RequestBuilder.cs @@ -17,6 +17,7 @@ using DockerSdk.Networks; using DockerSdk.Registries; using DockerSdk.Registries.Dto; +using DockerSdk.Volumes; namespace DockerSdk { @@ -391,6 +392,10 @@ private async Task ThrowAsync(HttpResponseMessage response) if (match.Success) throw new NetworkNotFoundException($"No network with name or ID \"{match.Groups[1].Value}\" exists."); + match = Regex.Match(error, "^get (.*): no such volume$"); + if (match.Success) + throw new VolumeNotFoundException($"No volume with name \"{match.Groups[1].Value}\" exists."); + throw CreateResourceNotFoundException(error); } diff --git a/DockerSdk/Volumes/Dto/ListResponse.cs b/DockerSdk/Volumes/Dto/ListResponse.cs new file mode 100644 index 0000000..e401f68 --- /dev/null +++ b/DockerSdk/Volumes/Dto/ListResponse.cs @@ -0,0 +1,15 @@ +namespace DockerSdk.Volumes.Dto +{ + internal class ListResponse + { + /// + /// List of volumes. + /// + public VolumeResponse[] Volumes { get; set; } = null!; + + /// + /// Warnings that occurred when fetching the list of volumes. + /// + public string[] Warnings { get; set; } = null!; + } +} diff --git a/DockerSdk/Volumes/Dto/UsageData.cs b/DockerSdk/Volumes/Dto/UsageData.cs new file mode 100644 index 0000000..14ff6f8 --- /dev/null +++ b/DockerSdk/Volumes/Dto/UsageData.cs @@ -0,0 +1,22 @@ +namespace DockerSdk.Volumes.Dto +{ + /// + /// Usage details about the volume. This information is used by the GET /system/df endpoint, and omitted in other + /// endpoints. + /// + internal class UsageData + { + /// + /// The number of containers referencing this volume. This field is set to -1 if the reference-count is not + /// available. + /// + public int RefCount { get; set; } = -1; + + /// + /// Amount of disk space used by the volume (in bytes). This information is only available for volumes created + /// with the "local" volume driver. For volumes created with other volume drivers, this field is set to -1 ("not + /// available") + /// + public long Size { get; set; } = -1; + } +} diff --git a/DockerSdk/Volumes/Dto/VolumeResponse.cs b/DockerSdk/Volumes/Dto/VolumeResponse.cs new file mode 100644 index 0000000..c877854 --- /dev/null +++ b/DockerSdk/Volumes/Dto/VolumeResponse.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace DockerSdk.Volumes.Dto +{ + internal class VolumeResponse + { + /// + /// Date and time of when the volume was created. + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Name of the volume driver used by the volume. + /// + public string Driver { get; set; } = null!; + + /// + /// User-defined key/value metadata. + /// + public Dictionary Labels { get; set; } = null!; + + /// + /// Mount path of the volume on the host. + /// + public string Mountpoint { get; set; } = null!; + + /// + /// Name of the volume. + /// + public string Name { get; set; } = null!; + + /// + /// The driver specific options used when creating the volume. + /// + public Dictionary Options { get; set; } = null!; + + /// + /// The level at which the volume exists. Either global for cluster-wide, or local for machine level. + /// + public VolumeScope Scope { get; set; } + + /// + /// Low-level details about the volume, provided by the volume driver. This value is omitted if the volume + /// driver does not support this feature. + /// + public Dictionary? Status { get; set; } + + /// + /// Usage details about the volume. + /// + public UsageData? UsageData { get; set; } + } +} diff --git a/DockerSdk/Volumes/IVolume.cs b/DockerSdk/Volumes/IVolume.cs new file mode 100644 index 0000000..b2a3817 --- /dev/null +++ b/DockerSdk/Volumes/IVolume.cs @@ -0,0 +1,30 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace DockerSdk.Volumes +{ + /// + /// Represents a Docker volume, which is a persistent filesystem mount used by containers. + /// + /// + public interface IVolume + { + /// + /// Gets the volume's name. + /// + VolumeName Name { get; } + + /// + /// Gets detailed information about the volume. + /// + /// A used to cancel the operation. + /// A that completes when the result is available. + /// The volume no longer exists. + /// + /// The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate + /// validation, or timeout. + /// + Task GetDetailsAsync(CancellationToken ct = default); + + } +} diff --git a/DockerSdk/Volumes/IVolumeInfo.cs b/DockerSdk/Volumes/IVolumeInfo.cs new file mode 100644 index 0000000..d933bed --- /dev/null +++ b/DockerSdk/Volumes/IVolumeInfo.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace DockerSdk.Volumes +{ + /// + /// Provides detailed information about a volume. + /// + /// This class holds a snapshot in time. Its information is immutable once created. + public interface IVolumeInfo : IVolume + { + /// + /// Gets the time at which the volume was created. + /// + DateTimeOffset CreationTime { get; } + + /// + /// Gets the name of the storage driver that operates the volume. + /// + string Driver { get; } + + /// + /// Gets the labels that have been applied to the volume. + /// + IReadOnlyDictionary Labels { get; } + + /// + /// Gets the absolute path of where the volume's data is stored on the host. + /// + string Mountpoint { get; } + + /// + /// Gets the level at which the volume exists: either Global for cluster-wide or Local for machine-level. + /// + VolumeScope Scope { get; } + + // TODO: Options + } +} diff --git a/DockerSdk/Volumes/ListVolumesOptions.cs b/DockerSdk/Volumes/ListVolumesOptions.cs new file mode 100644 index 0000000..63492a4 --- /dev/null +++ b/DockerSdk/Volumes/ListVolumesOptions.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DockerSdk.Volumes +{ + /// + /// Specifies how to list volumes. + /// + public class ListVolumesOptions + { + /// + /// Gets or sets a filter for dangling volumes. False means to return only volumes in use by one or more + /// containers; true means to only return volumes that are not in use by containers; and null means not to + /// filter by whether the volume is dangling. + /// + public bool? DanglingVolumesFilter { get; set; } + + /// + /// If set, the query will only return volumes that use the specified driver. + /// + public string? DriverFilter { get; set; } + + /// + /// Gets a list of labels to filter by. Only volumes that have all of the given labels will be returned. + /// + /// + /// If both this setting and are set, they are combined with "or" logic. + /// + public List LabelExistsFilters { get; } = new(); + + /// + /// Gets a set of label-value pairs to filter by. Only volumes that have all of the given labels set to the given + /// values will be returned. + /// + /// + /// If both this setting and are set, they are combined with "or" logic. + /// + public Dictionary LabelValueFilters { get; } = new(); + + /// + /// If set, the query will only return volumes whose names contain the given text. + /// + public string? NameContainsFilter { get; set; } + + internal string ToQueryString() + { + var dangling = DanglingVolumesFilter switch + { + true => "true", + false => "false", + null => null + }; + + var labels = LabelValueFilters.Select(kvp => $"{kvp.Key}={kvp.Value}").Concat(LabelExistsFilters); + + var filters = new QueryStringBuilder.StringStringBool(); + filters.Set("dangling", dangling); + filters.Set("driver", DriverFilter); + filters.Set("label", labels); + filters.Set("name", NameContainsFilter); + + var builder = new QueryStringBuilder(); + builder.Set("filters", filters); + return builder.Build(); + } + } +} diff --git a/DockerSdk/Volumes/Volume.cs b/DockerSdk/Volumes/Volume.cs new file mode 100644 index 0000000..03e3680 --- /dev/null +++ b/DockerSdk/Volumes/Volume.cs @@ -0,0 +1,29 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace DockerSdk.Volumes +{ + /// + internal class Volume : IVolume + { + protected DockerClient client; + + /// + /// Initializes a new instance of the type. + /// + /// The instance to use. + /// The volume's name. + internal Volume(DockerClient client, VolumeName name) + { + this.client = client; + Name = name; + } + + /// + public VolumeName Name { get; } + + /// + public Task GetDetailsAsync(CancellationToken ct = default) + => VolumeFactory.LoadInfoAsync(client, Name, ct); + } +} diff --git a/DockerSdk/Volumes/VolumeAccess.cs b/DockerSdk/Volumes/VolumeAccess.cs new file mode 100644 index 0000000..7539906 --- /dev/null +++ b/DockerSdk/Volumes/VolumeAccess.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using DockerSdk.Volumes.Dto; + +namespace DockerSdk.Volumes +{ + /// + /// Provides methods for interacting with Docker networks. + /// + public class VolumeAccess + { + internal VolumeAccess(DockerClient dockerClient) + { + client = dockerClient; + } + + private readonly DockerClient client; + + /// + /// Loads an object that can be used to interact with the indicated volume. + /// + /// A volume name. + /// A token used to cancel the operation. + /// A that resolves to the volume object. + /// No such network exists. + /// + /// The request failed due to an underlying issue such as loss of network connectivity. + /// + /// The volume reference is improperly formatted. + /// The input is null. + public Task GetAsync(string volume, CancellationToken ct = default) + => GetAsync(VolumeName.Parse(volume), ct); + + /// + /// Loads an object that can be used to interact with the indicated volume. + /// + /// A volume name. + /// A token used to cancel the operation. + /// A that resolves to the volume object. + /// No such network exists. + /// + /// The request failed due to an underlying issue such as loss of network connectivity. + /// + /// The input is null. + public Task GetAsync(VolumeName volume, CancellationToken ct = default) + { + if (volume is null) + throw new ArgumentNullException(nameof(volume)); + + return VolumeFactory.LoadAsync(client, volume, ct); + } + + /// + /// Loads detailed information about a volume. + /// + /// A volume name. + /// A token used to cancel the operation. + /// A that resolves to the details. + /// No such volume exists. + /// + /// The request failed due to an underlying issue such as network connectivity. + /// + /// The volume reference is improperly formatted. + public Task GetDetailsAsync(string volume, CancellationToken ct = default) + => GetDetailsAsync(VolumeName.Parse(volume), ct); + + /// + /// Loads detailed information about a volume. + /// + /// A volume name. + /// A token used to cancel the operation. + /// A that resolves to the details. + /// No such volume exists. + /// + /// The request failed due to an underlying issue such as network connectivity. + /// + public Task GetDetailsAsync(VolumeName volume, CancellationToken ct = default) + => VolumeFactory.LoadInfoAsync(client, volume, ct); + + /// + /// Gets a list of Docker volumes known to the daemon. + /// + /// A token used to cancel the operation. + /// A that resolves to the list of volumes. + /// The sequence of the results is undefined. + public Task> ListAsync(CancellationToken ct = default) + => ListAsync(new(), ct); + + /// + /// Gets a list of Docker volumes known to the daemon. + /// + /// Filters for which volumes to return. + /// A token used to cancel the operation. + /// A that resolves to the list of volumes. + /// The sequence of the results is undefined. + public async Task> ListAsync(ListVolumesOptions options, CancellationToken ct = default) + { + ListResponse response = await client.BuildRequest(HttpMethod.Get, "volumes") + .WithParameters(options.ToQueryString()) + .SendAsync(ct) + .ConfigureAwait(false); + + return response.Volumes.Select(raw => new Volume(client, new VolumeName(raw.Name))).ToArray(); + } + } +} diff --git a/DockerSdk/Volumes/VolumeFactory.cs b/DockerSdk/Volumes/VolumeFactory.cs new file mode 100644 index 0000000..682a014 --- /dev/null +++ b/DockerSdk/Volumes/VolumeFactory.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Immutable; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using DockerSdk.Volumes.Dto; + +namespace DockerSdk.Volumes +{ + internal static class VolumeFactory + { + internal static Task LoadAsync(DockerClient client, VolumeName name, CancellationToken _) + { + if (name is null) + throw new ArgumentNullException(nameof(name)); + + IVolume volume = new Volume(client, name); + return Task.FromResult(volume); + } + + internal static async Task LoadInfoAsync(DockerClient client, VolumeName name, CancellationToken ct) + { + if (name is null) + throw new ArgumentNullException(nameof(name)); + + var raw = await LoadCoreAsync(client, name, ct).ConfigureAwait(false); + + return new VolumeInfo(client, name) + { + CreationTime = raw.CreatedAt, + Driver = raw.Driver, + Labels = raw.Labels?.ToImmutableDictionary() ?? ImmutableDictionary.Create(), + Mountpoint = raw.Mountpoint, + Scope = raw.Scope, + }; + } + + private static Task LoadCoreAsync(DockerClient client, VolumeName name, CancellationToken ct) + { + return client.BuildRequest(HttpMethod.Get, $"volumes/{name}") + .RejectStatus(HttpStatusCode.NotFound, _ => new VolumeNotFoundException($"No volume with name \"{name}\" exists.")) + .SendAsync(ct); + } + } +} diff --git a/DockerSdk/Volumes/VolumeInfo.cs b/DockerSdk/Volumes/VolumeInfo.cs new file mode 100644 index 0000000..ca310ea --- /dev/null +++ b/DockerSdk/Volumes/VolumeInfo.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace DockerSdk.Volumes +{ + /// + internal class VolumeInfo : Volume, IVolumeInfo + { + /// + /// Initializes a new instance of the type. + /// + /// The instance to use. + /// The volume's name. + public VolumeInfo(DockerClient client, VolumeName name) + : base(client, name) + { } + + /// + public DateTimeOffset CreationTime { get; init; } + + /// + public string Driver { get; init; } = null!; + + /// + public IReadOnlyDictionary Labels { get; init; } = ImmutableDictionary.Empty; + + /// + public string Mountpoint { get; init; } = null!; + + /// + public VolumeScope Scope { get; init; } + } +} diff --git a/DockerSdk/Volumes/VolumeName.cs b/DockerSdk/Volumes/VolumeName.cs new file mode 100644 index 0000000..5bbcee6 --- /dev/null +++ b/DockerSdk/Volumes/VolumeName.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace DockerSdk.Volumes +{ + /// + /// Represents the name of a Docker volume. + /// + public class VolumeName + { + /// + /// The full text of the reference. + /// + protected readonly string value; + + internal VolumeName(string value) + => this.value = value; + + /// + /// Lets the reference be implicitly cast to a string. + /// + /// + public static implicit operator string(VolumeName reference) + => reference.ToString(); + + /// + public override string ToString() => value; + + /// + /// Parses the input as a Docker volume name. + /// + /// The text to parse. + /// The reference. + /// + /// The input is not a validly-formatted volume reference. + /// + internal static VolumeName Parse(string input) + { + if (input is null) + throw new ArgumentNullException(nameof(input)); + if (TryParse(input, out var reference)) + return reference; + throw new MalformedReferenceException($"\"{input}\" is not a valid Docker volume name."); + } + + /// + /// Tries to parse the input as a Docker volume name. + /// + /// The text to parse. + /// The reference, or null if parsing failed. + /// True if parsing succeeded; false otherwise. + /// is null or empty. + public static bool TryParse(string input, [NotNullWhen(returnValue: true)] out VolumeName? reference) + { + // TODO: are there any limits here? + reference = new VolumeName(input); + return true; + } + } +} diff --git a/DockerSdk/Volumes/VolumeNotFoundException.cs b/DockerSdk/Volumes/VolumeNotFoundException.cs new file mode 100644 index 0000000..a804bfd --- /dev/null +++ b/DockerSdk/Volumes/VolumeNotFoundException.cs @@ -0,0 +1,44 @@ +using System; + +namespace DockerSdk.Volumes +{ + /// + /// Indicates that the Docker daemon could not find the indicated volume where it was looking. + /// + [Serializable] + public class VolumeNotFoundException : ResourceNotFoundException + { + /// + /// Creates an instance of the class. + /// + public VolumeNotFoundException() + { + } + + /// + /// Creates an instance of the class. + /// + /// + public VolumeNotFoundException(string message) : base(message) + { + } + + /// + /// Creates an instance of the class. + /// + /// + /// + public VolumeNotFoundException(string message, Exception inner) : base(message, inner) + { + } + + /// + /// Creates an instance of the class. + /// + /// + /// + protected VolumeNotFoundException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/DockerSdk/Volumes/VolumeScope.cs b/DockerSdk/Volumes/VolumeScope.cs new file mode 100644 index 0000000..e207fa6 --- /dev/null +++ b/DockerSdk/Volumes/VolumeScope.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using DockerSdk.JsonConverters; + +namespace DockerSdk.Volumes +{ + /// + /// Indicates the level at which a volume exists. + /// + [JsonConverter(typeof(VolumeScopeConverter))] + public enum VolumeScope + { + /// + /// The volume exists on the local machine only. + /// + Local, + + /// + /// The volume exists at the cluster level. + /// + Global, + } +}