Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
@@ -5182,13 +5183,13 @@ internal static class CommandFactory
|
||||
Description = "Image digests to test (can be specified multiple times).",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
imagesOption.IsRequired = true;
|
||||
imagesOption.Required = true;
|
||||
|
||||
var scannerOption = new Option<string>("--scanner", "-s")
|
||||
{
|
||||
Description = "Scanner container image reference."
|
||||
};
|
||||
scannerOption.IsRequired = true;
|
||||
scannerOption.Required = true;
|
||||
|
||||
var policyBundleOption = new Option<string?>("--policy-bundle")
|
||||
{
|
||||
@@ -5582,13 +5583,13 @@ internal static class CommandFactory
|
||||
{
|
||||
Description = "Start timestamp (ISO-8601). Required."
|
||||
};
|
||||
fromOption.IsRequired = true;
|
||||
fromOption.Required = true;
|
||||
|
||||
var toOption = new Option<DateTimeOffset>("--to")
|
||||
{
|
||||
Description = "End timestamp (ISO-8601). Required."
|
||||
};
|
||||
toOption.IsRequired = true;
|
||||
toOption.Required = true;
|
||||
|
||||
var logsTenantOption = new Option<string?>("--tenant", "-t")
|
||||
{
|
||||
@@ -6544,7 +6545,7 @@ internal static class CommandFactory
|
||||
var secretsInjectRefOption = new Option<string>("--secret-ref")
|
||||
{
|
||||
Description = "Secret reference (provider-specific path).",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var secretsInjectProviderOption = new Option<string>("--provider")
|
||||
@@ -6844,19 +6845,18 @@ internal static class CommandFactory
|
||||
|
||||
return CommandHandlers.HandleExceptionsListAsync(
|
||||
services,
|
||||
tenant,
|
||||
vuln,
|
||||
scopeType,
|
||||
scopeValue,
|
||||
statuses,
|
||||
owner,
|
||||
effect,
|
||||
expiringDays,
|
||||
expiringDays.HasValue ? DateTimeOffset.UtcNow.AddDays(expiringDays.Value) : null,
|
||||
includeExpired,
|
||||
pageSize,
|
||||
pageToken,
|
||||
tenant,
|
||||
json,
|
||||
csv,
|
||||
json || csv,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
@@ -6977,7 +6977,8 @@ internal static class CommandFactory
|
||||
var effect = parseResult.GetValue(createEffectOption) ?? string.Empty;
|
||||
var justification = parseResult.GetValue(createJustificationOption) ?? string.Empty;
|
||||
var owner = parseResult.GetValue(createOwnerOption) ?? string.Empty;
|
||||
var expiration = parseResult.GetValue(createExpirationOption);
|
||||
var expirationStr = parseResult.GetValue(createExpirationOption);
|
||||
var expiration = !string.IsNullOrWhiteSpace(expirationStr) && DateTimeOffset.TryParse(expirationStr, out var exp) ? exp : (DateTimeOffset?)null;
|
||||
var evidence = parseResult.GetValue(createEvidenceOption) ?? Array.Empty<string>();
|
||||
var policy = parseResult.GetValue(createPolicyOption);
|
||||
var stage = parseResult.GetValue(createStageOption);
|
||||
@@ -6987,17 +6988,17 @@ internal static class CommandFactory
|
||||
|
||||
return CommandHandlers.HandleExceptionsCreateAsync(
|
||||
services,
|
||||
tenant ?? string.Empty,
|
||||
vuln,
|
||||
scopeType,
|
||||
scopeValue,
|
||||
effect,
|
||||
justification,
|
||||
owner,
|
||||
owner ?? string.Empty,
|
||||
expiration,
|
||||
evidence,
|
||||
policy,
|
||||
stage,
|
||||
tenant,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
@@ -7042,9 +7043,9 @@ internal static class CommandFactory
|
||||
return CommandHandlers.HandleExceptionsPromoteAsync(
|
||||
services,
|
||||
exceptionId,
|
||||
target,
|
||||
comment,
|
||||
tenant,
|
||||
target ?? "active",
|
||||
comment,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
@@ -7128,10 +7129,10 @@ internal static class CommandFactory
|
||||
|
||||
return CommandHandlers.HandleExceptionsImportAsync(
|
||||
services,
|
||||
tenant ?? string.Empty,
|
||||
file,
|
||||
stage,
|
||||
source,
|
||||
tenant,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
@@ -7184,11 +7185,13 @@ internal static class CommandFactory
|
||||
|
||||
return CommandHandlers.HandleExceptionsExportAsync(
|
||||
services,
|
||||
output,
|
||||
tenant,
|
||||
statuses,
|
||||
format,
|
||||
output,
|
||||
false, // includeManifest
|
||||
signed,
|
||||
tenant,
|
||||
false, // json output
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
@@ -7470,13 +7473,13 @@ internal static class CommandFactory
|
||||
var backfillFromOption = new Option<DateTimeOffset>("--from")
|
||||
{
|
||||
Description = "Start date/time for backfill (ISO 8601 format).",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var backfillToOption = new Option<DateTimeOffset>("--to")
|
||||
{
|
||||
Description = "End date/time for backfill (ISO 8601 format).",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var backfillDryRunOption = new Option<bool>("--dry-run")
|
||||
@@ -7732,19 +7735,19 @@ internal static class CommandFactory
|
||||
var quotaSetTenantOption = new Option<string>("--tenant")
|
||||
{
|
||||
Description = "Tenant ID.",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var quotaSetResourceTypeOption = new Option<string>("--resource-type")
|
||||
{
|
||||
Description = "Resource type (api_calls, data_ingested_bytes, items_processed, backfills, concurrent_jobs, storage_bytes).",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var quotaSetLimitOption = new Option<long>("--limit")
|
||||
{
|
||||
Description = "Quota limit value.",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var quotaSetPeriodOption = new Option<string>("--period")
|
||||
@@ -7800,13 +7803,13 @@ internal static class CommandFactory
|
||||
var quotaResetTenantOption = new Option<string>("--tenant")
|
||||
{
|
||||
Description = "Tenant ID.",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var quotaResetResourceTypeOption = new Option<string>("--resource-type")
|
||||
{
|
||||
Description = "Resource type to reset.",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var quotaResetReasonOption = new Option<string?>("--reason")
|
||||
@@ -9547,7 +9550,7 @@ internal static class CommandFactory
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output path for the downloaded spec (file or directory).",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var serviceOption = new Option<string?>("--service", "-s")
|
||||
|
||||
@@ -48,43 +48,72 @@ namespace StellaOps.Cli.Commands;
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
|
||||
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
|
||||
var shaPath = path + ".sha256";
|
||||
if (!File.Exists(shaPath))
|
||||
{
|
||||
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
|
||||
Environment.ExitCode = 21;
|
||||
throw new InvalidOperationException("Checksum file missing");
|
||||
}
|
||||
|
||||
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
var actual = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
|
||||
Environment.ExitCode = 22;
|
||||
throw new InvalidOperationException("Checksum verification failed");
|
||||
}
|
||||
|
||||
logger.LogInformation("Checksum verified for {Bundle}", path);
|
||||
}
|
||||
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
/// <summary>
|
||||
/// Standard JSON serializer options for CLI output.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options for output (alias for JsonOptions).
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the verbosity level for logging.
|
||||
/// </summary>
|
||||
private static void SetVerbosity(IServiceProvider services, bool verbose)
|
||||
{
|
||||
// Configure logging level based on verbose flag
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
if (loggerFactory is not null && verbose)
|
||||
{
|
||||
// Enable debug logging when verbose is true
|
||||
var logger = loggerFactory.CreateLogger("StellaOps.Cli.Commands.CommandHandlers");
|
||||
logger.LogDebug("Verbose logging enabled");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
|
||||
var shaPath = path + ".sha256";
|
||||
if (!File.Exists(shaPath))
|
||||
{
|
||||
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
|
||||
Environment.ExitCode = 21;
|
||||
throw new InvalidOperationException("Checksum file missing");
|
||||
}
|
||||
|
||||
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
var actual = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
|
||||
Environment.ExitCode = 22;
|
||||
throw new InvalidOperationException("Checksum verification failed");
|
||||
}
|
||||
|
||||
logger.LogInformation("Checksum verified for {Bundle}", path);
|
||||
}
|
||||
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -114,29 +143,29 @@ internal static class CommandHandlers
|
||||
|
||||
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
||||
|
||||
if (install)
|
||||
{
|
||||
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
if (install)
|
||||
{
|
||||
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
if (Environment.ExitCode == 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
if (Environment.ExitCode == 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTaskRunnerSimulateAsync(
|
||||
@@ -264,15 +293,15 @@ internal static class CommandHandlers
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
|
||||
console.MarkupLine($"Image: {result.ImageDigest}");
|
||||
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
|
||||
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
|
||||
|
||||
if (result.BestPlan is not null)
|
||||
{
|
||||
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
|
||||
}
|
||||
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
|
||||
console.MarkupLine($"Image: {result.ImageDigest}");
|
||||
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
|
||||
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
|
||||
|
||||
if (result.BestPlan is not null)
|
||||
{
|
||||
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
|
||||
}
|
||||
|
||||
var planTable = new Table()
|
||||
.AddColumn("Terminal")
|
||||
@@ -284,15 +313,15 @@ internal static class CommandHandlers
|
||||
|
||||
foreach (var plan in result.Graph.Plans.OrderByDescending(p => p.Confidence))
|
||||
{
|
||||
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
|
||||
planTable.AddRow(
|
||||
plan.TerminalPath,
|
||||
plan.Runtime ?? "-",
|
||||
plan.Type.ToString(),
|
||||
confidence,
|
||||
plan.User,
|
||||
plan.WorkingDirectory);
|
||||
}
|
||||
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
|
||||
planTable.AddRow(
|
||||
plan.TerminalPath,
|
||||
plan.Runtime ?? "-",
|
||||
plan.Type.ToString(),
|
||||
confidence,
|
||||
plan.User,
|
||||
plan.WorkingDirectory);
|
||||
}
|
||||
|
||||
if (planTable.Rows.Count > 0)
|
||||
{
|
||||
@@ -6860,7 +6889,7 @@ internal static class CommandHandlers
|
||||
}
|
||||
|
||||
AnsiConsole.Write(violationTable);
|
||||
}
|
||||
}
|
||||
|
||||
private static int DetermineVerifyExitCode(AocVerifyResponse response)
|
||||
{
|
||||
@@ -10895,13 +10924,10 @@ stella policy test {policyName}.stella
|
||||
Code = diag.Code,
|
||||
Message = diag.Message,
|
||||
Severity = diag.Severity.ToString().ToLowerInvariant(),
|
||||
Line = diag.Line,
|
||||
Column = diag.Column,
|
||||
Span = diag.Span,
|
||||
Suggestion = diag.Suggestion
|
||||
Path = diag.Path
|
||||
};
|
||||
|
||||
if (diag.Severity == PolicyDsl.DiagnosticSeverity.Error)
|
||||
if (diag.Severity == PolicyIssueSeverity.Error)
|
||||
{
|
||||
errors.Add(diagnostic);
|
||||
}
|
||||
@@ -10939,7 +10965,7 @@ stella policy test {policyName}.stella
|
||||
InputPath = fullPath,
|
||||
IrPath = irPath,
|
||||
Digest = digest,
|
||||
SyntaxVersion = compileResult.Document?.SyntaxVersion,
|
||||
SyntaxVersion = compileResult.Document?.Syntax,
|
||||
PolicyName = compileResult.Document?.Name,
|
||||
RuleCount = compileResult.Document?.Rules.Length ?? 0,
|
||||
ProfileCount = compileResult.Document?.Profiles.Length ?? 0,
|
||||
@@ -10985,24 +11011,14 @@ stella policy test {policyName}.stella
|
||||
|
||||
foreach (var err in errors)
|
||||
{
|
||||
var location = err.Line.HasValue ? $":{err.Line}" : "";
|
||||
if (err.Column.HasValue) location += $":{err.Column}";
|
||||
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{location}: {Markup.Escape(err.Message)}[/]");
|
||||
if (!string.IsNullOrWhiteSpace(err.Suggestion))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [cyan]suggestion: {Markup.Escape(err.Suggestion)}[/]");
|
||||
}
|
||||
var location = !string.IsNullOrWhiteSpace(err.Path) ? $" at {err.Path}" : "";
|
||||
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{Markup.Escape(location)}: {Markup.Escape(err.Message)}[/]");
|
||||
}
|
||||
|
||||
foreach (var warn in warnings)
|
||||
{
|
||||
var location = warn.Line.HasValue ? $":{warn.Line}" : "";
|
||||
if (warn.Column.HasValue) location += $":{warn.Column}";
|
||||
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{location}: {Markup.Escape(warn.Message)}[/]");
|
||||
if (!string.IsNullOrWhiteSpace(warn.Suggestion))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [cyan]suggestion: {Markup.Escape(warn.Suggestion)}[/]");
|
||||
}
|
||||
var location = !string.IsNullOrWhiteSpace(warn.Path) ? $" at {warn.Path}" : "";
|
||||
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{Markup.Escape(location)}: {Markup.Escape(warn.Message)}[/]");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13248,18 +13264,6 @@ stella policy test {policyName}.stella
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetVexStatusMarkup(string status)
|
||||
{
|
||||
return status?.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => "[red]affected[/]",
|
||||
"not_affected" => "[green]not_affected[/]",
|
||||
"fixed" => "[blue]fixed[/]",
|
||||
"under_investigation" => "[yellow]under_investigation[/]",
|
||||
_ => Markup.Escape(status ?? "(unknown)")
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vulnerability Explorer (CLI-VULN-29-001)
|
||||
@@ -14543,13 +14547,13 @@ stella policy test {policyName}.stella
|
||||
var fixText = obs.Fix?.Available == true ? "[green]available[/]" : "[grey]none[/]";
|
||||
|
||||
table.AddRow(
|
||||
Markup.Escape(obs.ObservationId),
|
||||
Markup.Escape(sourceVendor),
|
||||
Markup.Escape(aliasesText),
|
||||
Markup.Escape(severityText),
|
||||
new Markup(Markup.Escape(obs.ObservationId)),
|
||||
new Markup(Markup.Escape(sourceVendor)),
|
||||
new Markup(Markup.Escape(aliasesText)),
|
||||
new Markup(Markup.Escape(severityText)),
|
||||
new Markup(kevText),
|
||||
new Markup(fixText),
|
||||
obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
|
||||
new Markup(Markup.Escape(obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
@@ -15386,12 +15390,12 @@ stella policy test {policyName}.stella
|
||||
var size = FormatSize(snapshot.SizeBytes);
|
||||
|
||||
table.AddRow(
|
||||
Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId),
|
||||
Markup.Escape(snapshot.CaseId),
|
||||
new Markup(Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId)),
|
||||
new Markup(Markup.Escape(snapshot.CaseId)),
|
||||
new Markup(statusMarkup),
|
||||
artifactCount,
|
||||
size,
|
||||
snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
|
||||
new Markup(Markup.Escape(artifactCount)),
|
||||
new Markup(Markup.Escape(size)),
|
||||
new Markup(Markup.Escape(snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace StellaOps.Cli.Configuration;
|
||||
/// CLI profile for storing named configurations.
|
||||
/// Per CLI-CORE-41-001, supports profiles/contexts for multi-environment workflows.
|
||||
/// </summary>
|
||||
public sealed class CliProfile
|
||||
public sealed record CliProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "prod", "staging", "dev").
|
||||
|
||||
@@ -54,87 +54,45 @@ public sealed class GlobalOptions
|
||||
/// </summary>
|
||||
public static IEnumerable<Option> CreateGlobalOptions()
|
||||
{
|
||||
yield return new Option<string?>(
|
||||
aliases: ["--profile", "-p"],
|
||||
description: "Profile name to use for this invocation");
|
||||
yield return new Option<string?>("--profile", "-p")
|
||||
{
|
||||
Description = "Profile name to use for this invocation"
|
||||
};
|
||||
|
||||
yield return new Option<OutputFormat>(
|
||||
aliases: ["--output", "-o"],
|
||||
getDefaultValue: () => OutputFormat.Table,
|
||||
description: "Output format (table, json, yaml)");
|
||||
yield return new Option<OutputFormat>("--output", "-o")
|
||||
{
|
||||
Description = "Output format (table, json, yaml)",
|
||||
DefaultValueFactory = _ => OutputFormat.Table
|
||||
};
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--verbose", "-v"],
|
||||
description: "Enable verbose output");
|
||||
yield return new Option<bool>("--verbose", "-v")
|
||||
{
|
||||
Description = "Enable verbose output"
|
||||
};
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--quiet", "-q"],
|
||||
description: "Quiet mode - suppress non-error output");
|
||||
yield return new Option<bool>("--quiet", "-q")
|
||||
{
|
||||
Description = "Quiet mode - suppress non-error output"
|
||||
};
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--no-color",
|
||||
description: "Disable colored output");
|
||||
yield return new Option<bool>("--no-color")
|
||||
{
|
||||
Description = "Disable colored output"
|
||||
};
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--backend-url",
|
||||
description: "Override backend URL for this invocation");
|
||||
yield return new Option<string?>("--backend-url")
|
||||
{
|
||||
Description = "Override backend URL for this invocation"
|
||||
};
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--tenant-id",
|
||||
description: "Override tenant ID for this invocation");
|
||||
yield return new Option<string?>("--tenant-id")
|
||||
{
|
||||
Description = "Override tenant ID for this invocation"
|
||||
};
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--dry-run",
|
||||
description: "Show what would happen without executing");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses global options from invocation context.
|
||||
/// </summary>
|
||||
public static GlobalOptions FromInvocationContext(System.CommandLine.Invocation.InvocationContext context)
|
||||
{
|
||||
var options = new GlobalOptions();
|
||||
|
||||
var profileOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--profile"));
|
||||
if (profileOption is not null)
|
||||
options.Profile = context.ParseResult.GetValueForOption(profileOption) as string;
|
||||
|
||||
var outputOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--output"));
|
||||
if (outputOption is not null && context.ParseResult.GetValueForOption(outputOption) is OutputFormat format)
|
||||
options.OutputFormat = format;
|
||||
|
||||
var verboseOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--verbose"));
|
||||
if (verboseOption is not null && context.ParseResult.GetValueForOption(verboseOption) is bool verbose)
|
||||
options.Verbose = verbose;
|
||||
|
||||
var quietOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--quiet"));
|
||||
if (quietOption is not null && context.ParseResult.GetValueForOption(quietOption) is bool quiet)
|
||||
options.Quiet = quiet;
|
||||
|
||||
var noColorOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--no-color"));
|
||||
if (noColorOption is not null && context.ParseResult.GetValueForOption(noColorOption) is bool noColor)
|
||||
options.NoColor = noColor;
|
||||
|
||||
var backendOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--backend-url"));
|
||||
if (backendOption is not null)
|
||||
options.BackendUrl = context.ParseResult.GetValueForOption(backendOption) as string;
|
||||
|
||||
var tenantOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--tenant-id"));
|
||||
if (tenantOption is not null)
|
||||
options.TenantId = context.ParseResult.GetValueForOption(tenantOption) as string;
|
||||
|
||||
var dryRunOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--dry-run"));
|
||||
if (dryRunOption is not null && context.ParseResult.GetValueForOption(dryRunOption) is bool dryRun)
|
||||
options.DryRun = dryRun;
|
||||
|
||||
return options;
|
||||
yield return new Option<bool>("--dry-run")
|
||||
{
|
||||
Description = "Show what would happen without executing"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
@@ -12,9 +12,9 @@ public sealed class StellaOpsCliOptions
|
||||
|
||||
public string BackendUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ConcelierUrl { get; set; } = string.Empty;
|
||||
|
||||
public string AdvisoryAiUrl { get; set; } = string.Empty;
|
||||
public string ConcelierUrl { get; set; } = string.Empty;
|
||||
|
||||
public string AdvisoryAiUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ScannerCacheDirectory { get; set; } = "scanners";
|
||||
|
||||
@@ -32,11 +32,20 @@ public sealed class StellaOpsCliOptions
|
||||
|
||||
public StellaOpsCliOfflineOptions Offline { get; set; } = new();
|
||||
|
||||
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; set; } = new();
|
||||
|
||||
}
|
||||
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if CLI is running in offline mode.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory containing offline kits when in offline mode.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliAuthorityOptions
|
||||
{
|
||||
@@ -52,15 +61,15 @@ public sealed class StellaOpsCliAuthorityOptions
|
||||
|
||||
public string Scope { get; set; } = StellaOpsScopes.ConcelierJobsTrigger;
|
||||
|
||||
public string OperatorReason { get; set; } = string.Empty;
|
||||
|
||||
public string OperatorTicket { get; set; } = string.Empty;
|
||||
|
||||
public string BackfillReason { get; set; } = string.Empty;
|
||||
|
||||
public string BackfillTicket { get; set; } = string.Empty;
|
||||
|
||||
public string TokenCacheDirectory { get; set; } = string.Empty;
|
||||
public string OperatorReason { get; set; } = string.Empty;
|
||||
|
||||
public string OperatorTicket { get; set; } = string.Empty;
|
||||
|
||||
public string BackfillReason { get; set; } = string.Empty;
|
||||
|
||||
public string BackfillTicket { get; set; } = string.Empty;
|
||||
|
||||
public string TokenCacheDirectory { get; set; } = string.Empty;
|
||||
|
||||
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();
|
||||
}
|
||||
@@ -83,15 +92,15 @@ public sealed class StellaOpsCliOfflineOptions
|
||||
public string? MirrorUrl { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliPluginOptions
|
||||
{
|
||||
public sealed class StellaOpsCliPluginOptions
|
||||
{
|
||||
public string BaseDirectory { get; set; } = string.Empty;
|
||||
|
||||
public string Directory { get; set; } = "plugins/cli";
|
||||
|
||||
public IList<string> SearchPatterns { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> PluginOrder { get; set; } = new List<string>();
|
||||
|
||||
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
|
||||
}
|
||||
public IList<string> PluginOrder { get; set; } = new List<string>();
|
||||
|
||||
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
|
||||
}
|
||||
|
||||
32
src/Cli/StellaOps.Cli/Extensions/CommandLineExtensions.cs
Normal file
32
src/Cli/StellaOps.Cli/Extensions/CommandLineExtensions.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Cli.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility extensions for System.CommandLine 2.0.0-beta5+ API changes.
|
||||
/// These restore the older extension method patterns that were used in earlier versions.
|
||||
/// See: https://learn.microsoft.com/en-us/dotnet/standard/commandline/migration-guide-2.0.0-beta5
|
||||
/// </summary>
|
||||
public static class CommandLineExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the default value for an option (compatibility shim for older API).
|
||||
/// In beta5+, this maps to DefaultValueFactory.
|
||||
/// </summary>
|
||||
public static Option<T> SetDefaultValue<T>(this Option<T> option, T defaultValue)
|
||||
{
|
||||
option.DefaultValueFactory = _ => defaultValue;
|
||||
return option;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricts the option to accept only the specified values (compatibility shim).
|
||||
/// Works for both Option<string> and Option<string?>.
|
||||
/// </summary>
|
||||
public static Option<T> FromAmong<T>(this Option<T> option, params string[] allowedValues)
|
||||
where T : class?
|
||||
{
|
||||
option.AcceptOnlyFromAmong(allowedValues);
|
||||
return option;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Auth.Client;
|
||||
|
||||
namespace StellaOps.Cli.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for IStellaOpsTokenClient providing compatibility with older CLI patterns.
|
||||
/// These bridge the gap between the old API (GetTokenAsync, GetAccessTokenAsync) and the
|
||||
/// new API (RequestClientCredentialsTokenAsync, GetCachedTokenAsync).
|
||||
/// </summary>
|
||||
public static class StellaOpsTokenClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests an access token using client credentials flow with the specified scopes.
|
||||
/// This is a compatibility shim for the old GetAccessTokenAsync pattern.
|
||||
/// </summary>
|
||||
public static async Task<StellaOpsTokenResult> GetAccessTokenAsync(
|
||||
this IStellaOpsTokenClient client,
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
var scope = scopes is not null ? string.Join(" ", scopes.Where(s => !string.IsNullOrWhiteSpace(s))) : null;
|
||||
return await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests an access token using client credentials flow with a single scope.
|
||||
/// </summary>
|
||||
public static async Task<StellaOpsTokenResult> GetAccessTokenAsync(
|
||||
this IStellaOpsTokenClient client,
|
||||
string scope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
return await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached access token or requests a new one if not cached or expired.
|
||||
/// This is a compatibility shim for the old GetCachedAccessTokenAsync pattern.
|
||||
/// </summary>
|
||||
public static async Task<StellaOpsTokenCacheEntry> GetCachedAccessTokenAsync(
|
||||
this IStellaOpsTokenClient client,
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
var scopeList = scopes?.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToArray() ?? [];
|
||||
var scope = string.Join(" ", scopeList);
|
||||
var cacheKey = $"cc:{scope}";
|
||||
|
||||
// Check cache first
|
||||
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cached is not null && !cached.IsExpired(TimeProvider.System, TimeSpan.FromMinutes(1)))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Request new token
|
||||
var result = await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
var entry = result.ToCacheEntry();
|
||||
|
||||
// Cache the result
|
||||
await client.CacheTokenAsync(cacheKey, entry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached access token or requests a new one if not cached or expired.
|
||||
/// Single scope version.
|
||||
/// </summary>
|
||||
public static async Task<StellaOpsTokenCacheEntry> GetCachedAccessTokenAsync(
|
||||
this IStellaOpsTokenClient client,
|
||||
string scope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
var cacheKey = $"cc:{scope ?? "default"}";
|
||||
|
||||
// Check cache first
|
||||
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cached is not null && !cached.IsExpired(TimeProvider.System, TimeSpan.FromMinutes(1)))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Request new token
|
||||
var result = await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
var entry = result.ToCacheEntry();
|
||||
|
||||
// Cache the result
|
||||
await client.CacheTokenAsync(cacheKey, entry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a token using client credentials. Parameterless version for simple cases.
|
||||
/// </summary>
|
||||
public static async Task<StellaOpsTokenResult> GetTokenAsync(
|
||||
this IStellaOpsTokenClient client,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
return await client.RequestClientCredentialsTokenAsync(null, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,19 @@ public sealed record CliError(
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error code for offline mode violations.
|
||||
/// </summary>
|
||||
public const string OfflineMode = "ERR_OFFLINE_MODE";
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an error code.
|
||||
/// </summary>
|
||||
public static CliError FromCode(string code, string? message = null)
|
||||
{
|
||||
return new CliError(code, message ?? $"Error: {code}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an exception.
|
||||
/// </summary>
|
||||
@@ -167,7 +180,7 @@ public sealed record CliError(
|
||||
/// Creates an error from a parsed API error.
|
||||
/// CLI-SDK-62-002: Surfaces standardized API error envelope fields.
|
||||
/// </summary>
|
||||
public static CliError FromParsedApiError(ParsedApiError error)
|
||||
internal static CliError FromParsedApiError(ParsedApiError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
|
||||
@@ -195,7 +208,7 @@ public sealed record CliError(
|
||||
/// Creates an error from an API error envelope.
|
||||
/// CLI-SDK-62-002: Direct conversion from envelope format.
|
||||
/// </summary>
|
||||
public static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
|
||||
internal static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ namespace StellaOps.Cli.Services;
|
||||
internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly JsonSerializerOptions JsonOptions = SerializerOptions;
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
|
||||
@@ -523,8 +524,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
var errorCode = ExtractProblemErrorCode(problem);
|
||||
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new PolicyApiException(message, response.StatusCode, errorCode);
|
||||
}
|
||||
|
||||
@@ -639,8 +639,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
var errorCode = ExtractProblemErrorCode(problem);
|
||||
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new PolicyApiException(message, response.StatusCode, errorCode);
|
||||
}
|
||||
|
||||
@@ -758,8 +757,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
var errorCode = ExtractProblemErrorCode(problem);
|
||||
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new PolicyApiException(message, response.StatusCode, errorCode);
|
||||
}
|
||||
|
||||
@@ -807,8 +805,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
var errorCode = ExtractProblemErrorCode(problem);
|
||||
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new PolicyApiException(message, response.StatusCode, errorCode);
|
||||
}
|
||||
|
||||
@@ -858,8 +855,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
var errorCode = ExtractProblemErrorCode(problem);
|
||||
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new PolicyApiException(message, response.StatusCode, errorCode);
|
||||
}
|
||||
|
||||
@@ -909,11 +905,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("EntryTrace response payload was empty.");
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("EntryTrace response payload was empty.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -4512,11 +4508,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
|
||||
// CLI-SDK-64-001: SDK update operations
|
||||
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
EnsureBackendConfigured();
|
||||
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
EnsureBackendConfigured();
|
||||
OfflineModeGuard.ThrowIfOffline("sdk update");
|
||||
|
||||
var queryParams = new List<string>();
|
||||
@@ -4554,9 +4550,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
|
||||
}
|
||||
|
||||
public async Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -576,12 +577,10 @@ internal sealed class ExceptionClient : IExceptionClient
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
try
|
||||
{
|
||||
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
@@ -589,8 +588,10 @@ internal sealed class ExceptionClient : IExceptionClient
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Token acquisition failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
src/Cli/StellaOps.Cli/Services/MigrationModuleRegistry.cs
Normal file
60
src/Cli/StellaOps.Cli/Services/MigrationModuleRegistry.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a PostgreSQL module with its migration metadata.
|
||||
/// </summary>
|
||||
public sealed record MigrationModuleInfo(
|
||||
string Name,
|
||||
string SchemaName,
|
||||
Assembly MigrationsAssembly,
|
||||
string? ResourcePrefix = null);
|
||||
|
||||
/// <summary>
|
||||
/// Registry of all PostgreSQL modules and their migration assemblies.
|
||||
/// Stub implementation - actual module assemblies will be wired in Wave 3-8.
|
||||
/// </summary>
|
||||
public static class MigrationModuleRegistry
|
||||
{
|
||||
// TODO: Wire actual module assemblies when Storage.Postgres projects are implemented
|
||||
// Modules will be registered as:
|
||||
// - Authority (auth schema) - StellaOps.Authority.Storage.Postgres.AuthorityDataSource
|
||||
// - Scheduler (scheduler schema) - StellaOps.Scheduler.Storage.Postgres.SchedulerDataSource
|
||||
// - Concelier (vuln schema) - StellaOps.Concelier.Storage.Postgres.ConcelierDataSource
|
||||
// - Policy (policy schema) - StellaOps.Policy.Storage.Postgres.PolicyDataSource
|
||||
// - Notify (notify schema) - StellaOps.Notify.Storage.Postgres.NotifyDataSource
|
||||
// - Excititor (vex schema) - StellaOps.Excititor.Storage.Postgres.ExcititorDataSource
|
||||
private static readonly List<MigrationModuleInfo> _modules = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered modules.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<MigrationModuleInfo> Modules => _modules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets module names for CLI completion.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> ModuleNames => _modules.Select(m => m.Name);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a module by name (case-insensitive).
|
||||
/// </summary>
|
||||
public static MigrationModuleInfo? FindModule(string name) =>
|
||||
_modules.FirstOrDefault(m =>
|
||||
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Gets modules matching the filter, or all if filter is null/empty.
|
||||
/// </summary>
|
||||
public static IEnumerable<MigrationModuleInfo> GetModules(string? moduleFilter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(moduleFilter) || moduleFilter.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var module = FindModule(moduleFilter);
|
||||
return module != null ? [module] : [];
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ internal sealed class AttestationSubjectInfo
|
||||
/// <summary>
|
||||
/// Signature information for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignatureInfo
|
||||
internal sealed record AttestationSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
@@ -162,7 +162,7 @@ internal sealed class AttestationSignerInfo
|
||||
/// <summary>
|
||||
/// Summary of the predicate for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationPredicateSummary
|
||||
internal sealed record AttestationPredicateSummary
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
@@ -519,17 +519,8 @@ internal sealed class PolicyDiagnostic
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "error";
|
||||
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyName("column")]
|
||||
public int? Column { get; init; }
|
||||
|
||||
[JsonPropertyName("span")]
|
||||
public string? Span { get; init; }
|
||||
|
||||
[JsonPropertyName("suggestion")]
|
||||
public string? Suggestion { get; init; }
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
// CLI-POLICY-27-002: Policy submission/review workflow models
|
||||
|
||||
@@ -236,7 +236,7 @@ internal sealed class ReachabilityFunction
|
||||
/// <summary>
|
||||
/// Reachability override for policy simulation.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityOverride
|
||||
internal sealed record ReachabilityOverride
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -634,12 +635,10 @@ internal sealed class NotifyClient : INotifyClient
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
try
|
||||
{
|
||||
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
@@ -647,8 +646,10 @@ internal sealed class NotifyClient : INotifyClient
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Token acquisition failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -170,12 +171,10 @@ internal sealed class ObservabilityClient : IObservabilityClient
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = ["obs:read"] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
try
|
||||
{
|
||||
var result = await tokenClient.GetAccessTokenAsync(StellaOpsScopes.ObservabilityRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
@@ -183,9 +182,11 @@ internal sealed class ObservabilityClient : IObservabilityClient
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Token acquisition failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Trace retrieval
|
||||
|
||||
41
src/Cli/StellaOps.Cli/Services/OfflineModeGuard.cs
Normal file
41
src/Cli/StellaOps.Cli/Services/OfflineModeGuard.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Guard for operations that require network connectivity.
|
||||
/// Stub implementation - will be wired to actual offline mode detection.
|
||||
/// </summary>
|
||||
internal static class OfflineModeGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the CLI is currently in offline mode.
|
||||
/// </summary>
|
||||
public static bool IsOffline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether network operations are allowed.
|
||||
/// </summary>
|
||||
public static bool IsNetworkAllowed() => !IsOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether network operations are allowed, checking options and logging operation.
|
||||
/// </summary>
|
||||
/// <param name="options">CLI options (used to check offline mode setting).</param>
|
||||
/// <param name="operationName">Name of the operation being checked.</param>
|
||||
public static bool IsNetworkAllowed(object? options, string operationName) => !IsOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the CLI is in offline mode and the operation requires network.
|
||||
/// </summary>
|
||||
/// <param name="operationName">Name of the operation being guarded.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when offline and network required.</exception>
|
||||
public static void ThrowIfOffline(string operationName)
|
||||
{
|
||||
if (IsOffline)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Operation '{operationName}' requires network connectivity but CLI is in offline mode.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,10 @@ using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.Client.Scopes;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -386,7 +387,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
private async Task ConfigureAuthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokenClient.GetCachedAccessTokenAsync(
|
||||
new[] { StellaOpsScope.OrchRead },
|
||||
new[] { StellaOpsScopes.OrchRead },
|
||||
cancellationToken);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -997,12 +998,10 @@ internal sealed class PackClient : IPackClient
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
try
|
||||
{
|
||||
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
@@ -1010,8 +1009,10 @@ internal sealed class PackClient : IPackClient
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Token acquisition failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -463,12 +464,10 @@ internal sealed class SbomClient : ISbomClient
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
try
|
||||
{
|
||||
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
@@ -476,8 +475,10 @@ internal sealed class SbomClient : ISbomClient
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Token acquisition failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -194,11 +195,18 @@ internal sealed class SbomerClient : ISbomerClient
|
||||
if (_tokenClient == null)
|
||||
return;
|
||||
|
||||
var token = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
try
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
var tokenResult = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(tokenResult.AccessToken))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to acquire token for Sbomer API access.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -20,7 +21,7 @@ namespace StellaOps.Cli.Services;
|
||||
internal sealed class VexObservationsClient : IVexObservationsClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenClient? _tokenClient;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly ILogger<VexObservationsClient> _logger;
|
||||
private string? _cachedToken;
|
||||
private DateTimeOffset _tokenExpiry;
|
||||
@@ -33,7 +34,7 @@ internal sealed class VexObservationsClient : IVexObservationsClient
|
||||
public VexObservationsClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<VexObservationsClient> logger,
|
||||
ITokenClient? tokenClient = null)
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -138,20 +139,23 @@ internal sealed class VexObservationsClient : IVexObservationsClient
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenResult = await _tokenClient.GetAccessTokenAsync(
|
||||
new[] { StellaOpsScopes.VexRead },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var tokenResult = await _tokenClient.GetAccessTokenAsync(
|
||||
StellaOpsScopes.VexRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (tokenResult.IsSuccess && !string.IsNullOrWhiteSpace(tokenResult.AccessToken))
|
||||
{
|
||||
_cachedToken = tokenResult.AccessToken;
|
||||
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
if (!string.IsNullOrWhiteSpace(tokenResult.AccessToken))
|
||||
{
|
||||
_cachedToken = tokenResult.AccessToken;
|
||||
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Failed to acquire token for VEX API access.");
|
||||
_logger.LogWarning(ex, "Failed to acquire token for VEX API access.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
Reference in New Issue
Block a user