Add folder support to Azure blob storage commands

This change adds a --folder parameter to both 'vpk upload az' and 'vpk download az' commands, allowing users to organize releases in subdirectories within their Azure blob containers.

Key changes:
- Added Folder property to AzureUploadOptions and AzureDownloadOptions
- Modified AzureRepository to prepend folder paths to all blob operations
- Updated upload, download, and delete operations to work with folder paths
- Retention policy (--keepMaxReleases) correctly handles files in folders
- Release index and legacy release files are stored in the specified folder

This enables multiple applications or environments to share a single Azure container by using folders as isolated namespaces. For example:
- vpk upload az --folder "releases/v1" ...
- vpk download az --folder "releases/v1" ...

The implementation is backward compatible - existing deployments without folders continue to work as before.
This commit is contained in:
azegallo
2025-07-25 23:24:16 -04:00
committed by Caelan
parent 09f9df1d11
commit 095567789a
5 changed files with 177 additions and 11 deletions

View File

@@ -1,4 +1,5 @@
using Azure;
using System.Text;
using Azure;
using Azure.Storage;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
@@ -19,6 +20,8 @@ public class AzureDownloadOptions : RepositoryOptions, IObjectDownloadOptions
public string Container { get; set; }
public string SasToken { get; set; }
public string Folder { get; set; }
}
public class AzureUploadOptions : AzureDownloadOptions, IObjectUploadOptions
@@ -56,6 +59,11 @@ public class AzureRepository : ObjectRepository<AzureDownloadOptions, AzureUploa
protected override async Task DeleteObject(BlobContainerClient client, string key)
{
// Prepend folder path if specified (using _uploadFolder since this is called during upload)
if (!string.IsNullOrEmpty(_uploadFolder)) {
key = _uploadFolder.TrimEnd('/') + "/" + key;
}
await RetryAsync(
async () => {
await client.DeleteBlobIfExistsAsync(key);
@@ -79,19 +87,62 @@ public class AzureRepository : ObjectRepository<AzureDownloadOptions, AzureUploa
$"Downloading {key}...");
}
protected override async Task<VelopackAssetFeed> GetReleasesAsync(AzureDownloadOptions options)
{
var releasesName = CoreUtil.GetVeloReleaseIndexName(options.Channel);
// Prepend folder path if specified
if (!string.IsNullOrEmpty(options.Folder)) {
releasesName = options.Folder.TrimEnd('/') + "/" + releasesName;
}
var client = CreateClient(options);
var bytes = await GetObjectBytes(client, releasesName);
if (bytes == null || bytes.Length == 0) {
return new VelopackAssetFeed();
}
return VelopackAssetFeed.FromJson(Encoding.UTF8.GetString(bytes));
}
protected override async Task SaveEntryToFileAsync(AzureDownloadOptions options, VelopackAsset entry, string filePath)
{
await RetryAsync(
async () => {
var client = CreateClient(options);
var obj = client.GetBlobClient(entry.FileName);
var key = entry.FileName;
// Prepend folder path if specified
if (!string.IsNullOrEmpty(options.Folder)) {
key = options.Folder.TrimEnd('/') + "/" + key;
}
var obj = client.GetBlobClient(key);
using var response = await obj.DownloadToAsync(filePath, CancellationToken.None);
},
$"Downloading {entry.FileName}...");
}
public override async Task UploadMissingAssetsAsync(AzureUploadOptions options)
{
// Store the folder in a private field for use in UploadObject
// Note: Azure Blob Storage will handle path validation and reject invalid paths
_uploadFolder = options.Folder;
try {
await base.UploadMissingAssetsAsync(options);
} finally {
_uploadFolder = null;
}
}
private string _uploadFolder;
protected override async Task UploadObject(BlobContainerClient client, string key, FileInfo f, bool overwriteRemote, bool noCache)
{
// Prepend folder path if specified
if (!string.IsNullOrEmpty(_uploadFolder)) {
key = _uploadFolder.TrimEnd('/') + "/" + key;
}
var blobClient = client.GetBlobClient(key);
try {
var properties = await blobClient.GetPropertiesAsync();

View File

@@ -2,8 +2,13 @@
public class AzureDownloadCommand : AzureBaseCommand
{
public string Folder { get; private set; }
public AzureDownloadCommand()
: base("az", "Download latest release from an Azure Blob Storage container.")
{
AddOption<string>((x) => Folder = x, "--folder")
.SetDescription("The folder path within the container where files are stored.")
.SetArgumentHelpName("PATH");
}
}

View File

@@ -3,6 +3,8 @@
public class AzureUploadCommand : AzureBaseCommand
{
public int KeepMaxReleases { get; private set; }
public string Folder { get; private set; }
public AzureUploadCommand()
: base("az", "Upload releases to an Azure Blob Storage container.")
@@ -11,6 +13,10 @@ public class AzureUploadCommand : AzureBaseCommand
.SetDescription("The maximum number of releases to keep in the container, anything older will be deleted.")
.SetArgumentHelpName("COUNT");
AddOption<string>((x) => Folder = x, "--folder")
.SetDescription("The folder path within the container where files should be uploaded.")
.SetArgumentHelpName("PATH");
ReleaseDirectoryOption.SetRequired();
ReleaseDirectoryOption.MustNotBeEmpty();
}