584 lines
17 KiB
C#
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);
|
|
}
|