diff --git a/docker/kubernetes-agent-tentacle/scripts/configure-and-run.sh b/docker/kubernetes-agent-tentacle/scripts/configure-and-run.sh index b0318ff66..41c4ae832 100644 --- a/docker/kubernetes-agent-tentacle/scripts/configure-and-run.sh +++ b/docker/kubernetes-agent-tentacle/scripts/configure-and-run.sh @@ -156,6 +156,14 @@ function validateWorkerVariables() { echo " - worker pools '$WorkerPools'" } +function migrateFromPreinstallScript() { + tentacle migrate-preinstalled-k8s-config \ + --source-config-map-name "tentacle-config-pre" \ + --source-secret-name "tentacle-secret-pre" \ + --destination-config-map-name "tentacle-config" \ + --destination-secret-name "tentacle-secret" +} + function configureTentacle() { tentacle create-instance --instance "$instanceName" --config "$configurationDirectory/tentacle.config" --home "$configurationDirectory" @@ -409,6 +417,9 @@ function markAsInitialised() { setupVariablesForRegistrationCheck getStatusOfRegistration +# We expect the tentacle to always be registered due to the new pre-install hook +# But keeping this here just in case +# We can remove it in future if we need to if [ "$IS_REGISTERED" == "true" ]; then echo "Tentacle is already configured and registered with server." addAdditionalServerInstancesIfRequired @@ -421,6 +432,7 @@ else echo "===============================================" + migrateFromPreinstallScript configureTentacle registerTentacle addAdditionalServerInstancesIfRequired diff --git a/docker/kubernetes-agent-tentacle/scripts/register.sh b/docker/kubernetes-agent-tentacle/scripts/register.sh new file mode 100644 index 000000000..539a65d11 --- /dev/null +++ b/docker/kubernetes-agent-tentacle/scripts/register.sh @@ -0,0 +1,328 @@ +#!/bin/bash +set -eu + +if [[ "$ACCEPT_EULA" != "Y" ]]; then + echo "ERROR: You must accept the EULA at https://octopus.com/company/legal by passing an environment variable 'ACCEPT_EULA=Y'" + exit 1 +fi + +# In the scenario where a customer is using a custom certificate (which is mounted via a config map), we need to rehash the certificates +# We just do this all the time because there is no downside +echo "Rehashing SSL/TLS certificates" +openssl rehash /etc/ssl/certs + +# Tentacle Docker images only support once instance per container. Running multiple instances can be achieved by running multiple containers. +instanceName=Tentacle +internalListeningPort=10933 + +#If TentacleHome environment variable exists, use that +configurationDirectory=/etc/octopus +if [[ -n "$TentacleHome" ]]; then + configurationDirectory="$TentacleHome" +fi + +#If TentacleApplications environment variable exists, use that +applicationsDirectory=/home/Octopus/Applications +if [[ -n "$TentacleApplications" ]]; then + applicationsDirectory="$TentacleApplications" +fi + +mkdir -p $configurationDirectory +mkdir -p $applicationsDirectory + +if [ ! -f /usr/bin/tentacle ]; then + ln -s /opt/octopus/tentacle/Tentacle /usr/bin/tentacle +fi + +function getPublicHostName() { + if [[ "$PublicHostNameConfiguration" == "PublicIp" ]]; then + curl https://api.ipify.org/ + elif [[ "$PublicHostNameConfiguration" == "FQDN" ]]; then + hostname --fqdn + elif [[ "$PublicHostNameConfiguration" == "ComputerName" ]]; then + hostname + else + echo $CustomPublicHostName + fi +} + +function validateVariables() { + validateCommonVariables + + if [[ "$DeploymentTargetEnabled" != "true" && "$WorkerEnabled" != "true" ]]; then + echo "Please specify whether to install as a worker or a deployment target with the 'WorkerEnabled' or 'DeploymentTargetEnabled' environment variables" >&2 + exit 1 + fi + + if [[ "$DeploymentTargetEnabled" == "true" && "$WorkerEnabled" == "true" ]]; then + echo "The installation cannot be as both a worker and a deployment target, please choose one" >&2 + exit 1 + fi + + if [[ "$DeploymentTargetEnabled" == "true" ]]; then + validateDeploymentTargetVariables + fi + + if [[ "$WorkerEnabled" == "true" ]]; then + validateWorkerVariables + fi +} + +function validateCommonVariables() { + if [[ -z "$ServerApiKey" && -z "$BearerToken" ]]; then + if [[ -z "$ServerPassword" || -z "$ServerUsername" ]]; then + echo "Please specify either an API key, a Bearer Token or a username/password with the 'ServerApiKey' or 'ServerUsername'/'ServerPassword' environment variables" >&2 + exit 1 + fi + fi + + if [[ -z "$ServerUrl" ]]; then + echo "Please specify an Octopus Server with the 'ServerUrl' environment variable" >&2 + exit 1 + fi + + echo " - server endpoint '$ServerUrl'" + echo " - api key '##########'" + echo " - host '$PublicHostNameConfiguration'" + + if [[ -n "$ServerCommsAddress" || -n "$ServerCommsAddresses" || -n "$ServerPort" ]]; then + echo " - communication mode 'Kubernetes' (Polling)" + + if [[ -n "$ServerCommsAddress" ]]; then + echo " - server comms address $ServerCommsAddress" + fi + if [[ -n "$ServerCommsAddresses" ]]; then + echo " - HA server comms addresses $ServerCommsAddresses" + fi + if [[ -n "$ServerPort" ]]; then + echo " - server port $ServerPort" + fi + if [[ -n "$ServerSubscriptionId" ]]; then + echo " - server subscription id '$ServerSubscriptionId'" + fi + else + echo " - communication mode 'Kubernetes' (Listening)" + echo " - registered port $ListeningPort" + fi + + if [[ -n "$AgentName" ]]; then + echo " - name '$AgentName'" + fi + + if [[ -n "$Space" ]]; then + echo " - space '$Space'" + fi + + if [[ -n "$TentacleCertificateBase64" ]]; then + echo " - tentacle certificate '${TentacleCertificateBase64:0:3}...${TentacleCertificateBase64: -3}'" + fi +} + +function validateDeploymentTargetVariables() { + if [[ -z "$TargetEnvironment" ]]; then + echo "Please specify one or more environment names/ids/slugs (comma delimited) with the 'TargetEnvironment' environment variable" >&2 + exit 1 + fi + + if [[ -z "$TargetRole" ]]; then + echo "Please specify one or more role names (comma delimited) with the 'TargetRole' environment variable" >&2 + exit 1 + fi + + echo " - environment '$TargetEnvironment'" + echo " - role '$TargetRole'" + + if [[ -n "$TargetTenant" ]]; then + echo " - tenant '$TargetTenant'" + fi + if [[ -n "$TargetTenantTag" ]]; then + echo " - tenant tag '$TargetTenantTag'" + fi + if [[ -n "$TargetTenantedDeploymentParticipation" ]]; then + echo " - tenanted deployment participation '$TargetTenantedDeploymentParticipation'" + fi + + if [[ -n "$DefaultNamespace" ]]; then + echo " - default namespace '$DefaultNamespace'" + fi +} + +function validateWorkerVariables() { + if [[ -z "$WorkerPools" ]]; then + echo "Please specify one or more worker pool names/ids/slugs (comma delimited) with the 'WorkerPools' environment variable" >&2 + exit 1 + fi + + echo " - worker pools '$WorkerPools'" +} + +function configureTentacle() { + tentacle create-instance --instance "$instanceName" --config "$configurationDirectory/tentacle.config" --home "$configurationDirectory" + + echo "Setting directory paths ..." + tentacle configure --instance "$instanceName" --app "$applicationsDirectory" + + echo "Configuring communication type ..." + if [[ -n "$ServerCommsAddress" || -n "$ServerCommsAddresses" || -n "$ServerPort" ]]; then + tentacle configure --instance "$instanceName" --noListen "True" + else + tentacle configure --instance "$instanceName" --port $internalListeningPort --noListen "False" + fi + + if [[ -n "$TentacleCertificateBase64" ]]; then + echo "Importing custom certificate ..." + tentacle import-certificate --instance "$instanceName" --from-base64="$TentacleCertificateBase64" + else + echo "Creating certificate ..." + tentacle new-certificate --instance "$instanceName" --if-blank + fi +} + +function setupVariablesForRegistrationCheck() { + local namespace=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) + local config_map_name="tentacle-config" + SERVICE_URL="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT/api/v1/namespaces/$namespace/configmaps/$config_map_name" + SERVICE_ACCOUNT_TOKEN_PATH="/var/run/secrets/kubernetes.io/serviceaccount/token" +} + +function getStatusOfRegistration() { + echo "Checking registration status..." + + IS_REGISTERED=$(curl -s --request GET \ + --url "$SERVICE_URL" \ + --header "Authorization: Bearer $(cat $SERVICE_ACCOUNT_TOKEN_PATH)" \ + --header 'Accept: application/json' \ + --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt | + grep -o '"Tentacle.Services.IsRegistered": "[^"]*"' | + cut -d'"' -f4) +} + +function registerTentacle() { + echo "Registering with server ..." + + local ARGS=() + + if [[ "$DeploymentTargetEnabled" == "true" ]]; then + ARGS+=('register-k8s-target') + + if [[ -n "$TargetEnvironment" ]]; then + IFS=',' read -ra ENVIRONMENTS <<<"$TargetEnvironment" + for i in "${ENVIRONMENTS[@]}"; do + ARGS+=('--environment' "$i") + done + fi + + if [[ -n "$TargetRole" ]]; then + IFS=',' read -ra ROLES <<<"$TargetRole" + for i in "${ROLES[@]}"; do + ARGS+=('--role' "$i") + done + fi + + if [[ -n "$TargetTenant" ]]; then + IFS=',' read -ra TENANTS <<<"$TargetTenant" + for i in "${TENANTS[@]}"; do + ARGS+=('--tenant' "$i") + done + fi + + if [[ -n "$TargetTenantTag" ]]; then + IFS=',' read -ra TENANTTAGS <<<"$TargetTenantTag" + for i in "${TENANTTAGS[@]}"; do + ARGS+=('--tenanttag' "$i") + done + fi + + if [[ -n "$TargetTenantedDeploymentParticipation" ]]; then + ARGS+=('--tenanted-deployment-participation' "$TargetTenantedDeploymentParticipation") + fi + + if [[ -n "$DefaultNamespace" ]]; then + ARGS+=('--default-namespace' "$DefaultNamespace") + fi + elif [[ "$WorkerEnabled" == "true" ]]; then + ARGS+=('register-k8s-worker') + + if [[ -n "$WorkerPools" ]]; then + IFS=',' read -ra WORKERPOOLS <<<"$WorkerPools" + for i in "${WORKERPOOLS[@]}"; do + ARGS+=('--workerpool' "$i") + done + fi + fi + + ARGS+=( + '--instance' "$instanceName" + '--server' "$ServerUrl" + '--space' "$Space" + '--policy' "$MachinePolicy") + + if [[ -n "$AgentName" ]]; then + ARGS+=('--name' "$AgentName") + fi + + if [[ -n "$ServerCommsAddress" || -n "$ServerCommsAddresses" || -n "$ServerPort" ]]; then + ARGS+=('--comms-style' 'TentacleActive') + + # If ServerCommsAddress (singular) is not set, use the first value in ServerCommsAddresses (plural) + if [[ -n "$ServerCommsAddress" ]]; then + ARGS+=('--server-comms-address' "$ServerCommsAddress") + elif [[ -n "$ServerCommsAddresses" ]]; then + IFS=',' read -ra SERVER_ADDRESSES <<<"$ServerCommsAddresses" + ARGS+=('--server-comms-address' "${SERVER_ADDRESSES[0]}") + fi + + if [[ -n "$ServerPort" ]]; then + ARGS+=('--server-comms-port' $ServerPort) + fi + + if [[ -n "$ServerSubscriptionId" ]]; then + ARGS+=('--server-subscription-id' $ServerSubscriptionId) + fi + else + ARGS+=( + '--comms-style' 'TentaclePassive' + '--publicHostName' $(getPublicHostName)) + + if [[ -n "$ListeningPort" && "$ListeningPort" != "$internalListeningPort" ]]; then + ARGS+=('--tentacle-comms-port' $ListeningPort) + fi + fi + + if [[ -n "$ServerApiKey" ]]; then + echo "Registering Tentacle with API key" + ARGS+=('--apiKey' $ServerApiKey) + elif [[ -n "$BearerToken" ]]; then + echo "Registering Tentacle with Bearer Token" + ARGS+=('--bearerToken' "$BearerToken") + else + echo "Registering Tentacle with username/password" + ARGS+=( + '--username' "$ServerUsername" + '--password' "$ServerPassword") + fi + + tentacle "${ARGS[@]}" +} + +setupVariablesForRegistrationCheck +getStatusOfRegistration + +if [ "$IS_REGISTERED" == "true" ]; then + echo "Tentacle is already configured and registered with server." +else + echo "===============================================" + echo "Configuring Octopus Deploy Kubernetes Tentacle" + echo "===============================================" + + validateVariables + + configureTentacle + registerTentacle + echo "Configuration successful" +fi + +echo "===============================================" +echo "Finished Configuring Octopus Deploy Kubernetes Tentacle" +echo "===============================================" +exit 0 diff --git a/source/Octopus.Tentacle.Kubernetes.Tests.Integration/KubernetesAgent/KubernetesAgentMigrateFromPreinstallationTest.cs b/source/Octopus.Tentacle.Kubernetes.Tests.Integration/KubernetesAgent/KubernetesAgentMigrateFromPreinstallationTest.cs new file mode 100644 index 000000000..6fcde12f6 --- /dev/null +++ b/source/Octopus.Tentacle.Kubernetes.Tests.Integration/KubernetesAgent/KubernetesAgentMigrateFromPreinstallationTest.cs @@ -0,0 +1,179 @@ +using System.Text; +using FluentAssertions; +using k8s; +using k8s.Models; +using Octopus.Diagnostics; +using Octopus.Tentacle.Commands; +using Octopus.Tentacle.Diagnostics; +using Octopus.Tentacle.Startup; + +namespace Octopus.Tentacle.Kubernetes.Tests.Integration.KubernetesAgent; + +public class KubernetesAgentMigrateFromPreinstallationTest +{ + + readonly ISystemLog systemLog = new SystemLog(); + + const string SourceConfigMapName = "tentacle-config-pre"; + const string SourceSecretName = "tentacle-secret-pre"; + const string DestinationConfigMapName = "tentacle-config"; + const string DestinationSecretName = "tentacle-secret"; + MigratePreInstalledKubernetesDeploymentTargetCommand commandToRun; + KubernetesFileWrappedProvider kubernetesConfigClient; + string commandNamespace; + k8s.Kubernetes client; + string[] commandArguments; + + [SetUp] + public void Init() + { + kubernetesConfigClient = new KubernetesFileWrappedProvider(KubernetesTestsGlobalContext.Instance.KubeConfigPath); + commandToRun = new MigratePreInstalledKubernetesDeploymentTargetCommand(new Lazy(kubernetesConfigClient), systemLog, new LogFileOnlyLogger()); + commandNamespace = Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(KubernetesConfig.NamespaceVariableName, commandNamespace); + client = new k8s.Kubernetes(kubernetesConfigClient.Get()); + commandArguments = [ + $"--source-config-map-name={SourceConfigMapName}", + $"--source-secret-name={SourceSecretName}", + $"--destination-config-map-name={DestinationConfigMapName}", + $"--destination-secret-name={DestinationSecretName}", + $"--namespace={commandNamespace}" + ]; + } + + [Test] + public async Task MigrationFromPreinstallHook_ShouldCopyData() + { + //Arrange + var validationKey = Guid.NewGuid().ToString("N"); + var sourceConfigMapData = new Dictionary + { + {"validationKey", validationKey}, + {"Tentacle.Services.IsRegistered", "true"} + }; + var sourceSecretData = new Dictionary + { + {"validationKey", validationKey} + }; + + // Namespace + await CreateCommandNamespace(commandNamespace); + + // Sources + await CreateConfigmap(SourceConfigMapName, commandNamespace, sourceConfigMapData); + await CreateSecret(SourceSecretName, commandNamespace, sourceSecretData); + + + // Targets + await CreateConfigmap(DestinationConfigMapName, commandNamespace); + await CreateSecret(DestinationSecretName, commandNamespace); + + + + //Act + commandToRun.Start(commandArguments, new NoninteractiveHost(), []); + + //Assert + var configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(DestinationConfigMapName, commandNamespace); + var secret = await client.CoreV1.ReadNamespacedSecretAsync(DestinationSecretName, commandNamespace); + + configMap.Data.TryGetValue("validationKey", out var validationKeyFromKubernetesConfigMap); + validationKeyFromKubernetesConfigMap.Should().Be(validationKey); + + secret.Data.TryGetValue("validationKey", out var validationKeyFromKubernetesSecret); + validationKeyFromKubernetesSecret.Should().Equal(Encoding.UTF8.GetBytes(validationKey)); + } + + [Test] + public async Task MigrationFromPreinstallHook_ShouldNotRunWhenTentacleIsAlreadyRegistered() + { + //Arrange + var validationKey = Guid.NewGuid().ToString("N"); + var sourceConfigMapData = new Dictionary + { + {"validationKey", "should-not-be-here"}, + {"Tentacle.Services.IsRegistered", "true"} + }; + var sourceSecretData = new Dictionary + { + {"validationKey", "should-not-be-here"} + }; + var destinationConfigMapData = new Dictionary + { + {"validationKey", validationKey}, + {"Tentacle.Services.IsRegistered", "true"} + }; + var destinationSecretData = new Dictionary + { + {"validationKey", validationKey} + }; + + // namespace + await CreateCommandNamespace(commandNamespace); + + //Sources + await CreateConfigmap(SourceConfigMapName, commandNamespace, sourceConfigMapData); + await CreateSecret(SourceSecretName, commandNamespace, sourceSecretData); + + // Targets + await CreateConfigmap(DestinationConfigMapName, commandNamespace, destinationConfigMapData); + await CreateSecret(DestinationSecretName, commandNamespace, destinationSecretData); + + + //Act + commandToRun.Start(commandArguments, new NoninteractiveHost(), []); + + //Assert + var configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(DestinationConfigMapName, commandNamespace); + var secret = await client.CoreV1.ReadNamespacedSecretAsync(DestinationSecretName, commandNamespace); + + configMap.Data.TryGetValue("validationKey", out var validationKeyFromKubernetesConfigMap); + validationKeyFromKubernetesConfigMap.Should().Be(validationKey); + + secret.Data.TryGetValue("validationKey", out var validationKeyFromKubernetesSecret); + validationKeyFromKubernetesSecret.Should().Equal(Encoding.UTF8.GetBytes(validationKey)); + } + + async Task CreateCommandNamespace(string name) + { + await client.CoreV1.CreateNamespaceAsync(new V1Namespace + { + Metadata = new V1ObjectMeta + { + Name = name + } + }); + } + + async Task CreateConfigmap(string name, string ns, Dictionary? data = null) + { + return await client.CoreV1.CreateNamespacedConfigMapAsync(new V1ConfigMap + { + Metadata = new V1ObjectMeta + { + Name = name + }, + Data = data + }, ns); + } + + async Task CreateSecret(string name, string ns, Dictionary? data = null) + { + return await client.CoreV1.CreateNamespacedSecretAsync(new V1Secret + { + Metadata = new V1ObjectMeta + { + Name = name + }, + StringData = data + }, ns); + } + + class KubernetesFileWrappedProvider(string filename) : IKubernetesClientConfigProvider + { + public KubernetesClientConfiguration Get() + { + return KubernetesClientConfiguration.BuildConfigFromConfigFile(filename); + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/HelmDownloader.cs b/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/HelmDownloader.cs index a0b121c7c..ae09c80ee 100644 --- a/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/HelmDownloader.cs +++ b/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/HelmDownloader.cs @@ -81,7 +81,7 @@ void ExtractTarGzip(string gzArchiveName, string destFolder) var exitCode = SilentProcessRunner.ExecuteCommand( "tar", - $"xzvf {gzArchiveName} -C {destFolder}", + $"xzvf \"{gzArchiveName}\" -C \"{destFolder}\"", tmp.DirectoryPath, Logger.Debug, Logger.Information, diff --git a/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/ToolDownloader.cs b/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/ToolDownloader.cs index 64ed223bc..f89ae9037 100644 --- a/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/ToolDownloader.cs +++ b/source/Octopus.Tentacle.Kubernetes.Tests.Integration/Setup/Tooling/ToolDownloader.cs @@ -44,7 +44,7 @@ public async Task Download(string targetDirectory, CancellationToken can { var exitCode = SilentProcessRunner.ExecuteCommand( "chmod", - $"+x {downloadFilePath}", + $"+x \"{downloadFilePath}\"", targetDirectory, Logger.Debug, Logger.Information, diff --git a/source/Octopus.Tentacle.Tests/Commands/MigratePreInstalledKubernetesDeploymentTargetCommandTest.cs b/source/Octopus.Tentacle.Tests/Commands/MigratePreInstalledKubernetesDeploymentTargetCommandTest.cs new file mode 100644 index 000000000..5090ce9c8 --- /dev/null +++ b/source/Octopus.Tentacle.Tests/Commands/MigratePreInstalledKubernetesDeploymentTargetCommandTest.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using k8s.Models; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using NUnit.Framework; +using Octopus.Tentacle.Commands; + +namespace Octopus.Tentacle.Tests.Commands +{ + [TestFixture] + public class MigratePreInstalledKubernetesDeploymentTargetCommandFixture + { + [Test] + public void ShouldNotMigrate_IfNoSource() + { + var targetConfigMap = Substitute.For(); + var targetSecret = Substitute.For(); + + var (shouldMigrate, reason) = MigratePreInstalledKubernetesDeploymentTargetCommand.ShouldMigrateData(null, null, targetConfigMap, targetSecret); + shouldMigrate.Should().BeFalse(); + reason.Should().Be("Source config map or secret not found, skipping migration"); + } + + [Test] + public void ShouldNotMigrate_IfNoDestination() + { + var sourceConfigMap = Substitute.For(); + var sourceSecret = Substitute.For(); + + MigratePreInstalledKubernetesDeploymentTargetCommand.ShouldMigrateData(sourceConfigMap, sourceSecret, null, null).Item1.Should().BeFalse(); + } + + [Test] + public void ShouldNotMigrate_IfDestinationRegistered() + { + var sourceConfigMap = Substitute.For(); + var sourceSecret = Substitute.For(); + var targetSecret = Substitute.For(); + var targetConfigMap = new V1ConfigMap + { + Data = new Dictionary + { + {"Tentacle.Services.IsRegistered", "true"} + } + }; + + MigratePreInstalledKubernetesDeploymentTargetCommand.ShouldMigrateData(sourceConfigMap, sourceSecret, targetConfigMap, targetSecret).Item1.Should().BeFalse(); + } + + + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle/Commands/MigratePreInstalledKubernetesDeploymentTargetCommand.cs b/source/Octopus.Tentacle/Commands/MigratePreInstalledKubernetesDeploymentTargetCommand.cs new file mode 100644 index 000000000..ae195846c --- /dev/null +++ b/source/Octopus.Tentacle/Commands/MigratePreInstalledKubernetesDeploymentTargetCommand.cs @@ -0,0 +1,118 @@ +using System; +using System.Net; +using Octopus.Diagnostics; +using Octopus.Tentacle.Startup; +using k8s; +using k8s.Autorest; +using k8s.Models; +using Octopus.Tentacle.Kubernetes; +using Octopus.Tentacle.Util; + +namespace Octopus.Tentacle.Commands +{ + public class MigratePreInstalledKubernetesDeploymentTargetCommand : AbstractCommand + { + readonly ISystemLog log; + readonly Lazy configProvider; + + string? sourceConfigMapName; + string? sourceSecretName; + string? destinationConfigMapName; + string? destinationSecretName; + string? @namespace; + + public MigratePreInstalledKubernetesDeploymentTargetCommand(Lazy configProvider, ISystemLog log, ILogFileOnlyLogger logFileOnlyLogger) : base(logFileOnlyLogger) + { + this.log = log; + this.configProvider = configProvider; + + Options.Add("source-config-map-name=", "The name of the source config map (created by the pre-installation of the agent)", v => sourceConfigMapName = v); + Options.Add("source-secret-name=", "The name of the source secret (created by the pre-installation of the agent)", v => sourceSecretName = v); + Options.Add("destination-config-map-name=", "The name of the destination config map", v => destinationConfigMapName = v); + Options.Add("destination-secret-name=", "The name of the destination secret", v => destinationSecretName = v); + Options.Add("namespace=", "The namespace to use for the migration", v => @namespace = v); + } + + // This command is only used as a way to programatically copy the config map and secret from the pre-installation hook to the new agent + // It does not use the IWritableTentacleConfiguration so we don't accidentally generate any new keys + protected override void Start() + { + if (!PlatformDetection.Kubernetes.IsRunningAsKubernetesAgent) + { + throw new ControlledFailureException("This command can only be run from within a Kubernetes agent."); + } + + // Check that the sources and destinations are different + if (sourceSecretName == destinationSecretName || sourceConfigMapName == destinationConfigMapName) + { + throw new ControlledFailureException("Source and destination names must be different."); + } + + var migrationNamespace = @namespace ?? KubernetesConfig.Namespace; + + var config = configProvider.Value.Get(); + var client = new k8s.Kubernetes(config); + var sourceConfigMap = TryGetCoreV1Object(() => client.CoreV1.ReadNamespacedConfigMap(sourceConfigMapName, migrationNamespace)); + var sourceSecret = TryGetCoreV1Object(() => client.CoreV1.ReadNamespacedSecret(sourceSecretName, migrationNamespace)); + var destinationConfigMap = TryGetCoreV1Object(() => client.CoreV1.ReadNamespacedConfigMap(destinationConfigMapName, migrationNamespace)); + var destinationSecret = TryGetCoreV1Object(() => client.CoreV1.ReadNamespacedSecret(destinationSecretName, migrationNamespace)); + + var (migrateData, reason) = ShouldMigrateData(sourceConfigMap, sourceSecret, destinationConfigMap, destinationSecret); + if (!migrateData) + { + log.Info(reason); + return; + } + + // Copy the data from the source to the destination + destinationConfigMap!.Data = sourceConfigMap!.Data; + destinationSecret!.Data = sourceSecret!.Data; + client.CoreV1.ReplaceNamespacedConfigMap(destinationConfigMap, destinationConfigMapName, migrationNamespace); + client.CoreV1.ReplaceNamespacedSecret(destinationSecret, destinationSecretName, migrationNamespace); + + // Delete the sources (they are no longer needed) + client.CoreV1.DeleteNamespacedConfigMap(sourceConfigMapName, migrationNamespace); + client.CoreV1.DeleteNamespacedSecret(sourceSecretName, migrationNamespace); + + log.Info("Migration complete."); + } + + public static (bool ShouldMigrate, string Reason) ShouldMigrateData(V1ConfigMap? sourceConfigMap, V1Secret? sourceSecret, V1ConfigMap? destinationConfigMap, V1Secret? destinationSecret) + { + // Check that the sources exist + if (sourceConfigMap is null || sourceSecret is null) + { + return (false, "Source config map or secret not found, skipping migration"); + } + + // Check if the destinations exist + if (destinationConfigMap is null || destinationSecret is null) + { + return (false, "destination config map or secret not found, skipping migration."); + } + + + // Check if the destination is already registered + if (destinationConfigMap.Data is not null && destinationConfigMap.Data.TryGetValue("Tentacle.Services.IsRegistered", out var isRegistered) && string.Equals(isRegistered, "true", StringComparison.OrdinalIgnoreCase)) + { + return (false, "Tentacle is already registered, skipping registration."); + } + + return (true, string.Empty); + } + + static T? TryGetCoreV1Object(Func kubernetesFunc) where T : class + { + try + { + return kubernetesFunc(); + } + catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + } + + +} \ No newline at end of file diff --git a/source/Octopus.Tentacle/Kubernetes/ConfigMapNames.cs b/source/Octopus.Tentacle/Kubernetes/ConfigMapNames.cs index 84311180d..e8e05d3ac 100644 --- a/source/Octopus.Tentacle/Kubernetes/ConfigMapNames.cs +++ b/source/Octopus.Tentacle/Kubernetes/ConfigMapNames.cs @@ -3,7 +3,6 @@ namespace Octopus.Tentacle.Kubernetes public static class ConfigMapNames { public const string AgentMetrics = "kubernetes-agent-metrics"; - public const string TentacleConfig = "tentacle-config"; public const string AgentMetricsConfigMapKey = "metrics"; } } \ No newline at end of file diff --git a/source/Octopus.Tentacle/Kubernetes/Configuration/ConfigMapKeyValueStore.cs b/source/Octopus.Tentacle/Kubernetes/Configuration/ConfigMapKeyValueStore.cs index ab5c4a172..eaaf8765b 100644 --- a/source/Octopus.Tentacle/Kubernetes/Configuration/ConfigMapKeyValueStore.cs +++ b/source/Octopus.Tentacle/Kubernetes/Configuration/ConfigMapKeyValueStore.cs @@ -14,7 +14,7 @@ class ConfigMapKeyValueStore : IWritableKeyValueStore, IAggregatableKeyValueStor readonly IKubernetesConfigMapService configMapService; readonly IKubernetesMachineKeyEncryptor encryptor; - const string Name = ConfigMapNames.TentacleConfig; + static string Name => KubernetesConfig.TentacleConfigMapName; readonly Lazy configMap; IDictionary ConfigMapData => configMap.Value.Data ??= new Dictionary(); diff --git a/source/Octopus.Tentacle/Kubernetes/Crypto/KubernetesMachineEncryptionKeyProvider.cs b/source/Octopus.Tentacle/Kubernetes/Crypto/KubernetesMachineEncryptionKeyProvider.cs index 09d8c1a7f..7fd779de2 100644 --- a/source/Octopus.Tentacle/Kubernetes/Crypto/KubernetesMachineEncryptionKeyProvider.cs +++ b/source/Octopus.Tentacle/Kubernetes/Crypto/KubernetesMachineEncryptionKeyProvider.cs @@ -16,7 +16,7 @@ public interface IKubernetesMachineEncryptionKeyProvider public class KubernetesMachineEncryptionKeyProvider : IKubernetesMachineEncryptionKeyProvider { - const string SecretName = "tentacle-secret"; + static string SecretName => KubernetesConfig.TentacleEncryptionSecretName; const string MachineKeyName = "machine-key"; const string MachineIvName = "machine-iv"; diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesConfig.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesConfig.cs index 8c2586cd0..1485a7665 100644 --- a/source/Octopus.Tentacle/Kubernetes/KubernetesConfig.cs +++ b/source/Octopus.Tentacle/Kubernetes/KubernetesConfig.cs @@ -62,6 +62,12 @@ public static class KubernetesConfig public static readonly string PodSecurityContextJsonVariableName = $"{EnvVarPrefix}__PODSECURITYCONTEXTJSON"; public static string? PodSecurityContextJson => Environment.GetEnvironmentVariable(PodSecurityContextJsonVariableName); + public static readonly string TentacleConfigMapNameVariableName = $"{EnvVarPrefix}__TENTACLECONFIGMAPNAME"; + public static string TentacleConfigMapName => GetEnvironmentVariableOrDefault(TentacleConfigMapNameVariableName, "tentacle-config"); + + public static readonly string TentacleEncryptionSecretNameVariableName = $"{EnvVarPrefix}__TENTACLEENCRYPTIONSECRETNAME"; + public static string TentacleEncryptionSecretName => GetEnvironmentVariableOrDefault(TentacleEncryptionSecretNameVariableName, "tentacle-secret"); + public static string MetricsEnableVariableName => $"{EnvVarPrefix}__ENABLEMETRICSCAPTURE"; public static bool MetricsIsEnabled @@ -100,5 +106,8 @@ public static string[] ServerCommsAddresses static string GetRequiredEnvVar(string variable, string errorMessage) => Environment.GetEnvironmentVariable(variable) ?? throw new InvalidOperationException($"{errorMessage} The environment variable '{variable}' must be defined with a non-null value."); + + static string GetEnvironmentVariableOrDefault(string variable, string defaultValue) + => Environment.GetEnvironmentVariable(variable) ?? defaultValue; } } \ No newline at end of file diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs index da64cba9d..a892fe8ab 100644 --- a/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs +++ b/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs @@ -8,6 +8,7 @@ using Octopus.Tentacle.Kubernetes.Diagnostics; using Octopus.Tentacle.Kubernetes.Synchronisation; using Octopus.Tentacle.Kubernetes.Synchronisation.Internal; +using Octopus.Tentacle.Util; namespace Octopus.Tentacle.Kubernetes { @@ -15,6 +16,12 @@ public class KubernetesModule : Module { protected override void Load(ContainerBuilder builder) { + if (!PlatformDetection.Kubernetes.IsRunningAsKubernetesAgent) + { + builder.RegisterType().As(); + return; + } + builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); diff --git a/source/Octopus.Tentacle/Kubernetes/UnimplementedKubernetesClientConfigProvider.cs b/source/Octopus.Tentacle/Kubernetes/UnimplementedKubernetesClientConfigProvider.cs new file mode 100644 index 000000000..6903dc503 --- /dev/null +++ b/source/Octopus.Tentacle/Kubernetes/UnimplementedKubernetesClientConfigProvider.cs @@ -0,0 +1,13 @@ +using System; +using k8s; + +namespace Octopus.Tentacle.Kubernetes +{ + class UnimplementedKubernetesConfigProvider : IKubernetesClientConfigProvider + { + public KubernetesClientConfiguration Get() + { + throw new NotImplementedException("This provider is not implemented when running outside of the Kubernetes Agent."); + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle/Program.cs b/source/Octopus.Tentacle/Program.cs index a9a05a74f..6de70d9d6 100644 --- a/source/Octopus.Tentacle/Program.cs +++ b/source/Octopus.Tentacle/Program.cs @@ -57,11 +57,7 @@ public override IContainer BuildContainer(StartUpInstanceRequest startUpInstance builder.RegisterModule(new ServicesModule()); builder.RegisterModule(new VersioningModule(GetType().Assembly)); builder.RegisterModule(new MaintenanceModule()); - - if (PlatformDetection.Kubernetes.IsRunningAsKubernetesAgent) - { - builder.RegisterModule(); - } + builder.RegisterModule(new KubernetesModule()); builder.RegisterCommand("create-instance", "Registers a new instance of the Tentacle service"); builder.RegisterCommand("delete-instance", "Deletes an instance of the Tentacle service"); @@ -79,6 +75,7 @@ public override IContainer BuildContainer(StartUpInstanceRequest startUpInstance #pragma warning restore CS0618 // Type or member is obsolete builder.RegisterCommand("register-k8s-target", "Registers this kubernetes agent as a deployment target with an Octopus Server"); builder.RegisterCommand("register-k8s-worker", "Registers this kubernetes agent as a worker with an Octopus Server"); + builder.RegisterCommand("migrate-preinstalled-k8s-config", "Migrates the configuration from the pre-install hook to the running agent instance"); builder.RegisterCommand("extract", "Extracts a NuGet package"); builder.RegisterCommand("deregister-from", "Deregisters this deployment target from an Octopus Server"); builder.RegisterCommand("deregister-worker", "Deregisters this worker from an Octopus Server");