Skip to content

Commit 324a3e7

Browse files
authored
Allow KUBECONFIG environment variable to point to multiple files (#411)
* Allow KUBECONFIG environment variable to point to multiple files * Add more tests, add API (can make internal if necessary) * test * allow passing in env var * small amount of feedback * Feedback * Nits * Some extra tests and comments
1 parent 8e7bf0b commit 324a3e7

File tree

5 files changed

+232
-5
lines changed

5 files changed

+232
-5
lines changed

src/KubernetesClient/KubeConfigModels/K8SConfiguration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace k8s.KubeConfigModels
99
/// </summary>
1010
/// <remarks>
1111
/// 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
12+
/// Should update MergeKubeConfig in KubernetesClientConfiguration.ConfigFile.cs if updated.
1213
/// </remarks>
1314
public class K8SConfiguration
1415
{

src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using System;
2+
using System.Collections.Generic;
23
#if NETSTANDARD2_0
34
using Newtonsoft.Json;
4-
using System.Collections.Generic;
55
using System.Diagnostics;
66
#endif
77
using System.IO;
@@ -30,34 +30,45 @@ public partial class KubernetesClientConfiguration
3030
/// </summary>
3131
public string CurrentContext { get; private set; }
3232

33+
// For testing
34+
internal static string KubeConfigEnvironmentVariable { get; set; } = "KUBECONFIG";
35+
3336
/// <summary>
3437
/// Initializes a new instance of the <see cref="KubernetesClientConfiguration" /> from default locations
3538
/// If the KUBECONFIG environment variable is set, then that will be used.
3639
/// Next, it looks for a config file at <see cref="KubeConfigDefaultLocation"/>.
3740
/// Then, it checks whether it is executing inside a cluster and will use <see cref="InClusterConfig()" />.
3841
/// Finally, if nothing else exists, it creates a default config with localhost:8080 as host.
3942
/// </summary>
43+
/// <remarks>
44+
/// If multiple kubeconfig files are specified in the KUBECONFIG environment variable,
45+
/// merges the files, where first occurence wins. See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files.
46+
/// </remarks>
4047
public static KubernetesClientConfiguration BuildDefaultConfig()
4148
{
42-
var kubeconfig = Environment.GetEnvironmentVariable("KUBECONFIG");
49+
var kubeconfig = Environment.GetEnvironmentVariable(KubeConfigEnvironmentVariable);
4350
if (kubeconfig != null)
4451
{
45-
return BuildConfigFromConfigFile(kubeconfigPath: kubeconfig);
52+
var configList = kubeconfig.Split(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':').Select((s) => new FileInfo(s));
53+
var k8sConfig = LoadKubeConfig(configList.ToArray());
54+
return BuildConfigFromConfigObject(k8sConfig);
4655
}
56+
4757
if (File.Exists(KubeConfigDefaultLocation))
4858
{
4959
return BuildConfigFromConfigFile(kubeconfigPath: KubeConfigDefaultLocation);
5060
}
61+
5162
if (IsInCluster())
5263
{
5364
return InClusterConfig();
5465
}
66+
5567
var config = new KubernetesClientConfiguration();
5668
config.Host = "http://localhost:8080";
5769
return config;
5870
}
5971

60-
6172
/// <summary>
6273
/// Initializes a new instance of the <see cref="KubernetesClientConfiguration" /> from config file
6374
/// </summary>
@@ -564,6 +575,46 @@ public static K8SConfiguration LoadKubeConfig(Stream kubeconfigStream)
564575
return LoadKubeConfigAsync(kubeconfigStream).GetAwaiter().GetResult();
565576
}
566577

578+
/// <summary>
579+
/// Loads Kube Config
580+
/// </summary>
581+
/// <param name="kubeconfigs">List of kube config file contents</param>
582+
/// <param name="useRelativePaths">When <see langword="true"/>, the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig
583+
/// file is located. When <see langword="false"/>, the paths will be considered to be relative to the current working directory.</param>
584+
/// <returns>Instance of the <see cref="K8SConfiguration"/> class</returns>
585+
/// <remarks>
586+
/// The kube config files will be merges into a single <see cref="K8SConfiguration"/>, where first occurence wins.
587+
/// See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files.
588+
/// </remarks>
589+
internal static K8SConfiguration LoadKubeConfig(FileInfo[] kubeConfigs, bool useRelativePaths = true)
590+
{
591+
return LoadKubeConfigAsync(kubeConfigs, useRelativePaths).GetAwaiter().GetResult();
592+
}
593+
594+
/// <summary>
595+
/// Loads Kube Config
596+
/// </summary>
597+
/// <param name="kubeconfigs">List of kube config file contents</param>
598+
/// <param name="useRelativePaths">When <see langword="true"/>, the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig
599+
/// file is located. When <see langword="false"/>, the paths will be considered to be relative to the current working directory.</param>
600+
/// <returns>Instance of the <see cref="K8SConfiguration"/> class</returns>
601+
/// <remarks>
602+
/// The kube config files will be merges into a single <see cref="K8SConfiguration"/>, where first occurence wins.
603+
/// See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files.
604+
/// </remarks>
605+
internal static async Task<K8SConfiguration> LoadKubeConfigAsync(FileInfo[] kubeConfigs, bool useRelativePaths = true)
606+
{
607+
var basek8SConfig = await LoadKubeConfigAsync(kubeConfigs[0], useRelativePaths).ConfigureAwait(false);
608+
609+
for (var i = 1; i < kubeConfigs.Length; i++)
610+
{
611+
var mergek8SConfig = await LoadKubeConfigAsync(kubeConfigs[i], useRelativePaths).ConfigureAwait(false);
612+
MergeKubeConfig(basek8SConfig, mergek8SConfig);
613+
}
614+
615+
return basek8SConfig;
616+
}
617+
567618
/// <summary>
568619
/// Tries to get the full path to a file referenced from the Kubernetes configuration.
569620
/// </summary>
@@ -593,5 +644,76 @@ private static string GetFullPath(K8SConfiguration configuration, string path)
593644
return Path.Combine(Path.GetDirectoryName(configuration.FileName), path);
594645
}
595646
}
647+
648+
/// <summary>
649+
/// Merges kube config files together, preferring configuration present in the base config over the merge config.
650+
/// </summary>
651+
/// <param name="basek8SConfig">The <see cref="K8SConfiguration"/> to merge into</param>
652+
/// <param name="mergek8SConfig">The <see cref="K8SConfiguration"/> to merge from</param>
653+
private static void MergeKubeConfig(K8SConfiguration basek8SConfig, K8SConfiguration mergek8SConfig)
654+
{
655+
// For scalar values, prefer local values
656+
basek8SConfig.CurrentContext = basek8SConfig.CurrentContext ?? mergek8SConfig.CurrentContext;
657+
basek8SConfig.FileName = basek8SConfig.FileName ?? mergek8SConfig.FileName;
658+
659+
// Kinds must match in kube config, otherwise throw.
660+
if (basek8SConfig.Kind != mergek8SConfig.Kind)
661+
{
662+
throw new KubeConfigException($"kubeconfig \"kind\" are different between {basek8SConfig.FileName} and {mergek8SConfig.FileName}");
663+
}
664+
665+
if (mergek8SConfig.Preferences != null)
666+
{
667+
foreach (var preference in mergek8SConfig.Preferences)
668+
{
669+
if (basek8SConfig.Preferences?.ContainsKey(preference.Key) == false)
670+
{
671+
basek8SConfig.Preferences[preference.Key] = preference.Value;
672+
}
673+
}
674+
}
675+
676+
if (mergek8SConfig.Extensions != null)
677+
{
678+
foreach (var extension in mergek8SConfig.Extensions)
679+
{
680+
if (basek8SConfig.Extensions?.ContainsKey(extension.Key) == false)
681+
{
682+
basek8SConfig.Extensions[extension.Key] = extension.Value;
683+
}
684+
}
685+
}
686+
687+
// Note, Clusters, Contexts, and Extensions are map-like in config despite being represented as a list here:
688+
// https://github.com/kubernetes/client-go/blob/ede92e0fe62deed512d9ceb8bf4186db9f3776ff/tools/clientcmd/api/types.go#L238
689+
basek8SConfig.Clusters = MergeLists(basek8SConfig.Clusters, mergek8SConfig.Clusters, (s) => s.Name);
690+
basek8SConfig.Users = MergeLists(basek8SConfig.Users, mergek8SConfig.Users, (s) => s.Name);
691+
basek8SConfig.Contexts = MergeLists(basek8SConfig.Contexts, mergek8SConfig.Contexts, (s) => s.Name);
692+
}
693+
694+
private static IEnumerable<T> MergeLists<T>(IEnumerable<T> baseList, IEnumerable<T> mergeList, Func<T, string> getNameFunc)
695+
{
696+
if (mergeList != null && mergeList.Count() > 0)
697+
{
698+
var mapping = new Dictionary<string, T>();
699+
foreach (var item in baseList)
700+
{
701+
mapping[getNameFunc(item)] = item;
702+
}
703+
704+
foreach (var item in mergeList)
705+
{
706+
var name = getNameFunc(item);
707+
if (!mapping.ContainsKey(name))
708+
{
709+
mapping[name] = item;
710+
}
711+
}
712+
713+
return mapping.Values;
714+
}
715+
716+
return baseList;
717+
}
596718
}
597719
}

tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
using System;
12
using System.IO;
23
using System.Linq;
4+
using System.Runtime.InteropServices;
35
using System.Threading;
46
using k8s.Exceptions;
57
using k8s.KubeConfigModels;
@@ -407,6 +409,102 @@ public void LoadKubeConfigStream()
407409
AssertConfigEqual(expectedCfg, cfg);
408410
}
409411

412+
[Fact]
413+
public void LoadKubeConfigFromEnvironmentVariable()
414+
{
415+
// BuildDefaultConfig assumes UseRelativePaths: true, which isn't
416+
// done by any tests.
417+
var filePath = Path.GetFullPath("assets/kubeconfig.relative.yml");
418+
var environmentVariable = "KUBECONFIG_LoadKubeConfigFromEnvironmentVariable";
419+
420+
Environment.SetEnvironmentVariable(environmentVariable, filePath);
421+
KubernetesClientConfiguration.KubeConfigEnvironmentVariable = environmentVariable;
422+
423+
var cfg = KubernetesClientConfiguration.BuildDefaultConfig();
424+
425+
Assert.NotNull(cfg);
426+
}
427+
428+
[Fact]
429+
public void LoadKubeConfigFromEnvironmentVariable_MultipleConfigs()
430+
{
431+
// This test makes sure that a list of environment variables works (no exceptions),
432+
// doesn't check validity of configuration, which is done in other tests.
433+
434+
var filePath = Path.GetFullPath("assets/kubeconfig.relative.yml");
435+
var environmentVariable = "KUBECONFIG_LoadKubeConfigFromEnvironmentVariable_MultipleConfigs";
436+
437+
Environment.SetEnvironmentVariable(environmentVariable, string.Concat(filePath, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':', filePath));
438+
KubernetesClientConfiguration.KubeConfigEnvironmentVariable = environmentVariable;
439+
440+
var cfg = KubernetesClientConfiguration.BuildDefaultConfig();
441+
442+
Assert.NotNull(cfg);
443+
}
444+
445+
[Fact]
446+
public void LoadSameKubeConfigFromEnvironmentVariableUnmodified()
447+
{
448+
var txt = File.ReadAllText("assets/kubeconfig.yml");
449+
var expectedCfg = Yaml.LoadFromString<K8SConfiguration>(txt);
450+
451+
var fileInfo = new FileInfo(Path.GetFullPath("assets/kubeconfig.yml"));
452+
453+
var cfg = KubernetesClientConfiguration.LoadKubeConfig(new FileInfo[] { fileInfo, fileInfo });
454+
455+
AssertConfigEqual(expectedCfg, cfg);
456+
}
457+
458+
[Fact]
459+
public void MergeKubeConfigNoDuplicates()
460+
{
461+
var firstPath = Path.GetFullPath("assets/kubeconfig.as-user-extra.yml");
462+
var secondPath = Path.GetFullPath("assets/kubeconfig.yml");
463+
464+
var cfg = KubernetesClientConfiguration.LoadKubeConfig(new FileInfo[] { new FileInfo(firstPath), new FileInfo(secondPath) });
465+
466+
// Merged file has 6 users now.
467+
Assert.Equal(6, cfg.Users.Count());
468+
Assert.Equal(5, cfg.Clusters.Count());
469+
Assert.Equal(5, cfg.Contexts.Count());
470+
}
471+
472+
[Fact]
473+
public void AlwaysPicksFirstOccurence()
474+
{
475+
var firstPath = Path.GetFullPath("assets/kubeconfig.no-cluster.yml");
476+
var secondPath = Path.GetFullPath("assets/kubeconfig.no-context.yml");
477+
478+
var cfg = KubernetesClientConfiguration.LoadKubeConfig(new FileInfo[] { new FileInfo(firstPath), new FileInfo(secondPath) });
479+
480+
var user = cfg.Users.Where(u => u.Name == "green-user").Single();
481+
Assert.NotNull(user.UserCredentials.Password);
482+
Assert.Null(user.UserCredentials.ClientCertificate);
483+
}
484+
485+
[Fact]
486+
public void ContextFromSecondWorks()
487+
{
488+
var firstPath = Path.GetFullPath("assets/kubeconfig.no-current-context.yml");
489+
var secondPath = Path.GetFullPath("assets/kubeconfig.no-user.yml");
490+
491+
var cfg = KubernetesClientConfiguration.LoadKubeConfig(new FileInfo[] { new FileInfo(firstPath), new FileInfo(secondPath) });
492+
493+
// green-user
494+
Assert.NotNull(cfg.CurrentContext);
495+
}
496+
497+
[Fact]
498+
public void ContextPreferencesExtensionsMergeWithDuplicates()
499+
{
500+
var path = Path.GetFullPath("assets/kubeconfig.preferences-extensions.yml");
501+
502+
var cfg = KubernetesClientConfiguration.LoadKubeConfig(new FileInfo[] { new FileInfo(path), new FileInfo(path) });
503+
504+
Assert.Equal(1, cfg.Extensions.Count);
505+
Assert.Equal(1, cfg.Preferences.Count);
506+
}
507+
410508
/// <summary>
411509
/// Ensures Kube config file can be loaded from within a non-default <see cref="SynchronizationContext"/>.
412510
/// The use of <see cref="UIFactAttribute"/> ensures the test is run from within a UI-like <see cref="SynchronizationContext"/>.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
apiVersion: v1
2+
kind: Config
3+
preferences:
4+
colors: true
5+
extensions:
6+
foo: bar

tests/KubernetesClient.Tests/assets/kubeconfig.user-pass.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ users:
1919
- name: green-user
2020
user:
2121
password: secret
22-
username: admin
22+
username: admin

0 commit comments

Comments
 (0)