diff --git a/source/Calamari.Tests/KubernetesFixtures/Commands/Executors/GatherAndApplyRawYamlExecutorTests.cs b/source/Calamari.Tests/KubernetesFixtures/Commands/Executors/GatherAndApplyRawYamlExecutorTests.cs index 9dd568053..d1f32a8c3 100644 --- a/source/Calamari.Tests/KubernetesFixtures/Commands/Executors/GatherAndApplyRawYamlExecutorTests.cs +++ b/source/Calamari.Tests/KubernetesFixtures/Commands/Executors/GatherAndApplyRawYamlExecutorTests.cs @@ -6,7 +6,6 @@ using Calamari.Common.Commands; using Calamari.Common.Features.Processes; using Calamari.Common.Plumbing.FileSystem; -using Calamari.Common.Plumbing.ServiceMessages; using Calamari.Common.Plumbing.Variables; using Calamari.Kubernetes; using Calamari.Kubernetes.Commands; @@ -27,6 +26,7 @@ public class GatherAndApplyRawYamlExecutorTests { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); readonly ICommandLineRunner commandLineRunner = Substitute.For(); + readonly IManifestReporter manifestReporter = Substitute.For(); InMemoryLog log; List receivedCallbacks; @@ -337,7 +337,8 @@ void SetupCommandLineRunnerMocks() IRawYamlKubernetesApplyExecutor CreateExecutor(IVariables variables, ICalamariFileSystem fs) { var kubectl = new Kubectl(variables, log, commandLineRunner); - return new GatherAndApplyRawYamlExecutor(log, fs, kubectl); + + return new GatherAndApplyRawYamlExecutor(log, fs, manifestReporter, kubectl); } Task RecordingCallback(ResourceIdentifier[] identifiers) diff --git a/source/Calamari.Tests/KubernetesFixtures/ManifestReporterTests.cs b/source/Calamari.Tests/KubernetesFixtures/ManifestReporterTests.cs new file mode 100644 index 000000000..b5b7d674d --- /dev/null +++ b/source/Calamari.Tests/KubernetesFixtures/ManifestReporterTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Common.Plumbing.ServiceMessages; +using Calamari.Common.Plumbing.Variables; +using Calamari.Kubernetes; +using Calamari.Testing.Helpers; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.Tests.KubernetesFixtures +{ + [TestFixture] + public class ManifestReporterTests + { + [Test] + public void GivenValidYaml_ShouldPostSingleServiceMessage() + { + var memoryLog = new InMemoryLog(); + var variables = new CalamariVariables(); + + var yaml = @"foo: bar"; + var expectedJson = "{\"foo\": \"bar\"}"; + using (CreateFile(yaml, out var filePath)) + { + var mr = new ManifestReporter(variables, CalamariPhysicalFileSystem.GetPhysicalFileSystem(), memoryLog); + + mr.ReportManifestApplied(filePath); + + var expected = ServiceMessage.Create(SpecialVariables.ServiceMessageNames.ManifestApplied.Name, ("ns", "default"), ("manifest", expectedJson)); + memoryLog.ServiceMessages.Should().BeEquivalentTo(new List { expected }); + } + } + + [Test] + public void GivenInValidManifest_ShouldNotPostServiceMessage() + { + var memoryLog = new InMemoryLog(); + var variables = new CalamariVariables(); + + var yaml = @"text - Bar"; + using (CreateFile(yaml, out var filePath)) + { + var mr = new ManifestReporter(variables, CalamariPhysicalFileSystem.GetPhysicalFileSystem(), memoryLog); + + mr.ReportManifestApplied(filePath); + + memoryLog.ServiceMessages.Should().BeEmpty(); + } + } + + [Test] + public void GivenNamespaceInManifest_ShouldReportManifestNamespace() + { + var memoryLog = new InMemoryLog(); + var variables = new CalamariVariables(); + var yaml = @"metadata: + name: game-demo + namespace: XXX"; + using (CreateFile(yaml, out var filePath)) + { + var variableNs = Some.String(); + variables.Set(SpecialVariables.Namespace, variableNs); + var mr = new ManifestReporter(variables, CalamariPhysicalFileSystem.GetPhysicalFileSystem(), memoryLog); + + mr.ReportManifestApplied(filePath); + + memoryLog.ServiceMessages.First().Properties.Should().Contain(new KeyValuePair("ns", "XXX")); + } + } + + [Test] + public void GivenNamespaceNotInManifest_ShouldReportVariableNamespace() + { + var memoryLog = new InMemoryLog(); + var variables = new CalamariVariables(); + var yaml = @"foo: bar"; + using (CreateFile(yaml, out var filePath)) + { + var variableNs = Some.String(); + variables.Set(SpecialVariables.Namespace, variableNs); + var mr = new ManifestReporter(variables, CalamariPhysicalFileSystem.GetPhysicalFileSystem(), memoryLog); + + mr.ReportManifestApplied(filePath); + + memoryLog.ServiceMessages.First().Properties.Should().Contain(new KeyValuePair("ns", variableNs)); + } + } + + [Test] + public void GiveNoNamespaces_ShouldDefaultNamespace() + { + var memoryLog = new InMemoryLog(); + var variables = new CalamariVariables(); + var yaml = @"foo: bar"; + using (CreateFile(yaml, out var filePath)) + { + var mr = new ManifestReporter(variables, CalamariPhysicalFileSystem.GetPhysicalFileSystem(), memoryLog); + + mr.ReportManifestApplied(filePath); + + memoryLog.ServiceMessages.First().Properties.Should().Contain(new KeyValuePair("ns", "default")); + } + } + + IDisposable CreateFile(string yaml, out string filePath) + { + var tempDir = TemporaryDirectory.Create(); + filePath = Path.Combine(tempDir.DirectoryPath, $"{Guid.NewGuid():d}.tmp"); + File.WriteAllText(filePath, yaml); + return tempDir; + } + } +} \ No newline at end of file diff --git a/source/Calamari.Tests/KubernetesFixtures/ResourceStatus/ResourceUpdateReporterTests.cs b/source/Calamari.Tests/KubernetesFixtures/ResourceStatus/ResourceUpdateReporterTests.cs index 9c1b461bb..7337eaa16 100644 --- a/source/Calamari.Tests/KubernetesFixtures/ResourceStatus/ResourceUpdateReporterTests.cs +++ b/source/Calamari.Tests/KubernetesFixtures/ResourceStatus/ResourceUpdateReporterTests.cs @@ -29,7 +29,7 @@ public void ReportsCreatedResourcesCorrectly() reporter.ReportUpdatedResources(originalStatuses, newStatuses, 1); var serviceMessages = log.ServiceMessages - .Where(message => message.Name == SpecialVariables.KubernetesResourceStatusServiceMessageName) + .Where(message => message.Name == SpecialVariables.ServiceMessageNames.ResourceStatus.Name) .ToList(); serviceMessages.Select(message => message.Properties["name"]) @@ -72,7 +72,7 @@ public void ReportsUpdatedResourcesCorrectly() reporter.ReportUpdatedResources(originalStatuses, newStatuses, 1); var serviceMessages = log.ServiceMessages - .Where(message => message.Name == SpecialVariables.KubernetesResourceStatusServiceMessageName) + .Where(message => message.Name == SpecialVariables.ServiceMessageNames.ResourceStatus.Name) .ToList(); serviceMessages.Should().ContainSingle().Which.Properties @@ -102,7 +102,7 @@ public void ReportsRemovedResourcesCorrectly() reporter.ReportUpdatedResources(originalStatuses, newStatuses, 1); var serviceMessages = log.ServiceMessages - .Where(message => message.Name == SpecialVariables.KubernetesResourceStatusServiceMessageName) + .Where(message => message.Name == SpecialVariables.ServiceMessageNames.ResourceStatus.Name) .ToList(); serviceMessages.Should().ContainSingle().Which.Properties @@ -129,7 +129,7 @@ public void ClusterScopedResourcesAreIgnored() reporter.ReportUpdatedResources(new Dictionary(), newStatuses, 1); var serviceMessages = log.ServiceMessages - .Where(message => message.Name == SpecialVariables.KubernetesResourceStatusServiceMessageName) + .Where(message => message.Name == SpecialVariables.ServiceMessageNames.ResourceStatus.Name) .ToList(); serviceMessages.Should().ContainSingle().Which.Properties diff --git a/source/Calamari.Tests/Some.cs b/source/Calamari.Tests/Some.cs new file mode 100644 index 000000000..dd5a47147 --- /dev/null +++ b/source/Calamari.Tests/Some.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; + +namespace Calamari.Tests +{ + public static class Some + { + static int next; + + public static string String() => "S__" + Int(); + + public static int Int() => Interlocked.Increment(ref next); + } +} \ No newline at end of file diff --git a/source/Calamari/Kubernetes/Commands/Executors/GatherAndApplyRawYamlExecutor.cs b/source/Calamari/Kubernetes/Commands/Executors/GatherAndApplyRawYamlExecutor.cs index cc1384c5d..f831dcaf9 100644 --- a/source/Calamari/Kubernetes/Commands/Executors/GatherAndApplyRawYamlExecutor.cs +++ b/source/Calamari/Kubernetes/Commands/Executors/GatherAndApplyRawYamlExecutor.cs @@ -6,7 +6,6 @@ using Calamari.Common.Commands; using Calamari.Common.Plumbing.FileSystem; using Calamari.Common.Plumbing.Logging; -using Calamari.Common.Plumbing.Variables; using Calamari.Kubernetes.Integration; using Calamari.Kubernetes.ResourceStatus.Resources; using Octopus.CoreUtilities.Extensions; @@ -21,15 +20,18 @@ class GatherAndApplyRawYamlExecutor : BaseKubernetesApplyExecutor, IRawYamlKuber { readonly ILog log; readonly ICalamariFileSystem fileSystem; + readonly IManifestReporter manifestReporter; readonly Kubectl kubectl; public GatherAndApplyRawYamlExecutor( ILog log, ICalamariFileSystem fileSystem, + IManifestReporter manifestReporter, Kubectl kubectl) : base(log) { this.log = log; this.fileSystem = fileSystem; + this.manifestReporter = manifestReporter; this.kubectl = kubectl; } @@ -37,7 +39,7 @@ protected override async Task> ApplyAndGetResour { var variables = deployment.Variables; var globs = variables.GetPaths(SpecialVariables.CustomResourceYamlFileName); - + if (globs.IsNullOrEmpty()) return Enumerable.Empty(); @@ -65,38 +67,31 @@ protected override async Task> ApplyAndGetResour IEnumerable ApplyBatchAndReturnResourceIdentifiers(RunningDeployment deployment, GlobDirectory globDirectory) { - if (!LogFoundFiles(globDirectory)) + var files = fileSystem.EnumerateFilesRecursively(globDirectory.Directory).ToArray(); + if (!files.Any()) + { + log.Warn($"No files found matching '{globDirectory.Glob}'"); return Array.Empty(); + } + + ReportEachManifestBeingApplied(globDirectory, files); string[] executeArgs = {"apply", "-f", $@"""{globDirectory.Directory}""", "--recursive", "-o", "json"}; executeArgs = executeArgs.AddOptionsForServerSideApply(deployment.Variables, log); - var result = kubectl.ExecuteCommandAndReturnOutput(executeArgs); return ProcessKubectlCommandOutput(deployment, result, globDirectory.Directory); } - /// - /// Logs files that are found at the relevant glob locations. - /// - /// - /// True if files are found, False if no files exist at this location - bool LogFoundFiles(GlobDirectory globDirectory) + void ReportEachManifestBeingApplied(GlobDirectory globDirectory, string[] files) { var directoryWithTrailingSlash = globDirectory.Directory + Path.DirectorySeparatorChar; - var files = fileSystem.EnumerateFilesRecursively(globDirectory.Directory).ToArray(); - if (!files.Any()) - { - log.Warn($"No files found matching '{globDirectory.Glob}'"); - return false; - } - foreach (var file in files) { - log.Verbose($"Matched file: {fileSystem.GetRelativePath(directoryWithTrailingSlash, file)}"); + var fullFilePath = fileSystem.GetRelativePath(directoryWithTrailingSlash, file); + log.Verbose($"Matched file: {fullFilePath}"); + manifestReporter.ReportManifestApplied(file); } - - return true; } } } \ No newline at end of file diff --git a/source/Calamari/Kubernetes/ManifestReporter.cs b/source/Calamari/Kubernetes/ManifestReporter.cs new file mode 100644 index 000000000..b5c54ef6c --- /dev/null +++ b/source/Calamari/Kubernetes/ManifestReporter.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.ServiceMessages; +using Calamari.Common.Plumbing.Variables; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Calamari.Kubernetes +{ + public interface IManifestReporter + { + void ReportManifestApplied(string filePath); + } + + public class ManifestReporter : IManifestReporter + { + readonly IVariables variables; + readonly ICalamariFileSystem fileSystem; + readonly ILog log; + + static readonly IDeserializer YamlDeserializer = new Deserializer(); + + static readonly ISerializer YamlSerializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .JsonCompatible() + .Build(); + + public ManifestReporter(IVariables variables, ICalamariFileSystem fileSystem, ILog log) + { + this.variables = variables; + this.fileSystem = fileSystem; + this.log = log; + } + + string GetNamespace(YamlMappingNode yamlRoot) + { + var implicitNamespace = variables.Get(SpecialVariables.Namespace) ?? "default"; + + if (yamlRoot.Children.TryGetValue("metadata", out var metadataNode) && metadataNode is YamlMappingNode metadataMappingNode && + metadataMappingNode.Children.TryGetValue("namespace", out var namespaceNode) && namespaceNode is YamlScalarNode namespaceScalarNode && + !string.IsNullOrWhiteSpace(namespaceScalarNode.Value)) + { + implicitNamespace = namespaceScalarNode.Value; + } + + return implicitNamespace; + } + + public void ReportManifestApplied(string filePath) + { + using (var yamlFile = fileSystem.OpenFile(filePath, FileAccess.ReadWrite)) + { + try + { + var yamlStream = new YamlStream(); + yamlStream.Load(new StreamReader(yamlFile)); + + foreach (var document in yamlStream.Documents) + { + if (!(document.RootNode is YamlMappingNode rootNode)) + { + log.Warn("Could not parse manifest, resources will not be added to live object status"); + continue; + } + + var updatedDocument = YamlNodeToJson(rootNode); + + var ns = GetNamespace(rootNode); + log.WriteServiceMessage(new ServiceMessage(SpecialVariables.ServiceMessageNames.ManifestApplied.Name, + new Dictionary + { + { SpecialVariables.ServiceMessageNames.ManifestApplied.ManifestAttribute, updatedDocument }, + { SpecialVariables.ServiceMessageNames.ManifestApplied.NamespaceAttribute, ns } + })); + } + } + catch (SemanticErrorException) + { + log.Warn("Invalid YAML syntax found, resources will not be added to live object status"); + } + } + } + + static string YamlNodeToJson(YamlNode node) + { + var stream = new YamlStream { new YamlDocument(node) }; + using (var writer = new StringWriter()) + { + stream.Save(writer); + + using (var reader = new StringReader(writer.ToString())) + { + var yamlObject = YamlDeserializer.Deserialize(reader); + return yamlObject is null ? string.Empty : YamlSerializer.Serialize(yamlObject).Trim(); + } + } + } + } +} \ No newline at end of file diff --git a/source/Calamari/Kubernetes/ResourceStatus/ResourceUpdateReporter.cs b/source/Calamari/Kubernetes/ResourceStatus/ResourceUpdateReporter.cs index f596fc9d7..3c7096074 100644 --- a/source/Calamari/Kubernetes/ResourceStatus/ResourceUpdateReporter.cs +++ b/source/Calamari/Kubernetes/ResourceStatus/ResourceUpdateReporter.cs @@ -99,7 +99,7 @@ private void SendServiceMessage(Resource resource, bool removed, int checkCount) {"checkCount", checkCount.ToString()} }; - var message = new ServiceMessage(SpecialVariables.KubernetesResourceStatusServiceMessageName, parameters); + var message = new ServiceMessage(SpecialVariables.ServiceMessageNames.ResourceStatus.Name, parameters); log.WriteServiceMessage(message); } } diff --git a/source/Calamari/Kubernetes/SpecialVariables.cs b/source/Calamari/Kubernetes/SpecialVariables.cs index 1c3b6be11..aa79808c7 100644 --- a/source/Calamari/Kubernetes/SpecialVariables.cs +++ b/source/Calamari/Kubernetes/SpecialVariables.cs @@ -36,8 +36,6 @@ public static class SpecialVariables public const string KubeConfig = "Octopus.KubeConfig.Path"; public const string KustomizeManifest = "Octopus.Kustomize.Manifest.Path"; - public const string KubernetesResourceStatusServiceMessageName = "k8s-status"; - public const string ServerSideApplyEnabled = "Octopus.Action.Kubernetes.ServerSideApply.Enabled"; public const string ServerSideApplyForceConflicts = "Octopus.Action.Kubernetes.ServerSideApply.ForceConflicts"; @@ -66,5 +64,20 @@ public static string ValuesFilePath(string key) } } } + + public class ServiceMessageNames + { + public static class ResourceStatus + { + public const string Name = "k8s-status"; + } + + public static class ManifestApplied + { + public const string Name = "k8s-manifest-applied"; + public const string ManifestAttribute = "manifest"; + public const string NamespaceAttribute = "ns"; + } + } } } diff --git a/source/Calamari/Program.cs b/source/Calamari/Program.cs index 02097551d..e83d185b4 100644 --- a/source/Calamari/Program.cs +++ b/source/Calamari/Program.cs @@ -26,6 +26,7 @@ using IContainer = Autofac.IContainer; using Calamari.Aws.Deployment; using Calamari.Azure.Kubernetes.Discovery; +using Calamari.Kubernetes; using Calamari.Kubernetes.Commands.Executors; namespace Calamari @@ -71,6 +72,7 @@ protected override void ConfigureContainer(ContainerBuilder builder, CommonOptio builder.RegisterType().As().SingleInstance(); builder.RegisterType().AsSelf(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As();