diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index c8424b6f6..a34eb7f73 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -80,7 +80,16 @@ jobs: cat skip.log echo "CASES MUST NOT BE SKIPPED" exit 1 - fi + fi + - name: AOT Test + run: | + true > skip.log + env K8S_E2E_MINIKUBE=1 dotnet test tests/E2E.Aot.Tests --logger "SkipTestLogger;file=$PWD/skip.log" + if [ -s skip.log ]; then + cat skip.log + echo "CASES MUST NOT BE SKIPPED" + exit 1 + fi on: pull_request: diff --git a/examples/Directory.Build.targets b/examples/Directory.Build.targets index 3b7810177..65c630b6d 100644 --- a/examples/Directory.Build.targets +++ b/examples/Directory.Build.targets @@ -1,5 +1,5 @@ - + diff --git a/examples/aot/Program.cs b/examples/aot/Program.cs new file mode 100644 index 000000000..d5125c0ff --- /dev/null +++ b/examples/aot/Program.cs @@ -0,0 +1,16 @@ +using k8s; + +var config = KubernetesClientConfiguration.BuildDefaultConfig(); +IKubernetes client = new Kubernetes(config); +Console.WriteLine("Starting Request!"); + +var list = client.CoreV1.ListNamespacedPod("default"); +foreach (var item in list.Items) +{ + Console.WriteLine(item.Metadata.Name); +} + +if (list.Items.Count == 0) +{ + Console.WriteLine("Empty!"); +} \ No newline at end of file diff --git a/examples/aot/aot.csproj b/examples/aot/aot.csproj new file mode 100644 index 000000000..28741906d --- /dev/null +++ b/examples/aot/aot.csproj @@ -0,0 +1,11 @@ + + + Exe + enable + enable + true + + + + + diff --git a/src/KubernetesClient.Aot/Global.cs b/src/KubernetesClient.Aot/Global.cs new file mode 100644 index 000000000..2b5a4ae8e --- /dev/null +++ b/src/KubernetesClient.Aot/Global.cs @@ -0,0 +1,10 @@ +global using k8s.Autorest; +global using k8s.Models; +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/src/KubernetesClient.Aot/KubeConfigModels/AuthProvider.cs b/src/KubernetesClient.Aot/KubeConfigModels/AuthProvider.cs new file mode 100644 index 000000000..5bec9095e --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/AuthProvider.cs @@ -0,0 +1,23 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// Contains information that describes identity information. This is use to tell the kubernetes cluster who you are. + /// + [YamlSerializable] + public class AuthProvider + { + /// + /// Gets or sets the nickname for this auth provider. + /// + [YamlMember(Alias = "name")] + public string Name { get; set; } + + /// + /// Gets or sets the configuration for this auth provider + /// + [YamlMember(Alias = "config")] + public Dictionary Config { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/Cluster.cs b/src/KubernetesClient.Aot/KubeConfigModels/Cluster.cs new file mode 100644 index 000000000..80faf96a5 --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/Cluster.cs @@ -0,0 +1,23 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// Relates nicknames to cluster information. + /// + [YamlSerializable] + public class Cluster + { + /// + /// Gets or sets the cluster information. + /// + [YamlMember(Alias = "cluster")] + public ClusterEndpoint ClusterEndpoint { get; set; } + + /// + /// Gets or sets the nickname for this Cluster. + /// + [YamlMember(Alias = "name")] + public string Name { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/ClusterEndpoint.cs b/src/KubernetesClient.Aot/KubeConfigModels/ClusterEndpoint.cs new file mode 100644 index 000000000..c40827651 --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/ClusterEndpoint.cs @@ -0,0 +1,42 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// Contains information about how to communicate with a kubernetes cluster + /// + [YamlSerializable] + public class ClusterEndpoint + { + /// + /// Gets or sets the path to a cert file for the certificate authority. + /// + [YamlMember(Alias = "certificate-authority", ApplyNamingConventions = false)] + public string CertificateAuthority { get; set; } + + /// + /// Gets or sets =PEM-encoded certificate authority certificates. Overrides . + /// + [YamlMember(Alias = "certificate-authority-data", ApplyNamingConventions = false)] + public string CertificateAuthorityData { get; set; } + + /// + /// Gets or sets the address of the kubernetes cluster (https://hostname:port). + /// + [YamlMember(Alias = "server")] + public string Server { get; set; } + + /// + /// Gets or sets a value to override the TLS server name. + /// + [YamlMember(Alias = "tls-server-name", ApplyNamingConventions = false)] + public string TlsServerName { get; set; } + + /// + /// Gets or sets a value indicating whether to skip the validity check for the server's certificate. + /// This will make your HTTPS connections insecure. + /// + [YamlMember(Alias = "insecure-skip-tls-verify", ApplyNamingConventions = false)] + public bool SkipTlsVerify { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/Context.cs b/src/KubernetesClient.Aot/KubeConfigModels/Context.cs new file mode 100644 index 000000000..65241315f --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/Context.cs @@ -0,0 +1,23 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// Relates nicknames to context information. + /// + [YamlSerializable] + public class Context + { + /// + /// Gets or sets the context information. + /// + [YamlMember(Alias = "context")] + public ContextDetails ContextDetails { get; set; } + + /// + /// Gets or sets the nickname for this context. + /// + [YamlMember(Alias = "name")] + public string Name { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/ContextDetails.cs b/src/KubernetesClient.Aot/KubeConfigModels/ContextDetails.cs new file mode 100644 index 000000000..ca2bf1e07 --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/ContextDetails.cs @@ -0,0 +1,30 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// Represents a tuple of references to a cluster (how do I communicate with a kubernetes cluster), + /// a user (how do I identify myself), and a namespace (what subset of resources do I want to work with) + /// + [YamlSerializable] + public class ContextDetails + { + /// + /// Gets or sets the name of the cluster for this context. + /// + [YamlMember(Alias = "cluster")] + public string Cluster { get; set; } + + /// + /// Gets or sets the name of the user for this context. + /// + [YamlMember(Alias = "user")] + public string User { get; set; } + + /// + /// /Gets or sets the default namespace to use on unspecified requests. + /// + [YamlMember(Alias = "namespace")] + public string Namespace { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/ExecCredentialResponse.cs b/src/KubernetesClient.Aot/KubeConfigModels/ExecCredentialResponse.cs new file mode 100644 index 000000000..5bac4af5e --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/ExecCredentialResponse.cs @@ -0,0 +1,31 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + [YamlSerializable] + public class ExecCredentialResponse + { + public class ExecStatus + { +#nullable enable + public DateTime? ExpirationTimestamp { get; set; } + public string? Token { get; set; } + public string? ClientCertificateData { get; set; } + public string? ClientKeyData { get; set; } +#nullable disable + + public bool IsValid() + { + return !string.IsNullOrEmpty(Token) || + (!string.IsNullOrEmpty(ClientCertificateData) && !string.IsNullOrEmpty(ClientKeyData)); + } + } + + [JsonPropertyName("apiVersion")] + public string ApiVersion { get; set; } + [JsonPropertyName("kind")] + public string Kind { get; set; } + [JsonPropertyName("status")] + public ExecStatus Status { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/ExternalExecution.cs b/src/KubernetesClient.Aot/KubeConfigModels/ExternalExecution.cs new file mode 100644 index 000000000..7e18f449e --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/ExternalExecution.cs @@ -0,0 +1,42 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + [YamlSerializable] + public class ExternalExecution + { + [YamlMember(Alias = "apiVersion")] + public string ApiVersion { get; set; } + + /// + /// The command to execute. Required. + /// + [YamlMember(Alias = "command")] + public string Command { get; set; } + + /// + /// Environment variables to set when executing the plugin. Optional. + /// + [YamlMember(Alias = "env")] + public IList> EnvironmentVariables { get; set; } + + /// + /// Arguments to pass when executing the plugin. Optional. + /// + [YamlMember(Alias = "args")] + public IList Arguments { get; set; } + + /// + /// Text shown to the user when the executable doesn't seem to be present. Optional. + /// + [YamlMember(Alias = "installHint")] + public string InstallHint { get; set; } + + /// + /// Whether or not to provide cluster information to this exec plugin as a part of + /// the KUBERNETES_EXEC_INFO environment variable. Optional. + /// + [YamlMember(Alias = "provideClusterInfo")] + public bool ProvideClusterInfo { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/K8SConfiguration.cs b/src/KubernetesClient.Aot/KubeConfigModels/K8SConfiguration.cs new file mode 100644 index 000000000..a0c3a4fef --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/K8SConfiguration.cs @@ -0,0 +1,65 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// kubeconfig configuration model. Holds the information needed to build connect to remote + /// Kubernetes clusters as a given user. + /// + /// + /// Should be kept in sync with https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/client-go/tools/clientcmd/api/v1/types.go + /// Should update MergeKubeConfig in KubernetesClientConfiguration.ConfigFile.cs if updated. + /// + [YamlSerializable] + public class K8SConfiguration + { + // /// + // /// Gets or sets general information to be use for CLI interactions + // /// + // [YamlMember(Alias = "preferences")] + // public IDictionary Preferences { get; set; } + + [YamlMember(Alias = "apiVersion")] + public string ApiVersion { get; set; } + + [YamlMember(Alias = "kind")] + public string Kind { get; set; } + + /// + /// Gets or sets the name of the context that you would like to use by default. + /// + [YamlMember(Alias = "current-context", ApplyNamingConventions = false)] + public string CurrentContext { get; set; } + + /// + /// Gets or sets a map of referencable names to context configs. + /// + [YamlMember(Alias = "contexts")] + public List Contexts { get; set; } = new List(); + + /// + /// Gets or sets a map of referencable names to cluster configs. + /// + [YamlMember(Alias = "clusters")] + public List Clusters { get; set; } = new List(); + + /// + /// Gets or sets a map of referencable names to user configs + /// + [YamlMember(Alias = "users")] + public List Users { get; set; } = new List(); + + // /// + // /// Gets or sets additional information. This is useful for extenders so that reads and writes don't clobber unknown fields. + // /// + // [YamlMember(Alias = "extensions")] + // public List Extensions { get; set; } + + /// + /// Gets or sets the name of the Kubernetes configuration file. This property is set only when the configuration + /// was loaded from disk, and can be used to resolve relative paths. + /// + [YamlIgnore] + public string FileName { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/StaticContext.cs b/src/KubernetesClient.Aot/KubeConfigModels/StaticContext.cs new file mode 100644 index 000000000..ae9be922e --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/StaticContext.cs @@ -0,0 +1,8 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels; + +[YamlStaticContext] +public partial class StaticContext : YamlDotNet.Serialization.StaticContext +{ +} \ No newline at end of file diff --git a/src/KubernetesClient.Aot/KubeConfigModels/User.cs b/src/KubernetesClient.Aot/KubeConfigModels/User.cs new file mode 100644 index 000000000..557f02256 --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/User.cs @@ -0,0 +1,23 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// Relates nicknames to auth information. + /// + [YamlSerializable] + public class User + { + /// + /// Gets or sets the auth information. + /// + [YamlMember(Alias = "user")] + public UserCredentials UserCredentials { get; set; } + + /// + /// Gets or sets the nickname for this auth information. + /// + [YamlMember(Alias = "name")] + public string Name { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubeConfigModels/UserCredentials.cs b/src/KubernetesClient.Aot/KubeConfigModels/UserCredentials.cs new file mode 100644 index 000000000..bd8a5063e --- /dev/null +++ b/src/KubernetesClient.Aot/KubeConfigModels/UserCredentials.cs @@ -0,0 +1,83 @@ +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + /// + /// Contains information that describes identity information. This is use to tell the kubernetes cluster who you are. + /// + [YamlSerializable] + public class UserCredentials + { + /// + /// Gets or sets PEM-encoded data from a client cert file for TLS. Overrides . + /// + [YamlMember(Alias = "client-certificate-data", ApplyNamingConventions = false)] + public string ClientCertificateData { get; set; } + + /// + /// Gets or sets the path to a client cert file for TLS. + /// + [YamlMember(Alias = "client-certificate", ApplyNamingConventions = false)] + public string ClientCertificate { get; set; } + + /// + /// Gets or sets PEM-encoded data from a client key file for TLS. Overrides . + /// + [YamlMember(Alias = "client-key-data", ApplyNamingConventions = false)] + public string ClientKeyData { get; set; } + + /// + /// Gets or sets the path to a client key file for TLS. + /// + [YamlMember(Alias = "client-key", ApplyNamingConventions = false)] + public string ClientKey { get; set; } + + /// + /// Gets or sets the bearer token for authentication to the kubernetes cluster. + /// + [YamlMember(Alias = "token")] + public string Token { get; set; } + + /// + /// Gets or sets the username to impersonate. The name matches the flag. + /// + [YamlMember(Alias = "as")] + public string Impersonate { get; set; } + + /// + /// Gets or sets the groups to impersonate. + /// + [YamlMember(Alias = "as-groups", ApplyNamingConventions = false)] + public IEnumerable ImpersonateGroups { get; set; } = new string[0]; + + /// + /// Gets or sets additional information for impersonated user. + /// + [YamlMember(Alias = "as-user-extra", ApplyNamingConventions = false)] + public Dictionary ImpersonateUserExtra { get; set; } = new Dictionary(); + + /// + /// Gets or sets the username for basic authentication to the kubernetes cluster. + /// + [YamlMember(Alias = "username")] + public string UserName { get; set; } + + /// + /// Gets or sets the password for basic authentication to the kubernetes cluster. + /// + [YamlMember(Alias = "password")] + public string Password { get; set; } + + /// + /// Gets or sets custom authentication plugin for the kubernetes cluster. + /// + [YamlMember(Alias = "auth-provider", ApplyNamingConventions = false)] + public AuthProvider AuthProvider { get; set; } + + /// + /// Gets or sets external command and its arguments to receive user credentials + /// + [YamlMember(Alias = "exec")] + public ExternalExecution ExternalExecution { get; set; } + } +} diff --git a/src/KubernetesClient.Aot/KubernetesClient.Aot.csproj b/src/KubernetesClient.Aot/KubernetesClient.Aot.csproj new file mode 100644 index 000000000..401c32469 --- /dev/null +++ b/src/KubernetesClient.Aot/KubernetesClient.Aot.csproj @@ -0,0 +1,111 @@ + + + + net8.0 + k8s + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs new file mode 100644 index 000000000..597eea7c5 --- /dev/null +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -0,0 +1,760 @@ +using k8s.Authentication; +using k8s.Exceptions; +using k8s.KubeConfigModels; +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; + +namespace k8s +{ + public partial class KubernetesClientConfiguration + { + /// + /// kubeconfig Default Location + /// + public static readonly string KubeConfigDefaultLocation = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.Combine(Environment.GetEnvironmentVariable("USERPROFILE") ?? @"\", @".kube\config") + : Path.Combine(Environment.GetEnvironmentVariable("HOME") ?? "/", ".kube/config"); + + /// + /// Gets CurrentContext + /// + public string CurrentContext { get; private set; } + + // For testing + internal static string KubeConfigEnvironmentVariable { get; set; } = "KUBECONFIG"; + + /// + /// Exec process timeout + /// + public static TimeSpan ExecTimeout { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// Exec process Standard Errors + /// + public static event EventHandler ExecStdError; + + /// + /// Initializes a new instance of the from default locations + /// If the KUBECONFIG environment variable is set, then that will be used. + /// Next, it looks for a config file at . + /// Then, it checks whether it is executing inside a cluster and will use . + /// Finally, if nothing else exists, it creates a default config with localhost:8080 as host. + /// + /// + /// If multiple kubeconfig files are specified in the KUBECONFIG environment variable, + /// merges the files, where first occurrence wins. See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files. + /// + /// Instance of the class + public static KubernetesClientConfiguration BuildDefaultConfig() + { + var kubeconfig = Environment.GetEnvironmentVariable(KubeConfigEnvironmentVariable); + if (kubeconfig != null) + { + var configList = kubeconfig.Split(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':') + .Select((s) => new FileInfo(s.Trim('"'))); + var k8sConfig = LoadKubeConfig(configList.ToArray()); + return BuildConfigFromConfigObject(k8sConfig); + } + + if (File.Exists(KubeConfigDefaultLocation)) + { + return BuildConfigFromConfigFile(KubeConfigDefaultLocation); + } + + if (IsInCluster()) + { + return InClusterConfig(); + } + + var config = new KubernetesClientConfiguration + { + Host = "http://localhost:8080", + }; + + return config; + } + + /// + /// Initializes a new instance of the from config file + /// + /// Explicit file path to kubeconfig. Set to null to use the default file path + /// override the context in config file, set null if do not want to override + /// kube api server endpoint + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + string kubeconfigPath = null, + string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + { + return BuildConfigFromConfigFile(new FileInfo(kubeconfigPath ?? KubeConfigDefaultLocation), currentContext, + masterUrl, useRelativePaths); + } + + /// + /// Initializes a new instance of the from config file + /// + /// Fileinfo of the kubeconfig, cannot be null + /// override the context in config file, set null if do not want to override + /// override the kube api server endpoint, set null if do not want to override + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + FileInfo kubeconfig, + string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + { + return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, useRelativePaths).GetAwaiter() + .GetResult(); + } + + /// + /// Initializes a new instance of the from config file + /// + /// Fileinfo of the kubeconfig, cannot be null + /// override the context in config file, set null if do not want to override + /// override the kube api server endpoint, set null if do not want to override + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + public static async Task BuildConfigFromConfigFileAsync( + FileInfo kubeconfig, + string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + { + if (kubeconfig == null) + { + throw new NullReferenceException(nameof(kubeconfig)); + } + + var k8SConfig = await LoadKubeConfigAsync(kubeconfig, useRelativePaths).ConfigureAwait(false); + var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + + return k8SConfiguration; + } + + /// + /// Initializes a new instance of the from config file + /// + /// Stream of the kubeconfig, cannot be null + /// Override the current context in config, set null if do not want to override + /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Instance of the class + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + Stream kubeconfig, + string currentContext = null, string masterUrl = null) + { + return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl).GetAwaiter().GetResult(); + } + + /// + /// Initializes a new instance of the from config file + /// + /// Stream of the kubeconfig, cannot be null + /// Override the current context in config, set null if do not want to override + /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Instance of the class + public static async Task BuildConfigFromConfigFileAsync( + Stream kubeconfig, + string currentContext = null, string masterUrl = null) + { + if (kubeconfig == null) + { + throw new NullReferenceException(nameof(kubeconfig)); + } + + if (!kubeconfig.CanSeek) + { + throw new Exception("Stream don't support seeking!"); + } + + kubeconfig.Position = 0; + + var k8SConfig = await KubernetesYaml.LoadFromStreamAsync(kubeconfig).ConfigureAwait(false); + var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + + return k8SConfiguration; + } + + /// + /// Initializes a new instance of from pre-loaded config object. + /// + /// A , for example loaded from + /// Override the current context in config, set null if do not want to override + /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Instance of the class + public static KubernetesClientConfiguration BuildConfigFromConfigObject( + K8SConfiguration k8SConfig, + string currentContext = null, string masterUrl = null) + => GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + + private static KubernetesClientConfiguration GetKubernetesClientConfiguration( + string currentContext, + string masterUrl, K8SConfiguration k8SConfig) + { + if (k8SConfig == null) + { + throw new ArgumentNullException(nameof(k8SConfig)); + } + + var k8SConfiguration = new KubernetesClientConfiguration(); + + currentContext = currentContext ?? k8SConfig.CurrentContext; + // only init context if context is set + if (currentContext != null) + { + k8SConfiguration.InitializeContext(k8SConfig, currentContext); + } + + if (!string.IsNullOrWhiteSpace(masterUrl)) + { + k8SConfiguration.Host = masterUrl; + } + + if (string.IsNullOrWhiteSpace(k8SConfiguration.Host)) + { + throw new KubeConfigException("Cannot infer server host url either from context or masterUrl"); + } + + return k8SConfiguration; + } + + /// + /// Validates and Initializes Client Configuration + /// + /// Kubernetes Configuration + /// Current Context + private void InitializeContext(K8SConfiguration k8SConfig, string currentContext) + { + // current context + var activeContext = + k8SConfig.Contexts.FirstOrDefault( + c => c.Name.Equals(currentContext, StringComparison.OrdinalIgnoreCase)); + if (activeContext == null) + { + throw new KubeConfigException($"CurrentContext: {currentContext} not found in contexts in kubeconfig"); + } + + if (string.IsNullOrEmpty(activeContext.ContextDetails?.Cluster)) + { + // This serves as validation for any of the properties of ContextDetails being set. + // Other locations in code assume that ContextDetails is non-null. + throw new KubeConfigException($"Cluster not set for context `{currentContext}` in kubeconfig"); + } + + CurrentContext = activeContext.Name; + + // cluster + SetClusterDetails(k8SConfig, activeContext); + + // user + SetUserDetails(k8SConfig, activeContext); + + // namespace + Namespace = activeContext.ContextDetails?.Namespace; + } + + private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext) + { + var clusterDetails = + k8SConfig.Clusters.FirstOrDefault(c => c.Name.Equals( + activeContext.ContextDetails.Cluster, + StringComparison.OrdinalIgnoreCase)); + + if (clusterDetails?.ClusterEndpoint == null) + { + throw new KubeConfigException($"Cluster not found for context `{activeContext}` in kubeconfig"); + } + + if (string.IsNullOrWhiteSpace(clusterDetails.ClusterEndpoint.Server)) + { + throw new KubeConfigException($"Server not found for current-context `{activeContext}` in kubeconfig"); + } + + Host = clusterDetails.ClusterEndpoint.Server; + SkipTlsVerify = clusterDetails.ClusterEndpoint.SkipTlsVerify; + TlsServerName = clusterDetails.ClusterEndpoint.TlsServerName; + + if (!Uri.TryCreate(Host, UriKind.Absolute, out var uri)) + { + throw new KubeConfigException($"Bad server host URL `{Host}` (cannot be parsed)"); + } + + if (IPAddress.TryParse(uri.Host, out var ipAddress)) + { + if (IPAddress.Equals(IPAddress.Any, ipAddress)) + { + var builder = new UriBuilder(Host) + { + Host = $"{IPAddress.Loopback}", + }; + Host = builder.ToString(); + } + else if (IPAddress.Equals(IPAddress.IPv6Any, ipAddress)) + { + var builder = new UriBuilder(Host) + { + Host = $"{IPAddress.IPv6Loopback}", + }; + Host = builder.ToString(); + } + } + + if (uri.Scheme == "https") + { + if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthorityData)) + { + // This null password is to change the constructor to fix this KB: + // https://support.microsoft.com/en-us/topic/kb5025823-change-in-how-net-applications-import-x-509-certificates-bf81c936-af2b-446e-9f7a-016f4713b46b + string nullPassword = null; + var data = clusterDetails.ClusterEndpoint.CertificateAuthorityData; + SslCaCerts = new X509Certificate2Collection(new X509Certificate2(Convert.FromBase64String(data), nullPassword)); + } + else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) + { + SslCaCerts = new X509Certificate2Collection(new X509Certificate2(GetFullPath( + k8SConfig, + clusterDetails.ClusterEndpoint.CertificateAuthority))); + } + } + } + + private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) + { + if (string.IsNullOrWhiteSpace(activeContext.ContextDetails.User)) + { + return; + } + + var userDetails = k8SConfig.Users.FirstOrDefault(c => c.Name.Equals( + activeContext.ContextDetails.User, + StringComparison.OrdinalIgnoreCase)); + + if (userDetails == null) + { + throw new KubeConfigException($"User not found for context {activeContext.Name} in kubeconfig"); + } + + if (userDetails.UserCredentials == null) + { + throw new KubeConfigException($"User credentials not found for user: {userDetails.Name} in kubeconfig"); + } + + var userCredentialsFound = false; + + // Basic and bearer tokens are mutually exclusive + if (!string.IsNullOrWhiteSpace(userDetails.UserCredentials.Token)) + { + AccessToken = userDetails.UserCredentials.Token; + userCredentialsFound = true; + } + else if (!string.IsNullOrWhiteSpace(userDetails.UserCredentials.UserName) && + !string.IsNullOrWhiteSpace(userDetails.UserCredentials.Password)) + { + Username = userDetails.UserCredentials.UserName; + Password = userDetails.UserCredentials.Password; + userCredentialsFound = true; + } + + // Token and cert based auth can co-exist + if (!string.IsNullOrWhiteSpace(userDetails.UserCredentials.ClientCertificateData) && + !string.IsNullOrWhiteSpace(userDetails.UserCredentials.ClientKeyData)) + { + ClientCertificateData = userDetails.UserCredentials.ClientCertificateData; + ClientCertificateKeyData = userDetails.UserCredentials.ClientKeyData; + userCredentialsFound = true; + } + + if (!string.IsNullOrWhiteSpace(userDetails.UserCredentials.ClientCertificate) && + !string.IsNullOrWhiteSpace(userDetails.UserCredentials.ClientKey)) + { + ClientCertificateFilePath = GetFullPath(k8SConfig, userDetails.UserCredentials.ClientCertificate); + ClientKeyFilePath = GetFullPath(k8SConfig, userDetails.UserCredentials.ClientKey); + userCredentialsFound = true; + } + + if (userDetails.UserCredentials.AuthProvider != null) + { + if (userDetails.UserCredentials.AuthProvider.Config != null + && (userDetails.UserCredentials.AuthProvider.Config.ContainsKey("access-token") + || userDetails.UserCredentials.AuthProvider.Config.ContainsKey("id-token"))) + { + switch (userDetails.UserCredentials.AuthProvider.Name) + { + case "azure": + throw new Exception("Please use the https://github.com/Azure/kubelogin credential plugin instead. See https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins for further details`"); + + case "gcp": + throw new Exception("Please use the \"gke-gcloud-auth-plugin\" credential plugin instead. See https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke for further details"); + } + } + } + + if (userDetails.UserCredentials.ExternalExecution != null) + { + if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.Command)) + { + throw new KubeConfigException( + "External command execution to receive user credentials must include a command to execute"); + } + + if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.ApiVersion)) + { + throw new KubeConfigException("External command execution missing ApiVersion key"); + } + + var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution); + AccessToken = response.Status.Token; + // When reading ClientCertificateData from a config file it will be base64 encoded, and code later in the system (see CertUtils.GeneratePfx) + // expects ClientCertificateData and ClientCertificateKeyData to be base64 encoded because of this. However the string returned by external + // auth providers is the raw certificate and key PEM text, so we need to take that and base64 encoded it here so it can be decoded later. + ClientCertificateData = response.Status.ClientCertificateData == null ? null : Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes(response.Status.ClientCertificateData)); + ClientCertificateKeyData = response.Status.ClientKeyData == null ? null : Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes(response.Status.ClientKeyData)); + + userCredentialsFound = true; + + // TODO: support client certificates here too. + if (AccessToken != null) + { + TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution); + } + } + + if (!userCredentialsFound) + { + throw new KubeConfigException( + $"User: {userDetails.Name} does not have appropriate auth credentials in kubeconfig"); + } + } + + public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + var process = new Process(); + + process.StartInfo.EnvironmentVariables.Add("KUBERNETES_EXEC_INFO", $"{{ \"apiVersion\":\"{config.ApiVersion}\",\"kind\":\"ExecCredentials\",\"spec\":{{ \"interactive\":{Environment.UserInteractive.ToString().ToLower()} }} }}"); + if (config.EnvironmentVariables != null) + { + foreach (var configEnvironmentVariable in config.EnvironmentVariables) + { + if (configEnvironmentVariable.ContainsKey("name") && configEnvironmentVariable.ContainsKey("value")) + { + var name = configEnvironmentVariable["name"]; + process.StartInfo.EnvironmentVariables[name] = configEnvironmentVariable["value"]; + } + else + { + var badVariable = string.Join(",", configEnvironmentVariable.Select(x => $"{x.Key}={x.Value}")); + throw new KubeConfigException($"Invalid environment variable defined: {badVariable}"); + } + } + } + + process.StartInfo.FileName = config.Command; + if (config.Arguments != null) + { + process.StartInfo.Arguments = string.Join(" ", config.Arguments); + } + + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = captureStdError != null; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + + return process; + } + + /// + /// Implementation of the proposal for out-of-tree client + /// authentication providers as described here -- + /// https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md + /// Took inspiration from python exec_provider.py -- + /// https://github.com/kubernetes-client/python-base/blob/master/config/exec_provider.py + /// + /// The external command execution configuration + /// + /// The token, client certificate data, and the client key data received from the external command execution + /// + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + var captureStdError = ExecStdError; + var process = CreateRunnableExternalProcess(config, captureStdError); + + try + { + process.Start(); + if (captureStdError != null) + { + process.ErrorDataReceived += captureStdError.Invoke; + process.BeginErrorReadLine(); + } + } + catch (Exception ex) + { + throw new KubeConfigException($"external exec failed due to: {ex.Message}"); + } + + try + { + if (!process.WaitForExit((int)(ExecTimeout.TotalMilliseconds))) + { + throw new KubeConfigException("external exec failed due to timeout"); + } + + var responseObject = KubernetesJson.Deserialize(process.StandardOutput.ReadToEnd()); + if (responseObject == null || responseObject.ApiVersion != config.ApiVersion) + { + throw new KubeConfigException( + $"external exec failed because api version {responseObject.ApiVersion} does not match {config.ApiVersion}"); + } + + if (responseObject.Status.IsValid()) + { + return responseObject; + } + else + { + throw new KubeConfigException($"external exec failed missing token or clientCertificateData field in plugin output"); + } + } + catch (JsonException ex) + { + throw new KubeConfigException($"external exec failed due to failed deserialization process: {ex}"); + } + catch (Exception ex) + { + throw new KubeConfigException($"external exec failed due to uncaught exception: {ex}"); + } + } + + /// + /// Loads entire Kube Config from default or explicit file path + /// + /// Explicit file path to kubeconfig. Set to null to use the default file path + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + public static async Task LoadKubeConfigAsync( + string kubeconfigPath = null, + bool useRelativePaths = true) + { + var fileInfo = new FileInfo(kubeconfigPath ?? KubeConfigDefaultLocation); + + return await LoadKubeConfigAsync(fileInfo, useRelativePaths).ConfigureAwait(false); + } + + /// + /// Loads entire Kube Config from default or explicit file path + /// + /// Explicit file path to kubeconfig. Set to null to use the default file path + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + public static K8SConfiguration LoadKubeConfig(string kubeconfigPath = null, bool useRelativePaths = true) + { + return LoadKubeConfigAsync(kubeconfigPath, useRelativePaths).GetAwaiter().GetResult(); + } + + /// + /// Loads Kube Config + /// + /// Kube config file contents + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + public static async Task LoadKubeConfigAsync( + FileInfo kubeconfig, + bool useRelativePaths = true) + { + if (kubeconfig == null) + { + throw new ArgumentNullException(nameof(kubeconfig)); + } + + + if (!kubeconfig.Exists) + { + throw new KubeConfigException($"kubeconfig file not found at {kubeconfig.FullName}"); + } + + using (var stream = kubeconfig.OpenRead()) + { + var config = await KubernetesYaml.LoadFromStreamAsync(stream).ConfigureAwait(false); + + if (useRelativePaths) + { + config.FileName = kubeconfig.FullName; + } + + return config; + } + } + + /// + /// Loads Kube Config + /// + /// Kube config file contents + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + public static K8SConfiguration LoadKubeConfig(FileInfo kubeconfig, bool useRelativePaths = true) + { + return LoadKubeConfigAsync(kubeconfig, useRelativePaths).GetAwaiter().GetResult(); + } + + /// + /// Loads Kube Config + /// + /// Kube config file contents stream + /// Instance of the class + public static async Task LoadKubeConfigAsync(Stream kubeconfigStream) + { + return await KubernetesYaml.LoadFromStreamAsync(kubeconfigStream).ConfigureAwait(false); + } + + /// + /// Loads Kube Config + /// + /// Kube config file contents stream + /// Instance of the class + public static K8SConfiguration LoadKubeConfig(Stream kubeconfigStream) + { + return LoadKubeConfigAsync(kubeconfigStream).GetAwaiter().GetResult(); + } + + /// + /// Loads Kube Config + /// + /// List of kube config file contents + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + /// + /// The kube config files will be merges into a single , where first occurrence wins. + /// See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files. + /// + internal static K8SConfiguration LoadKubeConfig(FileInfo[] kubeConfigs, bool useRelativePaths = true) + { + return LoadKubeConfigAsync(kubeConfigs, useRelativePaths).GetAwaiter().GetResult(); + } + + /// + /// Loads Kube Config + /// + /// List of kube config file contents + /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig + /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Instance of the class + /// + /// The kube config files will be merges into a single , where first occurrence wins. + /// See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files. + /// + internal static async Task LoadKubeConfigAsync( + FileInfo[] kubeConfigs, + bool useRelativePaths = true) + { + var basek8SConfig = await LoadKubeConfigAsync(kubeConfigs[0], useRelativePaths).ConfigureAwait(false); + + for (var i = 1; i < kubeConfigs.Length; i++) + { + var mergek8SConfig = await LoadKubeConfigAsync(kubeConfigs[i], useRelativePaths).ConfigureAwait(false); + MergeKubeConfig(basek8SConfig, mergek8SConfig); + } + + return basek8SConfig; + } + + /// + /// Tries to get the full path to a file referenced from the Kubernetes configuration. + /// + /// + /// The Kubernetes configuration. + /// + /// + /// The path to resolve. + /// + /// + /// When possible a fully qualified path to the file. + /// + /// + /// For example, if the configuration file is at "C:\Users\me\kube.config" and path is "ca.crt", + /// this will return "C:\Users\me\ca.crt". Similarly, if path is "D:\ca.cart", this will return + /// "D:\ca.crt". + /// + private static string GetFullPath(K8SConfiguration configuration, string path) + { + // If we don't have a file name, + if (string.IsNullOrWhiteSpace(configuration.FileName) || Path.IsPathRooted(path)) + { + return path; + } + else + { + return Path.Combine(Path.GetDirectoryName(configuration.FileName), path); + } + } + + /// + /// Merges kube config files together, preferring configuration present in the base config over the merge config. + /// + /// The to merge into + /// The to merge from + private static void MergeKubeConfig(K8SConfiguration basek8SConfig, K8SConfiguration mergek8SConfig) + { + // For scalar values, prefer local values + basek8SConfig.CurrentContext = basek8SConfig.CurrentContext ?? mergek8SConfig.CurrentContext; + basek8SConfig.FileName = basek8SConfig.FileName ?? mergek8SConfig.FileName; + + // Kinds must match in kube config, otherwise throw. + if (basek8SConfig.Kind != mergek8SConfig.Kind) + { + throw new KubeConfigException( + $"kubeconfig \"kind\" are different between {basek8SConfig.FileName} and {mergek8SConfig.FileName}"); + } + + // Note, Clusters, Contexts, and Extensions are map-like in config despite being represented as a list here: + // https://github.com/kubernetes/client-go/blob/ede92e0fe62deed512d9ceb8bf4186db9f3776ff/tools/clientcmd/api/types.go#L238 + // basek8SConfig.Extensions = MergeLists(basek8SConfig.Extensions, mergek8SConfig.Extensions, (s) => s.Name).ToList(); + basek8SConfig.Clusters = MergeLists(basek8SConfig.Clusters, mergek8SConfig.Clusters, (s) => s.Name).ToList(); + basek8SConfig.Users = MergeLists(basek8SConfig.Users, mergek8SConfig.Users, (s) => s.Name).ToList(); + basek8SConfig.Contexts = MergeLists(basek8SConfig.Contexts, mergek8SConfig.Contexts, (s) => s.Name).ToList(); + } + + private static IEnumerable MergeLists(IEnumerable baseList, IEnumerable mergeList, + Func getNameFunc) + { + if (mergeList != null && mergeList.Any()) + { + var mapping = new Dictionary(); + foreach (var item in baseList) + { + mapping[getNameFunc(item)] = item; + } + + foreach (var item in mergeList) + { + var name = getNameFunc(item); + if (!mapping.ContainsKey(name)) + { + mapping[name] = item; + } + } + + return mapping.Values; + } + + return baseList; + } + } +} diff --git a/src/KubernetesClient.Aot/KubernetesJson.cs b/src/KubernetesClient.Aot/KubernetesJson.cs new file mode 100644 index 000000000..cfa69ec01 --- /dev/null +++ b/src/KubernetesClient.Aot/KubernetesJson.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using System.Xml; + +namespace k8s +{ + internal static class KubernetesJson + { + internal sealed class Iso8601TimeSpanConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString(); + return XmlConvert.ToTimeSpan(str); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + var iso8601TimeSpanString = XmlConvert.ToString(value); // XmlConvert for TimeSpan uses ISO8601, so delegate serialization to it + writer.WriteStringValue(iso8601TimeSpanString); + } + } + + internal sealed class KubernetesDateTimeOffsetConverter : JsonConverter + { + private const string RFC3339MicroFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.ffffffK"; + private const string RFC3339NanoFormat = "yyyy-MM-dd'T'HH':'mm':'ss.fffffffK"; + private const string RFC3339Format = "yyyy'-'MM'-'dd'T'HH':'mm':'ssK"; + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString(); + + if (DateTimeOffset.TryParseExact(str, new[] { RFC3339Format, RFC3339MicroFormat }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var result)) + { + return result; + } + + // try RFC3339NanoLenient by trimming 1-9 digits to 7 digits + var originalstr = str; + str = Regex.Replace(str, @"\.\d+", m => (m.Value + "000000000").Substring(0, 7 + 1)); // 7 digits + 1 for the dot + if (DateTimeOffset.TryParseExact(str, new[] { RFC3339NanoFormat }, CultureInfo.InvariantCulture, DateTimeStyles.None, out result)) + { + return result; + } + + throw new FormatException($"Unable to parse {originalstr} as RFC3339 RFC3339Micro or RFC3339Nano"); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(RFC3339MicroFormat)); + } + } + + internal sealed class KubernetesDateTimeConverter : JsonConverter + { + private static readonly JsonConverter UtcConverter = new KubernetesDateTimeOffsetConverter(); + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return UtcConverter.Read(ref reader, typeToConvert, options).UtcDateTime; + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + UtcConverter.Write(writer, value, options); + } + } + + /// + /// Configures for the . + /// To override existing converters, add them to the top of the list + /// e.g. as follows: options.Converters.Insert(index: 0, new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + /// + /// An to configure the . + public static void AddJsonOptions(Action configure) + { + } + + public static TValue Deserialize(string json, JsonSerializerOptions jsonSerializerOptions = null) + { + var info = SourceGenerationContext.Default.GetTypeInfo(typeof(TValue)); + return (TValue)JsonSerializer.Deserialize(json, info); + } + + public static TValue Deserialize(Stream json, JsonSerializerOptions jsonSerializerOptions = null) + { + var info = SourceGenerationContext.Default.GetTypeInfo(typeof(TValue)); + return (TValue)JsonSerializer.Deserialize(json, info); + } + + public static string Serialize(object value, JsonSerializerOptions jsonSerializerOptions = null) + { + var info = SourceGenerationContext.Default.GetTypeInfo(value.GetType()); + return JsonSerializer.Serialize(value, info); + } + } +} diff --git a/src/KubernetesClient.Aot/KubernetesYaml.cs b/src/KubernetesClient.Aot/KubernetesYaml.cs new file mode 100644 index 000000000..a017d1050 --- /dev/null +++ b/src/KubernetesClient.Aot/KubernetesYaml.cs @@ -0,0 +1,160 @@ +using System.Text; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace k8s +{ + /// + /// This is a utility class that helps you load objects from YAML files. + /// + internal static class KubernetesYaml + { + private static StaticDeserializerBuilder CommonDeserializerBuilder => + new StaticDeserializerBuilder(new k8s.KubeConfigModels.StaticContext()) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new IntOrStringYamlConverter()) + .WithTypeConverter(new ByteArrayStringYamlConverter()) + .WithTypeConverter(new ResourceQuantityYamlConverter()) + .WithAttemptingUnquotedStringTypeDeserialization() + ; + + private static readonly IDeserializer Deserializer = + CommonDeserializerBuilder + .IgnoreUnmatchedProperties() + .Build(); + private static IDeserializer GetDeserializer(bool strict) => Deserializer; + + private static readonly IValueSerializer Serializer = + new StaticSerializerBuilder(new k8s.KubeConfigModels.StaticContext()) + .DisableAliases() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new IntOrStringYamlConverter()) + .WithTypeConverter(new ByteArrayStringYamlConverter()) + .WithTypeConverter(new ResourceQuantityYamlConverter()) + .WithEventEmitter(e => new StringQuotingEmitter(e)) + .WithEventEmitter(e => new FloatEmitter(e)) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .BuildValueSerializer(); + + private class ByteArrayStringYamlConverter : IYamlTypeConverter + { + public bool Accepts(Type type) + { + return type == typeof(byte[]); + } + + public object ReadYaml(IParser parser, Type type) + { + if (parser?.Current is Scalar scalar) + { + try + { + if (string.IsNullOrEmpty(scalar.Value)) + { + return null; + } + + return Encoding.UTF8.GetBytes(scalar.Value); + } + finally + { + parser.MoveNext(); + } + } + + throw new InvalidOperationException(parser.Current?.ToString()); + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var obj = (byte[])value; + emitter?.Emit(new Scalar(Encoding.UTF8.GetString(obj))); + } + } + + public static async Task LoadFromStreamAsync(Stream stream, bool strict = false) + { + var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync().ConfigureAwait(false); + return Deserialize(content, strict); + } + + public static async Task LoadFromFileAsync(string file, bool strict = false) + { + using (var fs = File.OpenRead(file)) + { + return await LoadFromStreamAsync(fs, strict).ConfigureAwait(false); + } + } + + [Obsolete("use Deserialize")] + public static T LoadFromString(string content, bool strict = false) + { + return Deserialize(content, strict); + } + + [Obsolete("use Serialize")] + public static string SaveToString(T value) + { + return Serialize(value); + } + + public static TValue Deserialize(string yaml, bool strict = false) + { + using var reader = new StringReader(yaml); + return GetDeserializer(strict).Deserialize(new MergingParser(new Parser(reader))); + } + + public static TValue Deserialize(Stream yaml, bool strict = false) + { + using var reader = new StreamReader(yaml); + return GetDeserializer(strict).Deserialize(new MergingParser(new Parser(reader))); + } + + public static string SerializeAll(IEnumerable values) + { + if (values == null) + { + return ""; + } + + var stringBuilder = new StringBuilder(); + var writer = new StringWriter(stringBuilder); + var emitter = new Emitter(writer); + + emitter.Emit(new StreamStart()); + + foreach (var value in values) + { + if (value != null) + { + emitter.Emit(new DocumentStart()); + Serializer.SerializeValue(emitter, value, value.GetType()); + emitter.Emit(new DocumentEnd(true)); + } + } + + return stringBuilder.ToString(); + } + + public static string Serialize(object value) + { + if (value == null) + { + return ""; + } + + var stringBuilder = new StringBuilder(); + var writer = new StringWriter(stringBuilder); + var emitter = new Emitter(writer); + + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart()); + Serializer.SerializeValue(emitter, value, value.GetType()); + + return stringBuilder.ToString(); + } + } +} diff --git a/src/KubernetesClient.Aot/SourceGenerationContext.cs b/src/KubernetesClient.Aot/SourceGenerationContext.cs new file mode 100644 index 000000000..decb9b5a9 --- /dev/null +++ b/src/KubernetesClient.Aot/SourceGenerationContext.cs @@ -0,0 +1,12 @@ +using static k8s.KubernetesJson; + +namespace k8s; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + Converters = new[] { typeof(Iso8601TimeSpanConverter), typeof(KubernetesDateTimeConverter), typeof(KubernetesDateTimeOffsetConverter) }) + ] +internal partial class SourceGenerationContext : JsonSerializerContext +{ +} diff --git a/src/KubernetesClient.Aot/V1PatchJsonConverter.cs b/src/KubernetesClient.Aot/V1PatchJsonConverter.cs new file mode 100644 index 000000000..314ef5694 --- /dev/null +++ b/src/KubernetesClient.Aot/V1PatchJsonConverter.cs @@ -0,0 +1,29 @@ +namespace k8s.Models +{ +#pragma warning disable CA1812 // Avoid uninstantiated internal classes + internal sealed class V1PatchJsonConverter : JsonConverter +#pragma warning restore CA1812 // Avoid uninstantiated internal classes + { + public override V1Patch Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, V1Patch value, JsonSerializerOptions options) + { + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + var content = value?.Content; + if (content is string s) + { + writer.WriteRawValue(s); + return; + } + + throw new NotSupportedException("only string json patch is supported"); + } + } +} diff --git a/src/KubernetesClient/Watcher.cs b/src/KubernetesClient/Watcher.cs index 9546fa21b..23868d4e0 100644 --- a/src/KubernetesClient/Watcher.cs +++ b/src/KubernetesClient/Watcher.cs @@ -4,6 +4,9 @@ namespace k8s { /// Describes the type of a watch event. +#if NET8_0_OR_GREATER + [JsonConverter(typeof(JsonStringEnumConverter))] +#endif public enum WatchEventType { /// Emitted when an object is created, modified to match a watch's filter, or when a watch is first opened. diff --git a/src/nuget.proj b/src/nuget.proj index 1d04b6fb5..feec6cda8 100644 --- a/src/nuget.proj +++ b/src/nuget.proj @@ -2,6 +2,7 @@ + diff --git a/tests/E2E.Aot.Tests/E2E.Aot.Tests.csproj b/tests/E2E.Aot.Tests/E2E.Aot.Tests.csproj new file mode 100644 index 000000000..13842028b --- /dev/null +++ b/tests/E2E.Aot.Tests/E2E.Aot.Tests.csproj @@ -0,0 +1,37 @@ + + + false + k8s.E2E + net8.0 + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/tests/E2E.Aot.Tests/MinikubeTests.cs b/tests/E2E.Aot.Tests/MinikubeTests.cs new file mode 100644 index 000000000..2c4ca933b --- /dev/null +++ b/tests/E2E.Aot.Tests/MinikubeTests.cs @@ -0,0 +1,378 @@ +using ICSharpCode.SharpZipLib.Tar; +using k8s.Autorest; +using k8s.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace k8s.E2E +{ + [Collection(nameof(Onebyone))] + public class MinikubeTests + { + [MinikubeFact] + public void SimpleTest() + { + var namespaceParameter = "default"; + var podName = "k8scsharp-e2e-pod"; + + using var client = CreateClient(); + + void Cleanup() + { + var pods = client.CoreV1.ListNamespacedPod(namespaceParameter); + while (pods.Items.Any(p => p.Metadata.Name == podName)) + { + try + { + client.CoreV1.DeleteNamespacedPod(podName, namespaceParameter); + } + catch (HttpOperationException e) + { + if (e.Response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return; + } + } + } + } + + try + { + Cleanup(); + + client.CoreV1.CreateNamespacedPod( + new V1Pod() + { + Metadata = new V1ObjectMeta { Name = podName, }, + Spec = new V1PodSpec + { + Containers = new[] { new V1Container() { Name = "k8scsharp-e2e", Image = "nginx", }, }, + }, + }, + namespaceParameter); + + var pods = client.CoreV1.ListNamespacedPod(namespaceParameter); + Assert.Contains(pods.Items, p => p.Metadata.Name == podName); + } + finally + { + Cleanup(); + } + } + + [MinikubeFact] + public async Task LogStreamTestAsync() + { + var namespaceParameter = "default"; + var podName = "k8scsharp-e2e-logstream-pod"; + + using var client = CreateClient(); + + void Cleanup() + { + var pods = client.CoreV1.ListNamespacedPod(namespaceParameter); + while (pods.Items.Any(p => p.Metadata.Name == podName)) + { + try + { + client.CoreV1.DeleteNamespacedPod(podName, namespaceParameter); + } + catch (HttpOperationException e) + { + if (e.Response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return; + } + } + } + } + + try + { + Cleanup(); + + client.CoreV1.CreateNamespacedPod( + new V1Pod() + { + Metadata = new V1ObjectMeta { Name = podName, }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container() + { + Name = "k8scsharp-e2e-logstream", + Image = "busybox", + Command = new[] { "ping" }, + Args = new[] { "-i", "10", "127.0.0.1" }, + }, + }, + }, + }, + namespaceParameter); + + var lines = new List(); + var started = new ManualResetEvent(false); + + async Task Pod() + { + var pods = client.CoreV1.ListNamespacedPod(namespaceParameter); + var pod = pods.Items.First(p => p.Metadata.Name == podName); + while (pod.Status.Phase != "Running") + { + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + return await Pod().ConfigureAwait(false); + } + + return pod; + } + + var pod = await Pod().ConfigureAwait(false); + var stream = client.CoreV1.ReadNamespacedPodLog(pod.Metadata.Name, pod.Metadata.NamespaceProperty, follow: true); + using var reader = new StreamReader(stream); + + var copytask = Task.Run(() => + { + for (; ; ) + { + try + { + lines.Add(reader.ReadLine()); + } + finally + { + started.Set(); + } + } + }); + + Assert.True(started.WaitOne(TimeSpan.FromMinutes(2))); + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + Assert.Null(copytask.Exception); + Assert.Equal(2, lines.Count); + await Task.Delay(TimeSpan.FromSeconds(11)).ConfigureAwait(false); + Assert.Equal(3, lines.Count); + } + finally + { + Cleanup(); + } + } + + [MinikubeFact] + public async Task DatetimeFieldTest() + { + using var kubernetes = CreateClient(); + + await kubernetes.CoreV1.CreateNamespacedEventAsync( + new Corev1Event( + new V1ObjectReference( + "v1alpha1", + kind: "Test", + name: "test", + namespaceProperty: "default", + resourceVersion: "1", + uid: "1"), + new V1ObjectMeta() + { + GenerateName = "started-", + }, + action: "STARTED", + type: "Normal", + reason: "STARTED", + message: "Started", + eventTime: DateTime.Now, + firstTimestamp: DateTime.Now, + lastTimestamp: DateTime.Now, + reportingComponent: "37", + reportingInstance: "38"), "default").ConfigureAwait(false); + } + + [MinikubeFact] + public async Task CopyToPodTestAsync() + { + var namespaceParameter = "default"; + var podName = "k8scsharp-e2e-cp-pod"; + + using var client = CreateClient(); + + async Task CopyFileToPodAsync(string name, string @namespace, string container, Stream inputFileStream, string destinationFilePath, CancellationToken cancellationToken = default(CancellationToken)) + { + // The callback which processes the standard input, standard output and standard error of exec method + var handler = new ExecAsyncCallback(async (stdIn, stdOut, stdError) => + { + var fileInfo = new FileInfo(destinationFilePath); + try + { + using (var memoryStream = new MemoryStream()) + { + using (var tarOutputStream = new TarOutputStream(memoryStream, Encoding.Default)) + { + tarOutputStream.IsStreamOwner = false; + + var fileSize = inputFileStream.Length; + var entry = TarEntry.CreateTarEntry(fileInfo.Name); + + entry.Size = fileSize; + + tarOutputStream.PutNextEntry(entry); + await inputFileStream.CopyToAsync(tarOutputStream).ConfigureAwait(false); + tarOutputStream.CloseEntry(); + } + + memoryStream.Position = 0; + + await memoryStream.CopyToAsync(stdIn).ConfigureAwait(false); + await memoryStream.FlushAsync().ConfigureAwait(false); + stdIn.Close(); + } + } + catch (Exception ex) + { + throw new IOException($"Copy command failed: {ex.Message}"); + } + + using StreamReader streamReader = new StreamReader(stdError); + while (streamReader.EndOfStream == false) + { + string error = await streamReader.ReadToEndAsync().ConfigureAwait(false); + throw new IOException($"Copy command failed: {error}"); + } + }); + + string destinationFolder = Path.GetDirectoryName(destinationFilePath).Replace("\\", "/"); + + return await client.NamespacedPodExecAsync( + name, + @namespace, + container, + new string[] { "tar", "-xmf", "-", "-C", destinationFolder }, + false, + handler, + cancellationToken).ConfigureAwait(false); + } + + + void Cleanup() + { + var pods = client.CoreV1.ListNamespacedPod(namespaceParameter); + while (pods.Items.Any(p => p.Metadata.Name == podName)) + { + try + { + client.CoreV1.DeleteNamespacedPod(podName, namespaceParameter); + } + catch (HttpOperationException e) + { + if (e.Response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return; + } + } + } + } + + try + { + Cleanup(); + + client.CoreV1.CreateNamespacedPod( + new V1Pod() + { + Metadata = new V1ObjectMeta { Name = podName, }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container() + { + Name = "container", + Image = "ubuntu", + // Image = "busybox", // TODO not work with busybox + Command = new[] { "sleep" }, + Args = new[] { "infinity" }, + }, + }, + }, + }, + namespaceParameter); + + var lines = new List(); + var started = new ManualResetEvent(false); + + async Task Pod() + { + var pods = client.CoreV1.ListNamespacedPod(namespaceParameter); + var pod = pods.Items.First(p => p.Metadata.Name == podName); + while (pod.Status.Phase != "Running") + { + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + return await Pod().ConfigureAwait(false); + } + + return pod; + } + + var pod = await Pod().ConfigureAwait(false); + + + async Task AssertMd5sumAsync(string file, byte[] orig) + { + var ws = await client.WebSocketNamespacedPodExecAsync( + pod.Metadata.Name, + pod.Metadata.NamespaceProperty, + new string[] { "md5sum", file }, + "container").ConfigureAwait(false); + + var demux = new StreamDemuxer(ws); + demux.Start(); + + var buff = new byte[4096]; + var stream = demux.GetStream(1, 1); + var read = stream.Read(buff, 0, 4096); + var remotemd5 = Encoding.Default.GetString(buff); + remotemd5 = remotemd5.Substring(0, 32); + + var md5 = MD5.Create().ComputeHash(orig); + var localmd5 = BitConverter.ToString(md5).Replace("-", string.Empty).ToLower(); + + Assert.Equal(localmd5, remotemd5); + } + + + // + { + // small + var content = new byte[1 * 1024 * 1024]; + new Random().NextBytes(content); + await CopyFileToPodAsync(pod.Metadata.Name, pod.Metadata.NamespaceProperty, "container", new MemoryStream(content), "/tmp/test").ConfigureAwait(false); + await AssertMd5sumAsync("/tmp/test", content).ConfigureAwait(false); + } + + { + // big + var content = new byte[40 * 1024 * 1024]; + new Random().NextBytes(content); + await CopyFileToPodAsync(pod.Metadata.Name, pod.Metadata.NamespaceProperty, "container", new MemoryStream(content), "/tmp/test").ConfigureAwait(false); + await AssertMd5sumAsync("/tmp/test", content).ConfigureAwait(false); + } + } + finally + { + Cleanup(); + } + } + + public static IKubernetes CreateClient() + { + return new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); + } + } +} diff --git a/tests/E2E.Tests/E2E.Tests.csproj b/tests/E2E.Tests/E2E.Tests.csproj index e66d4ef87..11f59b2c0 100644 --- a/tests/E2E.Tests/E2E.Tests.csproj +++ b/tests/E2E.Tests/E2E.Tests.csproj @@ -1,7 +1,6 @@ false - true k8s.E2E net6.0;net7.0;net8.0