release orchestration strengthening
This commit is contained in:
582
src/Cli/StellaOps.Cli/GitOps/GitOpsController.cs
Normal file
582
src/Cli/StellaOps.Cli/GitOps/GitOpsController.cs
Normal file
@@ -0,0 +1,582 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.GitOps;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for GitOps-based release automation.
|
||||
/// Monitors Git repositories and triggers releases based on Git events.
|
||||
/// </summary>
|
||||
public sealed class GitOpsController : BackgroundService
|
||||
{
|
||||
private readonly IGitEventSource _eventSource;
|
||||
private readonly IReleaseService _releaseService;
|
||||
private readonly IPromotionService _promotionService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly GitOpsConfig _config;
|
||||
private readonly ILogger<GitOpsController> _logger;
|
||||
private readonly ConcurrentDictionary<string, GitOpsState> _repoStates = new();
|
||||
|
||||
public event EventHandler<GitOpsEventArgs>? ReleaseTriggered;
|
||||
public event EventHandler<GitOpsEventArgs>? PromotionTriggered;
|
||||
public event EventHandler<GitOpsEventArgs>? ValidationFailed;
|
||||
|
||||
public GitOpsController(
|
||||
IGitEventSource eventSource,
|
||||
IReleaseService releaseService,
|
||||
IPromotionService promotionService,
|
||||
TimeProvider timeProvider,
|
||||
GitOpsConfig config,
|
||||
ILogger<GitOpsController> logger)
|
||||
{
|
||||
_eventSource = eventSource;
|
||||
_releaseService = releaseService;
|
||||
_promotionService = promotionService;
|
||||
_timeProvider = timeProvider;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
|
||||
_eventSource.EventReceived += OnGitEventReceived;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a repository for GitOps monitoring.
|
||||
/// </summary>
|
||||
public async Task<RegistrationResult> RegisterRepositoryAsync(
|
||||
GitOpsRepositoryConfig repoConfig,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repoConfig);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registering repository {RepoUrl} for GitOps",
|
||||
repoConfig.RepositoryUrl);
|
||||
|
||||
var state = new GitOpsState
|
||||
{
|
||||
RepositoryUrl = repoConfig.RepositoryUrl,
|
||||
Config = repoConfig,
|
||||
Status = GitOpsStatus.Active,
|
||||
RegisteredAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_repoStates[repoConfig.RepositoryUrl] = state;
|
||||
|
||||
// Start monitoring
|
||||
await _eventSource.SubscribeAsync(repoConfig.RepositoryUrl, repoConfig.Branches, ct);
|
||||
|
||||
return new RegistrationResult
|
||||
{
|
||||
Success = true,
|
||||
RepositoryUrl = repoConfig.RepositoryUrl,
|
||||
MonitoredBranches = repoConfig.Branches
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a repository from GitOps monitoring.
|
||||
/// </summary>
|
||||
public async Task<bool> UnregisterRepositoryAsync(
|
||||
string repositoryUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_repoStates.TryRemove(repositoryUrl, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _eventSource.UnsubscribeAsync(repositoryUrl, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Unregistered repository {RepoUrl} from GitOps",
|
||||
repositoryUrl);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers a release for a commit.
|
||||
/// </summary>
|
||||
public async Task<TriggerResult> TriggerReleaseAsync(
|
||||
ManualTriggerRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Manually triggering release for {RepoUrl} at {CommitSha}",
|
||||
request.RepositoryUrl, request.CommitSha);
|
||||
|
||||
var gitEvent = new GitEvent
|
||||
{
|
||||
Type = GitEventType.Push,
|
||||
RepositoryUrl = request.RepositoryUrl,
|
||||
Branch = request.Branch,
|
||||
CommitSha = request.CommitSha,
|
||||
CommitMessage = request.CommitMessage ?? "Manual trigger",
|
||||
Author = request.Author ?? "system",
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return await ProcessGitEventAsync(gitEvent, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of all monitored repositories.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GitOpsState> GetRepositoryStatuses()
|
||||
{
|
||||
return _repoStates.Values.ToList();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("GitOps controller starting");
|
||||
|
||||
await _eventSource.StartAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Keep running until stopped
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown
|
||||
}
|
||||
|
||||
await _eventSource.StopAsync(CancellationToken.None);
|
||||
|
||||
_logger.LogInformation("GitOps controller stopped");
|
||||
}
|
||||
|
||||
private async void OnGitEventReceived(object? sender, GitEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessGitEventAsync(e, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error processing Git event for {RepoUrl}",
|
||||
e.RepositoryUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TriggerResult> ProcessGitEventAsync(
|
||||
GitEvent gitEvent,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_repoStates.TryGetValue(gitEvent.RepositoryUrl, out var state))
|
||||
{
|
||||
return new TriggerResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Repository not registered"
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Processing {EventType} event for {RepoUrl} on {Branch}",
|
||||
gitEvent.Type, gitEvent.RepositoryUrl, gitEvent.Branch);
|
||||
|
||||
// Check if branch matches triggers
|
||||
var trigger = FindMatchingTrigger(state.Config, gitEvent);
|
||||
if (trigger is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No matching trigger for branch {Branch}",
|
||||
gitEvent.Branch);
|
||||
|
||||
return new TriggerResult
|
||||
{
|
||||
Success = true,
|
||||
Skipped = true,
|
||||
Reason = "No matching trigger"
|
||||
};
|
||||
}
|
||||
|
||||
// Validate commit message patterns if configured
|
||||
if (!ValidateCommitMessage(gitEvent.CommitMessage, trigger))
|
||||
{
|
||||
ValidationFailed?.Invoke(this, new GitOpsEventArgs
|
||||
{
|
||||
Event = gitEvent,
|
||||
Reason = "Commit message validation failed"
|
||||
});
|
||||
|
||||
return new TriggerResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Commit message validation failed"
|
||||
};
|
||||
}
|
||||
|
||||
// Execute trigger action
|
||||
return trigger.Action switch
|
||||
{
|
||||
TriggerAction.CreateRelease => await CreateReleaseAsync(gitEvent, trigger, ct),
|
||||
TriggerAction.Promote => await PromoteAsync(gitEvent, trigger, ct),
|
||||
TriggerAction.ValidateOnly => await ValidateAsync(gitEvent, trigger, ct),
|
||||
_ => new TriggerResult { Success = false, Error = "Unknown action" }
|
||||
};
|
||||
}
|
||||
|
||||
private GitOpsTrigger? FindMatchingTrigger(GitOpsRepositoryConfig config, GitEvent gitEvent)
|
||||
{
|
||||
return config.Triggers.FirstOrDefault(t =>
|
||||
MatchesBranch(t.BranchPattern, gitEvent.Branch) &&
|
||||
(t.EventTypes.Length == 0 || t.EventTypes.Contains(gitEvent.Type)));
|
||||
}
|
||||
|
||||
private static bool MatchesBranch(string pattern, string branch)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.EndsWith("/*"))
|
||||
{
|
||||
var prefix = pattern[..^2];
|
||||
return branch.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return pattern.Equals(branch, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ValidateCommitMessage(string? message, GitOpsTrigger trigger)
|
||||
{
|
||||
if (string.IsNullOrEmpty(trigger.CommitMessagePattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(trigger.CommitMessagePattern);
|
||||
return regex.IsMatch(message);
|
||||
}
|
||||
|
||||
private async Task<TriggerResult> CreateReleaseAsync(
|
||||
GitEvent gitEvent,
|
||||
GitOpsTrigger trigger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating release from {CommitSha} on {Branch}",
|
||||
gitEvent.CommitSha, gitEvent.Branch);
|
||||
|
||||
try
|
||||
{
|
||||
var releaseId = await _releaseService.CreateReleaseAsync(new CreateReleaseRequest
|
||||
{
|
||||
RepositoryUrl = gitEvent.RepositoryUrl,
|
||||
CommitSha = gitEvent.CommitSha,
|
||||
Branch = gitEvent.Branch,
|
||||
Environment = trigger.TargetEnvironment ?? "development",
|
||||
Version = ExtractVersion(gitEvent, trigger),
|
||||
AutoPromote = trigger.AutoPromote
|
||||
}, ct);
|
||||
|
||||
ReleaseTriggered?.Invoke(this, new GitOpsEventArgs
|
||||
{
|
||||
Event = gitEvent,
|
||||
ReleaseId = releaseId
|
||||
});
|
||||
|
||||
return new TriggerResult
|
||||
{
|
||||
Success = true,
|
||||
ReleaseId = releaseId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to create release for {CommitSha}",
|
||||
gitEvent.CommitSha);
|
||||
|
||||
return new TriggerResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TriggerResult> PromoteAsync(
|
||||
GitEvent gitEvent,
|
||||
GitOpsTrigger trigger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Promoting from {SourceEnv} to {TargetEnv}",
|
||||
trigger.SourceEnvironment, trigger.TargetEnvironment);
|
||||
|
||||
try
|
||||
{
|
||||
var promotionId = await _promotionService.PromoteAsync(new PromoteRequest
|
||||
{
|
||||
SourceEnvironment = trigger.SourceEnvironment!,
|
||||
TargetEnvironment = trigger.TargetEnvironment!,
|
||||
CommitSha = gitEvent.CommitSha,
|
||||
AutoApprove = trigger.AutoApprove
|
||||
}, ct);
|
||||
|
||||
PromotionTriggered?.Invoke(this, new GitOpsEventArgs
|
||||
{
|
||||
Event = gitEvent,
|
||||
PromotionId = promotionId
|
||||
});
|
||||
|
||||
return new TriggerResult
|
||||
{
|
||||
Success = true,
|
||||
PromotionId = promotionId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to promote");
|
||||
|
||||
return new TriggerResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private Task<TriggerResult> ValidateAsync(
|
||||
GitEvent gitEvent,
|
||||
GitOpsTrigger trigger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Validating commit {CommitSha}",
|
||||
gitEvent.CommitSha);
|
||||
|
||||
// Validation-only mode - no actual release creation
|
||||
return Task.FromResult(new TriggerResult
|
||||
{
|
||||
Success = true,
|
||||
ValidationOnly = true
|
||||
});
|
||||
}
|
||||
|
||||
private static string ExtractVersion(GitEvent gitEvent, GitOpsTrigger trigger)
|
||||
{
|
||||
// Try to extract version from tag or branch
|
||||
if (gitEvent.Type == GitEventType.Tag && gitEvent.Tag is not null)
|
||||
{
|
||||
var tag = gitEvent.Tag;
|
||||
if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tag = tag[1..];
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
// Use commit SHA prefix as version
|
||||
return gitEvent.CommitSha[..8];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for GitOps controller.
|
||||
/// </summary>
|
||||
public sealed record GitOpsConfig
|
||||
{
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public bool EnableWebhooks { get; init; } = true;
|
||||
public int MaxConcurrentEvents { get; init; } = 5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a GitOps-monitored repository.
|
||||
/// </summary>
|
||||
public sealed record GitOpsRepositoryConfig
|
||||
{
|
||||
public required string RepositoryUrl { get; init; }
|
||||
public ImmutableArray<string> Branches { get; init; } = ["main", "release/*"];
|
||||
public ImmutableArray<GitOpsTrigger> Triggers { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A GitOps trigger definition.
|
||||
/// </summary>
|
||||
public sealed record GitOpsTrigger
|
||||
{
|
||||
public required string BranchPattern { get; init; }
|
||||
public ImmutableArray<GitEventType> EventTypes { get; init; } = [];
|
||||
public required TriggerAction Action { get; init; }
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? SourceEnvironment { get; init; }
|
||||
public string? CommitMessagePattern { get; init; }
|
||||
public bool AutoPromote { get; init; }
|
||||
public bool AutoApprove { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger action types.
|
||||
/// </summary>
|
||||
public enum TriggerAction
|
||||
{
|
||||
CreateRelease,
|
||||
Promote,
|
||||
ValidateOnly
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of a monitored repository.
|
||||
/// </summary>
|
||||
public sealed record GitOpsState
|
||||
{
|
||||
public required string RepositoryUrl { get; init; }
|
||||
public required GitOpsRepositoryConfig Config { get; init; }
|
||||
public required GitOpsStatus Status { get; init; }
|
||||
public required DateTimeOffset RegisteredAt { get; init; }
|
||||
public DateTimeOffset? LastEventAt { get; init; }
|
||||
public string? LastCommitSha { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GitOps status.
|
||||
/// </summary>
|
||||
public enum GitOpsStatus
|
||||
{
|
||||
Active,
|
||||
Paused,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Git event.
|
||||
/// </summary>
|
||||
public sealed record GitEvent
|
||||
{
|
||||
public required GitEventType Type { get; init; }
|
||||
public required string RepositoryUrl { get; init; }
|
||||
public required string Branch { get; init; }
|
||||
public required string CommitSha { get; init; }
|
||||
public string? CommitMessage { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public required string Author { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Git event types.
|
||||
/// </summary>
|
||||
public enum GitEventType
|
||||
{
|
||||
Push,
|
||||
Tag,
|
||||
PullRequest,
|
||||
Merge
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of repository registration.
|
||||
/// </summary>
|
||||
public sealed record RegistrationResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? RepositoryUrl { get; init; }
|
||||
public ImmutableArray<string> MonitoredBranches { get; init; } = [];
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to manually trigger.
|
||||
/// </summary>
|
||||
public sealed record ManualTriggerRequest
|
||||
{
|
||||
public required string RepositoryUrl { get; init; }
|
||||
public required string Branch { get; init; }
|
||||
public required string CommitSha { get; init; }
|
||||
public string? CommitMessage { get; init; }
|
||||
public string? Author { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a trigger.
|
||||
/// </summary>
|
||||
public sealed record TriggerResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public bool Skipped { get; init; }
|
||||
public bool ValidationOnly { get; init; }
|
||||
public Guid? ReleaseId { get; init; }
|
||||
public Guid? PromotionId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for GitOps events.
|
||||
/// </summary>
|
||||
public sealed class GitOpsEventArgs : EventArgs
|
||||
{
|
||||
public required GitEvent Event { get; init; }
|
||||
public Guid? ReleaseId { get; init; }
|
||||
public Guid? PromotionId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a release.
|
||||
/// </summary>
|
||||
public sealed record CreateReleaseRequest
|
||||
{
|
||||
public required string RepositoryUrl { get; init; }
|
||||
public required string CommitSha { get; init; }
|
||||
public required string Branch { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public bool AutoPromote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to promote.
|
||||
/// </summary>
|
||||
public sealed record PromoteRequest
|
||||
{
|
||||
public required string SourceEnvironment { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string CommitSha { get; init; }
|
||||
public bool AutoApprove { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Git event source.
|
||||
/// </summary>
|
||||
public interface IGitEventSource
|
||||
{
|
||||
event EventHandler<GitEvent>? EventReceived;
|
||||
Task StartAsync(CancellationToken ct = default);
|
||||
Task StopAsync(CancellationToken ct = default);
|
||||
Task SubscribeAsync(string repositoryUrl, ImmutableArray<string> branches, CancellationToken ct = default);
|
||||
Task UnsubscribeAsync(string repositoryUrl, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for release service.
|
||||
/// </summary>
|
||||
public interface IReleaseService
|
||||
{
|
||||
Task<Guid> CreateReleaseAsync(CreateReleaseRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for promotion service.
|
||||
/// </summary>
|
||||
public interface IPromotionService
|
||||
{
|
||||
Task<Guid> PromoteAsync(PromoteRequest request, CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user