feat: Add Go module and workspace test fixtures
- Created expected JSON files for Go modules and workspaces. - Added go.mod and go.sum files for example projects. - Implemented private module structure with expected JSON output. - Introduced vendored dependencies with corresponding expected JSON. - Developed PostgresGraphJobStore for managing graph jobs. - Established SQL migration scripts for graph jobs schema. - Implemented GraphJobRepository for CRUD operations on graph jobs. - Created IGraphJobRepository interface for repository abstraction. - Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
@@ -75,7 +75,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(SystemCommandBuilder.BuildSystemCommand());
|
||||
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -23,60 +27,118 @@ internal static class SystemCommandBuilder
|
||||
};
|
||||
}
|
||||
|
||||
internal static Command BuildSystemCommand()
|
||||
internal static Command BuildSystemCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var moduleOption = new Option<string?>("--module", description: "Module name (Authority, Scheduler, Concelier, Policy, Notify, Excititor, all)");
|
||||
var categoryOption = new Option<string?>("--category", description: "Migration category (startup, release, seed, data)");
|
||||
var moduleOption = new Option<string?>(
|
||||
"--module",
|
||||
description: "Module name (Authority, Scheduler, Concelier, Policy, Notify, Excititor, all)");
|
||||
var categoryOption = new Option<string?>(
|
||||
"--category",
|
||||
description: "Migration category (startup, release, seed, data)");
|
||||
var dryRunOption = new Option<bool>("--dry-run", description: "List migrations without executing");
|
||||
var connectionOption = new Option<string?>(
|
||||
"--connection",
|
||||
description: "PostgreSQL connection string override (otherwise uses STELLAOPS_POSTGRES_* env vars)");
|
||||
var timeoutOption = new Option<int?>(
|
||||
"--timeout",
|
||||
description: "Command timeout in seconds for each migration (default 300).");
|
||||
var forceOption = new Option<bool>(
|
||||
"--force",
|
||||
description: "Allow execution of release migrations without --dry-run.");
|
||||
|
||||
var run = new Command("migrations-run", "Run migrations for the selected module(s).");
|
||||
run.AddOption(moduleOption);
|
||||
run.AddOption(categoryOption);
|
||||
run.AddOption(dryRunOption);
|
||||
run.AddOption(connectionOption);
|
||||
run.AddOption(timeoutOption);
|
||||
run.AddOption(forceOption);
|
||||
run.SetAction(async parseResult =>
|
||||
{
|
||||
var modules = MigrationModuleRegistry.GetModules(parseResult.GetValue(moduleOption)).ToList();
|
||||
if (!modules.Any())
|
||||
{
|
||||
throw new CommandLineException("No modules matched the filter; available: " + string.Join(", ", MigrationModuleRegistry.ModuleNames));
|
||||
throw new CommandLineException(
|
||||
"No modules matched the filter; available: " + string.Join(", ", MigrationModuleRegistry.ModuleNames));
|
||||
}
|
||||
|
||||
var category = ParseCategory(parseResult.GetValue(categoryOption));
|
||||
if (category == MigrationCategory.Release && parseResult.GetValue(dryRunOption) == false)
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
|
||||
if (category == MigrationCategory.Release && !dryRun && !force)
|
||||
{
|
||||
throw new CommandLineException("Release migrations require explicit approval; use --dry-run to preview or run approved release migrations manually.");
|
||||
throw new CommandLineException(
|
||||
"Release migrations require explicit approval; use --dry-run to preview or --force to execute.");
|
||||
}
|
||||
|
||||
var connection = parseResult.GetValue(connectionOption);
|
||||
var timeoutSeconds = parseResult.GetValue(timeoutOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var migrationService = services.GetRequiredService<MigrationCommandService>();
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
var result = await migrationService
|
||||
.RunAsync(module, connection, category, dryRun, timeoutSeconds, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WriteRunResult(module, result, verbose);
|
||||
}
|
||||
// TODO: wire MigrationRunnerAdapter to execute migrations per module/category.
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
var status = new Command("migrations-status", "Show migration status for the selected module(s).");
|
||||
status.AddOption(moduleOption);
|
||||
status.AddOption(categoryOption);
|
||||
status.AddOption(connectionOption);
|
||||
status.SetAction(async parseResult =>
|
||||
{
|
||||
var modules = MigrationModuleRegistry.GetModules(parseResult.GetValue(moduleOption)).ToList();
|
||||
if (!modules.Any())
|
||||
{
|
||||
throw new CommandLineException("No modules matched the filter; available: " + string.Join(", ", MigrationModuleRegistry.ModuleNames));
|
||||
throw new CommandLineException(
|
||||
"No modules matched the filter; available: " + string.Join(", ", MigrationModuleRegistry.ModuleNames));
|
||||
}
|
||||
|
||||
var connection = parseResult.GetValue(connectionOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var migrationService = services.GetRequiredService<MigrationCommandService>();
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
var statusResult = await migrationService
|
||||
.GetStatusAsync(module, connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WriteStatusResult(module, statusResult, verbose);
|
||||
}
|
||||
ParseCategory(parseResult.GetValue(categoryOption));
|
||||
// TODO: wire MigrationRunnerAdapter to fetch status.
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
var verify = new Command("migrations-verify", "Verify migration checksums for the selected module(s).");
|
||||
verify.AddOption(moduleOption);
|
||||
verify.AddOption(categoryOption);
|
||||
verify.AddOption(connectionOption);
|
||||
verify.SetAction(async parseResult =>
|
||||
{
|
||||
var modules = MigrationModuleRegistry.GetModules(parseResult.GetValue(moduleOption)).ToList();
|
||||
if (!modules.Any())
|
||||
{
|
||||
throw new CommandLineException("No modules matched the filter; available: " + string.Join(", ", MigrationModuleRegistry.ModuleNames));
|
||||
throw new CommandLineException(
|
||||
"No modules matched the filter; available: " + string.Join(", ", MigrationModuleRegistry.ModuleNames));
|
||||
}
|
||||
|
||||
var connection = parseResult.GetValue(connectionOption);
|
||||
var migrationService = services.GetRequiredService<MigrationCommandService>();
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
var errors = await migrationService
|
||||
.VerifyAsync(module, connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WriteVerifyResult(module, errors);
|
||||
}
|
||||
ParseCategory(parseResult.GetValue(categoryOption));
|
||||
// TODO: wire MigrationRunnerAdapter to verify checksums.
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
var system = new Command("system", "System operations (migrations).");
|
||||
@@ -85,4 +147,84 @@ internal static class SystemCommandBuilder
|
||||
system.Add(verify);
|
||||
return system;
|
||||
}
|
||||
|
||||
private static void WriteRunResult(MigrationModuleInfo module, MigrationResult result, bool verbose)
|
||||
{
|
||||
var prefix = $"[{module.Name}]";
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"{prefix} FAILED: {result.ErrorMessage}");
|
||||
foreach (var error in result.ChecksumErrors)
|
||||
{
|
||||
Console.Error.WriteLine($"{prefix} checksum: {error}");
|
||||
}
|
||||
|
||||
if (Environment.ExitCode == 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"{prefix} applied={result.AppliedCount} skipped={result.SkippedCount} filtered={result.FilteredCount} duration_ms={result.DurationMs}");
|
||||
|
||||
if (verbose && result.AppliedMigrations.Count > 0)
|
||||
{
|
||||
foreach (var migration in result.AppliedMigrations.OrderBy(m => m.Name))
|
||||
{
|
||||
var mode = migration.WasDryRun ? "DRY-RUN" : "APPLIED";
|
||||
Console.WriteLine($"{prefix} {mode}: {migration.Name} ({migration.Category}) {migration.DurationMs}ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteStatusResult(MigrationModuleInfo module, MigrationStatus status, bool verbose)
|
||||
{
|
||||
var prefix = $"[{module.Name}]";
|
||||
|
||||
Console.WriteLine(
|
||||
$"{prefix} applied={status.AppliedCount} pending_startup={status.PendingStartupCount} pending_release={status.PendingReleaseCount} checksum_errors={status.ChecksumErrors.Count}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
foreach (var pending in status.PendingMigrations.OrderBy(p => p.Name))
|
||||
{
|
||||
Console.WriteLine($"{prefix} pending {pending.Category}: {pending.Name}");
|
||||
}
|
||||
|
||||
foreach (var error in status.ChecksumErrors)
|
||||
{
|
||||
Console.WriteLine($"{prefix} checksum: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
if (status.HasBlockingIssues && Environment.ExitCode == 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteVerifyResult(MigrationModuleInfo module, IReadOnlyList<string> errors)
|
||||
{
|
||||
var prefix = $"[{module.Name}]";
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"{prefix} checksum verification passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"{prefix} checksum verification failed ({errors.Count}).");
|
||||
foreach (var error in errors)
|
||||
{
|
||||
Console.Error.WriteLine($"{prefix} {error}");
|
||||
}
|
||||
|
||||
if (Environment.ExitCode == 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ internal static class Program
|
||||
|
||||
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
|
||||
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
|
||||
services.AddSingleton<MigrationCommandService>();
|
||||
|
||||
// CLI-FORENSICS-53-001: Forensic snapshot client
|
||||
services.AddHttpClient<IForensicSnapshotClient, ForensicSnapshotClient>(client =>
|
||||
|
||||
123
src/Cli/StellaOps.Cli/Services/MigrationCommandService.cs
Normal file
123
src/Cli/StellaOps.Cli/Services/MigrationCommandService.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for running, verifying, and querying PostgreSQL migrations from the CLI.
|
||||
/// </summary>
|
||||
internal sealed class MigrationCommandService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
public MigrationCommandService(IConfiguration configuration, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
}
|
||||
|
||||
public Task<MigrationResult> RunAsync(
|
||||
MigrationModuleInfo module,
|
||||
string? connectionOverride,
|
||||
MigrationCategory? category,
|
||||
bool dryRun,
|
||||
int? timeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module, connectionOverride);
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
|
||||
var options = new MigrationRunOptions
|
||||
{
|
||||
CategoryFilter = category,
|
||||
DryRun = dryRun,
|
||||
TimeoutSeconds = timeoutSeconds.GetValueOrDefault(300),
|
||||
ValidateChecksums = true,
|
||||
FailOnChecksumMismatch = true
|
||||
};
|
||||
|
||||
return runner.RunFromAssemblyAsync(module.MigrationsAssembly, module.ResourcePrefix, options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MigrationStatus> GetStatusAsync(
|
||||
MigrationModuleInfo module,
|
||||
string? connectionOverride,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module, connectionOverride);
|
||||
var logger = _loggerFactory.CreateLogger($"migrationstatus.{module.Name}");
|
||||
var statusService = new MigrationStatusService(
|
||||
connectionString,
|
||||
module.SchemaName,
|
||||
module.Name,
|
||||
module.MigrationsAssembly,
|
||||
logger);
|
||||
|
||||
return await statusService.GetStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> VerifyAsync(
|
||||
MigrationModuleInfo module,
|
||||
string? connectionOverride,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module, connectionOverride);
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
return runner.ValidateChecksumsAsync(module.MigrationsAssembly, module.ResourcePrefix, cancellationToken);
|
||||
}
|
||||
|
||||
private MigrationRunner CreateRunner(MigrationModuleInfo module, string connectionString) =>
|
||||
new(connectionString, module.SchemaName, module.Name, _loggerFactory.CreateLogger($"migration.{module.Name}"));
|
||||
|
||||
private string ResolveConnectionString(MigrationModuleInfo module, string? connectionOverride)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(connectionOverride))
|
||||
{
|
||||
return connectionOverride;
|
||||
}
|
||||
|
||||
var envCandidates = new[]
|
||||
{
|
||||
$"STELLAOPS_POSTGRES_{module.Name.ToUpperInvariant()}_CONNECTION",
|
||||
$"STELLAOPS_POSTGRES_{module.SchemaName.ToUpperInvariant()}_CONNECTION",
|
||||
"STELLAOPS_POSTGRES_CONNECTION",
|
||||
"STELLAOPS_DB_CONNECTION"
|
||||
};
|
||||
|
||||
foreach (var key in envCandidates)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(key);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
var configCandidates = new[]
|
||||
{
|
||||
$"StellaOps:Database:{module.Name}:ConnectionString",
|
||||
$"Database:{module.Name}:ConnectionString",
|
||||
$"StellaOps:Postgres:ConnectionString",
|
||||
$"Postgres:ConnectionString",
|
||||
"Database:ConnectionString"
|
||||
};
|
||||
|
||||
foreach (var key in configCandidates)
|
||||
{
|
||||
var value = _configuration[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No PostgreSQL connection string found for module '{module.Name}'. " +
|
||||
"Provide --connection or set STELLAOPS_POSTGRES_CONNECTION.");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper kept for DI compatibility; prefer using <see cref="MigrationCommandService"/>.
|
||||
/// </summary>
|
||||
internal sealed class MigrationRunnerAdapter
|
||||
{
|
||||
private readonly IMigrationRunner _runner;
|
||||
@@ -13,9 +17,22 @@ internal sealed class MigrationRunnerAdapter
|
||||
_runner = runner;
|
||||
}
|
||||
|
||||
public Task<int> RunAsync(string migrationsPath, MigrationCategory? category, CancellationToken cancellationToken) =>
|
||||
_runner.RunAsync(migrationsPath, category, cancellationToken);
|
||||
public Task<MigrationResult> RunAsync(
|
||||
string migrationsPath,
|
||||
MigrationRunOptions? options,
|
||||
CancellationToken cancellationToken) =>
|
||||
_runner.RunAsync(migrationsPath, options, cancellationToken);
|
||||
|
||||
public Task<int> VerifyAsync(string migrationsPath, MigrationCategory? category, CancellationToken cancellationToken) =>
|
||||
_runner.VerifyAsync(migrationsPath, category, cancellationToken);
|
||||
public Task<MigrationResult> RunFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix,
|
||||
MigrationRunOptions? options,
|
||||
CancellationToken cancellationToken) =>
|
||||
_runner.RunFromAssemblyAsync(assembly, resourcePrefix, options, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<string>> VerifyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix,
|
||||
CancellationToken cancellationToken) =>
|
||||
_runner.ValidateChecksumsAsync(assembly, resourcePrefix, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using Xunit;
|
||||
@@ -10,7 +12,7 @@ public class SystemCommandBuilderTests
|
||||
[Fact]
|
||||
public void BuildSystemCommand_AddsMigrationsSubcommands()
|
||||
{
|
||||
var system = SystemCommandBuilder.BuildSystemCommand();
|
||||
var system = BuildSystemCommand();
|
||||
Assert.NotNull(system);
|
||||
Assert.Equal("system", system.Name);
|
||||
Assert.Contains(system.Subcommands, c => c.Name == "migrations-run");
|
||||
@@ -28,4 +30,16 @@ public class SystemCommandBuilderTests
|
||||
Assert.Contains("Notify", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Excititor", MigrationModuleRegistry.ModuleNames);
|
||||
}
|
||||
|
||||
private static Command BuildSystemCommand()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddSingleton<MigrationCommandService>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
return SystemCommandBuilder.BuildSystemCommand(provider, verboseOption, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user