using Microsoft.Extensions.Logging; using YamlDotNet.RepresentationModel; namespace OTSSignsOrchestrator.Core.Services; /// /// Validates a rendered Compose YAML before deployment. /// public class ComposeValidationService { private readonly ILogger _logger; public ComposeValidationService(ILogger logger) { _logger = logger; } public ValidationResult Validate(string composeYaml, string? customerAbbrev = null) { var errors = new List(); var warnings = new List(); YamlStream yamlStream; try { yamlStream = new YamlStream(); using var reader = new StringReader(composeYaml); yamlStream.Load(reader); } catch (Exception ex) { errors.Add($"YAML parse error: {ex.Message}"); return new ValidationResult { Errors = errors, Warnings = warnings }; } if (yamlStream.Documents.Count == 0) { errors.Add("YAML document is empty."); return new ValidationResult { Errors = errors, Warnings = warnings }; } var root = yamlStream.Documents[0].RootNode as YamlMappingNode; if (root == null) { errors.Add("YAML root is not a mapping node."); return new ValidationResult { Errors = errors, Warnings = warnings }; } if (!HasKey(root, "services")) errors.Add("Missing required top-level key: 'services'."); if (!HasKey(root, "secrets")) warnings.Add("Missing top-level key: 'secrets'. Secrets may not be available."); if (HasKey(root, "services") && root.Children[new YamlScalarNode("services")] is YamlMappingNode services) { var presentServices = services.Children.Keys .OfType() .Select(k => k.Value!) .ToHashSet(StringComparer.OrdinalIgnoreCase); // Determine required service suffixes var requiredSuffixes = new[] { "-web", "-memcached", "-quickchart" }; var prefix = customerAbbrev ?? string.Empty; if (!string.IsNullOrEmpty(prefix)) { foreach (var suffix in requiredSuffixes) { if (!presentServices.Contains($"{prefix}{suffix}")) errors.Add($"Missing required service: '{prefix}{suffix}'."); } } else { // Fallback: at least check that there are web/memcached/quickchart services if (!presentServices.Any(s => s.EndsWith("-web", StringComparison.OrdinalIgnoreCase))) errors.Add("Missing a '-web' service."); if (!presentServices.Any(s => s.EndsWith("-memcached", StringComparison.OrdinalIgnoreCase))) errors.Add("Missing a '-memcached' service."); } foreach (var (key, value) in services.Children) { if (key is YamlScalarNode keyNode && value is YamlMappingNode svcNode) { if (!HasKey(svcNode, "image")) errors.Add($"Service '{keyNode.Value}' is missing 'image'."); } } } if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode) { foreach (var (key, value) in secretsNode.Children) { if (value is YamlMappingNode secretNode) { if (!HasKey(secretNode, "external")) warnings.Add($"Secret '{(key as YamlScalarNode)?.Value}' is not external."); } } } return new ValidationResult { Errors = errors, Warnings = warnings }; } private static bool HasKey(YamlMappingNode node, string key) => node.Children.ContainsKey(new YamlScalarNode(key)); } public class ValidationResult { public List Errors { get; set; } = new(); public List Warnings { get; set; } = new(); public bool IsValid => Errors.Count == 0; }