Add Azure Blob startup profile and config sample

This commit is contained in:
Jacob Dubin
2026-05-17 08:01:04 -05:00
parent 478a320581
commit 05efeb2853
12 changed files with 211 additions and 6 deletions

View File

@@ -889,8 +889,8 @@ For `1.0.19`:
9. Durable memory persistence path (multi-tenant backing store) 9. Durable memory persistence path (multi-tenant backing store)
- reference design captured in `docs/persistence-architecture.md` - reference design captured in `docs/persistence-architecture.md`
- store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries - store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries
- the backend seam is now selectable, with file-backed local persistence as default and an Azure SQL slot wired for future deployment when credentials are available - the backend seam is now selectable, with file-backed local persistence as default and an Azure Blob Storage slot wired for future deployment when a storage account connection string is available
- next implementation pass should supply the real Azure SQL connection string / deployment wiring and validate the live round-trip in Azure - next implementation pass should supply the real Azure Storage connection string / deployment wiring and validate the live round-trip in the storage account smoke test
10. Update, backup, and restore proof 10. Update, backup, and restore proof
11. STT upgrade and noise screening 11. STT upgrade and noise screening
12. Hosted capture/storage plan / indexing for group testing 12. Hosted capture/storage plan / indexing for group testing

View File

@@ -95,7 +95,7 @@ The goal is to port these in small batches, capture the source-backed phrasing w
- seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change - seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change
- keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer - keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer
- the store seam now exposes revision metadata plus explicit load/save boundaries so durable adapters can drop in later without changing behavior code - the store seam now exposes revision metadata plus explicit load/save boundaries so durable adapters can drop in later without changing behavior code
- the backend seam is now selectable, with file-backed local persistence as the default and an Azure SQL slot wired for future deployment wiring - the backend seam is now selectable, with file-backed local persistence as the default and an Azure Blob Storage slot wired for future deployment wiring
### 6. Multi-Server Sync Path ### 6. Multi-Server Sync Path

View File

@@ -45,6 +45,47 @@ Human-facing entry points will live on domains such as:
Robot traffic may still arrive using legacy hostnames routed to the OpenJibo service. Robot traffic may still arrive using legacy hostnames routed to the OpenJibo service.
## Azure Storage Wiring Sample
For local or hosted Blob-backed persistence, use the Azure sample config in:
- [appsettings.AzureBlob.sample.json](dotnet/src/Jibo.Cloud.Api/appsettings.AzureBlob.sample.json)
It shows the expected keys for:
- `OpenJibo:State:Backend`
- `OpenJibo:State:ConnectionString`
- `OpenJibo:PersonalMemory:Backend`
- `OpenJibo:PersonalMemory:ConnectionString`
The connection string can also come from:
- `OPENJIBO_STATE_STORAGE_CONNECTION_STRING`
- `OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING`
For a real storage account, swap `UseDevelopmentStorage=true` with your Azure Storage connection string.
## Local Startup Note
To run the API with the Blob-backed sample config in Visual Studio or `dotnet run`, choose the
`Jibo.Cloud.Api.AzureBlob` launch profile.
The test project also has a matching `Jibo.Cloud.Tests.AzureBlob` profile so the smoke test can use
the same environment-variable shape when you run it from an IDE.
Equivalent environment variables:
```powershell
$env:OpenJibo__State__Backend = "AzureBlob"
$env:OpenJibo__State__ConnectionString = "UseDevelopmentStorage=true"
$env:OpenJibo__PersonalMemory__Backend = "AzureBlob"
$env:OpenJibo__PersonalMemory__ConnectionString = "UseDevelopmentStorage=true"
dotnet run --project dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj
```
Replace `UseDevelopmentStorage=true` with your real storage account connection string when you move
from local emulation to Azure.
## Recovery Strategy ## Recovery Strategy
The first supported device path is: The first supported device path is:

View File

@@ -7,6 +7,18 @@
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"applicationUrl": "https://localhost:24604;http://localhost:24605" "applicationUrl": "https://localhost:24604;http://localhost:24605"
},
"Jibo.Cloud.Api.AzureBlob": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"OpenJibo__State__Backend": "AzureBlob",
"OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__PersonalMemory__Backend": "AzureBlob",
"OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true"
},
"applicationUrl": "https://localhost:24604;http://localhost:24605"
} }
} }
} }

View File

@@ -0,0 +1,14 @@
{
"OpenJibo": {
"State": {
"Backend": "AzureBlob",
"ConnectionString": "UseDevelopmentStorage=true",
"PersistencePath": "App_Data/cloud-state.json"
},
"PersonalMemory": {
"Backend": "AzureBlob",
"ConnectionString": "UseDevelopmentStorage=true",
"PersistencePath": "App_Data/personal-memory.json"
}
}
}

View File

@@ -58,8 +58,12 @@ public static class ServiceCollectionExtensions
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json"); ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json");
var stateBackendKind = ParseBackendKind(configuration?["OpenJibo:State:Backend"]); var stateBackendKind = ParseBackendKind(configuration?["OpenJibo:State:Backend"]);
var personalMemoryBackendKind = ParseBackendKind(configuration?["OpenJibo:PersonalMemory:Backend"]); var personalMemoryBackendKind = ParseBackendKind(configuration?["OpenJibo:PersonalMemory:Backend"]);
var stateConnectionString = configuration?["OpenJibo:State:ConnectionString"] ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING"); var stateConnectionString = configuration?["OpenJibo:State:ConnectionString"]
var personalMemoryConnectionString = configuration?["OpenJibo:PersonalMemory:ConnectionString"] ?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING"); ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING");
var personalMemoryConnectionString = configuration?["OpenJibo:PersonalMemory:ConnectionString"]
?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING");
services.AddSingleton<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>(); services.AddSingleton<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>();
services.AddSingleton<ICloudStateStore>(provider => services.AddSingleton<ICloudStateStore>(provider =>
{ {

View File

@@ -25,6 +25,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.28.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,68 @@
using System.Text.Json;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
namespace Jibo.Cloud.Infrastructure.Persistence;
internal sealed class AzureBlobSnapshotStore : ISnapshotStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly BlobContainerClient _containerClient;
private readonly string _blobName;
public AzureBlobSnapshotStore(string connectionString, string snapshotName, string containerName = "openjibo-snapshots")
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("Azure Blob persistence requires a storage connection string.");
}
if (string.IsNullOrWhiteSpace(snapshotName))
{
throw new ArgumentException("A snapshot name is required for Azure Blob persistence.", nameof(snapshotName));
}
_containerClient = new BlobContainerClient(connectionString, string.IsNullOrWhiteSpace(containerName) ? "openjibo-snapshots" : containerName);
_blobName = $"{snapshotName}.json";
}
public TSnapshot? Load<TSnapshot>() where TSnapshot : class
{
try
{
if (!_containerClient.Exists())
{
return default;
}
var blobClient = _containerClient.GetBlobClient(_blobName);
if (!blobClient.Exists())
{
return default;
}
var content = blobClient.DownloadContent();
var json = content.Value.Content.ToString();
return string.IsNullOrWhiteSpace(json)
? default
: JsonSerializer.Deserialize<TSnapshot>(json, JsonOptions);
}
catch
{
return default;
}
}
public void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class
{
_containerClient.CreateIfNotExists(PublicAccessType.None);
var blobClient = _containerClient.GetBlobClient(_blobName);
var json = JsonSerializer.Serialize(snapshot, JsonOptions);
blobClient.Upload(BinaryData.FromString(json), overwrite: true);
}
}

View File

@@ -3,5 +3,6 @@ namespace Jibo.Cloud.Infrastructure.Persistence;
public enum PersistenceBackendKind public enum PersistenceBackendKind
{ {
File, File,
AzureBlob,
AzureSql AzureSql
} }

View File

@@ -15,6 +15,9 @@ public sealed class PersistenceSnapshotStoreFactory : IPersistenceSnapshotStoreF
return backendKind switch return backendKind switch
{ {
PersistenceBackendKind.File => new JsonFileSnapshotStore(persistencePath, JsonOptions), PersistenceBackendKind.File => new JsonFileSnapshotStore(persistencePath, JsonOptions),
PersistenceBackendKind.AzureBlob => new AzureBlobSnapshotStore(
connectionString ?? throw new InvalidOperationException("Azure Blob persistence requires a connection string."),
snapshotName),
PersistenceBackendKind.AzureSql => new AzureSqlSnapshotStore( PersistenceBackendKind.AzureSql => new AzureSqlSnapshotStore(
connectionString ?? throw new InvalidOperationException("Azure SQL persistence requires a connection string."), connectionString ?? throw new InvalidOperationException("Azure SQL persistence requires a connection string."),
snapshotName), snapshotName),

View File

@@ -0,0 +1,48 @@
using Jibo.Cloud.Infrastructure.Persistence;
namespace Jibo.Cloud.Tests.Infrastructure;
public sealed class AzureBlobPersistenceSmokeTests
{
[Fact]
public void AzureBlobSnapshotStore_RoundTripsState_WhenAzureBlobProfileIsConfigured()
{
var stateBackend = Environment.GetEnvironmentVariable("OpenJibo__State__Backend");
var stateConnectionString = Environment.GetEnvironmentVariable("OpenJibo__State__ConnectionString");
var personalMemoryBackend = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__Backend");
var personalMemoryConnectionString = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__ConnectionString");
if (!string.Equals(stateBackend, "AzureBlob", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(stateConnectionString))
{
return;
}
var factory = new PersistenceSnapshotStoreFactory();
var snapshotName = $"smoke-{Guid.NewGuid():N}";
var store = factory.Create(null, PersistenceBackendKind.AzureBlob, snapshotName, stateConnectionString);
var payload = new SmokeSnapshot
{
Name = "azure-smoke",
Value = "round-trip"
};
store.Save(payload);
var loaded = store.Load<SmokeSnapshot>();
Assert.NotNull(loaded);
Assert.Equal(payload.Name, loaded!.Name);
Assert.Equal(payload.Value, loaded.Value);
Assert.Equal("AzureBlob", stateBackend);
Assert.Equal("AzureBlob", personalMemoryBackend);
Assert.False(string.IsNullOrWhiteSpace(personalMemoryConnectionString));
}
private sealed class SmokeSnapshot
{
public string Name { get; init; } = string.Empty;
public string Value { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,13 @@
{
"profiles": {
"Jibo.Cloud.Tests.AzureBlob": {
"commandName": "Project",
"environmentVariables": {
"OpenJibo__State__Backend": "AzureBlob",
"OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__PersonalMemory__Backend": "AzureBlob",
"OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true"
}
}
}
}