Skip to content

Commit d2d3cdc

Browse files
shibayanCopilot
andauthored
Implement state management with Blob and File system storage (#1064)
* Implement state management with Blob and File system storage options * Update CertificateIssuanceOrchestrator to handle null CertificateId in order creation * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent e0dfb37 commit d2d3cdc

12 files changed

Lines changed: 202 additions & 50 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,6 @@ FodyWeavers.xsd
396396

397397
# JetBrains Rider
398398
*.sln.iml
399+
400+
# C# Dev Kit for Visual Studio Code
401+
*.csproj.lscache

deploy/azuredeploy.bicep

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ var workspaceName = 'log-${appNamePrefix}-${take(generatedToken, 4)}'
4747
var storageAccountName = 'st${generatedToken}func'
4848
var keyVaultName = 'kv-${appNamePrefix}-${take(generatedToken, 4)}'
4949
var deploymentStorageContainerName = 'app-package-${take(appNamePrefix, 32)}-${take(generatedToken, 7)}'
50+
var stateStorageContainerName = 'acmebot-state'
5051

5152
var roleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions/', 'a4417e6f-fecd-4de8-b567-7b0420556985')
5253

@@ -105,6 +106,13 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = {
105106
publicAccess: 'None'
106107
}
107108
}
109+
110+
resource stateContainer 'containers' = {
111+
name: stateStorageContainerName
112+
properties: {
113+
publicAccess: 'None'
114+
}
115+
}
108116
}
109117
}
110118

deploy/azuredeploy.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"_generator": {
66
"name": "bicep",
77
"version": "0.43.8.12551",
8-
"templateHash": "13592480369520105572"
8+
"templateHash": "5333924408175871037"
99
}
1010
},
1111
"parameters": {
@@ -89,6 +89,7 @@
8989
"storageAccountName": "[format('st{0}func', variables('generatedToken'))]",
9090
"keyVaultName": "[format('kv-{0}-{1}', parameters('appNamePrefix'), take(variables('generatedToken'), 4))]",
9191
"deploymentStorageContainerName": "[format('app-package-{0}-{1}', take(parameters('appNamePrefix'), 32), take(variables('generatedToken'), 7))]",
92+
"stateStorageContainerName": "acmebot-state",
9293
"roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions/', 'a4417e6f-fecd-4de8-b567-7b0420556985')]"
9394
},
9495
"resources": [
@@ -103,6 +104,17 @@
103104
"[resourceId('Microsoft.Storage/storageAccounts/blobServices', variables('storageAccountName'), 'default')]"
104105
]
105106
},
107+
{
108+
"type": "Microsoft.Storage/storageAccounts/blobServices/containers",
109+
"apiVersion": "2025-06-01",
110+
"name": "[format('{0}/{1}/{2}', variables('storageAccountName'), 'default', variables('stateStorageContainerName'))]",
111+
"properties": {
112+
"publicAccess": "None"
113+
},
114+
"dependsOn": [
115+
"[resourceId('Microsoft.Storage/storageAccounts/blobServices', variables('storageAccountName'), 'default')]"
116+
]
117+
},
106118
{
107119
"type": "Microsoft.Storage/storageAccounts/blobServices",
108120
"apiVersion": "2025-06-01",

deploy/azuredeploy_ui.bicep

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ var workspaceName = resourceNames.logAnalyticsWorkspaceName
8383
var storageAccountName = resourceNames.storageAccountName
8484
var newKeyVaultName = resourceNames.keyVaultName
8585
var deploymentStorageContainerName = 'app-package-${toLower(functionAppName)}'
86+
var stateStorageContainerName = 'acmebot-state'
8687

8788
var createKeyVault = keyVault.createNew
8889
var createLogAnalyticsWorkspace = monitoring.createLogAnalyticsWorkspace
@@ -331,6 +332,13 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = {
331332
publicAccess: 'None'
332333
}
333334
}
335+
336+
resource stateContainer 'containers' = {
337+
name: stateStorageContainerName
338+
properties: {
339+
publicAccess: 'None'
340+
}
341+
}
334342
}
335343
}
336344

deploy/azuredeploy_ui.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"_generator": {
77
"name": "bicep",
88
"version": "0.43.8.12551",
9-
"templateHash": "17724728527648616220"
9+
"templateHash": "17385215688447288281"
1010
}
1111
},
1212
"definitions": {
@@ -186,6 +186,7 @@
186186
"storageAccountName": "[parameters('resourceNames').storageAccountName]",
187187
"newKeyVaultName": "[parameters('resourceNames').keyVaultName]",
188188
"deploymentStorageContainerName": "[format('app-package-{0}', toLower(variables('functionAppName')))]",
189+
"stateStorageContainerName": "acmebot-state",
189190
"createKeyVault": "[parameters('keyVault').createNew]",
190191
"createLogAnalyticsWorkspace": "[parameters('monitoring').createLogAnalyticsWorkspace]",
191192
"useUserAssignedIdentity": "[equals(parameters('managedIdentity').type, 'UserAssigned')]",
@@ -239,6 +240,17 @@
239240
"storageAccount::blobServices"
240241
]
241242
},
243+
"storageAccount::blobServices::stateContainer": {
244+
"type": "Microsoft.Storage/storageAccounts/blobServices/containers",
245+
"apiVersion": "2025-06-01",
246+
"name": "[format('{0}/{1}/{2}', variables('storageAccountName'), 'default', variables('stateStorageContainerName'))]",
247+
"properties": {
248+
"publicAccess": "None"
249+
},
250+
"dependsOn": [
251+
"storageAccount::blobServices"
252+
]
253+
},
242254
"storageAccount::blobServices": {
243255
"type": "Microsoft.Storage/storageAccounts/blobServices",
244256
"apiVersion": "2025-06-01",

src/Acmebot.App/Acme/AcmeClientFactory.cs

Lines changed: 8 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
using System.Text.Json;
2-
3-
using Acmebot.Acme;
1+
using Acmebot.Acme;
42
using Acmebot.Acme.Models;
53
using Acmebot.App.Infrastructure;
64
using Acmebot.App.Options;
@@ -9,20 +7,14 @@
97

108
namespace Acmebot.App.Acme;
119

12-
public class AcmeClientFactory(IOptions<AcmebotOptions> options)
10+
public class AcmeClientFactory(IOptions<AcmebotOptions> options, IAcmeStateStore stateStore)
1311
{
1412
private readonly AcmebotOptions _options = options.Value;
1513

16-
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new()
17-
{
18-
PropertyNameCaseInsensitive = true,
19-
WriteIndented = true
20-
};
21-
2214
public async Task<AcmeClientContext> CreateClientAsync()
2315
{
24-
var account = LoadState<AccountDetails>("account.json");
25-
var accountKey = LoadState<AccountKey>("account_key.json");
16+
var account = await stateStore.LoadAsync<AccountDetails>("account.json");
17+
var accountKey = await stateStore.LoadAsync<AccountKey>("account_key.json");
2618
var contacts = GetContacts();
2719
var isNewAccountKey = false;
2820

@@ -66,12 +58,12 @@ public async Task<AcmeClientContext> CreateClientAsync()
6658
externalAccountBinding);
6759
account = AccountDetails.FromAccountHandle(accountHandle, directory.Metadata?.TermsOfService);
6860

69-
SaveState(account, "account.json");
70-
7161
if (isNewAccountKey)
7262
{
73-
SaveState(accountKey, "account_key.json");
63+
await stateStore.SaveAsync(accountKey, "account_key.json");
7464
}
65+
66+
await stateStore.SaveAsync(account, "account.json");
7567
}
7668
else
7769
{
@@ -88,7 +80,7 @@ public async Task<AcmeClientContext> CreateClientAsync()
8880
});
8981
account = AccountDetails.FromAccountHandle(accountHandle, directory.Metadata?.TermsOfService);
9082

91-
SaveState(account, "account.json");
83+
await stateStore.SaveAsync(account, "account.json");
9284
}
9385

9486
return new AcmeClientContext
@@ -117,35 +109,4 @@ public async Task<AcmeClientContext> CreateClientAsync()
117109

118110
private static bool ContactsEqual(IReadOnlyList<string>? actualContacts, IReadOnlyList<string> expectedContacts)
119111
=> actualContacts is not null && actualContacts.SequenceEqual(expectedContacts, StringComparer.Ordinal);
120-
121-
private TState? LoadState<TState>(string path)
122-
{
123-
var fullPath = ResolveStateFullPath(path);
124-
125-
if (!File.Exists(fullPath))
126-
{
127-
return default;
128-
}
129-
130-
var json = File.ReadAllText(fullPath);
131-
132-
return JsonSerializer.Deserialize<TState>(json, s_jsonSerializerOptions);
133-
}
134-
135-
private void SaveState<TState>(TState value, string path)
136-
{
137-
var fullPath = ResolveStateFullPath(path);
138-
var directoryPath = Path.GetDirectoryName(fullPath);
139-
140-
if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
141-
{
142-
Directory.CreateDirectory(directoryPath);
143-
}
144-
145-
var json = JsonSerializer.Serialize(value, s_jsonSerializerOptions);
146-
147-
File.WriteAllText(fullPath, json);
148-
}
149-
150-
private string ResolveStateFullPath(string path) => Environment.ExpandEnvironmentVariables($"%HOME%/data/.acmebot/{_options.Endpoint.Host}/{path}");
151112
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Text.Json;
2+
3+
using Acmebot.App.Options;
4+
5+
using Azure;
6+
using Azure.Storage.Blobs;
7+
using Azure.Storage.Blobs.Models;
8+
9+
using Microsoft.Extensions.Options;
10+
11+
namespace Acmebot.App.Acme;
12+
13+
internal sealed class BlobAcmeStateStore(BlobContainerClient containerClient, IOptions<AcmebotOptions> options) : IAcmeStateStore
14+
{
15+
private const string ContentType = "application/json";
16+
17+
private readonly Uri _endpoint = options.Value.Endpoint;
18+
19+
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new()
20+
{
21+
PropertyNameCaseInsensitive = true,
22+
WriteIndented = true
23+
};
24+
25+
public async Task<TState?> LoadAsync<TState>(string path, CancellationToken cancellationToken = default)
26+
{
27+
try
28+
{
29+
var blobClient = containerClient.GetBlobClient(CreateBlobName(path));
30+
var response = await blobClient.DownloadContentAsync(cancellationToken);
31+
32+
return response.Value.Content.ToObjectFromJson<TState>(s_jsonSerializerOptions);
33+
}
34+
catch (RequestFailedException ex) when (ex.Status == 404)
35+
{
36+
return default;
37+
}
38+
}
39+
40+
public async Task SaveAsync<TState>(TState value, string path, CancellationToken cancellationToken = default)
41+
{
42+
await containerClient.CreateIfNotExistsAsync(PublicAccessType.None, cancellationToken: cancellationToken);
43+
44+
var blobClient = containerClient.GetBlobClient(CreateBlobName(path));
45+
var content = BinaryData.FromObjectAsJson(value, s_jsonSerializerOptions);
46+
47+
await blobClient.UploadAsync(
48+
content,
49+
new BlobUploadOptions
50+
{
51+
HttpHeaders = new BlobHttpHeaders
52+
{
53+
ContentType = ContentType
54+
}
55+
},
56+
cancellationToken);
57+
}
58+
59+
private string CreateBlobName(string path) => $"{_endpoint.Host}/{path}";
60+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.Text.Json;
2+
3+
using Acmebot.App.Options;
4+
5+
using Microsoft.Extensions.Options;
6+
7+
namespace Acmebot.App.Acme;
8+
9+
internal sealed class FileSystemAcmeStateStore(IOptions<AcmebotOptions> options) : IAcmeStateStore
10+
{
11+
private readonly Uri _endpoint = options.Value.Endpoint;
12+
13+
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new()
14+
{
15+
PropertyNameCaseInsensitive = true,
16+
WriteIndented = true
17+
};
18+
19+
public async Task<TState?> LoadAsync<TState>(string path, CancellationToken cancellationToken = default)
20+
{
21+
var fullPath = ResolveStateFullPath(path);
22+
23+
if (!File.Exists(fullPath))
24+
{
25+
return default;
26+
}
27+
28+
await using var stream = File.OpenRead(fullPath);
29+
30+
return await JsonSerializer.DeserializeAsync<TState>(stream, s_jsonSerializerOptions, cancellationToken);
31+
}
32+
33+
public async Task SaveAsync<TState>(TState value, string path, CancellationToken cancellationToken = default)
34+
{
35+
var fullPath = ResolveStateFullPath(path);
36+
var directoryPath = Path.GetDirectoryName(fullPath);
37+
38+
if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
39+
{
40+
Directory.CreateDirectory(directoryPath);
41+
}
42+
43+
await using var stream = File.Create(fullPath);
44+
45+
await JsonSerializer.SerializeAsync(stream, value, s_jsonSerializerOptions, cancellationToken);
46+
}
47+
48+
private string ResolveStateFullPath(string path) => Environment.ExpandEnvironmentVariables($"%HOME%/data/.acmebot/{_endpoint.Host}/{path}");
49+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Acmebot.App.Acme;
2+
3+
public interface IAcmeStateStore
4+
{
5+
Task<TState?> LoadAsync<TState>(string path, CancellationToken cancellationToken = default);
6+
7+
Task SaveAsync<TState>(TState value, string path, CancellationToken cancellationToken = default);
8+
}

src/Acmebot.App/Acmebot.App.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<PackageReference Include="Azure.ResourceManager.PrivateDns" Version="1.2.2" />
2020
<PackageReference Include="Azure.Security.KeyVault.Certificates" Version="4.8.0" />
2121
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.10.0" />
22+
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
2223
<PackageReference Include="DnsClient" Version="1.8.0" />
2324
<PackageReference Include="Functions.Worker.Extensions.HttpApi" Version="3.0.5" />
2425
<PackageReference Include="Google.Apis.Dns.v1" Version="1.74.0.4128" />

0 commit comments

Comments
 (0)