using System.Security.Cryptography; using System.Text; using LibGit2Sharp; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Models.DTOs; namespace OTSSignsOrchestrator.Core.Services; /// /// Fetches template.yml and template.env from a Git repository using LibGit2Sharp. /// Caches clones on disk, keyed by SHA-256 hash of the repo URL. /// public class GitTemplateService { private readonly GitOptions _options; private readonly ILogger _logger; public GitTemplateService(IOptions options, ILogger logger) { _options = options.Value; _logger = logger; } public async Task FetchAsync(string repoUrl, string? pat = null, bool forceRefresh = false) { var cacheKey = ComputeCacheKey(repoUrl); var cacheDir = Path.Combine(_options.CacheDir, cacheKey); _logger.LogInformation("Fetching templates from repo (cacheKey={CacheKey}, force={Force})", cacheKey, forceRefresh); await Task.Run(() => { if (!Directory.Exists(cacheDir) || !Repository.IsValid(cacheDir)) { CloneRepo(repoUrl, pat, cacheDir); } else if (forceRefresh || IsCacheStale(cacheDir)) { FetchLatest(repoUrl, pat, cacheDir); } else { _logger.LogDebug("Using cached clone at {CacheDir}", cacheDir); } }); var yamlPath = FindFile(cacheDir, "template.yml"); if (yamlPath == null) throw new FileNotFoundException("template.yml not found in repository root. Commit the template file produced by ComposeRenderService.GetTemplateYaml() to the repo root."); var yaml = await File.ReadAllTextAsync(yamlPath); return new TemplateConfig { Yaml = yaml, FetchedAt = DateTime.UtcNow }; } private void CloneRepo(string repoUrl, string? pat, string cacheDir) { _logger.LogInformation("Shallow cloning repo to {CacheDir}", cacheDir); if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); Directory.CreateDirectory(cacheDir); var cloneOpts = new CloneOptions { IsBare = false, RecurseSubmodules = false }; if (!string.IsNullOrEmpty(pat)) { cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { Username = pat, Password = string.Empty }; } try { Repository.Clone(repoUrl, cacheDir, cloneOpts); } catch (LibGit2SharpException ex) { _logger.LogError(ex, "Git clone failed for repo (cacheKey={CacheKey})", ComputeCacheKey(repoUrl)); throw new InvalidOperationException($"Failed to clone repository. Check URL and credentials. Error: {ex.Message}", ex); } } private void FetchLatest(string repoUrl, string? pat, string cacheDir) { _logger.LogInformation("Fetching latest from origin in {CacheDir}", cacheDir); try { using var repo = new Repository(cacheDir); var fetchOpts = new FetchOptions(); if (!string.IsNullOrEmpty(pat)) { fetchOpts.CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { Username = pat, Password = string.Empty }; } var remote = repo.Network.Remotes["origin"]; var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToArray(); Commands.Fetch(repo, "origin", refSpecs, fetchOpts, "Auto-fetch for template update"); var trackingBranch = repo.Head.TrackedBranch; if (trackingBranch != null) { repo.Reset(ResetMode.Hard, trackingBranch.Tip); } WriteCacheTimestamp(cacheDir); } catch (LibGit2SharpException ex) { _logger.LogError(ex, "Git fetch failed for repo at {CacheDir}", cacheDir); throw new InvalidOperationException($"Failed to fetch latest templates. Error: {ex.Message}", ex); } } private bool IsCacheStale(string cacheDir) { var stampFile = Path.Combine(cacheDir, ".fetch-timestamp"); if (!File.Exists(stampFile)) return true; if (DateTime.TryParse(File.ReadAllText(stampFile), out var lastFetch)) { return (DateTime.UtcNow - lastFetch).TotalMinutes > _options.CacheTtlMinutes; } return true; } private static void WriteCacheTimestamp(string cacheDir) { var stampFile = Path.Combine(cacheDir, ".fetch-timestamp"); File.WriteAllText(stampFile, DateTime.UtcNow.ToString("O")); } private static string? FindFile(string dir, string fileName) { var path = Path.Combine(dir, fileName); return File.Exists(path) ? path : null; } private static string ComputeCacheKey(string repoUrl) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(repoUrl)); return Convert.ToHexString(hash)[..16].ToLowerInvariant(); } }