Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve status command output and improve error handling #5694

Merged
merged 1 commit into from
Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System.CommandLine.Invocation;
using System.CommandLine.Invocation;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Sdk.Tools.SecretRotation.Configuration;
using Azure.Sdk.Tools.SecretRotation.Core;

Expand All @@ -17,13 +20,130 @@ protected override async Task HandleCommandAsync(ILogger logger, RotationConfigu
var timeProvider = new TimeProvider();
IEnumerable<RotationPlan> plans = rotationConfiguration.GetAllRotationPlans(logger, timeProvider);

List<(RotationPlan Plan, RotationPlanStatus Status)> statuses = new();

foreach (RotationPlan plan in plans)
{
Console.WriteLine();
Console.WriteLine($"{plan.Name}:");

logger.LogInformation($"Getting status for plan '{plan.Name}'");
RotationPlanStatus status = await plan.GetStatusAsync();
Console.WriteLine(JsonSerializer.Serialize(status, new JsonSerializerOptions { WriteIndented = true }));

if (logger.IsEnabled(LogLevel.Debug))
{
var builder = new StringBuilder();

builder.AppendLine($" Plan:");
builder.AppendLine($" RotationPeriod: {plan.RotationPeriod}");
builder.AppendLine($" RotationThreshold: {plan.RotationThreshold}");
builder.AppendLine($" RevokeAfterPeriod: {plan.RevokeAfterPeriod}");

builder.AppendLine($" Status:");
builder.AppendLine($" ExpirationDate: {status.ExpirationDate}");
builder.AppendLine($" Expired: {status.Expired}");
builder.AppendLine($" ThresholdExpired: {status.ThresholdExpired}");
builder.AppendLine($" RequiresRevocation: {status.RequiresRevocation}");
builder.AppendLine($" Exception: {status.Exception?.Message}");

logger.LogDebug(builder.ToString());
}


statuses.Add((plan, status));
}

var errored = statuses.Where(x => x.Status.Exception is not null).ToArray();
var expired = statuses.Except(errored).Where(x => x.Status is { Expired: true }).ToArray();
var expiring = statuses.Except(errored).Where(x => x.Status is { Expired: false, ThresholdExpired: true }).ToArray();
var upToDate = statuses.Except(errored).Where(x => x.Status is { Expired: false, ThresholdExpired: false }).ToArray();

var statusBuilder = new StringBuilder();

void AppendStatusSection(IList<(RotationPlan Plan, RotationPlanStatus Status)> sectionStatuses, string header)
{
if (!sectionStatuses.Any())
{
return;
}

statusBuilder.AppendLine();
statusBuilder.AppendLine(header);
foreach ((RotationPlan plan, RotationPlanStatus status) in sectionStatuses)
{
foreach (string line in GetPlanStatusLine(plan, status).Split("\n"))
{
statusBuilder.Append(" ");
statusBuilder.AppendLine(line);
}
}
}

AppendStatusSection(expired, "Expired:");
AppendStatusSection(expiring, "Expiring:");
AppendStatusSection(upToDate, "Up-to-date:");
AppendStatusSection(errored, "Error reading plan status:");

logger.LogInformation(statusBuilder.ToString());

if (expired.Any())
{
invocationContext.ExitCode = 1;
}
}

private static string GetPlanStatusLine(RotationPlan plan, RotationPlanStatus status)
{
if (status.Exception != null)
{
return $"{plan.Name}:\n {status.Exception.Message}";
}

DateTimeOffset? expirationDate = status.ExpirationDate;

DateTimeOffset now = DateTimeOffset.UtcNow;

string expiration = expirationDate.HasValue
? $"{FormatTimeSpan(expirationDate.Value.Subtract(now))}"
: "No expiration date";

return $"{plan.Name} - {expiration} / ({FormatTimeSpan(plan.RotationPeriod)} @ {FormatTimeSpan(plan.RotationThreshold)})";
}

private static string FormatTimeSpan(TimeSpan timeSpan)
{
if (timeSpan == TimeSpan.Zero)
{
return "0d";
}

StringBuilder builder = new StringBuilder();

if (timeSpan.Days > 0)
{
builder.Append(timeSpan.Days);
builder.Append('d');
}

if (timeSpan.Days < 2 && timeSpan.TotalDays - timeSpan.Days > 0)
benbp marked this conversation as resolved.
Show resolved Hide resolved
{
if (timeSpan.Hours > 0)
{
if (builder.Length > 0)
{
builder.Append(' ');
}
builder.Append(timeSpan.Hours);
builder.Append('h');
}
if (timeSpan.Minutes > 0)
{
if (builder.Length > 0)
{
builder.Append(' ');
}
builder.Append(timeSpan.Minutes);
builder.Append('m');
}
}

return builder.ToString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class PlanConfiguration
ReadCommentHandling = JsonCommentHandling.Skip,
};

private static readonly Regex schemaPattern = new (@"https\://raw\.githubusercontent\.com/azure/azure-sdk-tools/(?<branch>.+?)/schemas/secretrotation/(?<version>.+?)/schema\.json", RegexOptions.IgnoreCase);
private static readonly Regex schemaPattern = new (@"/(?<version>.+?)/plan\.json", RegexOptions.IgnoreCase);

public string Name { get; set; } = string.Empty;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,42 +82,57 @@ public async Task ExecuteAsync(bool onlyRotateExpiring, bool whatIf)

public async Task<RotationPlanStatus> GetStatusAsync()
{
DateTimeOffset invocationTime = this.timeProvider.GetCurrentDateTimeOffset();
try
{
DateTimeOffset invocationTime = this.timeProvider.GetCurrentDateTimeOffset();

SecretState primaryStoreState = await PrimaryStore.GetCurrentStateAsync();
SecretState primaryStoreState = await PrimaryStore.GetCurrentStateAsync();

IEnumerable<SecretState> rotationArtifacts = await PrimaryStore.GetRotationArtifactsAsync();
IEnumerable<SecretState> rotationArtifacts = await PrimaryStore.GetRotationArtifactsAsync();

var secondaryStoreStates = new List<SecretState>();
var secondaryStoreStates = new List<SecretState>();

foreach (SecretStore secondaryStore in SecondaryStores)
{
if (secondaryStore.CanRead)
foreach (SecretStore secondaryStore in SecondaryStores)
{
secondaryStoreStates.Add(await secondaryStore.GetCurrentStateAsync());
if (secondaryStore.CanRead)
{
secondaryStoreStates.Add(await secondaryStore.GetCurrentStateAsync());
}
}
}

SecretState[] allStates = secondaryStoreStates.Prepend(primaryStoreState).ToArray();
SecretState[] allStates = secondaryStoreStates.Prepend(primaryStoreState).ToArray();

bool anyExpired = allStates.Any(state => state.ExpirationDate <= invocationTime);
DateTimeOffset thresholdDate = this.timeProvider.GetCurrentDateTimeOffset().Add(RotationThreshold);

DateTimeOffset thresholdDate = this.timeProvider.GetCurrentDateTimeOffset().Add(RotationThreshold);
DateTimeOffset? minExpirationDate = allStates.Where(x => x.ExpirationDate.HasValue).Min(x => x.ExpirationDate);

bool anyThresholdExpired = allStates.Any(state => state.ExpirationDate <= thresholdDate);
bool anyExpired = minExpirationDate == null || minExpirationDate <= invocationTime;

bool anyRequireRevocation = rotationArtifacts.Any(state => state.RevokeAfterDate <= invocationTime);
bool anyThresholdExpired = minExpirationDate <= thresholdDate;

var status = new RotationPlanStatus
bool anyRequireRevocation = rotationArtifacts.Any(state => state.RevokeAfterDate <= invocationTime);

var status = new RotationPlanStatus
{
ExpirationDate = minExpirationDate,
Expired = anyExpired,
ThresholdExpired = anyThresholdExpired,
RequiresRevocation = anyRequireRevocation,
PrimaryStoreState = primaryStoreState,
SecondaryStoreStates = secondaryStoreStates.ToArray()
};

return status;
}
catch (RotationException ex)
{
Expired = anyExpired,
ThresholdExpired = anyThresholdExpired,
RequiresRevocation = anyRequireRevocation,
PrimaryStoreState = primaryStoreState,
SecondaryStoreStates = secondaryStoreStates.ToArray()
};

return status;
var status = new RotationPlanStatus
{
Exception = ex
};

return status;
}
}

private async Task RotateAsync(string operationId, SecretState currentState, bool whatIf)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Azure.Sdk.Tools.SecretRotation.Core;
using Azure.Sdk.Tools.SecretRotation.Core;

namespace Azure.Sdk.Tools.SecretRotation.Core;

Expand All @@ -13,4 +13,8 @@ public class RotationPlanStatus
public SecretState? PrimaryStoreState { get; set; }

public IReadOnlyList<SecretState>? SecondaryStoreStates { get; set; }

public DateTimeOffset? ExpirationDate { get; set; }

public RotationException? Exception { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ namespace Azure.Sdk.Tools.SecretRotation.Stores.KeyVault;
public class KeyVaultCertificateStore : SecretStore
{
public const string MappingKey = "Key Vault Certificate";
private const string RevokeAfterTag = "RevokeAfter";
private const string OperationIdTag = "OperationId";
private const string RevokedTag = "Revoked";

private static readonly Regex uriRegex = new(
@"^(?<VaultUri>https://.+?)/certificates/(?<CertificateName>[^/]+)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromSeconds(5));

private readonly CertificateClient certificateClient;
private readonly string certificateName;
private readonly ILogger logger;
Expand All @@ -41,8 +44,6 @@ public KeyVaultCertificateStore(

public override bool CanAnnotate => true;

public override bool CanRevoke => true;

public static Func<StoreConfiguration, SecretStore> GetSecretStoreFactory(TokenCredential credential,
ILogger logger)
{
Expand Down Expand Up @@ -72,7 +73,7 @@ public override async Task<SecretState> GetCurrentStateAsync()
{
try
{
this.logger.LogDebug("Getting certificate '{SecretName}' from vault '{VaultUri}'", this.certificateName,
this.logger.LogDebug("Getting certificate '{CertificateName}' from vault '{VaultUri}'", this.certificateName,
this.vaultUri);
Response<KeyVaultCertificateWithPolicy>? response =
await this.certificateClient.GetCertificateAsync(this.certificateName);
Expand All @@ -82,9 +83,13 @@ public override async Task<SecretState> GetCurrentStateAsync()
ExpirationDate = response.Value.Properties.ExpiresOn, StatusCode = response.GetRawResponse().Status
};
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return new SecretState { ErrorMessage = GetExceptionMessage(ex) };
}
catch (RequestFailedException ex)
{
return new SecretState { ExpirationDate = null, StatusCode = ex.Status, ErrorMessage = ex.Message };
throw new RequestFailedException(GetExceptionMessage(ex), ex);
}
}

Expand Down Expand Up @@ -159,29 +164,63 @@ public override async Task<IEnumerable<SecretState>> GetRotationArtifactsAsync()
{
var results = new List<SecretState>();

await foreach (CertificateProperties? version in
this.certificateClient.GetPropertiesOfCertificateVersionsAsync(this.certificateName))
try
{
if (!version.Tags.TryGetValue("revokeAfter", out string? revokeAfterString))
await foreach (CertificateProperties version in
this.certificateClient.GetPropertiesOfCertificateVersionsAsync(this.certificateName))
{
continue;
if (!version.Tags.TryGetValue("revokeAfter", out string? revokeAfterString))
{
continue;
}

if (!DateTimeOffset.TryParse(revokeAfterString, out DateTimeOffset revokeAfterDate))
{
// TODO: Warning
continue;
}

version.Tags.TryGetValue(OperationIdTag, out string? operationId);

results.Add(new SecretState
{
Id = version.Id.ToString(),
OperationId = operationId,
Tags = version.Tags,
RevokeAfterDate = revokeAfterDate,
});
}

if (!DateTimeOffset.TryParse(revokeAfterString, out DateTimeOffset revokeAfterDate))
return results;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return results;
}
catch (RequestFailedException ex)
{
throw new RotationException(GetExceptionMessage(ex), ex);
}
}

private string GetExceptionMessage(RequestFailedException exception)
{
if (exception.Status == 403)
{
return $"Key vault request not authorized for vault '{this.vaultUri}'";
}

if (exception.Status == 404)
{
if (exception.Message.StartsWith($"A certificate with (name/id) "))
{
// TODO: Warning
continue;
return $"Certificate '{this.certificateName}' not found in vault '{this.vaultUri}'";
}

results.Add(new SecretState { Tags = version.Tags, RevokeAfterDate = revokeAfterDate });
return $"Vault {this.vaultUri} not found.";
}

return results;
}

public override Func<Task>? GetRevocationActionAsync(SecretState secretState, bool whatIf)
{
throw new NotImplementedException();
return $"Key vault request failed with code {exception.Status}: {exception.Message}";
}

private class Parameters
Expand Down
Loading