Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to execute scripts via Kubernetes Jobs #690

Merged
merged 30 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4666cec
Add Kubernetes Client SDK
APErebus Nov 15, 2023
8e74d6e
Support executing scripts via Kubernetes Job
APErebus Nov 16, 2023
b8d66f4
Continued changes
APErebus Nov 16, 2023
548af9d
Add ScriptStateStore support to k8s jobs
APErebus Nov 19, 2023
4b2cc93
Add support for supplying volume info via env vars
APErebus Nov 20, 2023
7f998e8
Set up for running in WSL
APErebus Nov 20, 2023
4d8b21d
Adjust run config
APErebus Nov 20, 2023
4a4670f
Fix a number of issues with running k8s scripts
APErebus Nov 21, 2023
ecb14b0
Update script runner with new code
APErebus Nov 21, 2023
282580e
Try building the scriptrunner as a self-contained app
APErebus Nov 22, 2023
0b2ad67
Build ScriptRunner as self-contained single file package
APErebus Nov 22, 2023
21d66e6
Run the script runner as the entry point
APErebus Nov 22, 2023
6fa5a4c
Run bash script to handle output and error logs
APErebus Nov 24, 2023
a057ba7
Read from job streams and write to Output.log
APErebus Nov 27, 2023
af2b333
Remove Kubernetes.ScriptRunner
APErebus Nov 27, 2023
966c6c2
Self-cleanup
APErebus Nov 27, 2023
f579f1c
Cleanup after self-review
APErebus Nov 27, 2023
8843207
More minor cleanup
APErebus Nov 27, 2023
f7cb8a8
Remove ScriptRunner configuration
APErebus Nov 27, 2023
5069e7a
Fix solution
APErebus Nov 27, 2023
b524284
Fix rebase issue
APErebus Nov 27, 2023
571df71
Revert global.json
APErebus Nov 27, 2023
6d2b9e5
Remove duplicate package references
APErebus Nov 27, 2023
cd625dd
Remove run configuration
APErebus Nov 27, 2023
5092c07
Merge branch 'main' into ap/k8s-jobs-script-execution
APErebus Nov 27, 2023
96ad11b
Update environment variables
APErebus Nov 28, 2023
4a20af7
Merge branch 'main' into ap/k8s-jobs-script-execution
APErebus Nov 28, 2023
97f639b
Change to exception filter
APErebus Nov 29, 2023
a2a48b7
PR feedback
APErebus Nov 29, 2023
e5f2f63
Merge branch 'main' into ap/k8s-jobs-script-execution
APErebus Nov 29, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
<PackageReference Include="NuGet.Packaging.Core" Version="3.6.0-octopus-58692" />
<PackageReference Include="NuGet.Packaging.Core.Types" Version="3.6.0-octopus-58692" />
<PackageReference Include="NuGet.Versioning" Version="3.6.0-octopus-58692" />
<PackageReference Include="Portable.BouncyCastle" Version="1.8.10" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="System.Collections" Version="4.3.0" />
<PackageReference Include="System.Collections.Concurrent" Version="4.3.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

namespace Octopus.Tentacle.Contracts.ScriptServiceV3Alpha
{
public class KubernetesJobScriptExecutionContext : IScriptExecutionContext
{ }
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ public void Dispose()
}

public void WriteOutput(ProcessOutputSource source, string message)
=> WriteOutput(source, message, DateTimeOffset.UtcNow);

public void WriteOutput(ProcessOutputSource source, string message, DateTimeOffset occurred)
{
Console.WriteLine($"{DateTime.UtcNow} {source} {message}");
Console.WriteLine($"{occurred} {source} {message}");
switch (source)
{
case ProcessOutputSource.Debug:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
using Octopus.Diagnostics;
using Octopus.Tentacle.CommonTestUtils.Builders;
using Octopus.Tentacle.Configuration;
using Octopus.Tentacle.Configuration.Instances;
using Octopus.Tentacle.Contracts;
using Octopus.Tentacle.Contracts.ScriptServiceV3Alpha;
using Octopus.Tentacle.Diagnostics;
using Octopus.Tentacle.Kubernetes;
using Octopus.Tentacle.Scripts;
using Octopus.Tentacle.Scripts.Kubernetes;
using Octopus.Tentacle.Services.Scripts;
using Octopus.Tentacle.Util;

Expand All @@ -41,6 +44,12 @@ public void SetUp()
new Lazy<LocalShellScriptExecutor>(() =>
new LocalShellScriptExecutor(
PlatformDetection.IsRunningOnWindows ? new PowerShell() : new Bash(),
Substitute.For<ISystemLog>())),
new Lazy<KubernetesJobScriptExecutor>(() =>
new KubernetesJobScriptExecutor(
Substitute.For<IKubernetesJobService>(),
Substitute.For<IKubernetesClusterService>(),
Substitute.For<IApplicationInstanceSelector>(),
Substitute.For<ISystemLog>())));

service = new ScriptServiceV3Alpha(
Expand Down
4 changes: 4 additions & 0 deletions source/Octopus.Tentacle/ExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace System.Runtime.CompilerServices
APErebus marked this conversation as resolved.
Show resolved Hide resolved
{
internal static class IsExternalInit {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using k8s;

namespace Octopus.Tentacle.Kubernetes
{
public interface IKubernetesClientConfigProvider
{
KubernetesClientConfiguration Get();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;
using k8s;

namespace Octopus.Tentacle.Kubernetes
{
class InClusterKubernetesClientConfigProvider : IKubernetesClientConfigProvider
{
public KubernetesClientConfiguration Get()
{
return KubernetesClientConfiguration.InClusterConfig();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we are running in a cluster, this will just use the ambient service account auth

}
}
}
33 changes: 33 additions & 0 deletions source/Octopus.Tentacle/Kubernetes/KubernetesClusterService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using System.Threading.Tasks;
using k8s;
using Nito.AsyncEx;

namespace Octopus.Tentacle.Kubernetes
{
public interface IKubernetesClusterService
{
Task<ClusterVersion> GetClusterVersion();
}

public class KubernetesClusterService : KubernetesService, IKubernetesClusterService
{
readonly AsyncLazy<ClusterVersion> lazyVersion;
public KubernetesClusterService(IKubernetesClientConfigProvider configProvider)
: base(configProvider)
{
//As the cluster version isn't going to change without restarting, we just cache the version in an AsyncLazy
lazyVersion = new AsyncLazy<ClusterVersion>(async () =>
{
var versionInfo = await Client.Version.GetCodeAsync();

return new ClusterVersion(int.Parse(versionInfo.Major), int.Parse(versionInfo.Minor));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the patch number of a k8s cluster is not provided by the cluster itself (and isn't important for what we need it for)

});
}

public async Task<ClusterVersion> GetClusterVersion()
=> await lazyVersion;
}

public record ClusterVersion(int Major, int Minor);
}
18 changes: 18 additions & 0 deletions source/Octopus.Tentacle/Kubernetes/KubernetesConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace Octopus.Tentacle.Kubernetes
{
public static class KubernetesConfig
{
public static string Namespace => Environment.GetEnvironmentVariable("OCTOPUS__TENTACLE__K8SNAMESPACE")
?? throw new InvalidOperationException("Unable to determine Kubernetes namespace. An environment variable 'OCTOPUS__TENTACLE__K8SNAMESPACE' must be defined.");

public static bool UseJobs => bool.TryParse(Environment.GetEnvironmentVariable("OCTOPUS__TENTACLE__K8SUSEJOBS"), out var useJobs) && useJobs;

public static string ServiceAccountName => Environment.GetEnvironmentVariable("OCTOPUS__TENTACLE__K8SSERVICEACCOUNTNAME")
?? throw new InvalidOperationException("Unable to determine Kubernetes Job service account name. An environment variable 'OCTOPUS__TENTACLE__K8SSERVICEACCOUNTNAME' must be defined.");

public static string JobVolumeYaml => Environment.GetEnvironmentVariable("OCTOPUS__TENTACLE__K8SJOBVOLUMEYAML")
?? throw new InvalidOperationException("Unable to determine Kubernetes Job volume yaml. An environment variable 'OCTOPUS__TENTACLE__K8SJOBVOLUMEYAML' must be defined.");
}
}
89 changes: 89 additions & 0 deletions source/Octopus.Tentacle/Kubernetes/KubernetesJobService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using k8s;
using k8s.Autorest;
using k8s.Models;
using Octopus.Tentacle.Contracts;

namespace Octopus.Tentacle.Kubernetes
{
public interface IKubernetesJobService
{
Task<V1Job?> TryGet(ScriptTicket scriptTicket, CancellationToken cancellationToken);
string BuildJobName(ScriptTicket scriptTicket);
Task CreateJob(V1Job job, CancellationToken cancellationToken);
void Delete(ScriptTicket scriptTicket);
Task Watch(ScriptTicket scriptTicket, Func<V1Job, bool> onChange, Action<Exception> onError, CancellationToken cancellationToken);
}

public class KubernetesJobService : KubernetesService, IKubernetesJobService
{
public KubernetesJobService(IKubernetesClientConfigProvider configProvider)
: base(configProvider)
{
}

public async Task<V1Job?> TryGet(ScriptTicket scriptTicket, CancellationToken cancellationToken)
{
var jobName = BuildJobName(scriptTicket);

try
{
return await Client.ReadNamespacedJobStatusAsync(jobName, KubernetesConfig.Namespace, cancellationToken: cancellationToken);
}
catch (HttpOperationException opException)
{
if (opException.Response.StatusCode == HttpStatusCode.NotFound)
return null;

//if there is some other error, just throw the exception
throw;
}
APErebus marked this conversation as resolved.
Show resolved Hide resolved
}

public async Task Watch(ScriptTicket scriptTicket, Func<V1Job, bool> onChange, Action<Exception> onError, CancellationToken cancellationToken)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than polling the TryGet endpoint continually, this creates an IAsyncEnumerable which yields when there is a change

{
var jobName = BuildJobName(scriptTicket);

using var response = Client.BatchV1.ListNamespacedJobWithHttpMessagesAsync(
KubernetesConfig.Namespace,
//only list this job
fieldSelector: $"metadata.name=={jobName}",
watch: true,
timeoutSeconds: 1800, //same as the TTL of the job itself
APErebus marked this conversation as resolved.
Show resolved Hide resolved
cancellationToken: cancellationToken);

await foreach (var (type, job) in response.WatchAsync<V1Job, V1JobList>(onError, cancellationToken: cancellationToken))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting pattern 👍

{
//we are only watching for modifications
if (type != WatchEventType.Modified)
continue;

var stopWatching = onChange(job);
if (stopWatching)
break;
}
}

public string BuildJobName(ScriptTicket scriptTicket) => $"octopus-{scriptTicket.TaskId}".ToLowerInvariant();

public async Task CreateJob(V1Job job, CancellationToken cancellationToken)
{
await Client.CreateNamespacedJobAsync(job, KubernetesConfig.Namespace, cancellationToken: cancellationToken);
}

public void Delete(ScriptTicket scriptTicket)
{
try
{
Client.DeleteNamespacedJob(BuildJobName(scriptTicket), KubernetesConfig.Namespace);
}
catch
{
//we are comfortable silently consuming this as the jobs have a TTL that will clean it up anyway
}
}
}
}
19 changes: 19 additions & 0 deletions source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Autofac;

namespace Octopus.Tentacle.Kubernetes
{
public class KubernetesModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<KubernetesJobService>().As<IKubernetesJobService>().SingleInstance();
builder.RegisterType<KubernetesClusterService>().As<IKubernetesClusterService>().SingleInstance();

#if DEBUG
builder.RegisterType<LocalMachineKubernetesClientConfigProvider>().As<IKubernetesClientConfigProvider>().SingleInstance();
#else
builder.RegisterType<InClusterKubernetesClientConfigProvider>().As<IKubernetesClientConfigProvider>().SingleInstance();
#endif
}
}
}
14 changes: 14 additions & 0 deletions source/Octopus.Tentacle/Kubernetes/KubernetesService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using k8sClient = k8s.Kubernetes;

namespace Octopus.Tentacle.Kubernetes
{
public abstract class KubernetesService
{
protected k8sClient Client { get; }

protected KubernetesService(IKubernetesClientConfigProvider configProvider)
{
Client = new k8sClient(configProvider.Get());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;
using k8s;

namespace Octopus.Tentacle.Kubernetes
{
class LocalMachineKubernetesClientConfigProvider : IKubernetesClientConfigProvider
{
public KubernetesClientConfiguration Get()
{
#if DEBUG
var kubeConfigEnvVar = Environment.GetEnvironmentVariable("KUBECONFIG");
return KubernetesClientConfiguration.BuildConfigFromConfigFile(kubeConfigEnvVar);
#else
throw new NotSupportedException("Local machine configuration is only supported when debugging.");
#endif
}
}
}
46 changes: 46 additions & 0 deletions source/Octopus.Tentacle/Kubernetes/bootstrapRunner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#! /usr/bin/bash

WORK_DIR=$1
STDOUT_LOG="$WORK_DIR/stdout.log"
STDERR_LOG="$WORK_DIR/stderr.log"

format() {
now=$(date -u +"%Y-%m-%dT%H:%M:%S.%N%z")
echo "$now|$2" | tee -a "$1"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use tee so the logs are written to the job/pod logs as well as the log files

}

logStdOut() {
while read -r IN
do
format "$STDOUT_LOG" "$IN"
done
}

logStdErr() {
while read -r IN
do
format "$STDERR_LOG" "$IN"
done
}

#ensure these files exist
rm -f "$STDOUT_LOG";
rm -f "$STDERR_LOG";
touch "$STDOUT_LOG"
touch "$STDERR_LOG"

#pass the remaining args (skipping the first which is the working directory)
shift

BOOTSTRAP_SCRIPT=$1

#This is the args for the Bootstrap script
shift

exec > >(logStdOut)
exec 2> >(logStdErr >&2)

/bin/bash $BOOTSTRAP_SCRIPT "$@"

# This ungodly hack is to stop the pod from being killed before the last log has been flushed
sleep 0.250 #250ms
Comment on lines +50 to +51
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the pod is destroyed before the file logs are flushed. This little hack just gives the pod time to flush the last redirected output to the files

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 yeah I feel your pain. Might be an idea to pipe one last verbose message to the logs so that we can tell if someone ever complains about logs getting cut out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's a great idea

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a2a48b7

16 changes: 13 additions & 3 deletions source/Octopus.Tentacle/Octopus.Tentacle.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
<PropertyGroup Condition=" '$(TargetFramework)' == 'net6.0' ">
<DefineConstants>$(DefineConstants);HTTP_CLIENT_SUPPORTS_SSL_OPTIONS;REQUIRES_EXPLICIT_LOG_CONFIG;REQUIRES_CODE_PAGE_PROVIDER;USER_INTERACTIVE_DOES_NOT_WORK;DEFAULT_PROXY_IS_NOT_AVAILABLE;HAS_NULLABLE_REF_TYPES</DefineConstants>
</PropertyGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net48' ">
<PackageReference Include="KubernetesClient.Classic" Version="10.1.19" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be for the sake of compilation completeness, but would we ever expect to build/distribute this for netframework?
Even if for some unknown reason a user wants to use a Windows Container, it should be safe to be netcore

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's more for completeness and then I don't need to put pre-processor symbols everywhere to not compile the code for .NET 4.8.

</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0'">
<PackageReference Include="KubernetesClient" Version="10.1.19" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Octopus.Client" Version="14.3.980" />
</ItemGroup>
Expand All @@ -54,14 +60,14 @@
<PackageReference Include="Octopus.Configuration" Version="4.1.0" />
<PackageReference Include="Octopus.Diagnostics" Version="2.1.0" />
<PackageReference Include="Octopus.Time" Version="1.1.339" />
<PackageReference Include="Portable.BouncyCastle" Version="1.8.4" />
<PackageReference Include="Polly" Version="7.2.2" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="System.Management" Version="4.7.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="System.Net.Primitives" Version="4.3.1" />
<PackageReference Include="TaskScheduler" Version="2.7.2" />
<PackageReference Include="Nito.AsyncEx" Version="5.0.0" />
<PackageReference Include="NLog" Version="5.0.4" />
<PackageReference Include="Polly" Version="7.2.2" />
<PackageReference Include="System.Management" Version="4.7.0" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Solution Items\SolutionInfo.cs">
Expand Down Expand Up @@ -112,5 +118,9 @@
<EmbeddedResource Include="Internals\Options\_Readme.txt" />
<EmbeddedResource Include="Startup\PathsToDeleteOnStartup.core.txt" />
<EmbeddedResource Include="Startup\PathsToDeleteOnStartup.netfx.txt" />
<None Remove="Kubernetes\bootstrapRunner.sh" />
<EmbeddedResource Include="Kubernetes\bootstrapRunner.sh">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup>
</Project>
2 changes: 2 additions & 0 deletions source/Octopus.Tentacle/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Octopus.Tentacle.Communications;
using Octopus.Tentacle.Configuration;
using Octopus.Tentacle.Diagnostics;
using Octopus.Tentacle.Kubernetes;
using Octopus.Tentacle.Maintenance;
using Octopus.Tentacle.Properties;
using Octopus.Tentacle.Services;
Expand Down Expand Up @@ -56,6 +57,7 @@ public override IContainer BuildContainer(StartUpInstanceRequest startUpInstance
builder.RegisterModule(new ServicesModule());
builder.RegisterModule(new VersioningModule(GetType().Assembly));
builder.RegisterModule(new MaintenanceModule());
builder.RegisterModule<KubernetesModule>();

builder.RegisterCommand<CreateInstanceCommand>("create-instance", "Registers a new instance of the Tentacle service");
builder.RegisterCommand<DeleteInstanceCommand>("delete-instance", "Deletes an instance of the Tentacle service");
Expand Down
Loading