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><profile version="1.0">
+ <option name="myName" value="Octopus Deploy" />
+</profile></IDEA_SETTINGS><RIDER_SETTINGS><profile>
+ <Language id="CSS">
+ <Rearrange>true</Rearrange>
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="EditorConfig">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="HCL">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="HTML">
+ <Rearrange>true</Rearrange>
+ <OptimizeImports>true</OptimizeImports>
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="HTTP Request">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Handlebars">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Ini">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="JSON">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Jade">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="JavaScript">
+ <Rearrange>true</Rearrange>
+ <OptimizeImports>true</OptimizeImports>
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Markdown">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="PowerShell">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Properties">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="RELAX-NG">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="SQL">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="VueExpr">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="XML">
+ <Rearrange>true</Rearrange>
+ <OptimizeImports>true</OptimizeImports>
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="yaml">
+ <Reformat>true</Reformat>
+ </Language>
+</profile></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