Add support for executing commands within a container (#63)

* Add support for executing commands within a container

* Add WebSocketNamespacedPodPortForwardAsync

* Add Attach functionality

* Simplify code
This commit is contained in:
Frederik Carlier
2017-12-11 05:21:09 +01:00
committed by Brendan Burns
parent 14b59f6511
commit 2a54a8c370
6 changed files with 600 additions and 1 deletions

View File

@@ -0,0 +1,130 @@
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
namespace k8s
{
public partial interface IKubernetes
{
/// <summary>
/// Executes a command in a pod.
/// </summary>
/// <param name='name'>
/// name of the Pod
/// </param>
/// <param name='namespace'>
/// object name and auth scope, such as for teams and projects
/// </param>
/// <param name='command'>
/// Command is the remote command to execute. argv array. Not executed within a
/// shell.
/// </param>
/// <param name='container'>
/// Container in which to execute the command. Defaults to only container if
/// there is only one container in the pod.
/// </param>
/// <param name='stderr'>
/// Redirect the standard error stream of the pod for this call. Defaults to
/// <see langword="true"/>.
/// </param>
/// <param name='stdin'>
/// Redirect the standard input stream of the pod for this call. Defaults to
/// <see langword="true"/>.
/// </param>
/// <param name='stdout'>
/// Redirect the standard output stream of the pod for this call. Defaults to
/// <see langword="true"/>.
/// </param>
/// <param name='tty'>
/// TTY if true indicates that a tty will be allocated for the exec call.
/// Defaults to <see langword="true"/>.
/// </param>
/// <param name='customHeaders'>
/// Headers that will be added to request.
/// </param>
/// <param name='cancellationToken'>
/// The cancellation token.
/// </param>
/// <exception cref="ArgumentNullException">
/// Thrown when a required parameter is null
/// </exception>
/// <return>
/// A <see cref="ClientWebSocket"/> which can be used to communicate with the process running in the pod.
/// </return>
Task<WebSocket> WebSocketNamespacedPodExecAsync(string name, string @namespace = "default", string command = "/bin/bash", string container = null, bool stderr = true, bool stdin = true, bool stdout = true, bool tty = true, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken));
/// <summary>
/// Start port forwarding one or more ports of a pod.
/// </summary>
/// <param name='name'>
/// The name of the Pod
/// </param>
/// <param name='namespace'>
/// The object name and auth scope, such as for teams and projects
/// </param>
/// <param name='ports'>
/// List of ports to forward.
/// </param>
/// <param name='customHeaders'>
/// The headers that will be added to request.
/// </param>
/// <param name='cancellationToken'>
/// The cancellation token.
/// </param>
Task<WebSocket> WebSocketNamespacedPodPortForwardAsync(string name, string @namespace, IEnumerable<int> ports, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken));
/// <summary>
/// connect GET requests to attach of Pod
/// </summary>
/// <param name='name'>
/// name of the Pod
/// </param>
/// <param name='namespace'>
/// object name and auth scope, such as for teams and projects
/// </param>
/// <param name='container'>
/// The container in which to execute the command. Defaults to only container
/// if there is only one container in the pod.
/// </param>
/// <param name='stderr'>
/// Stderr if true indicates that stderr is to be redirected for the attach
/// call. Defaults to true.
/// </param>
/// <param name='stdin'>
/// Stdin if true, redirects the standard input stream of the pod for this
/// call. Defaults to false.
/// </param>
/// <param name='stdout'>
/// Stdout if true indicates that stdout is to be redirected for the attach
/// call. Defaults to true.
/// </param>
/// <param name='tty'>
/// TTY if true indicates that a tty will be allocated for the attach call.
/// This is passed through the container runtime so the tty is allocated on the
/// worker node by the container runtime. Defaults to false.
/// </param>
/// <param name='customHeaders'>
/// Headers that will be added to request.
/// </param>
/// <param name='cancellationToken'>
/// The cancellation token.
/// </param>
/// <exception cref="HttpOperationException">
/// Thrown when the operation returned an invalid status code
/// </exception>
/// <exception cref="SerializationException">
/// Thrown when unable to deserialize the response
/// </exception>
/// <exception cref="ValidationException">
/// Thrown when a required parameter is null
/// </exception>
/// <exception cref="System.ArgumentNullException">
/// Thrown when a required parameter is null
/// </exception>
/// <return>
/// A response object containing the response body and response headers.
/// </return>
Task<WebSocket> WebSocketNamespacedPodAttachAsync(string name, string @namespace, string container = default(string), bool stderr = true, bool stdin = false, bool stdout = true, bool tty = false, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken));
}
}

246
src/Kubernetes.WebSocket.cs Normal file
View File

@@ -0,0 +1,246 @@
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Rest;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
namespace k8s
{
public partial class Kubernetes
{
/// <summary>
/// Gets a function which returns a <see cref="WebSocketBuilder"/> which <see cref="Kubernetes"/> will use to
/// create a new <see cref="WebSocket"/> connection to the Kubernetes cluster.
/// </summary>
public Func<WebSocketBuilder> CreateWebSocketBuilder { get; set; } = () => new WebSocketBuilder();
/// <inheritdoc/>
public Task<WebSocket> WebSocketNamespacedPodExecAsync(string name, string @namespace = "default", string command = "/bin/sh", string container = null, bool stderr = true, bool stdin = true, bool stdout = true, bool tty = true, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken))
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}
if (@namespace == null)
{
throw new ArgumentNullException(nameof(@namespace));
}
if (command == null)
{
throw new ArgumentNullException(nameof(command));
}
// Tracing
bool _shouldTrace = ServiceClientTracing.IsEnabled;
string _invocationId = null;
if (_shouldTrace)
{
_invocationId = ServiceClientTracing.NextInvocationId.ToString();
Dictionary<string, object> tracingParameters = new Dictionary<string, object>();
tracingParameters.Add("command", command);
tracingParameters.Add("container", container);
tracingParameters.Add("name", name);
tracingParameters.Add("namespace", @namespace);
tracingParameters.Add("stderr", stderr);
tracingParameters.Add("stdin", stdin);
tracingParameters.Add("stdout", stdout);
tracingParameters.Add("tty", tty);
tracingParameters.Add("cancellationToken", cancellationToken);
ServiceClientTracing.Enter(_invocationId, this, nameof(WebSocketNamespacedPodExecAsync), tracingParameters);
}
// Construct URL
var uriBuilder = new UriBuilder(BaseUri);
uriBuilder.Scheme = BaseUri.Scheme == "https" ? "wss" : "ws";
if (!uriBuilder.Path.EndsWith("/"))
{
uriBuilder.Path += "/";
}
uriBuilder.Path += $"api/v1/namespaces/{@namespace}/pods/{name}/exec";
uriBuilder.Query = QueryHelpers.AddQueryString(string.Empty, new Dictionary<string, string>
{
{ "command", command},
{ "container", container},
{ "stderr", stderr ? "1": "0"},
{ "stdin", stdin ? "1": "0"},
{ "stdout", stdout ? "1": "0"},
{ "tty", tty ? "1": "0"}
});
return this.StreamConnectAsync(uriBuilder.Uri, _invocationId, customHeaders, cancellationToken);
}
/// <inheritdoc/>
public Task<WebSocket> WebSocketNamespacedPodPortForwardAsync(string name, string @namespace, IEnumerable<int> ports, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken))
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}
if (@namespace == null)
{
throw new ArgumentNullException(nameof(@namespace));
}
if (ports == null)
{
throw new ArgumentNullException(nameof(ports));
}
// Tracing
bool _shouldTrace = ServiceClientTracing.IsEnabled;
string _invocationId = null;
if (_shouldTrace)
{
_invocationId = ServiceClientTracing.NextInvocationId.ToString();
Dictionary<string, object> tracingParameters = new Dictionary<string, object>();
tracingParameters.Add("name", name);
tracingParameters.Add("@namespace", @namespace);
tracingParameters.Add("ports", ports);
tracingParameters.Add("cancellationToken", cancellationToken);
ServiceClientTracing.Enter(_invocationId, this, nameof(WebSocketNamespacedPodPortForwardAsync), tracingParameters);
}
// Construct URL
var uriBuilder = new UriBuilder(this.BaseUri);
uriBuilder.Scheme = this.BaseUri.Scheme == "https" ? "wss" : "ws";
if (!uriBuilder.Path.EndsWith("/"))
{
uriBuilder.Path += "/";
}
uriBuilder.Path += $"api/v1/namespaces/{@namespace}/pods/{name}/portforward";
foreach (var port in ports)
{
uriBuilder.Query += $"ports={port}&";
}
return StreamConnectAsync(uriBuilder.Uri, _invocationId, customHeaders, cancellationToken);
}
/// <inheritdoc/>
public Task<WebSocket> WebSocketNamespacedPodAttachAsync(string name, string @namespace, string container = default(string), bool stderr = true, bool stdin = false, bool stdout = true, bool tty = false, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken))
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}
if (@namespace == null)
{
throw new ArgumentNullException(nameof(@namespace));
}
// Tracing
bool _shouldTrace = ServiceClientTracing.IsEnabled;
string _invocationId = null;
if (_shouldTrace)
{
_invocationId = ServiceClientTracing.NextInvocationId.ToString();
Dictionary<string, object> tracingParameters = new Dictionary<string, object>();
tracingParameters.Add("container", container);
tracingParameters.Add("name", name);
tracingParameters.Add("namespace", @namespace);
tracingParameters.Add("stderr", stderr);
tracingParameters.Add("stdin", stdin);
tracingParameters.Add("stdout", stdout);
tracingParameters.Add("tty", tty);
tracingParameters.Add("cancellationToken", cancellationToken);
ServiceClientTracing.Enter(_invocationId, this, nameof(WebSocketNamespacedPodAttachAsync), tracingParameters);
}
// Construct URL
var uriBuilder = new UriBuilder(this.BaseUri);
uriBuilder.Scheme = this.BaseUri.Scheme == "https" ? "wss" : "ws";
if (!uriBuilder.Path.EndsWith("/"))
{
uriBuilder.Path += "/";
}
uriBuilder.Path += $"api/v1/namespaces/{@namespace}/pods/{name}/portforward";
uriBuilder.Query = QueryHelpers.AddQueryString(string.Empty, new Dictionary<string, string>
{
{ "container", container},
{ "stderr", stderr ? "1": "0"},
{ "stdin", stdin ? "1": "0"},
{ "stdout", stdout ? "1": "0"},
{ "tty", tty ? "1": "0"}
});
return StreamConnectAsync(uriBuilder.Uri, _invocationId, customHeaders, cancellationToken);
}
protected async Task<WebSocket> StreamConnectAsync(Uri uri, string invocationId = null, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken))
{
bool _shouldTrace = ServiceClientTracing.IsEnabled;
// Create WebSocket transport objects
WebSocketBuilder webSocketBuilder = this.CreateWebSocketBuilder();
// Set Headers
if (customHeaders != null)
{
foreach (var _header in customHeaders)
{
webSocketBuilder.SetRequestHeader(_header.Key, string.Join(" ", _header.Value));
}
}
// Set Credentials
foreach (var cert in this.HttpClientHandler.ClientCertificates)
{
webSocketBuilder.AddClientCertificate(cert);
}
HttpRequestMessage message = new HttpRequestMessage();
await this.Credentials.ProcessHttpRequestAsync(message, cancellationToken);
foreach (var _header in message.Headers)
{
webSocketBuilder.SetRequestHeader(_header.Key, string.Join(" ", _header.Value));
}
// Send Request
cancellationToken.ThrowIfCancellationRequested();
WebSocket webSocket = null;
try
{
webSocket = await webSocketBuilder.BuildAndConnectAsync(uri, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
if (_shouldTrace)
{
ServiceClientTracing.Error(invocationId, ex);
}
throw;
}
finally
{
if (_shouldTrace)
{
ServiceClientTracing.Exit(invocationId, null);
}
}
return webSocket;
}
}
}

View File

@@ -4,7 +4,7 @@
<Authors>The Kubernetes Project Authors</Authors> <Authors>The Kubernetes Project Authors</Authors>
<Copyright>2017 The Kubernetes Project Authors</Copyright> <Copyright>2017 The Kubernetes Project Authors</Copyright>
<Description>Client library for the Kubernetes open source container orchestrator.</Description> <Description>Client library for the Kubernetes open source container orchestrator.</Description>
<PackageLicenseUrl>https://www.apache.org/licenses/LICENSE-2.0</PackageLicenseUrl> <PackageLicenseUrl>https://www.apache.org/licenses/LICENSE-2.0</PackageLicenseUrl>
<PackageProjectUrl>https://github.com/kubernetes-client/csharp</PackageProjectUrl> <PackageProjectUrl>https://github.com/kubernetes-client/csharp</PackageProjectUrl>
<PackageTags>kubernetes;docker;containers;</PackageTags> <PackageTags>kubernetes;docker;containers;</PackageTags>
@@ -23,5 +23,6 @@
<PackageReference Include="Newtonsoft.Json" Version="10.0.2" /> <PackageReference Include="Newtonsoft.Json" Version="10.0.2" />
<PackageReference Include="System.ValueTuple" Version="4.4.0" /> <PackageReference Include="System.ValueTuple" Version="4.4.0" />
<PackageReference Include="YamlDotNet.NetCore" Version="1.0.0" /> <PackageReference Include="YamlDotNet.NetCore" Version="1.0.0" />
<PackageReference Include="System.Net.WebSockets.Client" Version="4.3.2"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

43
src/WebSocketBuilder.cs Normal file
View File

@@ -0,0 +1,43 @@
using System;
using System.Net.WebSockets;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
namespace k8s
{
/// <summary>
/// The <see cref="WebSocketBuilder"/> creates a new <see cref="WebSocket"/> object which connects to a remote WebSocket.
/// </summary>
/// <remarks>
/// By default, this uses the .NET <see cref="ClientWebSocket"/> class, but you can inherit from this class and change it to
/// use any class which inherits from <see cref="WebSocket"/>, should you want to use a third party framework or mock the requests.
/// </remarks>
public class WebSocketBuilder
{
protected ClientWebSocket WebSocket { get; private set; } = new ClientWebSocket();
public WebSocketBuilder()
{
this.WebSocket = new ClientWebSocket();
}
public virtual WebSocketBuilder SetRequestHeader(string headerName, string headerValue)
{
this.WebSocket.Options.SetRequestHeader(headerName, headerValue);
return this;
}
public virtual WebSocketBuilder AddClientCertificate(X509Certificate certificate)
{
this.WebSocket.Options.ClientCertificates.Add(certificate);
return this;
}
public virtual async Task<WebSocket> BuildAndConnectAsync(Uri uri, CancellationToken cancellationToken)
{
await this.WebSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false);
return this.WebSocket;
}
}
}

View File

@@ -0,0 +1,140 @@
using k8s.tests.Mock;
using Microsoft.Rest;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace k8s.tests
{
public class KubernetesExecTests
{
/// <summary>
/// Tests the <see cref="Kubernetes.WebSocketNamespacedPodExecWithHttpMessagesAsync(string, string, string, string, bool, bool, bool, bool, Dictionary{string, List{string}}, CancellationToken)"/>
/// method. Changes the <see cref="WebSocketBuilder"/> used by the client with a mock builder, so this test never hits the network.
/// </summary>
/// <returns>
/// A <see cref="Task"/> which represents the asynchronous test.
/// </returns>
[Fact]
public async Task WebSocketNamespacedPodExecAsync()
{
var credentials = new BasicAuthenticationCredentials()
{
UserName = "my-user",
Password = "my-secret-password"
};
Kubernetes client = new Kubernetes(credentials);
client.BaseUri = new Uri("http://localhost");
MockWebSocketBuilder mockWebSocketBuilder = new MockWebSocketBuilder();
client.CreateWebSocketBuilder = () => mockWebSocketBuilder;
var webSocket = await client.WebSocketNamespacedPodExecAsync(
name: "mypod",
@namespace: "mynamespace",
command: "/bin/bash",
container: "mycontainer",
stderr: true,
stdin: true,
stdout: true,
tty: true,
customHeaders: new Dictionary<string, List<string>>()
{
{ "X-My-Header", new List<string>() { "myHeaderValue", "myHeaderValue2"} }
},
cancellationToken: CancellationToken.None).ConfigureAwait(false);
var expectedHeaders = new Dictionary<string, string>()
{
{ "X-My-Header", "myHeaderValue myHeaderValue2" },
{ "Authorization", "Basic bXktdXNlcjpteS1zZWNyZXQtcGFzc3dvcmQ=" }
};
Assert.Equal(mockWebSocketBuilder.PublicWebSocket, webSocket); // Did the method return the correct web socket?
Assert.Equal(new Uri("ws://localhost:80/api/v1/namespaces/mynamespace/pods/mypod/exec?command=%2Fbin%2Fbash&container=mycontainer&stderr=1&stdin=1&stdout=1&tty=1"), mockWebSocketBuilder.Uri); // Did we connect to the correct URL?
Assert.Empty(mockWebSocketBuilder.Certificates); // No certificates were used in this test
Assert.Equal(expectedHeaders, mockWebSocketBuilder.RequestHeaders); // Did we use the expected headers
}
[Fact]
public async Task WebSocketNamespacedPodPortForwardAsync()
{
var credentials = new BasicAuthenticationCredentials()
{
UserName = "my-user",
Password = "my-secret-password"
};
Kubernetes client = new Kubernetes(credentials);
client.BaseUri = new Uri("http://localhost");
MockWebSocketBuilder mockWebSocketBuilder = new MockWebSocketBuilder();
client.CreateWebSocketBuilder = () => mockWebSocketBuilder;
var webSocket = await client.WebSocketNamespacedPodPortForwardAsync(
name: "mypod",
@namespace: "mynamespace",
ports: new int[] { 80, 8080 },
customHeaders: new Dictionary<string, List<string>>()
{
{ "X-My-Header", new List<string>() { "myHeaderValue", "myHeaderValue2"} }
},
cancellationToken: CancellationToken.None).ConfigureAwait(false);
var expectedHeaders = new Dictionary<string, string>()
{
{ "X-My-Header", "myHeaderValue myHeaderValue2" },
{ "Authorization", "Basic bXktdXNlcjpteS1zZWNyZXQtcGFzc3dvcmQ=" }
};
Assert.Equal(mockWebSocketBuilder.PublicWebSocket, webSocket); // Did the method return the correct web socket?
Assert.Equal(new Uri("ws://localhost/api/v1/namespaces/mynamespace/pods/mypod/portforward?ports=80&ports=8080&"), mockWebSocketBuilder.Uri); // Did we connect to the correct URL?
Assert.Empty(mockWebSocketBuilder.Certificates); // No certificates were used in this test
Assert.Equal(expectedHeaders, mockWebSocketBuilder.RequestHeaders); // Did we use the expected headers
}
[Fact]
public async Task WebSocketNamespacedPodAttachAsync()
{
var credentials = new BasicAuthenticationCredentials()
{
UserName = "my-user",
Password = "my-secret-password"
};
Kubernetes client = new Kubernetes(credentials);
client.BaseUri = new Uri("http://localhost");
MockWebSocketBuilder mockWebSocketBuilder = new MockWebSocketBuilder();
client.CreateWebSocketBuilder = () => mockWebSocketBuilder;
var webSocket = await client.WebSocketNamespacedPodAttachAsync(
name: "mypod",
@namespace: "mynamespace",
container: "my-container",
stderr: true,
stdin: true,
stdout: true,
tty: true,
customHeaders: new Dictionary<string, List<string>>()
{
{ "X-My-Header", new List<string>() { "myHeaderValue", "myHeaderValue2"} }
},
cancellationToken: CancellationToken.None).ConfigureAwait(false);
var expectedHeaders = new Dictionary<string, string>()
{
{ "X-My-Header", "myHeaderValue myHeaderValue2" },
{ "Authorization", "Basic bXktdXNlcjpteS1zZWNyZXQtcGFzc3dvcmQ=" }
};
Assert.Equal(mockWebSocketBuilder.PublicWebSocket, webSocket); // Did the method return the correct web socket?
Assert.Equal(new Uri("ws://localhost:80/api/v1/namespaces/mynamespace/pods/mypod/portforward?container=my-container&stderr=1&stdin=1&stdout=1&tty=1"), mockWebSocketBuilder.Uri); // Did we connect to the correct URL?
Assert.Empty(mockWebSocketBuilder.Certificates); // No certificates were used in this test
Assert.Equal(expectedHeaders, mockWebSocketBuilder.RequestHeaders); // Did we use the expected headers
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Net.WebSockets;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
namespace k8s.tests.Mock
{
public class MockWebSocketBuilder : WebSocketBuilder
{
public Dictionary<string, string> RequestHeaders { get; } = new Dictionary<string, string>();
public Collection<X509Certificate> Certificates { get; } = new Collection<X509Certificate>();
public Uri Uri { get; private set; }
public WebSocket PublicWebSocket => this.WebSocket;
public override WebSocketBuilder AddClientCertificate(X509Certificate certificate)
{
this.Certificates.Add(certificate);
return this;
}
public override Task<WebSocket> BuildAndConnectAsync(Uri uri, CancellationToken cancellationToken)
{
this.Uri = uri;
return Task.FromResult<WebSocket>(this.WebSocket);
}
public override WebSocketBuilder SetRequestHeader(string headerName, string headerValue)
{
this.RequestHeaders.Add(headerName, headerValue);
return this;
}
}
}