feat(secrets-cli): target config reader — operate on the app's own composed Secrets config
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>A resolved deployment store target: the app's composed Secrets options + where they came from.</summary>
|
||||||
|
/// <param name="Name">Display name (defaults to the appsettings parent directory name).</param>
|
||||||
|
/// <param name="AppSettingsPath">The base <c>appsettings.json</c> path, or <see langword="null"/> for manual-entry targets.</param>
|
||||||
|
/// <param name="Secrets">The bound application secrets options, with <see cref="SecretsOptions.SqlitePath"/> already made absolute.</param>
|
||||||
|
/// <param name="SqlServer">The bound SQL-Server hub options when a <c>Secrets:SqlServer</c> section is present; otherwise <see langword="null"/>.</param>
|
||||||
|
/// <param name="Configuration">The full composed configuration, consumed later by the reference auditor.</param>
|
||||||
|
public sealed record StoreTarget(
|
||||||
|
string Name,
|
||||||
|
string? AppSettingsPath,
|
||||||
|
SecretsOptions Secrets,
|
||||||
|
SqlServerSecretsOptions? SqlServer,
|
||||||
|
IConfigurationRoot Configuration);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.Secrets.MasterKey;
|
||||||
|
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes a deployment target's Secrets configuration the same way the target app would at
|
||||||
|
/// startup — base <c>appsettings.json</c>, optional per-environment overlay, and environment
|
||||||
|
/// variables — so the interactive console operates on exactly the store the app reads.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TargetConfigReader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the target app's config exactly the way the app would (base json + optional environment
|
||||||
|
/// overlay + environment variables) and binds the Secrets options out of it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="appSettingsPath">Absolute or relative path to the target app's base <c>appsettings.json</c>.</param>
|
||||||
|
/// <param name="environment">
|
||||||
|
/// Optional ASP.NET environment name; when non-empty an <c>appsettings.{environment}.json</c>
|
||||||
|
/// overlay is layered on top of the base file (optional — absent overlays are ignored).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="sectionPath">The configuration section the Secrets options bind from. Defaults to <c>Secrets</c>.</param>
|
||||||
|
/// <returns>The resolved <see cref="StoreTarget"/>, with the SQLite path made absolute against the app directory.</returns>
|
||||||
|
/// <exception cref="FileNotFoundException">The base <paramref name="appSettingsPath"/> does not exist.</exception>
|
||||||
|
public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets")
|
||||||
|
{
|
||||||
|
string fullPath = Path.GetFullPath(appSettingsPath);
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"Target appsettings file not found: {fullPath}", fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string dir = Path.GetDirectoryName(fullPath)!;
|
||||||
|
string fileName = Path.GetFileName(fullPath);
|
||||||
|
|
||||||
|
ConfigurationBuilder builder = new();
|
||||||
|
builder.SetBasePath(dir)
|
||||||
|
.AddJsonFile(fileName, optional: false);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(environment))
|
||||||
|
{
|
||||||
|
builder.AddJsonFile($"appsettings.{environment}.json", optional: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AddEnvironmentVariables();
|
||||||
|
|
||||||
|
IConfigurationRoot config = builder.Build();
|
||||||
|
|
||||||
|
SecretsOptions secrets = config.GetSection(sectionPath).Get<SecretsOptions>() ?? new SecretsOptions();
|
||||||
|
secrets.SqlitePath = Path.GetFullPath(secrets.SqlitePath, dir);
|
||||||
|
|
||||||
|
SqlServerSecretsOptions? sqlServer = null;
|
||||||
|
IConfigurationSection sqlSection = config.GetSection(sectionPath + ":SqlServer");
|
||||||
|
if (sqlSection.GetChildren().Any())
|
||||||
|
{
|
||||||
|
sqlServer = sqlSection.Get<SqlServerSecretsOptions>();
|
||||||
|
}
|
||||||
|
|
||||||
|
string name = new DirectoryInfo(dir).Name;
|
||||||
|
|
||||||
|
return new StoreTarget(name, fullPath, secrets, sqlServer, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a manual target from an explicit db path + master-key options, with no backing
|
||||||
|
/// appsettings file (for ad-hoc stores the console operates on directly).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sqlitePath">Path to the SQLite store; made absolute against the current directory.</param>
|
||||||
|
/// <param name="masterKey">The master-key resolution options for this manual store.</param>
|
||||||
|
/// <returns>A <see cref="StoreTarget"/> named <c>manual</c> with an empty composed configuration and no SQL-Server hub.</returns>
|
||||||
|
public StoreTarget Manual(string sqlitePath, MasterKeyOptions masterKey)
|
||||||
|
{
|
||||||
|
SecretsOptions secrets = new()
|
||||||
|
{
|
||||||
|
SqlitePath = Path.GetFullPath(sqlitePath),
|
||||||
|
MasterKey = masterKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
IConfigurationRoot config = new ConfigurationBuilder().Build();
|
||||||
|
|
||||||
|
return new StoreTarget("manual", AppSettingsPath: null, secrets, SqlServer: null, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
using ZB.MOM.WW.Secrets.MasterKey;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="TargetConfigReader"/>: it must compose the target app's configuration
|
||||||
|
/// (base json + optional environment overlay + environment variables) exactly the way the app
|
||||||
|
/// would at startup, so fixes land in the store the app actually reads.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TargetConfigReaderTests
|
||||||
|
{
|
||||||
|
private static string NewTempDir()
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(Path.GetTempPath(), "zb-secrets-cfg-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reads_secrets_section_and_binds_options()
|
||||||
|
{
|
||||||
|
string dir = NewTempDir();
|
||||||
|
string path = Path.Combine(dir, "appsettings.json");
|
||||||
|
File.WriteAllText(path, """
|
||||||
|
{
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "custom.db",
|
||||||
|
"MasterKey": {
|
||||||
|
"Source": "Environment",
|
||||||
|
"EnvVarName": "MY_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
StoreTarget target = new TargetConfigReader().Read(path);
|
||||||
|
|
||||||
|
Assert.Equal(MasterKeySource.Environment, target.Secrets.MasterKey.Source);
|
||||||
|
Assert.Equal("MY_KEY", target.Secrets.MasterKey.EnvVarName);
|
||||||
|
Assert.Equal(Path.Combine(dir, "custom.db"), target.Secrets.SqlitePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Environment_overlay_wins_over_base()
|
||||||
|
{
|
||||||
|
string dir = NewTempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
|
||||||
|
{
|
||||||
|
"Secrets": { "SqlitePath": "base.db" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "appsettings.Production.json"), """
|
||||||
|
{
|
||||||
|
"Secrets": { "SqlitePath": "prod.db" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
StoreTarget target = new TargetConfigReader().Read(
|
||||||
|
Path.Combine(dir, "appsettings.json"), environment: "Production");
|
||||||
|
|
||||||
|
Assert.Equal(Path.Combine(dir, "prod.db"), target.Secrets.SqlitePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Binds_sqlserver_options_when_section_present()
|
||||||
|
{
|
||||||
|
string dir = NewTempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
|
||||||
|
{
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "secrets.db",
|
||||||
|
"SqlServer": {
|
||||||
|
"ConnectionString": "Server=hub;Database=ZbSecrets;Trusted_Connection=True"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
StoreTarget withSql = new TargetConfigReader().Read(Path.Combine(dir, "appsettings.json"));
|
||||||
|
|
||||||
|
Assert.NotNull(withSql.SqlServer);
|
||||||
|
Assert.Equal(
|
||||||
|
"Server=hub;Database=ZbSecrets;Trusted_Connection=True",
|
||||||
|
withSql.SqlServer!.ConnectionString);
|
||||||
|
|
||||||
|
string dir2 = NewTempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir2, "appsettings.json"), """
|
||||||
|
{
|
||||||
|
"Secrets": { "SqlitePath": "secrets.db" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
StoreTarget noSql = new TargetConfigReader().Read(Path.Combine(dir2, "appsettings.json"));
|
||||||
|
|
||||||
|
Assert.Null(noSql.SqlServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SqlitePath_is_resolved_relative_to_the_app_directory()
|
||||||
|
{
|
||||||
|
string dir = NewTempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
|
||||||
|
{
|
||||||
|
"Secrets": { "SqlitePath": "secrets.db" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
StoreTarget target = new TargetConfigReader().Read(Path.Combine(dir, "appsettings.json"));
|
||||||
|
|
||||||
|
Assert.True(Path.IsPathRooted(target.Secrets.SqlitePath));
|
||||||
|
Assert.Equal(Path.Combine(dir, "secrets.db"), target.Secrets.SqlitePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_file_throws_FileNotFoundException_with_path()
|
||||||
|
{
|
||||||
|
string dir = NewTempDir();
|
||||||
|
string path = Path.Combine(dir, "appsettings.json");
|
||||||
|
|
||||||
|
FileNotFoundException ex = Assert.Throws<FileNotFoundException>(
|
||||||
|
() => new TargetConfigReader().Read(path));
|
||||||
|
|
||||||
|
Assert.Contains(path, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Custom_section_path_is_honored()
|
||||||
|
{
|
||||||
|
string dir = NewTempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
|
||||||
|
{
|
||||||
|
"MySecrets": {
|
||||||
|
"SqlitePath": "elsewhere.db",
|
||||||
|
"MasterKey": { "EnvVarName": "OTHER_KEY" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
StoreTarget target = new TargetConfigReader().Read(
|
||||||
|
Path.Combine(dir, "appsettings.json"), sectionPath: "MySecrets");
|
||||||
|
|
||||||
|
Assert.Equal("OTHER_KEY", target.Secrets.MasterKey.EnvVarName);
|
||||||
|
Assert.Equal(Path.Combine(dir, "elsewhere.db"), target.Secrets.SqlitePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user