commit 9fd5f5cbd80b4ffa291f0a7394c1b8c164ac641a Author: Andreas Billmann Date: Sun Jul 30 04:52:53 2023 +0200 First draft of kubernetes file browser diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76c5b41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +.idea/ +*.user \ No newline at end of file diff --git a/K8sFileBrowser.sln b/K8sFileBrowser.sln new file mode 100644 index 0000000..dbe7a88 --- /dev/null +++ b/K8sFileBrowser.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "K8sFileBrowser", "K8sFileBrowser\K8sFileBrowser.csproj", "{637A753B-3168-4C9C-8098-7A16024E1957}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {637A753B-3168-4C9C-8098-7A16024E1957}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {637A753B-3168-4C9C-8098-7A16024E1957}.Debug|Any CPU.Build.0 = Debug|Any CPU + {637A753B-3168-4C9C-8098-7A16024E1957}.Release|Any CPU.ActiveCfg = Release|Any CPU + {637A753B-3168-4C9C-8098-7A16024E1957}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/K8sFileBrowser/App.axaml b/K8sFileBrowser/App.axaml new file mode 100644 index 0000000..2a7be7a --- /dev/null +++ b/K8sFileBrowser/App.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/K8sFileBrowser/App.axaml.cs b/K8sFileBrowser/App.axaml.cs new file mode 100644 index 0000000..12ee8c5 --- /dev/null +++ b/K8sFileBrowser/App.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using K8sFileBrowser.ViewModels; +using K8sFileBrowser.Views; + +namespace K8sFileBrowser; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/K8sFileBrowser/ApplicationHelper.cs b/K8sFileBrowser/ApplicationHelper.cs new file mode 100644 index 0000000..1e02b71 --- /dev/null +++ b/K8sFileBrowser/ApplicationHelper.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Serilog; + +namespace K8sFileBrowser; + +public static class ApplicationHelper +{ + public static async Task SaveFile(string? initialFolder, string? initialFile) + { + Window? ret; + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is not { } wnd) return null; + try + { + var filter = new List { new("All files") { Patterns = new List { "*" } } }; + + var startLocation = initialFolder != null + ? await wnd.StorageProvider.TryGetFolderFromPathAsync(initialFolder) + : null; + var extension = initialFile != null ? Path.GetExtension(initialFile).TrimStart('.') : null; + var fileName = initialFile != null ? Path.GetFileNameWithoutExtension(initialFile) : null; + + var file = await wnd.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + SuggestedStartLocation = startLocation, + DefaultExtension = extension, + ShowOverwritePrompt = true, + SuggestedFileName = fileName, + FileTypeChoices = filter + }); + + if (file != null) + { + return file.Path.LocalPath; + } + } + catch (Exception ex) + { + Log.Error(ex, "Error saving file"); + } + return null; + } +} \ No newline at end of file diff --git a/K8sFileBrowser/Assets/avalonia-logo.ico b/K8sFileBrowser/Assets/avalonia-logo.ico new file mode 100644 index 0000000..da8d49f Binary files /dev/null and b/K8sFileBrowser/Assets/avalonia-logo.ico differ diff --git a/K8sFileBrowser/K8sFileBrowser.csproj b/K8sFileBrowser/K8sFileBrowser.csproj new file mode 100644 index 0000000..212d0e2 --- /dev/null +++ b/K8sFileBrowser/K8sFileBrowser.csproj @@ -0,0 +1,31 @@ + + + WinExe + net7.0 + enable + true + app.manifest + true + + + TRACE + + + + + + + + + + + + + + + + + + + + diff --git a/K8sFileBrowser/Models/ClusterContext.cs b/K8sFileBrowser/Models/ClusterContext.cs new file mode 100644 index 0000000..6a2d87c --- /dev/null +++ b/K8sFileBrowser/Models/ClusterContext.cs @@ -0,0 +1,10 @@ +namespace K8sFileBrowser.Models; + +public class ClusterContext +{ + public string Name { get; set; } = string.Empty; + public override string ToString() + { + return Name; + } +} \ No newline at end of file diff --git a/K8sFileBrowser/Models/FileInformation.cs b/K8sFileBrowser/Models/FileInformation.cs new file mode 100644 index 0000000..bf33e0f --- /dev/null +++ b/K8sFileBrowser/Models/FileInformation.cs @@ -0,0 +1,21 @@ +using System; + +namespace K8sFileBrowser.Models; + +public class FileInformation +{ + public string Parent { get; set; } = string.Empty; + public FileType Type { get; set; } = FileType.File; + public string Name { get; set; } = string.Empty; + public string Size { get; set; } = string.Empty; + public DateTimeOffset Date { get; set; } = DateTimeOffset.MinValue; + + public bool IsFile => Type == FileType.File; +} + +public enum FileType +{ + Directory, + File, + Unknown +} \ No newline at end of file diff --git a/K8sFileBrowser/Models/Namespace.cs b/K8sFileBrowser/Models/Namespace.cs new file mode 100644 index 0000000..53210c9 --- /dev/null +++ b/K8sFileBrowser/Models/Namespace.cs @@ -0,0 +1,11 @@ +namespace K8sFileBrowser.Models; + +public class Namespace +{ + public string Name { get; set; } = string.Empty; + + public override string ToString() + { + return Name; + } +} \ No newline at end of file diff --git a/K8sFileBrowser/Models/Pod.cs b/K8sFileBrowser/Models/Pod.cs new file mode 100644 index 0000000..7fe42ba --- /dev/null +++ b/K8sFileBrowser/Models/Pod.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace K8sFileBrowser.Models; + +public class Pod +{ + public string Name { get; init; } = string.Empty; + public IList Containers { get; set; } = new List(); + + public override string ToString() + { + return Name; + } +} \ No newline at end of file diff --git a/K8sFileBrowser/Program.cs b/K8sFileBrowser/Program.cs new file mode 100644 index 0000000..4388ae3 --- /dev/null +++ b/K8sFileBrowser/Program.cs @@ -0,0 +1,46 @@ +using Avalonia; +using Avalonia.ReactiveUI; +using System; +using Serilog; + +namespace K8sFileBrowser; + +class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) { + try + { + Log.Logger = new LoggerConfiguration() + //.Filter.ByIncludingOnly(Matching.WithProperty("Area", LogArea.Control)) + .MinimumLevel.Verbose() + .WriteTo.Console() + .CreateLogger(); + + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + catch (Exception e) + { + // here we can work with the exception, for example add it to our log file + Log.Fatal(e, "Something very bad happened"); + } + finally + { + // This block is optional. + // Use the finally-block if you need to clean things up or similar + Log.CloseAndFlush(); + } + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace() + .UseReactiveUI(); +} \ No newline at end of file diff --git a/K8sFileBrowser/Services/KubernetesFileInformationResult.cs b/K8sFileBrowser/Services/KubernetesFileInformationResult.cs new file mode 100644 index 0000000..724b4d9 --- /dev/null +++ b/K8sFileBrowser/Services/KubernetesFileInformationResult.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using K8sFileBrowser.Models; +using Serilog; + +namespace K8sFileBrowser.Services; + +public class KubernetesFileInformationResult +{ + private readonly string _parent; + public IList FileInformations { get; set; } = new List(); + + public KubernetesFileInformationResult(string parent) + { + _parent = parent; + } + + public Task ParseFileInformationCallback(Stream stdIn, Stream stdOut, Stream stdErr) + { + using (var stdOutReader = new StreamReader(stdOut)) + { + var output = stdOutReader.ReadToEnd(); + + output.Split("\n").ToList().ForEach(line => + { + if (line.Length <= 0) return; + line = line.TrimEnd('\n'); + + var fileInformation = line.Split("|").ToList(); + FileInformations.Add(new FileInformation + { + Parent = _parent, + Type = GetFileType(fileInformation[0]), + Name = fileInformation[1], + Size = fileInformation[2], + Date = GetDate(fileInformation[3]) + }); + }); + Log.Information(output); + } + + using (var stdErrReader = new StreamReader(stdErr)) + { + var output = stdErrReader.ReadToEnd(); + Log.Warning(output); + } + return Task.CompletedTask; + } + + private static FileType GetFileType(string type) + { + return type switch + { + "directory" => FileType.Directory, + "regular file" => FileType.File, + _ => FileType.Unknown + }; + } + + private static DateTimeOffset GetDate(string date) + { + var unixTime = long.Parse(date); + return DateTimeOffset.FromUnixTimeSeconds(unixTime); + } +} \ No newline at end of file diff --git a/K8sFileBrowser/Services/KubernetesService.cs b/K8sFileBrowser/Services/KubernetesService.cs new file mode 100644 index 0000000..7e80534 --- /dev/null +++ b/K8sFileBrowser/Services/KubernetesService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using k8s; +using k8s.KubeConfigModels; +using K8sFileBrowser.Models; +using Serilog; + +namespace K8sFileBrowser.Services; + +public class KubernetesService +{ + private readonly K8SConfiguration _k8SConfiguration; + private IKubernetes _kubernetesClient = null!; + + public KubernetesService() + { + _k8SConfiguration = KubernetesClientConfiguration.LoadKubeConfig(); + CreateKubernetesClient(); + } + + public IEnumerable GetClusterContexts() + { + return _k8SConfiguration.Contexts.Select(c => new ClusterContext { Name = c.Name }).ToList(); + } + + public string GetCurrentContext() + { + return _k8SConfiguration.CurrentContext; + } + + public void SwitchClusterContext(ClusterContext clusterContext) + { + CreateKubernetesClient(clusterContext); + } + + public IEnumerable GetNamespaces() + { + var namespaces = _kubernetesClient.CoreV1.ListNamespace(); + var namespaceList = namespaces != null + ? namespaces.Items.Select(n => new Namespace { Name = n.Metadata.Name }).ToList() + : new List(); + return namespaceList; + } + + public IEnumerable GetPods(string namespaceName) + { + var pods = _kubernetesClient.CoreV1.ListNamespacedPod(namespaceName); + var podList = pods != null + ? pods.Items.Select(n => + new Pod + { + Name = n.Metadata.Name, + Containers = n.Spec.Containers.Select(c => c.Name).ToList() + }).ToList() + : new List(); + return podList; + } + + public IList GetFiles(string namespaceName, string podName, string containerName, string path) + { + try + { + var execResult = new KubernetesFileInformationResult(path); + var resultCode = _kubernetesClient + .NamespacedPodExecAsync( + podName, namespaceName, containerName, + new[] { "find", path, "-maxdepth", "1", "-exec", "stat", "-c", "%F|%n|%s|%Y", "{}", ";" }, + true, + execResult.ParseFileInformationCallback, CancellationToken.None) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + return execResult.FileInformations; + } + catch (Exception e) + { + Log.Error(e, "exception while getting files"); + return new List(); + } + } + + private void CreateKubernetesClient(ClusterContext? clusterContext = null) + { + var clusterContextName = clusterContext == null ? _k8SConfiguration.CurrentContext : clusterContext.Name; + var kubernetesClientConfiguration = + KubernetesClientConfiguration.BuildConfigFromConfigFile(currentContext: clusterContextName); + var kubernetesClient = new Kubernetes(kubernetesClientConfiguration); + _kubernetesClient = kubernetesClient; + } + + + public void DownloadFile(Namespace? selectedNamespace, Pod? selectedPod, FileInformation selectedFile, string? saveFileName) + { + Log.Information($"{selectedNamespace} - {selectedPod} - {selectedFile} - {saveFileName}"); + + // TODO: this is done with Tar + } +} \ No newline at end of file diff --git a/K8sFileBrowser/ViewLocator.cs b/K8sFileBrowser/ViewLocator.cs new file mode 100644 index 0000000..fe9bb1f --- /dev/null +++ b/K8sFileBrowser/ViewLocator.cs @@ -0,0 +1,27 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using K8sFileBrowser.ViewModels; + +namespace K8sFileBrowser; + +public class ViewLocator : IDataTemplate +{ + public Control Build(object data) + { + var name = data.GetType().FullName!.Replace("ViewModel", "View"); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object data) + { + return data is ViewModelBase; + } +} \ No newline at end of file diff --git a/K8sFileBrowser/ViewModels/MainWindowViewModel.cs b/K8sFileBrowser/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..6449cef --- /dev/null +++ b/K8sFileBrowser/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using K8sFileBrowser.Models; +using K8sFileBrowser.Services; +using ReactiveUI; + +namespace K8sFileBrowser.ViewModels; + +public class MainWindowViewModel : ViewModelBase +{ + private readonly ObservableAsPropertyHelper> _clusterContexts; + public IEnumerable ClusterContexts => _clusterContexts.Value; + + private ClusterContext? _selectedClusterContext; + public ClusterContext? SelectedClusterContext + { + get => _selectedClusterContext; + set => this.RaiseAndSetIfChanged(ref _selectedClusterContext, value); + } + + private readonly ObservableAsPropertyHelper> _namespaces; + public IEnumerable Namespaces => _namespaces.Value; + + private Namespace? _selectedNamespace; + public Namespace? SelectedNamespace + { + get => _selectedNamespace; + set => this.RaiseAndSetIfChanged(ref _selectedNamespace, value); + } + + private readonly ObservableAsPropertyHelper> _pods; + public IEnumerable Pods => _pods.Value; + + private Pod? _selectedPod; + public Pod? SelectedPod + { + get => _selectedPod; + set => this.RaiseAndSetIfChanged(ref _selectedPod, value); + } + + private readonly ObservableAsPropertyHelper> _fileInformation; + public IEnumerable FileInformation => _fileInformation.Value; + + private FileInformation? _selectedFile; + public FileInformation? SelectedFile + { + get => _selectedFile; + set => this.RaiseAndSetIfChanged(ref _selectedFile, value); + } + + private string? _selectedPath; + public string? SelectedPath + { + get => _selectedPath; + set => this.RaiseAndSetIfChanged(ref _selectedPath, value); + } + + public ReactiveCommand DownloadCommand { get; } + public ReactiveCommand ParentCommand { get; } + public ReactiveCommand OpenCommand { get; } + + + public MainWindowViewModel() + { + var kubernetesService = new KubernetesService(); + + var isFile = this + .WhenAnyValue(x => x.SelectedFile) + .Select(x => x is { Type: FileType.File }); + + var isDirectory = this + .WhenAnyValue(x => x.SelectedFile) + .Select(x => x is { Type: FileType.Directory }); + + var isNotRoot = this + .WhenAnyValue(x => x.SelectedPath) + .Select(x => x is not "/"); + + OpenCommand = ReactiveCommand.Create(() => + { + SelectedPath = SelectedFile != null ? SelectedFile!.Name : "/"; + }, isDirectory, RxApp.MainThreadScheduler); + + DownloadCommand = ReactiveCommand.CreateFromTask(async () => + { + var fileName = SelectedFile!.Name.Substring(SelectedFile!.Name.LastIndexOf('/') + 1, SelectedFile!.Name.Length - SelectedFile!.Name.LastIndexOf('/') - 1); + var saveFileName = await ApplicationHelper.SaveFile(".", fileName); + kubernetesService.DownloadFile(SelectedNamespace, SelectedPod, SelectedFile, saveFileName); + + }, isFile, RxApp.TaskpoolScheduler); + + ParentCommand = ReactiveCommand.Create(() => + { + SelectedPath = SelectedPath![..SelectedPath!.LastIndexOf('/')]; + if (SelectedPath!.Length == 0) + { + SelectedPath = "/"; + } + }, isNotRoot, RxApp.MainThreadScheduler); + + // read the cluster contexts + _namespaces = this + .WhenAnyValue(c => c.SelectedClusterContext) + .Throttle(TimeSpan.FromMilliseconds(10)) + .Where(context => context != null) + .Select(context => + { + kubernetesService.SwitchClusterContext(context!); + return kubernetesService.GetNamespaces(); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.Namespaces); + + // read the pods when the namespace changes + _pods = this + .WhenAnyValue(c => c.SelectedNamespace) + .Throttle(TimeSpan.FromMilliseconds(10)) + .Where(ns => ns != null) + .Select(ns => kubernetesService.GetPods(ns!.Name)) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.Pods); + + // read the file information when the path changes + _fileInformation = this + .WhenAnyValue(c => c.SelectedPath, c => c.SelectedPod, c => c.SelectedNamespace) + .Throttle(TimeSpan.FromMilliseconds(10)) + .Select(x => x.Item3 == null || x.Item2 == null + ? new List() + : kubernetesService.GetFiles(x.Item3!.Name, x.Item2!.Name, x.Item2!.Containers.First(), + x.Item1)) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.FileInformation); + + // reset the path when the pod or namespace changes + this.WhenAnyValue(c => c.SelectedPod, c => c.SelectedNamespace) + .Subscribe(x => SelectedPath = "/"); + + // load the cluster contexts when the view model is created + var loadContexts = ReactiveCommand + .Create>(_ => kubernetesService.GetClusterContexts()); + _clusterContexts = loadContexts.Execute().ToProperty( + this, x => x.ClusterContexts, scheduler: RxApp.MainThreadScheduler); + + // select the current cluster context + SelectedClusterContext = ClusterContexts + .FirstOrDefault(x => x.Name == kubernetesService.GetCurrentContext()); + } +} \ No newline at end of file diff --git a/K8sFileBrowser/ViewModels/ViewModelBase.cs b/K8sFileBrowser/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..8980250 --- /dev/null +++ b/K8sFileBrowser/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using ReactiveUI; + +namespace K8sFileBrowser.ViewModels; + +public class ViewModelBase : ReactiveObject +{ +} \ No newline at end of file diff --git a/K8sFileBrowser/Views/MainWindow.axaml b/K8sFileBrowser/Views/MainWindow.axaml new file mode 100644 index 0000000..c7768e5 --- /dev/null +++ b/K8sFileBrowser/Views/MainWindow.axaml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/K8sFileBrowser/Views/MainWindow.axaml.cs b/K8sFileBrowser/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..5b09cf4 --- /dev/null +++ b/K8sFileBrowser/Views/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace K8sFileBrowser.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/K8sFileBrowser/app.manifest b/K8sFileBrowser/app.manifest new file mode 100644 index 0000000..c68ca8f --- /dev/null +++ b/K8sFileBrowser/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + +