Files
git.stella-ops.org/src/Cli/StellaOps.Cli/GitOps/GitOpsController.cs
2026-02-01 21:37:40 +02:00

584 lines
17 KiB
C#

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Immutable;
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);
}