diff --git a/.gitignore b/.gitignore index efc2f5b4..e01f1a7e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ octopus*.tgz index.yaml node_modules +.DS_STORE + *.orig \ No newline at end of file diff --git a/Global.json b/Global.json new file mode 100644 index 00000000..6c5cf46a --- /dev/null +++ b/Global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.101", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/.gitignore b/tests/kubernetes-agent/.gitignore index 5e57f180..4763eb2d 100644 --- a/tests/kubernetes-agent/.gitignore +++ b/tests/kubernetes-agent/.gitignore @@ -242,7 +242,6 @@ ClientBin/ *.dbmdl *.dbproj.schemaview *.jfm -*.pfx *.publishsettings orleans.codegen.cs diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/GlobalUsings.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/GlobalUsings.cs new file mode 100644 index 00000000..70748def --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/GlobalUsings.cs @@ -0,0 +1,3 @@ +// Global using directives + +global using Serilog; \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/HelmChartBuilder.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/HelmChartBuilder.cs new file mode 100644 index 00000000..0e53dfdf --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/HelmChartBuilder.cs @@ -0,0 +1,58 @@ +using System; +using KubernetesAgent.Integration.Setup.Common; + +namespace KubernetesAgent.Integration +{ + public static class HelmChartBuilder + { + public static string BuildHelmChart(string helmExecutable, TemporaryDirectory directory) + { + var version = GetChartVersion(); + version = $"{version}-{DateTime.Now:yyyymdHHmmss}"; + + var packager = ProcessRunner.Run(helmExecutable, directory, GetHelmChartPackageArguments(version)); + if (packager.ExitCode != 0) + { + throw new Exception($"Failed to package Helm chart. Exit code: {packager.ExitCode}"); + } + + var output = packager.StandardOutput.ReadToEnd(); + return output.Split(":")[^1].ToString().Trim(); + } + + static string[] GetHelmChartPackageArguments(string version) + { + var chartsDirectory = GetChartsDirectory().FullName; + return + [ + "package", + chartsDirectory, + "--version", + version + ]; + } + + static DirectoryInfo GetChartsDirectory() + { + var chartsDirectory = Path.Combine(AppContext.BaseDirectory); + var currentDirectory = new DirectoryInfo(chartsDirectory); + while (currentDirectory != null && currentDirectory.EnumerateDirectories().All(d => d.Name != "charts")) + { + currentDirectory = currentDirectory.Parent; + } + if (currentDirectory == null) + { + throw new DirectoryNotFoundException("Could not find the charts directory"); + } + return new DirectoryInfo(Path.Combine(currentDirectory.FullName, "charts", "kubernetes-agent")); + } + + static string GetChartVersion() + { + var chartDirectory = GetChartsDirectory(); + var chartYaml = Path.Combine(chartDirectory.FullName, "Chart.yaml"); + var chart = File.ReadAllText(chartYaml); + return chart.Split("\n").First(l => l.StartsWith("version:")).Split(":")[1].Trim('"').Trim(); + } + } +} diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/KubernetesAgent.IntegrationTests.csproj b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/KubernetesAgent.IntegrationTests.csproj index 951ed96b..80a2fbe9 100644 --- a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/KubernetesAgent.IntegrationTests.csproj +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/KubernetesAgent.IntegrationTests.csproj @@ -13,6 +13,10 @@ + + + + @@ -21,4 +25,31 @@ + + + + PreserveNewest + + + + PreserveNewest + + + + PreserveNewest + + + + PreserveNewest + + + + PreserveNewest + + + + PreserveNewest + + + diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/RunsAgentUpgrade.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/RunsAgentUpgrade.cs new file mode 100644 index 00000000..51e0c343 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/RunsAgentUpgrade.cs @@ -0,0 +1,89 @@ +using Halibut; +using KubernetesAgent.Integration.Setup; +using KubernetesAgent.Integration.Setup.Common; +using Octopus.Tentacle.Client; +using Octopus.Tentacle.Client.Scripts.Models; +using Octopus.Tentacle.Client.Scripts.Models.Builders; +using Octopus.Tentacle.Contracts; +using Xunit.Abstractions; + +namespace KubernetesAgent.Integration; + +public class HelmUpgradeTests(ITestOutputHelper output) : IAsyncLifetime +{ + readonly ITestOutputHelper output = output; + ILogger logger = null!; + readonly TemporaryDirectory workingDirectory = new(Directory.CreateTempSubdirectory()); + KubernetesClusterInstaller clusterInstaller = null!; + KubernetesAgentInstaller agentInstaller = null!; + TentacleClient client = null!; + string kindExePath = null!; + string helmExePath = null!; + string kubeCtlPath = null!; + + public async Task InitializeAsync() + { + logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(output) + .CreateLogger(); + + var requiredToolDownloader = new RequiredToolDownloader(workingDirectory, logger); + (kindExePath, helmExePath, kubeCtlPath) = await requiredToolDownloader.DownloadRequiredTools(CancellationToken.None); + clusterInstaller = new KubernetesClusterInstaller(workingDirectory, kindExePath, helmExePath, kubeCtlPath, logger); + await clusterInstaller.Install(); + agentInstaller = new KubernetesAgentInstaller(workingDirectory , helmExePath, kubeCtlPath, clusterInstaller.KubeConfigPath, logger); + client = await agentInstaller.InstallAgent(); + } + + public async Task DisposeAsync() + { + clusterInstaller.Dispose(); + workingDirectory.Dispose(); + await Task.CompletedTask; + } + + [Fact] + public async Task CanUpgradeAgentAndRunCommand() + { + var helmPackage = HelmChartBuilder.BuildHelmChart(helmExePath, workingDirectory); + var helmPackageFile = new FileInfo(helmPackage); + var packageName = helmPackageFile.Name; + var packageBytes = await File.ReadAllBytesAsync(helmPackage); + var helmUpgradeScript = + $""" + helm upgrade \ + --atomic \ + --namespace {agentInstaller.Namespace} \ + {agentInstaller.AgentName} \ + /tmp/{packageName} + """; + var upgradeHelmChartCommand = new ExecuteKubernetesScriptCommandBuilder(Guid.NewGuid().ToString()) + .WithScriptBody($"cp ./{packageName} /tmp/{packageName} && {helmUpgradeScript}") + .WithScriptFile(new ScriptFile(packageName, DataStream.FromBytes(packageBytes))) + .Build(); + void onScriptStatusResponseReceived(ScriptExecutionStatus res) => logger.Information("{Output}", res.ToString()); + async Task onScriptCompleted(CancellationToken t) + { + await Task.CompletedTask; + logger.Information("Script completed"); + } + var testLogger = new TestLogger(logger); + var result = await client.ExecuteScript(upgradeHelmChartCommand, onScriptStatusResponseReceived, onScriptCompleted, testLogger, CancellationToken.None); + if (result.ExitCode != 0) + { + throw new Exception($"Script failed with exit code {result.ExitCode}"); + } + logger.Information("Upgrade executed successfully"); + + var runHelloWorldCommand = new ExecuteKubernetesScriptCommandBuilder(Guid.NewGuid().ToString()) + .WithScriptBody("echo \"hello world\"") + .Build(); + result = await client.ExecuteScript(runHelloWorldCommand, onScriptStatusResponseReceived, onScriptCompleted, testLogger, CancellationToken.None); + if (result.ExitCode != 0) + { + throw new Exception($"Script failed with exit code {result.ExitCode}"); + } + logger.Information("Script executed successfully"); + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/AssemblyExtensions.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/AssemblyExtensions.cs new file mode 100644 index 00000000..59889b0c --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/AssemblyExtensions.cs @@ -0,0 +1,13 @@ +using System; +using System.Reflection; + +namespace KubernetesAgent.Integration.Setup.Common; + +public static class AssemblyExtensions +{ + public static Stream GetManifestResourceStreamFromPartialName(this Assembly assembly, string filename) + { + var valuesFileName = assembly.GetManifestResourceNames().Single(n => n.Contains(filename, StringComparison.OrdinalIgnoreCase)); + return assembly.GetManifestResourceStream(valuesFileName)!; + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Certificates/Server.pfx b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Certificates/Server.pfx new file mode 100644 index 00000000..3d24af13 Binary files /dev/null and b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Certificates/Server.pfx differ diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Certificates/Tentacle.pfx b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Certificates/Tentacle.pfx new file mode 100644 index 00000000..81088db4 Binary files /dev/null and b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Certificates/Tentacle.pfx differ diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/EnumerableExtensions.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/EnumerableExtensions.cs new file mode 100644 index 00000000..841ff861 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/EnumerableExtensions.cs @@ -0,0 +1,8 @@ +namespace KubernetesAgent.Integration.Setup.Common; + +public static class EnumerableExtensions +{ + public static IEnumerable WhereNotNull(this IEnumerable items) + where T : class + => items.Where(i => i != null)!; +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Extraction/TarFileExtractor.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Extraction/TarFileExtractor.cs new file mode 100644 index 00000000..fc691057 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/Extraction/TarFileExtractor.cs @@ -0,0 +1,30 @@ +using System.Formats.Tar; +using System.IO.Compression; + +namespace KubernetesAgent.Integration.Setup.Common.Extraction; + +public static class TarFileExtractor +{ + public static void Extract(FileSystemInfo tarFile, FileSystemInfo targetDirectory) + { + if (!targetDirectory.Exists) + { + Directory.CreateDirectory(targetDirectory.FullName); + } + using var compressedFileStream = File.Open(tarFile.FullName, FileMode.Open); + using var tarStream = new GZipStream(compressedFileStream, CompressionMode.Decompress); + TarFile.ExtractToDirectory(tarStream, targetDirectory.FullName, true); + } + + public static void ExtractAndMakeExecutable(FileSystemInfo tarFile, FileSystemInfo targetDirectory) + { + Extract(tarFile, targetDirectory); + targetDirectory.MakeExecutable(); + } + public static void ExtractAndMakeExecutable(string tarFilePath, string targetDirectoryPath) + { + var tarFile = new FileInfo(tarFilePath); + var targetDirectory = new FileInfo(targetDirectoryPath); + ExtractAndMakeExecutable(tarFile, targetDirectory); + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/FileSystemInfoExtensionMethods.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/FileSystemInfoExtensionMethods.cs new file mode 100644 index 00000000..4f52a3ad --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/FileSystemInfoExtensionMethods.cs @@ -0,0 +1,14 @@ +namespace KubernetesAgent.Integration.Setup.Common; + +public static class FileSystemInfoExtensionMethods +{ + public static void MakeExecutable(this FileSystemInfo fsObject) + { + var result = ProcessRunner.Run("chmod", "-R", "+x", fsObject.FullName); + if (result.ExitCode != 0) + { + throw new Exception($"Failed to make {fsObject.FullName} executable. Exit code: {result.ExitCode}. stdout: {result.StandardOutput.ReadToEnd()}, stderr: {result.StandardError.ReadToEnd()}."); + } + result.Close(); + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/PollingSubscriptionIdGenerator.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/PollingSubscriptionIdGenerator.cs new file mode 100644 index 00000000..2a1955bf --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/PollingSubscriptionIdGenerator.cs @@ -0,0 +1,17 @@ +namespace KubernetesAgent.Integration.Setup.Common; + +public class PollingSubscriptionIdGenerator +{ + private static Random random = new Random(); + public static Uri Generate() + { + return new Uri("poll://" + RandomString(20).ToLowerInvariant() + "/"); + } + + static string RandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[random.Next(s.Length)]).ToArray()); + } +} diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/ProcessRunner.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/ProcessRunner.cs new file mode 100644 index 00000000..7b4d8548 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/ProcessRunner.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; + +namespace KubernetesAgent.Integration.Setup.Common; + +public static class ProcessRunner +{ + public static Process Run(string command, TemporaryDirectory workingDirectory, params string[] arguments) + { + var process = new Process(); + process.StartInfo.FileName = command; + process.StartInfo.Arguments = arguments.Length > 0 ? string.Join(" ", arguments) : string.Empty; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.WorkingDirectory = workingDirectory.Directory.FullName; + process.Start(); + process.WaitForExit(); + return process; + } + public static Process Run(string command, params string[] arguments) + { + var process = new Process(); + process.StartInfo.FileName = command; + process.StartInfo.Arguments = arguments.Length > 0 ? string.Join(" ", arguments) : string.Empty; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + process.WaitForExit(); + return process; + } + + + public static Process RunWithLogger(string command, TemporaryDirectory directory, ILogger logger, params string[] arguments) + { + + var process = new Process(); + process.StartInfo.FileName = command; + process.StartInfo.Arguments = arguments.Length > 0 ? string.Join(" ", arguments) : string.Empty; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.WorkingDirectory = directory.Directory.FullName; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + logger.Information("{Data}",e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + logger.Error("{Data}",e.Data); + } + }; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + return process; + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TemporaryDirectory.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TemporaryDirectory.cs new file mode 100644 index 00000000..5f80e673 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TemporaryDirectory.cs @@ -0,0 +1,20 @@ +namespace KubernetesAgent.Integration.Setup.Common; + +public class TemporaryDirectory : IDisposable +{ + public DirectoryInfo Directory { get; } + + public TemporaryDirectory(DirectoryInfo directory) + { + Directory = directory; + if (!Directory.Exists) + { + Directory.Create(); + } + } + + public void Dispose() + { + Directory.Delete(true); + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TemporaryDirectoryExtensionMethods.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TemporaryDirectoryExtensionMethods.cs new file mode 100644 index 00000000..fc15b69f --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TemporaryDirectoryExtensionMethods.cs @@ -0,0 +1,20 @@ +using System.Reflection; + +namespace KubernetesAgent.Integration.Setup.Common +{ + public static class TemporaryDirectoryExtensionMethods + { + public static string WriteFileToTemporaryDirectory(this TemporaryDirectory tempDir, string resourceFileName, string? outputFilename = null) + { + using var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStreamFromPartialName(resourceFileName); + + var filePath = Path.Combine(tempDir.Directory.FullName, outputFilename ?? resourceFileName); + using var file = File.Create(filePath); + + resourceStream.Seek(0, SeekOrigin.Begin); + resourceStream.CopyTo(file); + + return filePath; + } + } +} diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TestCertificates.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TestCertificates.cs new file mode 100644 index 00000000..a49eb95c --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TestCertificates.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography.X509Certificates; + +namespace KubernetesAgent.Integration.Setup.Common +{ + public static class TestCertificates + { + public static X509Certificate2 Tentacle; + public static string TentaclePublicThumbprint; + public static X509Certificate2 Server; + public static string ServerPublicThumbprint; + + static TestCertificates() + { + using var tempDir = new TemporaryDirectory(Directory.CreateTempSubdirectory()); + var tentaclePfxPath = tempDir.WriteFileToTemporaryDirectory("Tentacle.pfx"); + Tentacle = new X509Certificate2(tentaclePfxPath); + TentaclePublicThumbprint = Tentacle.Thumbprint; + + var serverPfxPath = tempDir.WriteFileToTemporaryDirectory("Server.pfx"); + Server = new X509Certificate2(serverPfxPath); + ServerPublicThumbprint = Server.Thumbprint; + } + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TestLogger.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TestLogger.cs new file mode 100644 index 00000000..4e1d3573 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Common/TestLogger.cs @@ -0,0 +1,225 @@ +using System; +using Octopus.Diagnostics; +using ILog = Octopus.Diagnostics.ILog; + +namespace KubernetesAgent.Integration.Setup.Common +{ + public sealed class TestLogger(ILogger logger) : ILog, IDisposable + { + readonly ILogger logger = logger; + + public string CorrelationId { get; } + + public void Dispose() + { + Flush(); + } + + public void WithSensitiveValues(string[] sensitiveValues) + { + } + + public void WithSensitiveValue(string sensitiveValue) + { + } + + public void Write(LogCategory category, string messageText) + { + Write(category, null, messageText); + } + + public void Write(LogCategory category, Exception error) + { + WriteFormat(category, error.Message); + } + + public void Write(LogCategory category, Exception? error, string messageText) + { + WriteFormat(category, "Exception: {Message}, Message: {Message}", error?.Message ?? "", messageText); + } + + public void WriteFormat(LogCategory category, string messageFormat, params object[] args) + { + WriteFormat(category, null, messageFormat, args); + } + + public void WriteFormat(LogCategory category, Exception? error, string messageFormat, params object[] args) + { + switch (category) + { + case LogCategory.Trace: + case LogCategory.Verbose: + logger.Debug(messageFormat, args); + break; + case LogCategory.Info: + case LogCategory.Planned: + case LogCategory.Highlight: + case LogCategory.Abandoned: + case LogCategory.Wait: + case LogCategory.Progress: + logger.Information(messageFormat, args); + break; + case LogCategory.Finished: + case LogCategory.Warning: + case LogCategory.Error: + case LogCategory.Fatal: + logger.Error(messageFormat, args); + break; + } + } + + public void Trace(string messageText) + { + Write(LogCategory.Trace, messageText); + } + + public void Trace(Exception error) + { + Write(LogCategory.Trace, error); + } + + public void Trace(Exception error, string message) + { + Write(LogCategory.Trace, error, message); + } + + public void TraceFormat(string messageFormat, params object[] args) + { + WriteFormat(LogCategory.Trace, messageFormat, args); + } + + public void TraceFormat(Exception error, string format, params object[] args) + { + WriteFormat(LogCategory.Trace, error, format, args); + } + + public void Verbose(string messageText) + { + Write(LogCategory.Verbose, messageText); + } + + public void Verbose(Exception error) + { + Write(LogCategory.Verbose, error); + } + + public void Verbose(Exception error, string message) + { + Write(LogCategory.Verbose, error, message); + } + + public void VerboseFormat(string format, params object[] args) + { + WriteFormat(LogCategory.Verbose, format, args); + } + + public void VerboseFormat(Exception error, string format, params object[] args) + { + WriteFormat(LogCategory.Verbose, error, format, args); + } + + public void Info(string messageText) + { + Write(LogCategory.Info, messageText); + } + + public void Info(Exception error) + { + Write(LogCategory.Info, error); + } + + public void Info(Exception error, string message) + { + Write(LogCategory.Info, error, message); + } + + public void InfoFormat(string format, params object[] args) + { + WriteFormat(LogCategory.Info, format, args); + } + + public void InfoFormat(Exception error, string format, params object[] args) + { + WriteFormat(LogCategory.Info, error, format, args); + } + + public void Warn(string messageText) + { + Write(LogCategory.Warning, messageText); + } + + public void Warn(Exception error) + { + Write(LogCategory.Warning, error); + } + + public void Warn(Exception error, string messageText) + { + Write(LogCategory.Warning, error, messageText); + } + + public void WarnFormat(string format, params object[] args) + { + WriteFormat(LogCategory.Warning, format, args); + } + + public void WarnFormat(Exception exception, string format, params object[] args) + { + WriteFormat(LogCategory.Warning, exception, format, args); + } + + public void Error(string messageText) + { + Write(LogCategory.Error, messageText); + } + + public void Error(Exception error) + { + Write(LogCategory.Error, error); + } + + public void Error(Exception error, string messageText) + { + Write(LogCategory.Error, error, messageText); + } + + public void ErrorFormat(string format, params object[] args) + { + WriteFormat(LogCategory.Error, format, args); + } + + public void ErrorFormat(Exception exception, string format, params object[] args) + { + WriteFormat(LogCategory.Error, exception, format, args); + } + + public void Fatal(string messageText) + { + Write(LogCategory.Fatal, messageText); + } + + public void Fatal(Exception error) + { + Write(LogCategory.Fatal, error); + } + + public void Fatal(Exception error, string messageText) + { + Write(LogCategory.Fatal, error, messageText); + } + + public void FatalFormat(string messageFormat, params object[] args) + { + WriteFormat(LogCategory.Fatal, messageFormat, args); + } + + public void FatalFormat(Exception exception, string format, params object[] args) + { + WriteFormat(LogCategory.Fatal, exception, format, args); + } + + public void Flush() + { } + + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/KubernetesAgentInstaller.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/KubernetesAgentInstaller.cs new file mode 100644 index 00000000..f3d61670 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/KubernetesAgentInstaller.cs @@ -0,0 +1,226 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text; +using Halibut; +using Halibut.Diagnostics; +using KubernetesAgent.Integration.Setup.Common; +using Octopus.Client.Model; +using Octopus.Tentacle.Client; +using Octopus.Tentacle.Client.Retries; +using Octopus.Tentacle.Client.Scripts; +using Octopus.Tentacle.Contracts.Observability; + +namespace KubernetesAgent.Integration.Setup; + +public class KubernetesAgentInstaller +{ + //This is the DNS of the localhost Kubernetes Server we add to the cluster in the KubernetesClusterInstaller.SetLocalhostRouting() + const string LocalhostKubernetesServiceDns = "dockerhost.default.svc.cluster.local"; + + readonly string helmExePath; + readonly string kubeCtlExePath; + readonly TemporaryDirectory temporaryDirectory; + readonly ILogger logger; + readonly string kubeConfigPath; + protected HalibutRuntime ServerHalibutRuntime { get; private set; } = null!; + protected TentacleClient TentacleClient { get; private set; } = null!; + + bool isAgentInstalled; + + public KubernetesAgentInstaller(TemporaryDirectory temporaryDirectory, string helmExePath, string kubeCtlExePath, string kubeConfigPath, ILogger logger) + { + this.temporaryDirectory = temporaryDirectory; + this.helmExePath = helmExePath; + this.kubeCtlExePath = kubeCtlExePath; + this.kubeConfigPath = kubeConfigPath; + this.logger = logger; + + AgentName = Guid.NewGuid().ToString("N"); + } + + public string AgentName { get; } + + public string Namespace => $"octopus-agent-{AgentName}"; + + public Uri SubscriptionId { get; } = PollingSubscriptionIdGenerator.Generate(); + + public async Task InstallAgent() + { + var listeningPort = BuildServerHalibutRuntimeAndListen(); + var valuesFilePath = await WriteValuesFile(listeningPort); + var arguments = BuildAgentInstallArguments(valuesFilePath); + + var sw = new Stopwatch(); + sw.Restart(); + + + var result = ProcessRunner.RunWithLogger(helmExePath, temporaryDirectory, logger, arguments); + sw.Stop(); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to install Kubernetes Agent via Helm."); + } + + isAgentInstalled = true; + + var thumbprint = await GetAgentThumbprint(); + + logger.Information("Agent certificate thumbprint: {Thumbprint:l}", thumbprint); + + ServerHalibutRuntime.Trust(thumbprint); + + BuildTentacleClient(thumbprint); + + return TentacleClient; + + } + + async Task WriteValuesFile(int listeningPort) + { + using var reader = new StreamReader(Assembly.GetExecutingAssembly().GetManifestResourceStreamFromPartialName("agent-values.yaml")); + + var valuesFile = await reader.ReadToEndAsync(); + + var serverCommsAddress = $"https://{LocalhostKubernetesServiceDns}:{listeningPort}"; + + var configMapData = $@" + Octopus.Home: /octopus + Tentacle.Deployment.ApplicationDirectory: /octopus/Applications + Tentacle.Communication.TrustedOctopusServers: >- + [{{""Thumbprint"":""{TestCertificates.ServerPublicThumbprint}"",""CommunicationStyle"":{(int)CommunicationStyle.TentacleActive},""Address"":""{serverCommsAddress}"",""Squid"":null,""SubscriptionId"":""{SubscriptionId}""}}] + Tentacle.Services.IsRegistered: 'true' + Tentacle.Services.NoListen: 'true'"; + + valuesFile = valuesFile + .Replace("#{TargetName}", AgentName) + .Replace("#{ServerCommsAddress}", serverCommsAddress) + .Replace("#{ConfigMapData}", configMapData); + + var valuesFilePath = Path.Combine(temporaryDirectory.Directory.FullName, "agent-values.yaml"); + await File.WriteAllTextAsync(valuesFilePath, valuesFile, Encoding.UTF8); + + return valuesFilePath; + } + + string BuildAgentInstallArguments(string valuesFilePath) + { + var (chartVersion, chartRepo) = GetChartVersionAndRepository(); + var args = new[] + { + "upgrade", + "--install", + "--atomic", + $"-f \"{valuesFilePath}\"", + $"--version \"{chartVersion}\"", + "--create-namespace", + NamespaceFlag, + KubeConfigFlag, + AgentName, + chartRepo + }; + + return string.Join(" ", args.WhereNotNull()); + } + + static (string ChartVersion, string ChartRepo) GetChartVersionAndRepository() + { + var customHelmChartVersion = Environment.GetEnvironmentVariable("KubernetesIntegrationTests_HelmChartVersion"); + + return !string.IsNullOrWhiteSpace(customHelmChartVersion) + ? (customHelmChartVersion, "oci://docker.packages.octopushq.com/kubernetes-agent") + : ("1.*.*", "oci://registry-1.docker.io/octopusdeploy/kubernetes-agent"); + } + + static string? GetImageAndRepository(string? tentacleImageAndTag) + { + if (tentacleImageAndTag is null) + return null; + + var parts = tentacleImageAndTag.Split(":"); + var repo = parts[0]; + var tag = parts[1]; + + return $"--set agent.image.repository=\"{repo}\" --set agent.image.tag=\"{tag}\""; + } + + async Task GetAgentThumbprint() + { + string? thumbprint = null; + + var attempt = 0; + do + { + var result = ProcessRunner.Run(kubeCtlExePath, temporaryDirectory, $"get cm tentacle-config --namespace {Namespace} --kubeconfig=\"{kubeConfigPath}\" -o \"jsonpath={{.data['Tentacle\\.CertificateThumbprint']}}\""); + thumbprint = await result.StandardOutput.ReadToEndAsync(); + if (result.ExitCode != 0) + { + logger.Error("Failed to load thumbprint. Exit code {ExitCode}", result.ExitCode); + } + + if (!string.IsNullOrWhiteSpace(thumbprint)) + { + return thumbprint; + } + + if (attempt == 60) + { + break; + } + + attempt++; + await Task.Delay(1000); + } while (string.IsNullOrWhiteSpace(thumbprint)); + + throw new InvalidOperationException("Failed to load the generated thumbprint after 60 attempts"); + } + + void BuildTentacleClient(string thumbprint) + { + var endpoint = new ServiceEndPoint(SubscriptionId, thumbprint, ServerHalibutRuntime.TimeoutsAndLimits); + + var retrySettings = new RpcRetrySettings(true, TimeSpan.FromMinutes(2)); + var clientOptions = new TentacleClientOptions(retrySettings); + + TentacleClient.CacheServiceWasNotFoundResponseMessages(ServerHalibutRuntime); + + TentacleClient = new TentacleClient( + endpoint, + ServerHalibutRuntime, + new PollingTentacleScriptObserverBackoffStrategy(), + new NoTentacleClientObserver(), + clientOptions); + } + + int BuildServerHalibutRuntimeAndListen() + { + var serverHalibutRuntimeBuilder = new HalibutRuntimeBuilder() + .WithServerCertificate(TestCertificates.Server) + .WithHalibutTimeoutsAndLimits(HalibutTimeoutsAndLimits.RecommendedValues()); + + ServerHalibutRuntime = serverHalibutRuntimeBuilder.Build(); + + return ServerHalibutRuntime.Listen(); + } + string NamespaceFlag => $"--namespace \"{Namespace}\""; + string KubeConfigFlag => $"--kubeconfig \"{kubeConfigPath}\""; + + public void Dispose() + { + if (isAgentInstalled) + { + var uninstallArgs = string.Join(" ", + "uninstall", + KubeConfigFlag, + NamespaceFlag, + AgentName); + + var result = ProcessRunner.RunWithLogger(helmExePath, temporaryDirectory, logger, uninstallArgs); + + if (result.ExitCode != 0) + { + logger.Error("Failed to uninstall Kubernetes Agent {AgentName} via Helm", AgentName); + } + } + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/KubernetesClusterInstaller.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/KubernetesClusterInstaller.cs new file mode 100644 index 00000000..c982c970 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/KubernetesClusterInstaller.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Reflection; +using KubernetesAgent.Integration.Setup.Common; + +namespace KubernetesAgent.Integration.Setup; + +public class KubernetesClusterInstaller : IDisposable +{ + readonly string clusterName; + readonly string kubeConfigName; + + readonly TemporaryDirectory tempDir; + readonly string kindExePath; + readonly string helmExePath; + readonly string kubeCtlPath; + readonly ILogger logger; + + public string KubeConfigPath => Path.Combine(tempDir.Directory.FullName, kubeConfigName); + + public KubernetesClusterInstaller(TemporaryDirectory tempDirectory, string kindExePath, string helmExePath, string kubeCtlPath, ILogger logger) + { + tempDir = tempDirectory; + this.kindExePath = kindExePath; + this.helmExePath = helmExePath; + this.kubeCtlPath = kubeCtlPath; + this.logger = logger; + + clusterName = $"helm-octopus-agent-int-{DateTime.Now:yyyyMMddhhmmss}"; + kubeConfigName = $"{clusterName}.config"; + } + + public async Task Install() + { + var configFilePath = await WriteFileToTemporaryDirectory("kind-config.yaml"); + + var sw = new Stopwatch(); + sw.Restart(); + + var result = ProcessRunner.RunWithLogger(kindExePath, tempDir, logger, + $"create cluster --name={clusterName} --config=\"{configFilePath}\" --kubeconfig=\"{kubeConfigName}\""); + + sw.Stop(); + + if (result.ExitCode != 0) + { + logger.Error("Failed to create Kind Kubernetes cluster {ClusterName}", clusterName); + throw new InvalidOperationException($"Failed to create Kind Kubernetes cluster {clusterName}"); + } + + logger.Information("Test cluster kubeconfig path: {Path:l}", KubeConfigPath); + + logger.Information("Created Kind Kubernetes cluster {ClusterName} in {ElapsedTime}", clusterName, sw.Elapsed); + + await SetLocalhostRouting(); + + InstallNfsCsiDriver(); + } + + async Task SetLocalhostRouting() + { + var filename = OperatingSystem.IsLinux() ? "linux-network-routing.yaml" : "docker-desktop-network-routing.yaml"; + + var manifestFilePath = await WriteFileToTemporaryDirectory(filename, "manifest.yaml"); + + var result = ProcessRunner.RunWithLogger(kubeCtlPath, tempDir, logger, + "apply", + "-n default", + $"-f \"{manifestFilePath}\"", + $"--kubeconfig=\"{KubeConfigPath}\""); + + if (result.ExitCode != 0) + { + logger.Error("Failed to apply localhost routing to cluster {ClusterName}", clusterName); + throw new InvalidOperationException($"Failed to apply localhost routing to cluster {clusterName}."); + } + } + + async Task WriteFileToTemporaryDirectory(string resourceFileName, string? outputFilename = null) + { + await using var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStreamFromPartialName(resourceFileName); + + var filePath = Path.Combine(tempDir.Directory.FullName, outputFilename ?? resourceFileName); + await using var file = File.Create(filePath); + + resourceStream.Seek(0, SeekOrigin.Begin); + await resourceStream.CopyToAsync(file); + + return filePath; + } + + void InstallNfsCsiDriver() + { + var installArgs = BuildNfsCsiDriverInstallArguments(); + var result = ProcessRunner.RunWithLogger(helmExePath, tempDir, logger, installArgs); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to install NFS CSI driver into cluster {clusterName}."); + } + } + + string BuildNfsCsiDriverInstallArguments() + { + return string.Join(" ", + "install", + "--atomic", + "--repo https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts", + "--namespace kube-system", + "--version v4.6.0", + $"--kubeconfig \"{KubeConfigPath}\"", + "csi-driver-nfs", + "csi-driver-nfs" + ); + } + + public void Dispose() + { + var result = ProcessRunner.RunWithLogger(kindExePath, tempDir, logger, + $"delete cluster --name={clusterName}"); + if (result.ExitCode != 0) + { + logger.Error("Failed to delete Kind kubernetes cluster {ClusterName}", clusterName); + } + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/RequiredToolDownloader.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/RequiredToolDownloader.cs new file mode 100644 index 00000000..00320653 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/RequiredToolDownloader.cs @@ -0,0 +1,32 @@ +using KubernetesAgent.Integration.Setup.Common; +using KubernetesAgent.Integration.Setup.Tooling; + +namespace KubernetesAgent.Integration.Setup; + +public class RequiredToolDownloader +{ + readonly TemporaryDirectory temporaryDirectory; + readonly KindDownloader kindDownloader; + readonly HelmDownloader helmDownloader; + readonly KubeCtlDownloader kubeCtlDownloader; + + public RequiredToolDownloader(TemporaryDirectory temporaryDirectory, ILogger logger) + { + this.temporaryDirectory = temporaryDirectory; + + kindDownloader = new KindDownloader(logger); + helmDownloader = new HelmDownloader(logger); + kubeCtlDownloader = new KubeCtlDownloader(logger); + } + + public async Task<(string KindExePath, string HelmExePath, string KubeCtlPath)> DownloadRequiredTools(CancellationToken cancellationToken) + { + var kindExePathTask = kindDownloader.Download(temporaryDirectory.Directory.FullName, cancellationToken); + var helmExePathTask = helmDownloader.Download(temporaryDirectory.Directory.FullName, cancellationToken); + var kubeCtlExePathTask = kubeCtlDownloader.Download(temporaryDirectory.Directory.FullName, cancellationToken); + + await Task.WhenAll(kindExePathTask, helmExePathTask, kubeCtlExePathTask); + + return (kindExePathTask.Result, helmExePathTask.Result, kubeCtlExePathTask.Result); + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/HelmDownloader.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/HelmDownloader.cs new file mode 100644 index 00000000..f29f8c8d --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/HelmDownloader.cs @@ -0,0 +1,65 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; +using KubernetesAgent.Integration.Setup.Common.Extraction; + +namespace KubernetesAgent.Integration.Setup.Tooling; + +public class HelmDownloader : ToolDownloader +{ + const string LatestVersion = "v3.14.3"; + public HelmDownloader( ILogger logger) + : base("helm", logger) + { + } + + protected override string BuildDownloadUrl(Architecture processArchitecture, OperatingSystem operatingSystem) + { + var architecture = GetArchitectureLabel(processArchitecture); + var osName = GetOsName(operatingSystem); + + var suffix = operatingSystem is OperatingSystem.Windows ? "zip" : "tar.gz"; + + return $"https://get.helm.sh/helm-{LatestVersion}-{osName}-{architecture}.{suffix}"; + } + + static string GetArchitectureLabel(Architecture processArchitecture) => processArchitecture == Architecture.Arm64 ? "arm64" : "amd64"; + + protected override string PostDownload(string targetDirectory, string downloadFilePath, Architecture processArchitecture, OperatingSystem operatingSystem) + { + var architecture = GetArchitectureLabel(processArchitecture); + var osName = GetOsName(operatingSystem); + + var extractionDir = Path.Combine(targetDirectory, "extracted"); + + //the helm app is zipped, so we need to extract it + if (operatingSystem is OperatingSystem.Windows) + { + //on windows we need to unzip the file + ZipFile.ExtractToDirectory(downloadFilePath, extractionDir); + } + else + { + //everything else is tar.gz + TarFileExtractor.ExtractAndMakeExecutable(downloadFilePath, extractionDir); + } + + //move the extracted helm executable to the root target directory + var targetFilePath = Path.Combine(targetDirectory, ExecutableName); + File.Move(Path.Combine(extractionDir,$"{osName}-{architecture}", ExecutableName), targetFilePath); + + //delete the extracted directory + Directory.Delete(extractionDir,true); + File.Delete(downloadFilePath); + + return targetFilePath; + } + + static string GetOsName(OperatingSystem operatingSystem) + => operatingSystem switch + { + OperatingSystem.Windows => "windows", + OperatingSystem.Nix => "linux", + OperatingSystem.Mac => "darwin", + _ => throw new ArgumentOutOfRangeException(nameof(operatingSystem), operatingSystem, null) + }; +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/IToolDownloader.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/IToolDownloader.cs new file mode 100644 index 00000000..24bd777d --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/IToolDownloader.cs @@ -0,0 +1,6 @@ +namespace KubernetesAgent.Integration.Setup.Tooling; + +public interface IToolDownloader +{ + Task Download(string targetDirectory, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/KindDownloader.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/KindDownloader.cs new file mode 100644 index 00000000..29f32aea --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/KindDownloader.cs @@ -0,0 +1,31 @@ +using System.Runtime.InteropServices; + +namespace KubernetesAgent.Integration.Setup.Tooling +{ + public class KindDownloader : ToolDownloader + { + const string LatestKindVersion = "v0.22.0"; + + public KindDownloader(ILogger logger) + : base("kind", logger) + { + } + + protected override string BuildDownloadUrl(Architecture processArchitecture, OperatingSystem operatingSystem) + { + var architecture = processArchitecture == Architecture.Arm64 ? "arm64" : "amd64"; + var osName = GetOsName(operatingSystem); + + return $"https://github.com/kubernetes-sigs/kind/releases/download/{LatestKindVersion}/kind-{osName}-{architecture}"; + } + + static string GetOsName(OperatingSystem operatingSystem) + => operatingSystem switch + { + OperatingSystem.Windows => "windows", + OperatingSystem.Nix => "linux", + OperatingSystem.Mac => "darwin", + _ => throw new ArgumentOutOfRangeException(nameof(operatingSystem), operatingSystem, null) + }; + } +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/KubeCtlDownloader.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/KubeCtlDownloader.cs new file mode 100644 index 00000000..64a7b2b1 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/KubeCtlDownloader.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; + +namespace KubernetesAgent.Integration.Setup.Tooling; + +public class KubeCtlDownloader : ToolDownloader +{ + public const string LatestKubeCtlVersion = "v1.29.3"; + + public KubeCtlDownloader(ILogger logger) + : base("kubectl", logger) + { } + + protected override string BuildDownloadUrl(Architecture processArchitecture, OperatingSystem operatingSystem) + { + var architecture = processArchitecture == Architecture.Arm64 ? "arm64" : "amd64"; + var osName = GetOsName(operatingSystem); + + var extension = operatingSystem is OperatingSystem.Windows + ? ".exe" + : null; + + return $"https://dl.k8s.io/release/{LatestKubeCtlVersion}/bin/{osName}/{architecture}/kubectl{extension}"; + } + + static string GetOsName(OperatingSystem operatingSystem) + => operatingSystem switch + { + OperatingSystem.Windows => "windows", + OperatingSystem.Nix => "linux", + OperatingSystem.Mac => "darwin", + _ => throw new ArgumentOutOfRangeException(nameof(operatingSystem), operatingSystem, null) + }; +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/ToolDownloader.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/ToolDownloader.cs new file mode 100644 index 00000000..eafe1c67 --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/Tooling/ToolDownloader.cs @@ -0,0 +1,88 @@ +using System.Runtime.InteropServices; +using KubernetesAgent.Integration.Setup.Common; + +namespace KubernetesAgent.Integration.Setup.Tooling; + +public abstract class ToolDownloader : IToolDownloader +{ + readonly OperatingSystem os; + + protected ILogger Logger { get; } + protected string ExecutableName { get; } + + protected ToolDownloader(string executableName, ILogger logger) + { + ExecutableName = executableName; + Logger = logger; + + os = GetOperationSystem(); + + //we assume that windows always has .exe suffixed + if (os is OperatingSystem.Windows) + { + ExecutableName += ".exe"; + } + } + + public async Task Download(string targetDirectory, CancellationToken cancellationToken) + { + var downloadUrl = BuildDownloadUrl(RuntimeInformation.ProcessArchitecture, os); + + //we download to a random file name + var downloadFilePath = Path.Combine(targetDirectory, Guid.NewGuid().ToString("N")); + + Logger.Information("Downloading {DownloadUrl} to {DownloadFilePath}", downloadUrl, downloadFilePath); + using (var client = new HttpClient()) + { + + await using (var s = await client.GetStreamAsync(downloadUrl, cancellationToken)) + { + await using (var fs = new FileStream(downloadFilePath, FileMode.OpenOrCreate)) + { + await s.CopyToAsync(fs, cancellationToken); + } + } + } + + downloadFilePath = PostDownload(targetDirectory, downloadFilePath, RuntimeInformation.ProcessArchitecture, os); + + return downloadFilePath; + } + + protected abstract string BuildDownloadUrl(Architecture processArchitecture, OperatingSystem operatingSystem); + + protected virtual string PostDownload(string downloadDirectory, string downloadFilePath, Architecture processArchitecture, OperatingSystem operatingSystem) + { + var targetFilename = Path.Combine(downloadDirectory, ExecutableName); + File.Move(downloadFilePath, targetFilename); + new FileInfo(targetFilename).MakeExecutable(); + + return targetFilename; + } + + static OperatingSystem GetOperationSystem() + { + if (System.OperatingSystem.IsWindows()) + { + return OperatingSystem.Windows; + } + if (System.OperatingSystem.IsLinux()) + { + return OperatingSystem.Nix; + } + + if (System.OperatingSystem.IsMacOS()) + { + return OperatingSystem.Mac; + } + + throw new InvalidOperationException("Unsupported OS"); + } +} + +public enum OperatingSystem +{ + Windows, + Nix, + Mac +} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/agent-values.yaml b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/agent-values.yaml new file mode 100644 index 00000000..3b0bb2be --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/agent-values.yaml @@ -0,0 +1,14 @@ +agent: + acceptEula: "Y" + targetName: "#{TargetName}" + serverCommsAddress: "#{ServerCommsAddress}" + serverUrl: "https://this.is.not.required.com/" + bearerToken: "this-is-a-fake-bearer-token" + space: "Default" + targetEnvironments: ["development"] + targetRoles: ["testing-cluster"] + +testing: + tentacle: + configMap: + data: #{ConfigMapData} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/docker-desktop-network-routing.yaml b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/docker-desktop-network-routing.yaml new file mode 100644 index 00000000..a66ba86d --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/docker-desktop-network-routing.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Service +metadata: + name: dockerhost + namespace: default +spec: + type: ExternalName + externalName: host.docker.internal \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/kind-config.yaml b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/kind-config.yaml new file mode 100644 index 00000000..37eb950b --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/kind-config.yaml @@ -0,0 +1,8 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 + +nodes: + - role: control-plane + image: kindest/node:v1.29.2@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245 + - role: worker + image: kindest/node:v1.29.2@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245 diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/linux-network-routing.yaml b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/linux-network-routing.yaml new file mode 100644 index 00000000..b6f8e31c --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/Setup/linux-network-routing.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Endpoints +metadata: + name: dockerhost + namespace: default +subsets: + - addresses: + - ip: 172.17.0.1 # this is the gateway IP in the "bridge" docker network +--- +apiVersion: v1 +kind: Service +metadata: + name: dockerhost + namespace: default +spec: + clusterIP: None \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/UnitTest1.cs b/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/UnitTest1.cs deleted file mode 100644 index 3df9af36..00000000 --- a/tests/kubernetes-agent/KubernetesAgent.IntegrationTests/UnitTest1.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace KubernetesAgent.Integration; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - } -} \ No newline at end of file diff --git a/tests/kubernetes-agent/KubernetesAgent.sln.DotSettings b/tests/kubernetes-agent/KubernetesAgent.sln.DotSettings new file mode 100644 index 00000000..b58a4c8b --- /dev/null +++ b/tests/kubernetes-agent/KubernetesAgent.sln.DotSettings @@ -0,0 +1,418 @@ + + True + True + True + + + + + + True + ExplicitlyExcluded + + True + ExplicitlyExcluded + ExplicitlyExcluded + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + SUGGESTION + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + + + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + ERROR + <?xml version="1.0" encoding="utf-16"?><Profile name="Octopus Deploy"><CSArrangeThisQualifier>True</CSArrangeThisQualifier><CSRemoveCodeRedundancies>True</CSRemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSUseVar><BehavourStyle>CAN_CHANGE_TO_IMPLICIT</BehavourStyle><LocalVariableStyle>ALWAYS_IMPLICIT</LocalVariableStyle><ForeachVariableStyle>ALWAYS_IMPLICIT</ForeachVariableStyle></CSUseVar><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CssAlphabetizeProperties>True</CssAlphabetizeProperties><HtmlReformatCode>True</HtmlReformatCode><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSArrangeQualifiers>True</CSArrangeQualifiers><CSEnforceVarKeywordUsageSettings>True</CSEnforceVarKeywordUsageSettings><CSShortenReferences>True</CSShortenReferences><CSReorderTypeMembers>True</CSReorderTypeMembers><IDEA_SETTINGS>&lt;profile version="1.0"&gt; + &lt;option name="myName" value="Octopus Deploy" /&gt; +&lt;/profile&gt;</IDEA_SETTINGS><RIDER_SETTINGS>&lt;profile&gt; + &lt;Language id="CSS"&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="EditorConfig"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="HCL"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="HTML"&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="HTTP Request"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Handlebars"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Ini"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="JSON"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Jade"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="JavaScript"&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Markdown"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="PowerShell"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Properties"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="RELAX-NG"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="SQL"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="VueExpr"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="XML"&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="yaml"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; +&lt;/profile&gt;</RIDER_SETTINGS></Profile> + Octopus Deploy + RequiredForMultilineStatement + RequiredForMultilineStatement + RequiredForMultilineStatement + Implicit + Implicit + ExpressionBody + ExpressionBody + required public private protected internal file static abstract virtual sealed override new readonly extern unsafe volatile async + BlockScoped + False + + NEXT_LINE + False + False + SEPARATE + ALWAYS_ADD + ALWAYS_ADD + ALWAYS_ADD + ALWAYS_ADD + ALWAYS_ADD + ALWAYS_ADD + False + + NEXT_LINE + 1 + 1 + True + True + True + True + 5 + 1 + 5 + 5 + 5 + 5 + NEVER + True + False + NEVER + False + False + False + True + True + CHOP_IF_LONG + True + True + CHOP_IF_LONG + CHOP_IF_LONG + CHOP_IF_LONG + 200 + False + CHOP_ALWAYS + CHOP_IF_LONG + DoNotTouch + DoNotTouch + False + 1 + ByFirstAttr + 300 + 300 + <?xml version="1.0" encoding="utf-16"?> +<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> + <TypePattern DisplayName="COM interfaces or structs"> + <TypePattern.Match> + <Or> + <And> + <Kind Is="Interface" /> + <Or> + <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" /> + <HasAttribute Name="System.Runtime.InteropServices.ComImport" /> + </Or> + </And> + <Kind Is="Struct" /> + </Or> + </TypePattern.Match> + </TypePattern> + <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All"> + <TypePattern.Match> + <And> + <Kind Is="Class" /> + <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.TestCaseFixtureAttribute" Inherited="True" /> + </And> + </TypePattern.Match> + <Entry DisplayName="Setup/Teardown Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <Or> + <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureSetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureTearDownAttribute" Inherited="True" /> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="All other members" /> + <Entry Priority="100" DisplayName="Test Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <HasAttribute Name="NUnit.Framework.TestAttribute" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> + </TypePattern> + <TypePattern DisplayName="Default Pattern"> + <Entry Priority="100" DisplayName="Public Delegates"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Delegate" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> + <Entry Priority="100" DisplayName="Public Enums"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Enum" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Static Fields and Constants"> + <Entry.Match> + <Or> + <Kind Is="Constant" /> + <And> + <Kind Is="Field" /> + <Static /> + </And> + </Or> + </Entry.Match> + <Entry.SortBy> + <Kind Order="Constant Field" /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Fields"> + <Entry.Match> + <And> + <Kind Is="Field" /> + <Not> + <Static /> + </Not> + </And> + </Entry.Match> + <Entry.SortBy> + <Readonly /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Constructors"> + <Entry.Match> + <Kind Is="Constructor" /> + </Entry.Match> + <Entry.SortBy> + <Static /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Properties, Indexers"> + <Entry.Match> + <Or> + <Kind Is="Property" /> + <Kind Is="Indexer" /> + </Or> + </Entry.Match> + </Entry> + <Entry DisplayName="All other members" /> + <Entry DisplayName="Nested Types"> + <Entry.Match> + <Kind Is="Type" /> + </Entry.Match> + </Entry> + </TypePattern> +</Patterns> + System + System + AD + CSS + ID + SSL + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy> + + + + + + True + True + True + True + True + True + True + True + True + True + True + True + True + True + False + False + None + TRACE + True + True + Document map + True + 0 + True + True + Everywhere + map + True + public class $Target$Map : DocumentMap<$Target$> +{ + public $Target$Map() + { + Column(m => m.Foo); + } +} + True + True + True + True + True + True + True + True + True + True + True + True + True + True + + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + + True + True + True + True + True + True + True + True + True + True + True + True + True + True + diff --git a/tests/kubernetes-agent/NuGet.Config b/tests/kubernetes-agent/NuGet.Config new file mode 100644 index 00000000..13f33fd0 --- /dev/null +++ b/tests/kubernetes-agent/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file