**After installation, point the app at a kubeconfig that can reach a running cluster (KUBECONFIG or ~/.kube/config).**
- **A login that starts a program on this computer, such as aws, gcloud, or kubelogin, cannot start that program from the sandbox. A certificate or token in the kubeconfig is enough. Localhost on this computer stays reachable, including a local cluster API, local Ollama, and a port-forward opened in the browser.**
+ **Download CLI installs gcloud, az, aws, oci, doctl, ibmcloud, or kubelogin into the app so the sandbox can start them and refresh a token. A CLI installed on the host still cannot be started. A certificate or token in the kubeconfig needs no CLI. Localhost on this computer stays reachable, including a local cluster API, local Ollama, and a port-forward opened in the browser.**
Browse a Kubernetes cluster from the desktop using your kubeconfig. Apply YAML, follow logs, exec into pods, and manage port-forwards without a browser.
Settings stay on this computer. Cluster access uses the network and files under the home folder (typically ~/.kube).
@@ -65,6 +65,12 @@
+
+ https://github.com/MAKS-IT-COM/maksit-cluster-console/blob/v0.9.0/CHANGELOG.md
+
+ S3 Browser and Longhorn backup actions. Cloud adds Google, Azure, Amazon, Oracle, DigitalOcean, or IBM.
+
+
https://github.com/MAKS-IT-COM/maksit-cluster-console/blob/v0.8.9/CHANGELOG.md
diff --git a/docs/features.md b/docs/features.md
new file mode 100644
index 0000000..49cc09c
--- /dev/null
+++ b/docs/features.md
@@ -0,0 +1,198 @@
+# Features
+
+Screen notes for [MaksIT Cluster Console](../README.md). The README lists each area in one line. This page is the button names, columns, and screenshots.
+
+## Cluster overview
+
+Click a catalog row to open that context. A green dot is a live session in this app. The radio is labeled **kubectl context** and only writes kubectl `current-context` for the command line. This app does not follow that field. Until a row is open, the window stays on the catalog and asks you to select a cluster. Open clusters stay connected until you disconnect them. **Connections…** and **Reload kubeconfig** sit under the catalog. The status line counts contexts in the kubeconfig and how many are connected here.
+
+
+
+Overview names the Kubernetes version and how many nodes are in the cluster. **Cluster** and **Nodes** switch the charts. CPU, memory, and pods show usage, requests, limits, allocatable capacity, and capacity from metrics-server. **Resource limits** lists containers you can patch, and **Apply selected** writes the chosen CPU and memory against node capacity. Under the charts, Errors and Warnings list node, pod, and event problems, a LoadBalancer Service that is Pending or Unreachable, and a PersistentVolumeClaim that is Pending. When nothing is wrong, that pane says no issues were found. When Settings → AI is on, Chat sits on the right with the model name, **Analyze issues**, and **Clear**.
+
+
+
+
+## Applications
+
+One row per `app.kubernetes.io/instance` (or `name`) and namespace. Columns are Instance, Namespace, Managed by, Version, Ready, CPU, Memory, Status, and Age. CPU is percent of cluster allocatable. Memory is summed from owned pods when metrics-server is available. The details pane is Overview, YAML, Events, Pods, Logs, and Terminal.
+
+
+
+
+## Pods
+
+Name, Namespace, Age, Ready, Restarts, Status, and Node, plus CPU and Memory when metrics-server is available. Filters and sort persist per cluster. The open table follows the API watch. Details: Overview, YAML, Events, Logs, Terminal, and Chat. **Container** picks which container logs, the shell, Attach, and Debug use. The footer is **Delete**, **Force delete**, **Attach**, the debug image (default `busybox:1.36`), **Debug**, a local port, a remote port, and **Port-forward**. **Attach** follows that container's output. **Debug** adds an ephemeral container beside it. Kubernetes leaves that container on the running pod. It is gone when the pod is recreated. **Force delete** uses grace period 0 and strips finalizers.
+
+
+
+
+**Logs** names the container image and shows that container's output. **Follow** tails new lines instead of a one-time snapshot.
+
+
+
+## Chat
+
+Settings → AI opens **Local Ollama**. **Enable chat** turns Chat on, on a resource and on the cluster overview. The endpoint and model are any local [Ollama](https://ollama.com) model you already pulled. The default model is `qwen3:8b`. Reading the cluster is enough with that model. Repairs work better with `qwen3:14b` or larger (`qwen3:32b`, `qwen3-coder:30b`). **Timeout** is how long one reply may run, in seconds. The default is 240.
+
+
+
+On a selected resource, Chat has **Ask about selection** and **Clear**. On the overview, **Analyze issues** explains the current warnings and errors. Read-only tools are cluster issues, object YAML, logs, and events. **Allow the assistant to change the cluster** adds restart, pod delete (not force-delete), scale, and YAML apply. Each of those waits for **Approve** or **Reject** in the chat. Reject leaves the cluster unchanged. No cloud AI API. Chat is read-only until that switch is on.
+
+
+
+## Volume files
+
+Browse, edit, download, and upload files on a PersistentVolume or claim. Double-click a PV/PVC row, or use **Browse files** on the selected row.
+
+## Dapr
+
+The navigator lists Components, Configurations, Subscriptions, Resiliency, HTTP Endpoints, Sidecars, and the control plane.
+
+The control plane is the system processes in the `dapr` namespace: injector, operator, placement, and sentry. These are not application sidecars. Status and restarts show whether that system is up.
+
+
+
+A Configuration is sidecar settings for apps that reference it: tracing, metrics, and which features are on. It is not a ConfigMap, and it is not a Component.
+
+
+
+A Subscription says an app wants messages from a topic. Topic is the name. The sidecar receives them and posts them to the app.
+
+
+
+A sidecar is the `daprd` container in an application pod. This list is those pods: Name, Namespace, and Age. Logs and a terminal are for that pod. Details are Overview, YAML, Events, Logs, Terminal, and Chat.
+
+
+
+
+## Port forwarding
+
+**Network → Port Forwarding** lists each tunnel as `localhost:{port}`, the namespace, the pod, and the local and remote ports. Tunnels persist, restore on reconnect, and retarget a running pod. Double-click an active forward to open `http://127.0.0.1:{port}/`. **Rebind** changes the local port. The same local port, remote port, and **Port-forward** sit on a pod or Deployment footer. Details are Overview, YAML, and Chat.
+
+
+
+
+## Connections
+
+**Connections…** opens **Kubeconfig connections** for the file on disk. Each row is a context and its API server. The radio is **kubectl context**. A green dot is a live session in this app. The details pane shows the namespace, cluster name, server, whether TLS verify is skipped, whether CA data is present, the user, and how that user authenticates. **Add…** writes a context you fill in: bearer token, client certificate, pasted k3s certificate data, username and password, or an exec plugin when you already know the API server. **Cloud…** is a separate window for Google Kubernetes Engine, Azure Kubernetes Service, Amazon EKS, Oracle Kubernetes, DigitalOcean Kubernetes, and IBM Cloud Kubernetes. Sign in with that provider's CLI first. The window runs `gcloud container clusters get-credentials`, `az aks get-credentials`, `aws eks update-kubeconfig`, or the Oracle, DigitalOcean, or IBM command, and that CLI writes the API server, certificate, and login. The program must be on PATH. On Linux, **Download CLI** in that window installs gcloud, az, aws, oci, doctl, ibmcloud, or kubelogin into the app and puts them on PATH. The Flatpak sandbox starts that copy, including an exec plugin the kubeconfig names, so a token refresh runs inside the sandbox. A login already saved in the home directory is reused. Published checksums are checked. The AWS CLI zip is checked with the AWS CLI public key. A CLI installed on the host is not visible to the sandbox. **Connect** opens the selected context here. **Delete** removes it. **Cleanup unused cluster/user on delete** also drops a cluster or user entry that nothing else references. This app ignores kubectl `current-context` and uses the row you click. Several contexts stay connected until you disconnect them, while kubectl still has one current context. **Reload kubeconfig** rereads the file.
+
+
+
+## Navigator
+
+Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Dapr, Access Control, Custom Resources. Longhorn and CloudNativePG appear when those APIs are installed. Built-in kinds include admission policies, CSR, CSI objects, API services, flow control, and dynamic resource allocation, plus any CRD installed in the cluster.
+
+## Longhorn
+
+Longhorn is its own navigator group when that API is installed: **Volumes**, **Nodes**, **Recurring jobs**, **Backups**, and **S3 Browser**. These are Longhorn objects, not the Kubernetes node list and not a generic PersistentVolume. The screens view and operate Longhorn. They do not install it.
+
+**Volumes** matches the Longhorn UI. **State** is attached or detached. Attached is green. Degraded or faulted is shown with the state when a replica is unhealthy. **Name** is the technical Longhorn name, usually `pvc-` and an id. **Volume** is the PersistentVolumeClaim name, the name you gave the disk. **Node** is the Longhorn node the volume is attached to, and the tip lists pods using it. **Size** is the requested size. **Actual Size** is the space the replicas use. **Created** is when Longhorn created the volume. **Data Engine** is v1 or v2. **PV/PVC** is Bound, Available, or Released, and the tip names the PV and PVC. **Namespace** is the claim's namespace, not `longhorn-system`. **Schedule** is the recurring jobs on that volume. **Last Backup At** is the time of the latest backup. **Attach** and **Detach** are in the footer. Attach picks a Ready node. Detach can force a leftover ticket off. **Schedule backup…** creates a periodic backup job and labels the selected volumes. The schedule is five cron fields, and the cron line stays visible. A degraded volume that is still attached, or a failed replica of it, beside a new detached volume for the same claim or one restored from its backup, is called out on the overview. Detach the leftover, then attach the new one. Do not delete the new volume.
+
+
+
+
+**Nodes** is Longhorn's record of one machine and the disks it may store replicas on. It is not the Kubernetes node, though the name matches. Columns are Name, Namespace, Scheduling, Disks, and Age. **Scheduling** is whether Longhorn may place new replicas there. **Disks** is how many disks Longhorn sees, how much space is free, and how many are not ready.
+
+
+
+**Recurring jobs** is a Longhorn schedule. Columns are State, Name, Task, Schedule, Last Successful Run, Retain, and Groups. **State** is Running while a run is in progress, Failed when the latest run failed, and Idle when nothing is running. **Schedule** is when it runs. **Retain** is how many copies the job keeps. Snapshot Cleanup and Filesystem Trim do not use Retain. **Groups** are the Longhorn groups the job belongs to. The default group runs on volumes that have no other recurring job. **New job…** creates one. **Edit…** can change the name, which points the volumes at the new job and then removes the old one. **Run now** starts the selected job immediately, and does not start another while a run of that job is still in progress.
+
+The task is what the job does:
+
+- **Backup** copies a volume to the backup target, for a restore after the disk or the cluster is gone.
+- **Snapshot** takes a local point in time, for a rollback while the volume is still there.
+- **Snapshot Delete** removes local snapshots beyond Retain.
+- **Snapshot Cleanup** purges snapshots Longhorn has already marked removable.
+- **Filesystem Trim** releases unused filesystem blocks so Longhorn can reclaim the space.
+- **System Backup** copies the Longhorn configuration so the installation can be rebuilt. Volume data still comes from volume backups. The job window shows **Volume backup policy**: If not present, Always, or Disabled.
+
+
+
+**Backups** is one row per volume in the backup target, the same list as the Longhorn UI. Longhorn does not keep a separate Backup object for each copy. Columns are State, Volume, Last Backup, Last Backup At, Created, Size, and Target. **Volume** is the Longhorn volume name, the same string as Volumes → Name. **Last Backup** is the newest backup name. **Last Backup At** is when it was taken. **Created** is when this volume first appeared in the target. **Size** is the size of the volume that was backed up. **Target** is which backup target holds the copy. **State** is Ready, Terminating, or Error. **Delete** removes that volume's backups from the target.
+
+**S3 Browser** opens the default backup target and any other S3 targets. The target menu names the target, its URL, and how many buckets those credentials can see. **Reload**, **Add target…**, **Edit…**, and **Remove** sit on that bar. The backup bucket opens first. **New** creates a bucket. **Delete** removes a bucket and everything in it. A bucket used by a Longhorn backup target cannot be deleted. Adding a target writes the Longhorn backup target and its credential secret. Inside a bucket, **Open path** and **Filter** sit above the object list (Name, Kind, Size, Modified). The footer is **Refresh**, **Up**, **Open**, **New folder**, **Upload**, **Upload folder**, **Download**, **Delete**, **Rename**, and **Copy**. A small text object opens in its own window.
+
+
+
+## Tables
+
+**Search** filters rows already loaded. **Label selector** is sent with the list (`app=api`). The open table watches the API. Column filters and sort stay on this machine, per cluster. Copy includes the column header. **Hint** explains the kind on screen. Extended selection (Shift or Ctrl) plus a footer action that can run on its own applies to every selected row: Restart, Scale, Delete, Force delete, Force delete namespace, Cordon, Uncordon, Drain, Trigger, and Stop.
+
+## YAML
+
+View, server-side apply, create, and delete. **New** starts an empty document for a kind you can create. Apply falls back to create or replace when the API server rejects an apply patch. JSON and YAML highlighting follows the document. ConfigMap and Secret values use the key name (`.json`, `.yaml`, `.yml`) or stay plain.
+
+
+
+
+## Workloads
+
+The Workloads overview is a tile for Pods, Deployments, StatefulSets, DaemonSets, ReplicaSets, and Jobs. CronJobs, ReplicationControllers, and the other workload kinds are their own pages in the navigator.
+
+
+
+A tile opens that table. On a Deployment the footer is **Delete**, **Force delete**, a replica count, **Scale**, **Restart**, **Pause**, **Resume**, **History**, **Undo**, a local port, a remote port, and **Port-forward**. **Pod** and **Container** choose which pod Logs and Terminal follow. The Pods tab lists the pods of that workload. **Scale** also applies to a StatefulSet, ReplicaSet, or ReplicationController. **Restart** also applies to a StatefulSet or DaemonSet. **Trigger** starts a Job from a CronJob. **Force delete namespace** removes the selected namespace, except `default`, `kube-system`, `kube-public`, and `kube-node-lease`.
+
+
+
+
+## Nodes
+
+The table is Name, Status, Roles, and Version. Status is Ready in green. The footer is **Delete**, **Force delete**, **Cordon**, **Uncordon**, and **Drain**. Details are Overview, YAML, Images, Events, and Chat. **Cordon** refuses new pods. **Uncordon** accepts them again. **Cordon** and **Drain** stay hidden unless another Ready node can still take pods, so a single-node cluster has neither. **Uncordon** still restores a node that is already cordoned. **Drain** asks first. The dialog is a table: **Node**, **Outcome** (teal **Will move**, amber **Will remain**), **Namespace**, **Pod**, and **Reason**. Will remain covers a DaemonSet, a mirror pod, a completed pod, a pod with no controller, and any pod a PodDisruptionBudget would refuse. Long text wraps and the row grows. When AI is on, the dialog also includes a short note on whether it is safe to continue and which pods stay. The note does not drain the node. **Cancel** or Escape leaves the node as it is. **Drain** cordons the node and evicts only Will move. A refused eviction is not deleted.
+
+
+
+The Images tab marks cached images Used or Unused from the pods on that node. Columns are State, Image, Size, Pods, and other namespaces. The header counts used and unused images and their size. kubelet lists at most 50 images, largest first.
+
+
+
+
+## Config
+
+ConfigMaps and Secrets have a **Data** tab: keys, a decoded preview, a binary flag, **Add key**, and **Apply data**. Secret values are saved back as `stringData`. The same tables cover resource quotas, limit ranges, HorizontalPodAutoscalers, PodDisruptionBudgets, leases, runtime classes, webhooks, and admission policies.
+
+## Network
+
+A Service table is Name, Namespace, Age, and Type (`ClusterIP`, `LoadBalancer`, and the other Service types). **Status** is Active when a load-balancer address is assigned, Pending when none is set, and Unreachable when a requested or BGP address is not the one the cluster reports. Details are Overview, YAML, Events, Pods, and Chat. The navigator also lists Endpoints, EndpointSlices, Ingresses, NetworkPolicies, and IP addresses.
+
+
+
+## Storage
+
+A claim table is Name, Namespace, Age, and Status. Resize a PersistentVolumeClaim. **Reclaim** changes Delete or Retain on a storage class and its volumes. Changing the class itself is delete-and-recreate; the dialog explains the gap for new claims. CSI drivers, nodes, storage capacities, and volume attachments are in the navigator when the API has them. Double-click a PersistentVolume or claim, or use **Browse files**, to open files on that volume.
+
+
+
+## Access
+
+Approve or deny a certificate signing request. Signer is who should sign it. The decision is stored on the request. **Token** creates a ServiceAccount token and shows it once.
+
+
+
+Roles, RoleBindings, ClusterRoles, ClusterRoleBindings, and ClusterTrustBundles are editable YAML like any other kind. A Role lists permissions in one namespace and grants nothing until a RoleBinding attaches it.
+
+
+
+## Custom resources
+
+Extra columns from the CRD `additionalPrinterColumns`.
+
+
+
+## Helm
+
+Charts lists each chart version installed in the cluster, taken from Helm release secrets. Columns are Chart, Version, App, and Status. A release table is Name, Namespace, Revision, Status, and Chart. History lists every stored revision. **Compare** picks two revisions and shows the values diff under that pair, along with the user-supplied values and the rendered manifest. Chart install and upgrade stay on the Helm CLI.
+
+
+
+
+
+
+## Metrics
+
+CPU and memory when the metrics API is available. Charts use the same metrics-server data as `kubectl top`.
+
+## Help
+
+**Hint** on a table explains that kind. Help → Logs shows the app log and crash reports. An unhandled error opens a window you can copy instead of closing the app. Help → About shows the product, license, and contact. After an upgrade, What's New lists additions since the version you last opened.
diff --git a/packaging/flathub/manifest.md b/packaging/flathub/manifest.md
index deae84a..1fccb03 100644
--- a/packaging/flathub/manifest.md
+++ b/packaging/flathub/manifest.md
@@ -9,7 +9,7 @@ Values for `com.maks_it.clusterconsole.yml` (or `.json`) at the top of the Flath
| Homepage | [https://maks-it.com/](https://maks-it.com/) |
| Project | `src/MaksIT.ClusterConsole.UI/MaksIT.ClusterConsole.UI.csproj` |
| Target | `net10.0` |
-| Source | Release tag on `https://github.com/MAKS-IT-COM/maksit-cluster-console` (currently `v0.8.9`) |
+| Source | Release tag on `https://github.com/MAKS-IT-COM/maksit-cluster-console` (currently `v0.9.0`) |
| Runtime | `org.freedesktop.Platform` |
| SDK | `org.freedesktop.Sdk` |
| SDK extension | `org.freedesktop.Sdk.Extension.dotnet10` |
diff --git a/packaging/flathub/permissions.md b/packaging/flathub/permissions.md
index 953444d..2dcf233 100644
--- a/packaging/flathub/permissions.md
+++ b/packaging/flathub/permissions.md
@@ -13,6 +13,6 @@ Finish args for the manifest. The GitHub bundle uses this set. Keep the Flathub
Network reaches the cluster API. Settings stay in the sandbox config directory. Home covers a kubeconfig anywhere under the home directory, including `~/.kube` and certificate files the kubeconfig names, plus files chosen in the open and save dialogs. X11 and Wayland stay together until Avalonia's native Wayland path no longer hangs on GNOME. Linux runs through X11/XWayland. DRI is for GL rendering. The app has no audio, so the sandbox does not take pulseaudio.
-A kubeconfig and a reachable cluster are required after install. `--share=network` uses this computer's network, including localhost, so a local cluster API, local Ollama, and a port-forward opened in the browser stay reachable. A kubeconfig login that starts `aws`, `gcloud`, or `kubelogin` cannot start that program from the sandbox. A certificate or token in the kubeconfig is enough. The metainfo says so. The submission is from the project that maintains this repository.
+A kubeconfig and a reachable cluster are required after install. `--share=network` uses this computer's network, including localhost, so a local cluster API, local Ollama, and a port-forward opened in the browser stay reachable. Download CLI installs gcloud, az, aws, oci, doctl, ibmcloud, or kubelogin into the app so the sandbox can start them. A CLI installed on the host still cannot be started. A certificate or token in the kubeconfig is enough when no CLI is installed. The metainfo says so. The submission is from the project that maintains this repository.
The pull request steps are in [submission.md](submission.md). Manifest values are in [manifest.md](manifest.md). Verification is in [verification.md](verification.md).
diff --git a/packaging/flathub/sources.md b/packaging/flathub/sources.md
index f92fb70..dbad11d 100644
--- a/packaging/flathub/sources.md
+++ b/packaging/flathub/sources.md
@@ -9,7 +9,7 @@ Upstream files stay in this repository. The Flathub build installs them from the
| [src/MaksIT.ClusterConsole.UI/Assets/icon.svg](../../src/MaksIT.ClusterConsole.UI/Assets/icon.svg) | Icon, installed as `share/icons/hicolor/scalable/apps/com.maks_it.clusterconsole.svg`. |
| [LICENSE.md](../../LICENSE.md) | Apache-2.0. Install to `$FLATPAK_DEST/share/licenses/com.maks_it.clusterconsole/` because the filename is `LICENSE.md`. |
-Screenshot URLs in the metainfo use tag `v0.8.6`, where those images are stored. The `0.8.9` release entry points at tag `v0.8.9`. On a newer release, point `` at the tag that contains the screenshots and `` at that version.
+Screenshot URLs in the metainfo use tag `v0.8.6`, where those images are stored. The `0.9.0` release entry points at tag `v0.9.0`. On a newer release, point `` at the tag that contains the screenshots and `` at that version.
## Pull request
diff --git a/packaging/microsoft-store/description.md b/packaging/microsoft-store/description.md
index 393c368..cdd8bf5 100644
--- a/packaging/microsoft-store/description.md
+++ b/packaging/microsoft-store/description.md
@@ -11,7 +11,7 @@ Hardware checkboxes (keyboard, memory, processor) stay in [CONTRIBUTING.md](../.
Shown at the top of the listing. The field allows 1,000 characters. Keep this under 270 so every Store view shows the full sentence.
```text
-A desktop app for an existing Kubernetes cluster. Connect with your kubeconfig, then browse resources, apply YAML, follow pod logs, open a shell, and forward ports. Inspect Helm and Dapr. Optional local chat can repair the cluster after you approve each change.
+A desktop app for an existing Kubernetes cluster. Connect with your kubeconfig, then browse resources, apply YAML, follow logs, open a shell, and forward ports. Inspect Helm, Dapr, and Longhorn. Optional local chat can repair the cluster after you approve each change.
```
## Description
@@ -23,11 +23,11 @@ Work on a Kubernetes cluster from Windows without keeping a browser dashboard or
Connect with the kubeconfig you already use. What you can see and change is exactly what that identity is allowed to do. Click a catalog row to open that context in this app. The radio beside the row only writes kubectl current-context, so the command line can follow. This app does not read that field. Several contexts can stay open here while kubectl still has one current context.
-Move through the cluster from one navigator: cluster and nodes, applications, workloads, config, network, storage, namespaces, events, Helm releases, Dapr, access control, and custom resources, including CRDs already installed. Longhorn volumes and nodes, and CloudNativePG clusters, appear when those APIs are installed. Several contexts can stay connected. Add a context with a token, a client certificate, pasted k3s certificate data, or a username and password. The open table watches the API, and a label selector is sent with the list. Search filters the rows already loaded. Filters and sort stay per cluster. Selecting several rows runs a footer action on each of them. Custom resource tables show extra columns from the CRD. Open a row for YAML, events, and status. Hint explains the kind on screen.
+Move through the cluster from one navigator: cluster and nodes, applications, workloads, config, network, storage, namespaces, events, Helm releases, Dapr, access control, and custom resources, including CRDs already installed. Longhorn volumes and nodes, and CloudNativePG clusters, appear when those APIs are installed. Several contexts can stay connected. Add a context with a token, a client certificate, pasted k3s certificate data, a username and password, or an exec plugin. Cloud adds Google, Azure, Amazon, Oracle, DigitalOcean, or IBM by running that provider's CLI. The open table watches the API, and a label selector is sent with the list. Search filters the rows already loaded. Filters and sort stay per cluster. Selecting several rows runs a footer action on each of them. Custom resource tables show extra columns from the CRD. Open a row for YAML, events, and status. Hint explains the kind on screen.
Everyday changes stay in the same window. Apply or create YAML. If the API server rejects an apply patch, apply falls back to create or replace. JSON and YAML highlighting follows the document. Force-delete strips finalizers. Scale a Deployment, StatefulSet, ReplicaSet, or ReplicationController. Restart a Deployment, StatefulSet, or DaemonSet. Pause, resume, and undo a Deployment rollout. Trigger a CronJob. Force delete namespace removes a selected namespace, except default, kube-system, kube-public, and kube-node-lease. Cordon and Drain stay hidden unless another Ready node can still take pods. Uncordon still restores a node that is already cordoned. Drain asks first and shows which pods will move and which will remain, including anything a disruption budget would refuse. When AI is on, that dialog also includes a short note. The note does not drain the node. The Images tab marks cached images Used or Unused from the pods on that node. On a pod, follow the log, open a shell, attach output, or add a debug container. The image defaults to busybox and stays until the pod is recreated. ConfigMaps and Secrets edit as data keys, with a decoded preview. A Service shows Active, Pending, or Unreachable.
-Resize a persistent volume claim, or change reclaim policy on a storage class and its volumes. Approve or deny a certificate signing request, and create a ServiceAccount token that is shown once. Port forwards are saved, restored when you reconnect, and a double-click on an active forward opens it in the browser. Rebind changes the local port.
+Resize a persistent volume claim, or change reclaim policy on a storage class and its volumes. Longhorn includes S3 Browser for the default backup target and any other S3 targets. The bucket list shows every bucket those credentials can see, and the backup bucket opens first. New bucket creates one. Delete bucket removes a bucket and everything in it. A bucket used by a Longhorn backup target cannot be deleted. It opens folders, uploads and downloads, creates a folder, renames, copies, deletes, and edits a small text object in its own window. Adding a target writes the Longhorn backup target and its credential secret. Approve or deny a certificate signing request, and create a ServiceAccount token that is shown once. Port forwards are saved, restored when you reconnect, and a double-click on an active forward opens it in the browser. Rebind changes the local port.
CPU and memory appear on the overview, applications, and workload tables when the cluster has metrics-server. From the overview you can patch container CPU and memory against node capacity. Persistent volumes and claims open a file browser for viewing, editing, downloading, and uploading files. Helm charts list each installed chart version. A release shows revision history, user-supplied values, the rendered manifest, and a diff between two revisions. Chart install and upgrade stay on the Helm CLI. Dapr pages list components, configurations, subscriptions, sidecars, and the control plane that are already in the cluster.
diff --git a/packaging/microsoft-store/keywords.md b/packaging/microsoft-store/keywords.md
index 05d7789..2edd3ad 100644
--- a/packaging/microsoft-store/keywords.md
+++ b/packaging/microsoft-store/keywords.md
@@ -12,6 +12,7 @@ YAML
Helm
Dapr
local Ollama
+Longhorn
```
The listing description is in [description.md](description.md). Product features are in [product-features.md](product-features.md).
diff --git a/packaging/microsoft-store/product-features.md b/packaging/microsoft-store/product-features.md
index 7d450b8..22e4062 100644
--- a/packaging/microsoft-store/product-features.md
+++ b/packaging/microsoft-store/product-features.md
@@ -12,12 +12,13 @@ Short summaries of the product's key features. The Store shows them as a bullete
- Browse, edit, download, and upload files on a persistent volume or claim. Double-click a PV or PVC row to open the file browser.
- Open Dapr from the navigator: components, configurations, subscriptions, resiliency, HTTP endpoints, sidecars, and the control plane.
- Save port forwards, restore them on reconnect, and retarget a running pod. Double-click an active forward to open it in the browser. Rebind changes the local port.
-- Add a kubeconfig context with a token, client certificate, pasted k3s data, or a username and password. Several clusters can stay connected here while kubectl keeps one current-context.
+- Cloud adds Google, Azure, Amazon, Oracle, DigitalOcean, or IBM by running that CLI. Add takes a token or certificate. Several clusters stay connected. kubectl keeps one current-context.
- Browse cluster, nodes, workloads, config, network, storage, namespaces, events, Helm, Dapr, access control, and custom resources. Longhorn and CloudNativePG appear when those APIs are installed.
- The open table watches the API. A label selector is sent with the list. Search filters loaded rows. Filters and sort stay per cluster. Shift or Ctrl selection runs a footer action on the selection.
- View, apply, create, and delete resource YAML, including force delete. If the API server rejects an apply patch, apply falls back to create or replace. JSON and YAML highlighting follows the document.
- Scale and restart workloads. Pause, resume, and undo a Deployment. Trigger a CronJob. Cordon and Drain stay hidden unless another Ready node remains. When AI is on, drain adds a short note.
- Resize a persistent volume claim. Change reclaim policy on a storage class and its volumes. The Images tab marks cached node images Used or Unused.
+- Longhorn S3 Browser opens the backup target and other S3 targets. List buckets, upload and download, rename, copy, delete, and edit a small text file. A backup bucket cannot be deleted.
- ConfigMaps and Secrets have a Data tab with keys, a decoded preview, and apply. A Service status is Active, Pending, or Unreachable.
- Approve or deny a certificate signing request. Create a ServiceAccount token and show it once.
- Custom resource tables use extra columns from the CRD additionalPrinterColumns. Hint explains the kind on screen.
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 2dab745..34cc1fd 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
latest
enable
enable
- 0.8.9
+ 0.9.0
Cluster Console
Cluster Console
MaksIT
diff --git a/src/MaksIT.ClusterConsole.Client/Cluster/ClusterSession.Longhorn.cs b/src/MaksIT.ClusterConsole.Client/Cluster/ClusterSession.Longhorn.cs
new file mode 100644
index 0000000..7b0ee40
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/Cluster/ClusterSession.Longhorn.cs
@@ -0,0 +1,158 @@
+using System.Net;
+using k8s;
+using k8s.Models;
+using MaksIT.Results;
+using MaksIT.ClusterConsole.Client.Internal;
+
+
+namespace MaksIT.ClusterConsole.Client.Cluster;
+
+public sealed partial class ClusterSession {
+ public async Task AttachLonghornVolumeAsync(
+ string name,
+ string @namespace,
+ string nodeId,
+ bool disableFrontend,
+ CancellationToken cancellationToken = default) {
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(nodeId))
+ return Result.BadRequest("Volume name and node are required.");
+
+ var ns = string.IsNullOrWhiteSpace(@namespace) ? "longhorn-system" : @namespace;
+ var ticket = await UpsertAttachmentTicketAsync(name, ns, nodeId, disableFrontend, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!ticket.IsSuccess)
+ return ticket;
+
+ return await MergePatchAsync(
+ LonghornAttachment.Volume,
+ name,
+ ns,
+ LonghornAttachment.NodePatch(nodeId),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task DetachLonghornVolumeAsync(
+ string name,
+ string @namespace,
+ bool force,
+ CancellationToken cancellationToken = default) {
+ if (string.IsNullOrWhiteSpace(name))
+ return Result.BadRequest("Volume name is required.");
+
+ var ns = string.IsNullOrWhiteSpace(@namespace) ? "longhorn-system" : @namespace;
+ var existing = await GetAsync(LonghornAttachment.Attachment, name, ns, cancellationToken).ConfigureAwait(false);
+
+ if (existing.IsSuccess) {
+ var ids = force
+ ? LonghornAttachment.TicketIds(existing.Value)
+ : [LonghornAttachment.TicketId];
+ var cleared = await MergePatchAsync(
+ LonghornAttachment.Attachment,
+ name,
+ ns,
+ LonghornAttachment.ClearTickets(ids),
+ cancellationToken).ConfigureAwait(false);
+
+ if (!cleared.IsSuccess)
+ return cleared;
+ }
+ else if (existing.StatusCode != HttpStatusCode.NotFound) {
+ return Fail(existing);
+ }
+
+ return await MergePatchAsync(
+ LonghornAttachment.Volume,
+ name,
+ ns,
+ LonghornAttachment.NodePatch(""),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task PatchLonghornVolumeAsync(
+ string name,
+ string @namespace,
+ string mergePatchJson,
+ CancellationToken cancellationToken = default) {
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(mergePatchJson))
+ return Result.BadRequest("Volume name and patch are required.");
+
+ var ns = string.IsNullOrWhiteSpace(@namespace) ? "longhorn-system" : @namespace;
+
+ return await MergePatchAsync(LonghornAttachment.Volume, name, ns, mergePatchJson, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task UpsertAttachmentTicketAsync(
+ string name,
+ string ns,
+ string nodeId,
+ bool disableFrontend,
+ CancellationToken cancellationToken) {
+ var existing = await GetAsync(LonghornAttachment.Attachment, name, ns, cancellationToken).ConfigureAwait(false);
+
+ if (existing.IsSuccess) {
+ return await MergePatchAsync(
+ LonghornAttachment.Attachment,
+ name,
+ ns,
+ LonghornAttachment.TicketMerge(nodeId, disableFrontend),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ if (existing.StatusCode != HttpStatusCode.NotFound)
+ return Fail(existing);
+
+ try {
+ await _client.CustomObjects.CreateNamespacedCustomObjectAsync(
+ LonghornAttachment.CreateBody(name, ns, nodeId, disableFrontend),
+ LonghornAttachment.Group,
+ LonghornAttachment.Version,
+ ns,
+ LonghornAttachment.Plural,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return Result.Ok();
+ }
+ catch (Exception ex) {
+ var created = KubernetesResult.Map(ex);
+
+ if (created.StatusCode == HttpStatusCode.NotFound)
+ return Result.Ok();
+
+ if (created.StatusCode == HttpStatusCode.Conflict) {
+ return await MergePatchAsync(
+ LonghornAttachment.Attachment,
+ name,
+ ns,
+ LonghornAttachment.TicketMerge(nodeId, disableFrontend),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ return created;
+ }
+ }
+
+ private async Task MergePatchAsync(
+ ResourceRef resource,
+ string name,
+ string ns,
+ string json,
+ CancellationToken cancellationToken) {
+ try {
+ var patch = new V1Patch(json, V1Patch.PatchType.MergePatch);
+ await _client.CustomObjects.PatchNamespacedCustomObjectAsync(
+ patch,
+ resource.Group,
+ resource.Version,
+ ns,
+ resource.Plural,
+ name,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return Result.Ok();
+ }
+ catch (Exception ex) {
+ return KubernetesResult.Map(ex);
+ }
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Client/Cluster/ClusterSession.cs b/src/MaksIT.ClusterConsole.Client/Cluster/ClusterSession.cs
index 3d488a0..b1839ce 100644
--- a/src/MaksIT.ClusterConsole.Client/Cluster/ClusterSession.cs
+++ b/src/MaksIT.ClusterConsole.Client/Cluster/ClusterSession.cs
@@ -2,6 +2,7 @@ using System.Net;
using System.Text;
using System.IO.Compression;
using System.Text.Json.Nodes;
+using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using k8s;
using k8s.Models;
@@ -13,6 +14,8 @@ using MaksIT.ClusterConsole.Client.KubeConfig;
namespace MaksIT.ClusterConsole.Client.Cluster;
public sealed partial class ClusterSession : IClusterSession {
+ private static readonly ConcurrentDictionary _triggerGates = new(StringComparer.Ordinal);
+
private readonly Kubernetes _client;
public ClusterSession(string contextName, KubernetesClientConfiguration configuration) {
@@ -737,8 +740,16 @@ public sealed partial class ClusterSession : IClusterSession {
}
public async Task TriggerCronJobAsync(string name, string @namespace, CancellationToken cancellationToken = default) {
+ var gate = TriggerGate(ContextName, @namespace, name);
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+
try {
var cron = await _client.BatchV1.ReadNamespacedCronJobAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var jobs = await _client.BatchV1.ListNamespacedJobAsync(@namespace, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (jobs.Items is not null && jobs.Items.Any(job => CronJobRun.IsInProgress(job, name)))
+ return Result.Conflict("A run is already in progress.");
+
var job = new V1Job {
Metadata = new V1ObjectMeta {
Name = $"{name}-manual-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
@@ -761,8 +772,14 @@ public sealed partial class ClusterSession : IClusterSession {
catch (Exception ex) {
return KubernetesResult.Map(ex);
}
+ finally {
+ gate.Release();
+ }
}
+ private static SemaphoreSlim TriggerGate(string context, string ns, string name) =>
+ _triggerGates.GetOrAdd(context + "\n" + ns + "\n" + name, static _ => new SemaphoreSlim(1, 1));
+
public void Dispose() => _client.Dispose();
private async Task DeleteOnceAsync(
diff --git a/src/MaksIT.ClusterConsole.Client/Cluster/CronJobRun.cs b/src/MaksIT.ClusterConsole.Client/Cluster/CronJobRun.cs
new file mode 100644
index 0000000..89eea50
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/Cluster/CronJobRun.cs
@@ -0,0 +1,60 @@
+using k8s.Models;
+
+
+namespace MaksIT.ClusterConsole.Client.Cluster;
+
+///
+/// A Job still counts as a run of its CronJob until that Job has succeeded or failed.
+///
+internal static class CronJobRun {
+ public static bool IsInProgress(V1Job job, string cronJobName) {
+ if (!BelongsTo(job, cronJobName))
+ return false;
+
+ return !IsFinished(job);
+ }
+
+ private static bool BelongsTo(V1Job job, string cronJobName) {
+ var owners = job.Metadata?.OwnerReferences;
+
+ if (owners is null)
+ return false;
+
+ foreach (var owner in owners) {
+ if (string.Equals(owner.Kind, "CronJob", StringComparison.Ordinal) && string.Equals(owner.Name, cronJobName, StringComparison.Ordinal))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsFinished(V1Job job) {
+ var status = job.Status;
+
+ if (status is null)
+ return false;
+
+ if (HasTerminalCondition(status))
+ return true;
+
+ if (status.Active is > 0)
+ return false;
+
+ return status.Succeeded is > 0;
+ }
+
+ private static bool HasTerminalCondition(V1JobStatus status) {
+ if (status.Conditions is null)
+ return false;
+
+ foreach (var condition in status.Conditions) {
+ if (!string.Equals(condition.Status, "True", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (condition.Type is "Complete" or "Failed")
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Client/Cluster/IClusterSession.cs b/src/MaksIT.ClusterConsole.Client/Cluster/IClusterSession.cs
index fe8fb95..1b1ece8 100644
--- a/src/MaksIT.ClusterConsole.Client/Cluster/IClusterSession.cs
+++ b/src/MaksIT.ClusterConsole.Client/Cluster/IClusterSession.cs
@@ -191,4 +191,23 @@ public interface IClusterSession : IDisposable {
IReadOnlyList names,
string policy,
CancellationToken cancellationToken = default);
+
+ Task AttachLonghornVolumeAsync(
+ string name,
+ string @namespace,
+ string nodeId,
+ bool disableFrontend,
+ CancellationToken cancellationToken = default);
+
+ Task DetachLonghornVolumeAsync(
+ string name,
+ string @namespace,
+ bool force,
+ CancellationToken cancellationToken = default);
+
+ Task PatchLonghornVolumeAsync(
+ string name,
+ string @namespace,
+ string mergePatchJson,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/MaksIT.ClusterConsole.Client/Cluster/LonghornAttachment.cs b/src/MaksIT.ClusterConsole.Client/Cluster/LonghornAttachment.cs
new file mode 100644
index 0000000..ada5008
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/Cluster/LonghornAttachment.cs
@@ -0,0 +1,86 @@
+using System.Text.Json.Nodes;
+
+
+namespace MaksIT.ClusterConsole.Client.Cluster;
+
+///
+/// Longhorn UI attach and detach update the longhorn-ui ticket on
+/// volumeattachments.longhorn.io, and spec.nodeID for managers that still read it.
+///
+internal static class LonghornAttachment {
+ public const string TicketId = "longhorn-ui";
+ public const string Group = "longhorn.io";
+ public const string Version = "v1beta2";
+ public const string Plural = "volumeattachments";
+ public const string VolumePlural = "volumes";
+
+ public static readonly ResourceRef Attachment = new(Group, Version, Plural, "VolumeAttachment", true);
+
+ public static readonly ResourceRef Volume = new(Group, Version, VolumePlural, "Volume", true);
+
+ public static string TicketMerge(string nodeId, bool disableFrontend) =>
+ new JsonObject {
+ ["spec"] = new JsonObject {
+ ["attachmentTickets"] = new JsonObject {
+ [TicketId] = Ticket(nodeId, disableFrontend)
+ }
+ }
+ }.ToJsonString();
+
+ public static string ClearTickets(IEnumerable ticketIds) {
+ var tickets = new JsonObject();
+
+ foreach (var id in ticketIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal))
+ tickets[id] = JsonNode.Parse("null");
+
+ if (!tickets.ContainsKey(TicketId))
+ tickets[TicketId] = JsonNode.Parse("null");
+
+ return new JsonObject {
+ ["spec"] = new JsonObject {
+ ["attachmentTickets"] = tickets
+ }
+ }.ToJsonString();
+ }
+
+ public static string NodePatch(string nodeId) =>
+ new JsonObject {
+ ["spec"] = new JsonObject {
+ ["nodeID"] = nodeId
+ }
+ }.ToJsonString();
+
+ public static JsonObject CreateBody(string name, string @namespace, string nodeId, bool disableFrontend) =>
+ new() {
+ ["apiVersion"] = Group + "/" + Version,
+ ["kind"] = "VolumeAttachment",
+ ["metadata"] = new JsonObject {
+ ["name"] = name,
+ ["namespace"] = @namespace
+ },
+ ["spec"] = new JsonObject {
+ ["volume"] = name,
+ ["attachmentTickets"] = new JsonObject {
+ [TicketId] = Ticket(nodeId, disableFrontend)
+ }
+ }
+ };
+
+ public static IReadOnlyList TicketIds(JsonObject? attachment) {
+ if (attachment?["spec"]?["attachmentTickets"] is not JsonObject tickets)
+ return [];
+
+ return tickets.Select(ticket => ticket.Key).ToList();
+ }
+
+ private static JsonObject Ticket(string nodeId, bool disableFrontend) =>
+ new() {
+ ["id"] = TicketId,
+ ["type"] = "longhorn-api",
+ ["nodeID"] = nodeId,
+ ["parameters"] = new JsonObject {
+ ["disableFrontend"] = disableFrontend ? "true" : "false",
+ ["lastAttachedBy"] = ""
+ }
+ };
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/AwsCliPublicKey.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/AwsCliPublicKey.cs
new file mode 100644
index 0000000..c053679
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/AwsCliPublicKey.cs
@@ -0,0 +1,39 @@
+namespace MaksIT.ClusterConsole.Client.KubeConfig;
+
+///
+/// Official AWS CLI publisher key from the AWS CLI install guide.
+/// Fingerprint FB5D B77F D5C1 18B8 0511 ADA8 A631 0ACC 4672 475C.
+///
+internal static class AwsCliPublicKey {
+ public const string Armored = """
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
+
+ mQINBF2Cr7UBEADJZHcgusOJl7ENSyumXh85z0TRV0xJorM2B/JL0kHOyigQluUG
+ ZMLhENaG0bYatdrKP+3H91lvK050pXwnO/R7fB/FSTouki4ciIx5OuLlnJZIxSzx
+ PqGl0mkxImLNbGWoi6Lto0LYxqHN2iQtzlwTVmq9733zd3XfcXrZ3+LblHAgEt5G
+ TfNxEKJ8soPLyWmwDH6HWCnjZ/aIQRBTIQ05uVeEoYxSh6wOai7ss/KveoSNBbYz
+ gbdzoqI2Y8cgH2nbfgp3DSasaLZEdCSsIsK1u05CinE7k2qZ7KgKAUIcT/cR/grk
+ C6VwsnDU0OUCideXcQ8WeHutqvgZH1JgKDbznoIzeQHJD238GEu+eKhRHcz8/jeG
+ 94zkcgJOz3KbZGYMiTh277Fvj9zzvZsbMBCedV1BTg3TqgvdX4bdkhf5cH+7NtWO
+ lrFj6UwAsGukBTAOxC0l/dnSmZhJ7Z1KmEWilro/gOrjtOxqRQutlIqG22TaqoPG
+ fYVN+en3Zwbt97kcgZDwqbuykNt64oZWc4XKCa3mprEGC3IbJTBFqglXmZ7l9ywG
+ EEUJYOlb2XrSuPWml39beWdKM8kzr1OjnlOm6+lpTRCBfo0wa9F8YZRhHPAkwKkX
+ XDeOGpWRj4ohOx0d2GWkyV5xyN14p2tQOCdOODmz80yUTgRpPVQUtOEhXQARAQAB
+ tCFBV1MgQ0xJIFRlYW0gPGF3cy1jbGlAYW1hem9uLmNvbT6JAlQEEwEIAD4CGwMF
+ CwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQT7Xbd/1cEYuAURraimMQrMRnJHXAUC
+ akV0ygUJDqP4lQAKCRCmMQrMRnJHXFHjD/9eyZLYcKuQOlLvtqSDtUBiEZf6ZZjM
+ i3ygYH8rJNtuToUH+HvSpe819urJCquXhDrlK6N+aqW0hCLtNABJG/vsafIgvIYJ
+ hSGgpgtNnQyMV1jViRWqPjbouw8OkYKBThUfT1i2Y+wn58ifs6ODBCmTexWtXspA
+ Si+Gt49xDOW0APmbOPnI+a4HJW6tVEo6MWS0WjzpiBayR3d1A4pt4YrPfSdDgpLo
+ h2SLQqlRqvvVZJaWBjhkErNFpfsBA06sDcPEOb0G8LBUbR4WOcdvhe5LubJbZuxC
+ AG9kNPCVeQP1ixwjgjXKysaxeQ6rv0VzIQgRp6tLVLWhy6AKDNvLjFSsmXZ1Wl08
+ Y/RlOHXlzLuQMRE6sR1wOdRxc9TsrNWTGiBK65cvSWOy03JeBkQQ8pesqltiyxI9
+ U21kkgiXtTSKNGfKK8pO27D81YANhRqPK7iTp6kuFiY2WtOg90KTMNlIT+Ff85Y2
+ b1rHj6Z0SrCkJujhWk3IBPic/wJgz01LEc/OAdUPlby90RJZcIBhSlWhT7mXnXIO
+ c0HWlNQrns2s3CTyYwZSiSlYe9ApeLwhjDo8NhbFuCAy61l6O5UsR4AfZxx/rGKv
+ 2wFb1/RN/P4gNe6vmxZAPjR0AQcwD3tc2McimOLr/22kmPz8IH3I0X7WoSFr0Biz
+ E91G7bb0hOb/cA==
+ =knv7
+ -----END PGP PUBLIC KEY BLOCK-----
+ """;
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliFiles.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliFiles.cs
new file mode 100644
index 0000000..a134fb7
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliFiles.cs
@@ -0,0 +1,380 @@
+using System.Net;
+using System.Text;
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text.RegularExpressions;
+using Org.BouncyCastle.Bcpg.OpenPgp;
+using MaksIT.Results;
+
+
+
+namespace MaksIT.ClusterConsole.Client.KubeConfig;
+
+internal static class CloudCliFiles {
+ public const int MaxBytes = 250 * 1024 * 1024;
+
+ public static async Task> DownloadAsync(
+ HttpClient http,
+ string url,
+ string destination,
+ CancellationToken cancellationToken) {
+ using var response = await GetAsync(http, url, cancellationToken).ConfigureAwait(false);
+
+ if (response.StatusCode != HttpStatusCode.OK)
+ return Result.BadRequest(null, "Download failed (" + (int)response.StatusCode + ").");
+
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using var output = File.Create(destination);
+ var buffer = new byte[81920];
+ long copied = 0;
+
+ while (true) {
+ var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ if (read == 0)
+ break;
+
+ copied += read;
+
+ if (copied > MaxBytes)
+ return Result.BadRequest(null, "The download is larger than 250 MB.");
+
+ await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+ }
+
+ return Result.Ok(destination);
+ }
+
+ public static async Task> DownloadTextAsync(HttpClient http, string url, CancellationToken cancellationToken) {
+ var path = Path.Combine(Path.GetTempPath(), "maksit-cloud-" + Guid.NewGuid().ToString("N"));
+ var downloaded = await DownloadAsync(http, url, path, cancellationToken).ConfigureAwait(false);
+
+ if (!downloaded.IsSuccess)
+ return downloaded;
+
+ try {
+ return Result.Ok(await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false));
+ }
+ finally {
+ File.Delete(path);
+ }
+ }
+
+ public static async Task HeadTokenAsync(HttpClient http, string url, CancellationToken cancellationToken) {
+ for (var hop = 0; hop < 5; hop++) {
+ using var request = new HttpRequestMessage(HttpMethod.Head, url);
+ using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var status = (int)response.StatusCode;
+
+ if (status is 301 or 302 or 303 or 307 or 308) {
+ var next = response.Headers.Location;
+
+ if (next is null)
+ return null;
+
+ if (!next.IsAbsoluteUri)
+ next = new Uri(new Uri(url), next);
+
+ if (next.Scheme != Uri.UriSchemeHttps)
+ return null;
+
+ url = next.AbsoluteUri;
+
+ continue;
+ }
+
+ if (response.Headers.ETag is { } tag && tag.Tag.Length > 0)
+ return tag.Tag;
+
+ if (response.Content.Headers.ContentLength is > 0)
+ return "len:" + response.Content.Headers.ContentLength.Value;
+
+ return null;
+ }
+
+ return null;
+ }
+
+ public static async Task> LatestGitHubTagAsync(
+ HttpClient http,
+ string owner,
+ string repo,
+ CancellationToken cancellationToken) {
+ var url = "https://github.com/" + owner + "/" + repo + "/releases/latest";
+ using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var status = (int)response.StatusCode;
+
+ if (status is not (301 or 302 or 303 or 307 or 308))
+ return Result.BadRequest(null, "Could not read the latest " + repo + " release.");
+
+ var next = response.Headers.Location;
+
+ if (next is null)
+ return Result.BadRequest(null, "Could not read the latest " + repo + " release.");
+
+ if (!next.IsAbsoluteUri)
+ next = new Uri(new Uri(url), next);
+
+ var parts = next.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var index = Array.IndexOf(parts, "tag");
+
+ if (index < 0 || index + 1 >= parts.Length)
+ return Result.BadRequest(null, "Could not read the latest " + repo + " release.");
+
+ return Result.Ok(Uri.UnescapeDataString(parts[index + 1]));
+ }
+
+ public static async Task HashFileAsync(string path, CancellationToken cancellationToken) {
+ using var sha = SHA256.Create();
+ await using var stream = File.OpenRead(path);
+ var hash = await sha.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
+
+ return Convert.ToHexString(hash);
+ }
+
+ public static string? HashFor(string checksumText, string assetName) {
+ string? only = null;
+ var hexLines = 0;
+
+ foreach (var raw in checksumText.Split('\n', '\r')) {
+ var line = raw.Trim();
+
+ if (line.Length == 0 || line.StartsWith('#'))
+ continue;
+
+ var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+
+ if (parts.Length == 1 && IsHex(parts[0])) {
+ hexLines++;
+ only = parts[0];
+
+ continue;
+ }
+
+ if (parts.Length < 2 || !IsHex(parts[0]))
+ continue;
+
+ var name = Path.GetFileName(parts[^1].TrimStart('*').Replace('\\', '/'));
+
+ if (string.Equals(name, assetName, StringComparison.OrdinalIgnoreCase))
+ return parts[0];
+ }
+
+ return hexLines == 1 ? only : null;
+ }
+
+ public static string? HashNear(string text, string fileName) {
+ var index = text.IndexOf(fileName, StringComparison.Ordinal);
+
+ if (index < 0)
+ return null;
+
+ var start = Math.Max(0, index - 800);
+ var length = Math.Min(2300, text.Length - start);
+ var match = Regex.Match(text.Substring(start, length), "[a-fA-F0-9]{64}");
+
+ return match.Success ? match.Value : null;
+ }
+
+ public static void ExtractNamed(string archivePath, string fileName, string destinationFile) {
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!);
+
+ if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) {
+ ExtractZipNamed(archivePath, fileName, destinationFile);
+
+ return;
+ }
+
+ ExtractTarNamed(archivePath, fileName, destinationFile);
+ }
+
+ public static void ExtractAll(string archivePath, string destinationDirectory) {
+ Directory.CreateDirectory(destinationDirectory);
+
+ if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) {
+ using var stream = File.OpenRead(archivePath);
+ using var zip = new ZipArchive(stream, ZipArchiveMode.Read);
+
+ foreach (var entry in zip.Entries) {
+ if (entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\'))
+ continue;
+
+ if (!SafeName(entry.FullName))
+ throw new InvalidDataException("The archive has an unsafe path.");
+
+ var target = Path.Combine(destinationDirectory, entry.FullName.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ entry.ExtractToFile(target, overwrite: true);
+ }
+
+ return;
+ }
+
+ using var file = File.OpenRead(archivePath);
+ using var gzip = new GZipStream(file, CompressionMode.Decompress);
+ using var tar = new TarReader(gzip);
+
+ while (tar.GetNextEntry() is { } entry) {
+ if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile))
+ continue;
+
+ if (!SafeName(entry.Name))
+ throw new InvalidDataException("The archive has an unsafe path.");
+
+ if (entry.DataStream is null)
+ continue;
+
+ var target = Path.Combine(destinationDirectory, entry.Name.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ using var output = File.Create(target);
+ entry.DataStream.CopyTo(output);
+ }
+ }
+
+ public static string? FindNamed(string root, string fileName) {
+ if (!Directory.Exists(root))
+ return null;
+
+ return Directory.EnumerateFiles(root, fileName, SearchOption.AllDirectories)
+ .OrderBy(path => path.Length)
+ .FirstOrDefault();
+ }
+
+ public static bool SignatureMatches(string dataPath, byte[] signature) {
+ using var keyStream = new MemoryStream(Encoding.ASCII.GetBytes(AwsCliPublicKey.Armored));
+ using var keyDecoded = PgpUtilities.GetDecoderStream(keyStream);
+ var rings = new PgpPublicKeyRingBundle(keyDecoded);
+ using var sigStream = new MemoryStream(signature);
+ using var sigDecoded = PgpUtilities.GetDecoderStream(sigStream);
+ var factory = new PgpObjectFactory(sigDecoded);
+ var list = SignatureList(factory.NextPgpObject());
+
+ if (list is null)
+ return false;
+
+ var sig = list[0];
+ var key = rings.GetPublicKey(sig.KeyId);
+
+ if (key is null)
+ return false;
+
+ sig.InitVerify(key);
+ var buffer = new byte[81920];
+ using var data = File.OpenRead(dataPath);
+ int read;
+
+ while ((read = data.Read(buffer, 0, buffer.Length)) > 0)
+ sig.Update(buffer, 0, read);
+
+ return sig.Verify();
+ }
+
+ public static string MasterKeyId() {
+ using var keyStream = new MemoryStream(Encoding.ASCII.GetBytes(AwsCliPublicKey.Armored));
+ using var keyDecoded = PgpUtilities.GetDecoderStream(keyStream);
+ var rings = new PgpPublicKeyRingBundle(keyDecoded);
+
+ foreach (PgpPublicKeyRing ring in rings.GetKeyRings()) {
+ foreach (PgpPublicKey key in ring.GetPublicKeys()) {
+ if (key.IsMasterKey)
+ return key.KeyId.ToString("X16");
+ }
+ }
+
+ return "";
+ }
+
+ private static PgpSignatureList? SignatureList(PgpObject? obj) {
+ if (obj is PgpSignatureList list)
+ return list;
+
+ if (obj is not PgpCompressedData compressed)
+ return null;
+
+ var inner = new PgpObjectFactory(compressed.GetDataStream());
+
+ return inner.NextPgpObject() as PgpSignatureList;
+ }
+
+ private static void ExtractZipNamed(string archivePath, string fileName, string destinationFile) {
+ using var stream = File.OpenRead(archivePath);
+ using var zip = new ZipArchive(stream, ZipArchiveMode.Read);
+ var entry = zip.Entries
+ .Where(item => SafeName(item.FullName))
+ .Where(item => string.Equals(Path.GetFileName(item.FullName.Replace('\\', '/')), fileName, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(item => item.FullName.Length)
+ .FirstOrDefault();
+
+ if (entry is null)
+ throw new InvalidDataException("The archive does not contain " + fileName + ".");
+
+ entry.ExtractToFile(destinationFile, overwrite: true);
+ }
+
+ private static void ExtractTarNamed(string archivePath, string fileName, string destinationFile) {
+ using var stream = File.OpenRead(archivePath);
+ using var gzip = new GZipStream(stream, CompressionMode.Decompress);
+ using var tar = new TarReader(gzip);
+
+ while (tar.GetNextEntry() is { } entry) {
+ if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile))
+ continue;
+
+ if (!SafeName(entry.Name))
+ throw new InvalidDataException("The archive has an unsafe path.");
+
+ if (entry.DataStream is null)
+ continue;
+
+ if (!string.Equals(Path.GetFileName(entry.Name.Replace('\\', '/')), fileName, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ using var output = File.Create(destinationFile);
+ entry.DataStream.CopyTo(output);
+
+ return;
+ }
+
+ throw new InvalidDataException("The archive does not contain " + fileName + ".");
+ }
+
+ private static async Task GetAsync(HttpClient http, string url, CancellationToken cancellationToken) {
+ for (var hop = 0; hop < 5; hop++) {
+ var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var status = (int)response.StatusCode;
+
+ if (status is not (301 or 302 or 303 or 307 or 308))
+ return response;
+
+ var next = response.Headers.Location;
+ response.Dispose();
+
+ if (next is null)
+ return new HttpResponseMessage(HttpStatusCode.BadGateway);
+
+ if (!next.IsAbsoluteUri)
+ next = new Uri(new Uri(url), next);
+
+ if (next.Scheme != Uri.UriSchemeHttps)
+ return new HttpResponseMessage(HttpStatusCode.BadGateway);
+
+ url = next.AbsoluteUri;
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.BadGateway);
+ }
+
+ internal static bool SafeName(string name) {
+ var normalized = name.Replace('\\', '/');
+
+ if (normalized.StartsWith('/') || normalized.Contains("..", StringComparison.Ordinal))
+ return false;
+
+ return true;
+ }
+
+ private static bool IsHex(string value) =>
+ value.Length == 64 && value.All(Uri.IsHexDigit);
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliSandbox.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliSandbox.cs
new file mode 100644
index 0000000..fdaf685
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliSandbox.cs
@@ -0,0 +1,153 @@
+using System.Runtime.InteropServices;
+
+
+
+namespace MaksIT.ClusterConsole.Client.KubeConfig;
+
+///
+/// Provider CLIs downloaded into the app data folder. The directory is prepended
+/// to this process PATH so a Flatpak sandbox can start them.
+///
+public static class CloudCliSandbox {
+ public static string DefaultRoot =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "MaksIT",
+ "Cluster Console",
+ "cloud-cli");
+
+ public static string BinDirectory(string? root = null) =>
+ Path.Combine(root ?? DefaultRoot, "bin");
+
+ public static IReadOnlyList CommandsFor(string? presetId) =>
+ presetId switch {
+ "google" => ["gcloud", "gke-gcloud-auth-plugin"],
+ "azure" => ["az", "kubelogin"],
+ "amazon" => ["aws"],
+ "oracle" => ["oci"],
+ "digitalocean" => ["doctl"],
+ "ibm" => ["ibmcloud"],
+ _ => []
+ };
+
+ public static IReadOnlyList PackagesFor(IEnumerable missingCommands) {
+ var packages = new List();
+
+ foreach (var command in missingCommands) {
+ var package = command switch {
+ "gcloud" or "gke-gcloud-auth-plugin" => "gcloud",
+ "az" => "azure",
+ "oci" => "oci",
+ "kubelogin" => "kubelogin",
+ "doctl" => "doctl",
+ "aws" => "aws",
+ "ibmcloud" => "ibm",
+ _ => ""
+ };
+
+ if (package.Length > 0 && !packages.Contains(package))
+ packages.Add(package);
+ }
+
+ return packages;
+ }
+
+ public static IReadOnlyList Missing(string? presetId) {
+ ApplyPath();
+
+ return MissingFrom(presetId, Environment.GetEnvironmentVariable("PATH"));
+ }
+
+ public static IReadOnlyList MissingFrom(string? presetId, string? path) =>
+ CommandsFor(presetId).Where(command => !CommandExists(path, command)).ToList();
+
+ public static void ApplyPath(string? root = null) {
+ var bin = BinDirectory(root);
+
+ if (!Directory.Exists(bin))
+ return;
+
+ var next = Prepend(Environment.GetEnvironmentVariable("PATH"), [bin]);
+ Environment.SetEnvironmentVariable("PATH", next);
+ }
+
+ public static string Prepend(string? current, IEnumerable directories) {
+ var parts = (current ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList();
+ var missing = directories.Where(dir => !parts.Any(part => PathEquals(part, dir))).ToList();
+
+ if (missing.Count == 0)
+ return string.Join(Path.PathSeparator, parts);
+
+ return string.Join(Path.PathSeparator, missing.Concat(parts));
+ }
+
+ public static string? ReadVersion(string root, string packageId) {
+ var path = Path.Combine(root, packageId, "version");
+
+ if (!File.Exists(path))
+ return null;
+
+ var text = File.ReadAllText(path).Trim();
+
+ return text.Length == 0 ? null : text;
+ }
+
+ public static void WriteVersion(string root, string packageId, string version) {
+ var dir = Path.Combine(root, packageId);
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "version"), version);
+ }
+
+ internal static string? LinuxToken() =>
+ RuntimeInformation.OSArchitecture switch {
+ Architecture.X64 => "amd64",
+ Architecture.Arm64 => "arm64",
+ _ => null
+ };
+
+ internal static void MarkExecutable(string path) {
+ if (OperatingSystem.IsWindows())
+ return;
+
+ File.SetUnixFileMode(
+ path,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
+ | UnixFileMode.GroupRead | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
+ }
+
+ internal static void LinkCommand(string binDirectory, string name, string target) {
+ Directory.CreateDirectory(binDirectory);
+ var link = Path.Combine(binDirectory, name);
+
+ if (File.Exists(link))
+ File.Delete(link);
+
+ try {
+ File.CreateSymbolicLink(link, target);
+ }
+ catch (Exception) {
+ var script = "#!/bin/sh\nexec '" + target.Replace("'", "'\\''") + "' \"$@\"\n";
+ File.WriteAllText(link, script);
+ MarkExecutable(link);
+ }
+ }
+
+ private static bool CommandExists(string? path, string name) {
+ if (string.IsNullOrWhiteSpace(path))
+ return false;
+
+ foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) {
+ if (File.Exists(Path.Combine(dir.Trim(), name)))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool PathEquals(string left, string right) =>
+ string.Equals(
+ left.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
+ right.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
+ OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliSetup.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliSetup.cs
new file mode 100644
index 0000000..214fa3d
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudCliSetup.cs
@@ -0,0 +1,661 @@
+using System.Text;
+using System.Text.Json;
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using MaksIT.Results;
+
+
+
+namespace MaksIT.ClusterConsole.Client.KubeConfig;
+
+///
+/// Downloads provider CLIs into the app data folder after install.
+/// Archives are checked with the publisher checksum. The AWS CLI zip is checked
+/// with the AWS CLI public key. az and oci are installed with pip into a private
+/// Python so the Flatpak runtime does not need a system Python.
+///
+public static class CloudCliSetup {
+ private static readonly HttpClient Http = CreateHttp();
+ private static readonly SemaphoreSlim Gate = new(1, 1);
+
+ public static async Task> InstallMissingAsync(
+ string? presetId,
+ Action? report = null,
+ CancellationToken cancellationToken = default) {
+ if (!OperatingSystem.IsLinux())
+ return Result.BadRequest(null, "Provider CLIs are downloaded on Linux, including Flatpak.");
+
+ var packages = CloudCliSandbox.PackagesFor(CloudCliSandbox.Missing(presetId));
+
+ if (packages.Count == 0)
+ return Result.Ok("Already installed.");
+
+ foreach (var package in packages) {
+ var installed = await InstallAsync(package, report, cancellationToken).ConfigureAwait(false);
+
+ if (!installed.IsSuccess)
+ return installed;
+ }
+
+ CloudCliSandbox.ApplyPath();
+
+ return Result.Ok("Installed.");
+ }
+
+ public static async Task UpdateInstalledAsync(CancellationToken cancellationToken = default) {
+ try {
+ if (!OperatingSystem.IsLinux())
+ return;
+
+ foreach (var package in new[] { "python", "gcloud", "azure", "oci", "aws", "ibm", "kubelogin", "doctl" }) {
+ if (CloudCliSandbox.ReadVersion(CloudCliSandbox.DefaultRoot, package) is null)
+ continue;
+
+ await InstallAsync(package, null, cancellationToken).ConfigureAwait(false);
+ }
+
+ CloudCliSandbox.ApplyPath();
+ }
+ catch (Exception) {
+ // A failed update leaves the installed CLI in place.
+ }
+ }
+
+ public static async Task> InstallAsync(
+ string package,
+ Action? report = null,
+ CancellationToken cancellationToken = default) {
+ await Gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try {
+ return await InstallCoreAsync(package, CloudCliSandbox.DefaultRoot, report, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) {
+ throw;
+ }
+ catch (Exception ex) {
+ return Result.ServiceUnavailable(null, package + " did not install. " + ex.Message);
+ }
+ finally {
+ Gate.Release();
+ }
+ }
+
+ private static async Task> InstallCoreAsync(
+ string package,
+ string root,
+ Action? report,
+ CancellationToken cancellationToken) {
+ if (!OperatingSystem.IsLinux())
+ return Result.BadRequest(null, "Provider CLIs are downloaded on Linux, including Flatpak.");
+
+ if (CloudCliSandbox.LinuxToken() is null)
+ return Result.BadRequest(null, "This CPU has no portable build.");
+
+ return package switch {
+ "python" => await EnsurePythonAsync(root, report, cancellationToken).ConfigureAwait(false),
+ "azure" => await InstallPipAsync(root, "azure", "azure-cli", "az", report, cancellationToken).ConfigureAwait(false),
+ "oci" => await InstallPipAsync(root, "oci", "oci-cli", "oci", report, cancellationToken).ConfigureAwait(false),
+ "gcloud" => await InstallGcloudAsync(root, report, cancellationToken).ConfigureAwait(false),
+ "aws" => await InstallAwsAsync(root, report, cancellationToken).ConfigureAwait(false),
+ "ibm" => await InstallIbmAsync(root, report, cancellationToken).ConfigureAwait(false),
+ "kubelogin" => await InstallKubeloginAsync(root, report, cancellationToken).ConfigureAwait(false),
+ "doctl" => await InstallDoctlAsync(root, report, cancellationToken).ConfigureAwait(false),
+ _ => Result.BadRequest(null, "Unknown CLI " + package + ".")
+ };
+ }
+
+ private static async Task> EnsurePythonAsync(string root, Action? report, CancellationToken cancellationToken) {
+ var triple = CloudCliSandbox.LinuxToken() == "arm64"
+ ? "aarch64-unknown-linux-gnu"
+ : "x86_64-unknown-linux-gnu";
+ report?.Invoke("Checking Python…");
+ var tag = await CloudCliFiles.LatestGitHubTagAsync(Http, "astral-sh", "python-build-standalone", cancellationToken).ConfigureAwait(false);
+
+ if (!tag.IsSuccess || tag.Value is null)
+ return Result.BadRequest(null, string.Join("; ", tag.Messages));
+
+ var current = PythonPath(root);
+
+ if (CloudCliSandbox.ReadVersion(root, "python") == tag.Value && current is not null)
+ return Result.Ok(current);
+
+ report?.Invoke("Downloading Python…");
+ var release = "https://github.com/astral-sh/python-build-standalone/releases/download/" + Uri.EscapeDataString(tag.Value) + "/";
+ var sums = await CloudCliFiles.DownloadTextAsync(Http, release + "SHA256SUMS", cancellationToken).ConfigureAwait(false);
+
+ if (!sums.IsSuccess || sums.Value is null)
+ return sums;
+
+ var name = Regex.Match(
+ sums.Value,
+ "cpython-3\\.12\\.[0-9.]+\\+" + Regex.Escape(tag.Value) + "-" + triple + "-install_only\\.tar\\.gz").Value;
+
+ if (name.Length == 0)
+ return Result.BadRequest(null, "Python 3.12 was not in the release.");
+
+ var expected = CloudCliFiles.HashFor(sums.Value, name);
+
+ if (expected is null)
+ return Result.BadRequest(null, "Python checksum is missing.");
+
+ var archive = Path.Combine(root, "python.download.tar.gz");
+ var downloaded = await CloudCliFiles.DownloadAsync(Http, release + Uri.EscapeDataString(name), archive, cancellationToken).ConfigureAwait(false);
+
+ if (!downloaded.IsSuccess)
+ return downloaded;
+
+ if (!await HashMatchesAsync(archive, expected, cancellationToken).ConfigureAwait(false))
+ return Result.BadRequest(null, "Python checksum did not match.");
+
+ var home = Path.Combine(root, "python-home");
+ ReplaceTree(archive, home);
+ File.Delete(archive);
+ var python = CloudCliFiles.FindNamed(home, "python3");
+
+ if (python is null)
+ return Result.BadRequest(null, "The Python archive has no python3.");
+
+ CloudCliSandbox.MarkExecutable(python);
+ CloudCliSandbox.WriteVersion(root, "python", tag.Value);
+
+ return Result.Ok(python);
+ }
+
+ private static async Task> InstallPipAsync(
+ string root,
+ string packageId,
+ string pipName,
+ string command,
+ Action? report,
+ CancellationToken cancellationToken) {
+ var python = await EnsurePythonAsync(root, report, cancellationToken).ConfigureAwait(false);
+
+ if (!python.IsSuccess || python.Value is null)
+ return python;
+
+ report?.Invoke("Checking " + command + "…");
+ var meta = await CloudCliFiles.DownloadTextAsync(Http, "https://pypi.org/pypi/" + pipName + "/json", cancellationToken).ConfigureAwait(false);
+
+ if (!meta.IsSuccess || meta.Value is null)
+ return meta;
+
+ using var json = JsonDocument.Parse(meta.Value);
+ var version = json.RootElement.GetProperty("info").GetProperty("version").GetString();
+
+ if (string.IsNullOrWhiteSpace(version))
+ return Result.BadRequest(null, "Could not read the " + pipName + " version.");
+
+ var venv = Path.Combine(root, packageId, "venv");
+ var tool = Path.Combine(venv, "bin", command);
+
+ if (CloudCliSandbox.ReadVersion(root, packageId) == version && File.Exists(tool)) {
+ CloudCliSandbox.LinkCommand(CloudCliSandbox.BinDirectory(root), command, tool);
+
+ return Result.Ok(version);
+ }
+
+ report?.Invoke("Downloading " + command + "…");
+
+ if (!File.Exists(Path.Combine(venv, "bin", "python"))) {
+ var created = await RunAsync(python.Value, ["-m", "venv", venv], null, cancellationToken).ConfigureAwait(false);
+
+ if (!created.IsSuccess)
+ return created;
+ }
+
+ var venvPython = Path.Combine(venv, "bin", "python");
+ Dictionary env = new() { ["PYTHONNOUSERSITE"] = "1" };
+ var upgraded = await RunAsync(venvPython, ["-m", "pip", "install", "--disable-pip-version-check", "--upgrade", "pip"], env, cancellationToken).ConfigureAwait(false);
+
+ if (!upgraded.IsSuccess)
+ return upgraded;
+
+ var installed = await RunAsync(
+ venvPython,
+ ["-m", "pip", "install", "--disable-pip-version-check", pipName + "==" + version],
+ env,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!installed.IsSuccess)
+ return installed;
+
+ if (!File.Exists(tool))
+ return Result.BadRequest(null, command + " was not installed.");
+
+ CloudCliSandbox.MarkExecutable(tool);
+ CloudCliSandbox.LinkCommand(CloudCliSandbox.BinDirectory(root), command, tool);
+ CloudCliSandbox.WriteVersion(root, packageId, version);
+
+ return Result.Ok(version);
+ }
+
+ private static async Task> InstallGcloudAsync(string root, Action? report, CancellationToken cancellationToken) {
+ var arch = CloudCliSandbox.LinuxToken() == "arm64" ? "linux-arm" : "linux-x86_64";
+ var fileName = "google-cloud-cli-" + arch + ".tar.gz";
+ report?.Invoke("Checking gcloud…");
+ string? expected = null;
+
+ foreach (var pageUrl in new[] {
+ "https://docs.cloud.google.com/sdk/docs/downloads-versioned-archives",
+ "https://cloud.google.com/sdk/docs/downloads-versioned-archives"
+ }) {
+ var page = await CloudCliFiles.DownloadTextAsync(Http, pageUrl, cancellationToken).ConfigureAwait(false);
+
+ if (!page.IsSuccess || page.Value is null)
+ continue;
+
+ expected = CloudCliFiles.HashNear(page.Value, fileName);
+
+ if (expected is not null)
+ break;
+ }
+
+ if (expected is null)
+ return Result.BadRequest(null, "Could not read the gcloud checksum.");
+
+ var sdk = FindInBin(Path.Combine(root, "gcloud"), "gcloud");
+ var plugin = FindInBin(Path.Combine(root, "gcloud"), "gke-gcloud-auth-plugin");
+
+ if (string.Equals(CloudCliSandbox.ReadVersion(root, "gcloud"), expected, StringComparison.OrdinalIgnoreCase)
+ && sdk is not null
+ && plugin is not null) {
+ LinkGcloud(root, sdk, plugin);
+
+ return Result.Ok(expected);
+ }
+
+ var python = await EnsurePythonAsync(root, report, cancellationToken).ConfigureAwait(false);
+
+ if (!python.IsSuccess || python.Value is null)
+ return python;
+
+ report?.Invoke("Downloading gcloud…");
+ var archive = Path.Combine(root, fileName);
+ var downloaded = await CloudCliFiles.DownloadAsync(
+ Http,
+ "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/" + fileName,
+ archive,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!downloaded.IsSuccess)
+ return downloaded;
+
+ if (!await HashMatchesAsync(archive, expected, cancellationToken).ConfigureAwait(false))
+ return Result.BadRequest(null, "gcloud checksum did not match.");
+
+ var home = Path.Combine(root, "gcloud");
+ ReplaceTree(archive, home);
+ File.Delete(archive);
+ sdk = FindInBin(home, "gcloud");
+
+ if (sdk is null)
+ return Result.BadRequest(null, "The gcloud archive has no bin/gcloud.");
+
+ CloudCliSandbox.MarkExecutable(sdk);
+ LinkGcloud(root, sdk, null);
+ report?.Invoke("Downloading gke-gcloud-auth-plugin…");
+ var component = await RunAsync(
+ sdk,
+ ["--quiet", "components", "install", "gke-gcloud-auth-plugin"],
+ GcloudEnvironment(root, python.Value),
+ cancellationToken).ConfigureAwait(false);
+
+ if (!component.IsSuccess)
+ return component;
+
+ plugin = FindInBin(home, "gke-gcloud-auth-plugin");
+
+ if (plugin is null)
+ return Result.BadRequest(null, "gke-gcloud-auth-plugin was not installed.");
+
+ CloudCliSandbox.MarkExecutable(plugin);
+ LinkGcloud(root, sdk, plugin);
+ CloudCliSandbox.WriteVersion(root, "gcloud", expected);
+
+ return Result.Ok(expected);
+ }
+
+ private static async Task> InstallAwsAsync(string root, Action? report, CancellationToken cancellationToken) {
+ var fileName = CloudCliSandbox.LinuxToken() == "arm64"
+ ? "awscli-exe-linux-aarch64.zip"
+ : "awscli-exe-linux-x86_64.zip";
+ var url = "https://awscli.amazonaws.com/" + fileName;
+ report?.Invoke("Checking aws…");
+ var token = await CloudCliFiles.HeadTokenAsync(Http, url, cancellationToken).ConfigureAwait(false);
+
+ if (token is not null
+ && CloudCliSandbox.ReadVersion(root, "aws") == token
+ && File.Exists(Path.Combine(CloudCliSandbox.BinDirectory(root), "aws")))
+ return Result.Ok(token);
+
+ report?.Invoke("Downloading aws…");
+ var archive = Path.Combine(root, fileName);
+ var signaturePath = archive + ".sig";
+ var zip = await CloudCliFiles.DownloadAsync(Http, url, archive, cancellationToken).ConfigureAwait(false);
+
+ if (!zip.IsSuccess)
+ return zip;
+
+ var sig = await CloudCliFiles.DownloadAsync(Http, url + ".sig", signaturePath, cancellationToken).ConfigureAwait(false);
+
+ if (!sig.IsSuccess)
+ return sig;
+
+ var signature = await File.ReadAllBytesAsync(signaturePath, cancellationToken).ConfigureAwait(false);
+
+ if (!CloudCliFiles.SignatureMatches(archive, signature)) {
+ File.Delete(archive);
+ File.Delete(signaturePath);
+
+ return Result.BadRequest(null, "The AWS CLI signature did not match.");
+ }
+
+ var extract = Path.Combine(root, "aws-src");
+
+ if (Directory.Exists(extract))
+ Directory.Delete(extract, recursive: true);
+
+ CloudCliFiles.ExtractAll(archive, extract);
+ File.Delete(archive);
+ File.Delete(signaturePath);
+ var script = CloudCliFiles.FindNamed(extract, "install");
+
+ if (script is null)
+ return Result.BadRequest(null, "The AWS CLI archive has no install script.");
+
+ CloudCliSandbox.MarkExecutable(script);
+ var bin = CloudCliSandbox.BinDirectory(root);
+ Directory.CreateDirectory(bin);
+ var shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh";
+ var installDir = Path.Combine(root, "aws", "install");
+ var installArgs = new List { script, "--install-dir", installDir, "--bin-dir", bin };
+
+ if (Directory.Exists(installDir))
+ installArgs.Add("--update");
+
+ var ran = await RunAsync(shell, installArgs, null, cancellationToken).ConfigureAwait(false);
+
+ if (!ran.IsSuccess)
+ return ran;
+
+ if (token is null)
+ token = await CloudCliFiles.HashFileAsync(Path.Combine(bin, "aws"), cancellationToken).ConfigureAwait(false);
+
+ CloudCliSandbox.WriteVersion(root, "aws", token);
+
+ return Result.Ok(token);
+ }
+
+ private static async Task> InstallIbmAsync(string root, Action? report, CancellationToken cancellationToken) {
+ report?.Invoke("Checking ibmcloud…");
+ var tag = await CloudCliFiles.LatestGitHubTagAsync(Http, "IBM-Cloud", "ibm-cloud-cli-release", cancellationToken).ConfigureAwait(false);
+
+ if (!tag.IsSuccess || tag.Value is null)
+ return Result.BadRequest(null, string.Join("; ", tag.Messages));
+
+ var version = tag.Value.StartsWith('v') ? tag.Value[1..] : tag.Value;
+ var arch = CloudCliSandbox.LinuxToken() == "arm64" ? "linux_arm64" : "linux_amd64";
+ var dest = Path.Combine(CloudCliSandbox.BinDirectory(root), "ibmcloud");
+
+ if (CloudCliSandbox.ReadVersion(root, "ibm") == tag.Value && File.Exists(dest))
+ return Result.Ok(tag.Value);
+
+ report?.Invoke("Downloading ibmcloud…");
+ // IBM publishes no checksum. The file is accepted only from download.clis.cloud.ibm.com.
+ var url = "https://download.clis.cloud.ibm.com/ibm-cloud-cli/" + version + "/binaries/IBM_Cloud_CLI_" + version + "_" + arch + ".tgz";
+ var archive = Path.Combine(root, "ibmcloud.tgz");
+ var downloaded = await CloudCliFiles.DownloadAsync(Http, url, archive, cancellationToken).ConfigureAwait(false);
+
+ if (!downloaded.IsSuccess)
+ return downloaded;
+
+ Directory.CreateDirectory(CloudCliSandbox.BinDirectory(root));
+ CloudCliFiles.ExtractNamed(archive, "ibmcloud", dest);
+ File.Delete(archive);
+ CloudCliSandbox.MarkExecutable(dest);
+ CloudCliSandbox.ApplyPath(root);
+ report?.Invoke("Downloading the IBM Kubernetes plugin…");
+ var plugin = await RunAsync(
+ dest,
+ ["plugin", "install", "container-service"],
+ IbmEnvironment(root),
+ cancellationToken,
+ confirm: true).ConfigureAwait(false);
+
+ if (!plugin.IsSuccess
+ && !string.Join("; ", plugin.Messages).Contains("already installed", StringComparison.OrdinalIgnoreCase))
+ return plugin;
+
+ CloudCliSandbox.WriteVersion(root, "ibm", tag.Value);
+
+ return Result.Ok(tag.Value);
+ }
+
+ private static Task> InstallKubeloginAsync(string root, Action? report, CancellationToken cancellationToken) =>
+ InstallGitHubBinaryAsync(
+ root,
+ "kubelogin",
+ "Azure",
+ "kubelogin",
+ tag => "kubelogin-linux-" + CloudCliSandbox.LinuxToken() + ".zip",
+ (tag, asset) => asset + ".sha256",
+ report,
+ cancellationToken);
+
+ private static Task> InstallDoctlAsync(string root, Action? report, CancellationToken cancellationToken) =>
+ InstallGitHubBinaryAsync(
+ root,
+ "doctl",
+ "digitalocean",
+ "doctl",
+ tag => "doctl-" + (tag.StartsWith('v') ? tag[1..] : tag) + "-linux-" + CloudCliSandbox.LinuxToken() + ".tar.gz",
+ (tag, _) => "doctl-" + (tag.StartsWith('v') ? tag[1..] : tag) + "-checksums.sha256",
+ report,
+ cancellationToken);
+
+ private static async Task> InstallGitHubBinaryAsync(
+ string root,
+ string command,
+ string owner,
+ string repo,
+ Func assetName,
+ Func checksumName,
+ Action? report,
+ CancellationToken cancellationToken) {
+ report?.Invoke("Checking " + command + "…");
+ var tag = await CloudCliFiles.LatestGitHubTagAsync(Http, owner, repo, cancellationToken).ConfigureAwait(false);
+
+ if (!tag.IsSuccess || tag.Value is null)
+ return Result.BadRequest(null, string.Join("; ", tag.Messages));
+
+ var dest = Path.Combine(CloudCliSandbox.BinDirectory(root), command);
+
+ if (CloudCliSandbox.ReadVersion(root, command) == tag.Value && File.Exists(dest))
+ return Result.Ok(tag.Value);
+
+ var asset = assetName(tag.Value);
+ var checksumAsset = checksumName(tag.Value, asset);
+ var release = "https://github.com/" + owner + "/" + repo + "/releases/download/" + Uri.EscapeDataString(tag.Value) + "/";
+ report?.Invoke("Downloading " + command + "…");
+ var sums = await CloudCliFiles.DownloadTextAsync(Http, release + Uri.EscapeDataString(checksumAsset), cancellationToken).ConfigureAwait(false);
+
+ if (!sums.IsSuccess || sums.Value is null)
+ return sums;
+
+ var expected = CloudCliFiles.HashFor(sums.Value, asset);
+
+ if (expected is null)
+ return Result.BadRequest(null, "Checksum file has no entry for " + asset + ".");
+
+ var archive = Path.Combine(root, asset);
+ var downloaded = await CloudCliFiles.DownloadAsync(Http, release + Uri.EscapeDataString(asset), archive, cancellationToken).ConfigureAwait(false);
+
+ if (!downloaded.IsSuccess)
+ return downloaded;
+
+ if (!await HashMatchesAsync(archive, expected, cancellationToken).ConfigureAwait(false))
+ return Result.BadRequest(null, command + " checksum did not match.");
+
+ Directory.CreateDirectory(CloudCliSandbox.BinDirectory(root));
+ CloudCliFiles.ExtractNamed(archive, command, dest);
+ File.Delete(archive);
+ CloudCliSandbox.MarkExecutable(dest);
+ CloudCliSandbox.WriteVersion(root, command, tag.Value);
+
+ return Result.Ok(tag.Value);
+ }
+
+ private static void LinkGcloud(string root, string gcloud, string? plugin) {
+ var bin = CloudCliSandbox.BinDirectory(root);
+ var python = PythonPath(root) ?? "";
+ WriteEnvCommand(bin, "gcloud", gcloud, python);
+ var gsutil = Path.Combine(Path.GetDirectoryName(gcloud)!, "gsutil");
+
+ if (File.Exists(gsutil))
+ WriteEnvCommand(bin, "gsutil", gsutil, python);
+
+ if (plugin is not null)
+ CloudCliSandbox.LinkCommand(bin, "gke-gcloud-auth-plugin", plugin);
+ }
+
+ private static Dictionary GcloudEnvironment(string root, string python) =>
+ new() {
+ ["CLOUDSDK_PYTHON"] = python,
+ ["CLOUDSDK_CORE_DISABLE_PROMPTS"] = "1",
+ ["PATH"] = CloudCliSandbox.BinDirectory(root) + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
+ };
+
+ private static Dictionary IbmEnvironment(string root) =>
+ new() {
+ ["IBMCLOUD_VERSION_CHECK"] = "false",
+ ["PATH"] = CloudCliSandbox.BinDirectory(root) + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
+ };
+
+ private static void WriteEnvCommand(string bin, string name, string target, string python) {
+ Directory.CreateDirectory(bin);
+ var body = new StringBuilder();
+ body.Append("#!/bin/sh\n");
+
+ if (python.Length > 0)
+ body.Append("export CLOUDSDK_PYTHON='").Append(python.Replace("'", "'\\''")).Append("'\n");
+
+ body.Append("export CLOUDSDK_CORE_DISABLE_PROMPTS=1\n");
+ body.Append("exec '").Append(target.Replace("'", "'\\''")).Append("' \"$@\"\n");
+ var path = Path.Combine(bin, name);
+ File.WriteAllText(path, body.ToString());
+ CloudCliSandbox.MarkExecutable(path);
+ }
+
+ private static void ReplaceTree(string archive, string home) {
+ var staging = home + ".new";
+
+ if (Directory.Exists(staging))
+ Directory.Delete(staging, recursive: true);
+
+ CloudCliFiles.ExtractAll(archive, staging);
+
+ if (Directory.Exists(home))
+ Directory.Delete(home, recursive: true);
+
+ Directory.Move(staging, home);
+ }
+
+ private static string? PythonPath(string root) =>
+ CloudCliFiles.FindNamed(Path.Combine(root, "python-home"), "python3");
+
+ private static string? FindInBin(string root, string name) {
+ if (!Directory.Exists(root))
+ return null;
+
+ return Directory.EnumerateFiles(root, name, SearchOption.AllDirectories)
+ .Where(path => string.Equals(Path.GetFileName(Path.GetDirectoryName(path)), "bin", StringComparison.Ordinal))
+ .OrderBy(path => path.Length)
+ .FirstOrDefault();
+ }
+
+ private static async Task HashMatchesAsync(string path, string expected, CancellationToken cancellationToken) {
+ var actual = await CloudCliFiles.HashFileAsync(path, cancellationToken).ConfigureAwait(false);
+ var matches = string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
+
+ if (!matches)
+ File.Delete(path);
+
+ return matches;
+ }
+
+ private static async Task> RunAsync(
+ string file,
+ IReadOnlyList args,
+ IReadOnlyDictionary? env,
+ CancellationToken cancellationToken,
+ bool confirm = false) {
+ var start = new ProcessStartInfo {
+ FileName = file,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = true
+ };
+
+ foreach (var arg in args)
+ start.ArgumentList.Add(arg);
+
+ if (env is not null) {
+ foreach (var pair in env)
+ start.Environment[pair.Key] = pair.Value;
+ }
+
+ using var process = new Process { StartInfo = start };
+
+ try {
+ if (!process.Start())
+ return Result.ServiceUnavailable(null, "Could not start " + Path.GetFileName(file) + ".");
+ }
+ catch (Exception ex) {
+ return Result.ServiceUnavailable(null, Path.GetFileName(file) + " did not start. " + ex.Message);
+ }
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
+
+ if (confirm)
+ await process.StandardInput.WriteLineAsync("y").ConfigureAwait(false);
+
+ process.StandardInput.Close();
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromMinutes(20));
+
+ try {
+ await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) {
+ try {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (Exception) {
+ // The process may already have exited.
+ }
+
+ throw;
+ }
+
+ var stdout = (await stdoutTask.ConfigureAwait(false)).Trim();
+ var stderr = (await stderrTask.ConfigureAwait(false)).Trim();
+
+ if (process.ExitCode != 0)
+ return Result.BadRequest(null, Path.GetFileName(file) + " failed. " + (stderr.Length > 0 ? stderr : stdout));
+
+ return Result.Ok(stdout);
+ }
+
+ private static HttpClient CreateHttp() {
+ var http = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = false }) {
+ Timeout = TimeSpan.FromMinutes(20)
+ };
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("MaksIT.ClusterConsole");
+
+ return http;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudExecPreset.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudExecPreset.cs
new file mode 100644
index 0000000..e6c8d6d
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudExecPreset.cs
@@ -0,0 +1,177 @@
+using System.Text;
+
+
+namespace MaksIT.ClusterConsole.Client.KubeConfig;
+
+///
+/// Exec credential plugins kubectl writes for the major managed Kubernetes services.
+/// The CLI named by must be on PATH. Flatpak starts a copy downloaded
+/// into the app. It cannot start a host install.
+///
+public sealed record CloudExecPreset(
+ string Id,
+ string Title,
+ string Command,
+ IReadOnlyList Args,
+ string ArgumentLabel,
+ bool ArgumentRequired,
+ bool ProvideClusterInfo,
+ string InstallHint,
+ bool CanFetch = false,
+ string ClusterLabel = "Cluster",
+ string LocationLabel = "",
+ string ProjectLabel = "",
+ bool FetchOnly = false) {
+ public const string CustomId = "custom";
+
+ public static readonly CloudExecPreset Google = new(
+ "google",
+ "Google Kubernetes Engine",
+ "gke-gcloud-auth-plugin",
+ [],
+ "",
+ false,
+ true,
+ "Install with: gcloud components install gke-gcloud-auth-plugin. The plugin must be on PATH.",
+ CanFetch: true,
+ ClusterLabel: "Cluster name",
+ LocationLabel: "Location (region or zone)",
+ ProjectLabel: "Project");
+
+ public static readonly CloudExecPreset Azure = new(
+ "azure",
+ "Azure Kubernetes Service",
+ "kubelogin",
+ ["get-token", "--login", "azurecli", "--server-id", "6dae42f8-4368-4678-94ff-3960e28e3630"],
+ "",
+ false,
+ false,
+ "Install kubelogin and sign in with az login. This server id is public Azure. Use Custom for Azure Government or China.",
+ CanFetch: true,
+ ClusterLabel: "Cluster name",
+ ProjectLabel: "Resource group");
+
+ public static readonly CloudExecPreset Amazon = new(
+ "amazon",
+ "Amazon Elastic Kubernetes Service",
+ "aws",
+ ["eks", "get-token", "--cluster-name"],
+ "Cluster name",
+ true,
+ false,
+ "Install the AWS CLI and run aws configure. The default region is used unless the CLI config sets one.",
+ CanFetch: true,
+ ClusterLabel: "Cluster name",
+ LocationLabel: "Region");
+
+ public static readonly CloudExecPreset Oracle = new(
+ "oracle",
+ "Oracle Container Engine for Kubernetes",
+ "oci",
+ ["ce", "cluster", "generate-token", "--cluster-id"],
+ "Cluster OCID",
+ true,
+ false,
+ "Install the OCI CLI and configure a region. The cluster OCID is the extra argument.",
+ CanFetch: true,
+ ClusterLabel: "Cluster OCID",
+ LocationLabel: "Region");
+
+ public static readonly CloudExecPreset DigitalOcean = new(
+ "digitalocean",
+ "DigitalOcean Kubernetes",
+ "doctl",
+ ["kubernetes", "cluster", "kubeconfig", "exec", "--version", "v1beta1"],
+ "Cluster ID",
+ true,
+ false,
+ "Install doctl and run doctl auth init.",
+ CanFetch: true,
+ ClusterLabel: "Cluster ID or name");
+
+ public static readonly CloudExecPreset Ibm = new(
+ "ibm",
+ "IBM Cloud Kubernetes Service",
+ "ibmcloud",
+ [],
+ "",
+ false,
+ false,
+ "Install the IBM Cloud CLI and the container service plugin, then ibmcloud login. The CLI writes the kubeconfig.",
+ CanFetch: true,
+ ClusterLabel: "Cluster name or ID",
+ FetchOnly: true);
+
+ public static readonly CloudExecPreset Custom = new(
+ CustomId,
+ "Custom exec plugin",
+ "",
+ [],
+ "Arguments",
+ false,
+ false,
+ "Any other provider whose kubeconfig uses user.exec, such as Linode, Alibaba, Civo, or Scaleway. Command is the executable. Arguments are split on spaces. If that CLI writes a kubeconfig, open the context it created.");
+
+ public static IReadOnlyList All { get; } = [
+ Google,
+ Azure,
+ Amazon,
+ Oracle,
+ DigitalOcean,
+ Ibm,
+ Custom
+ ];
+
+ public static CloudExecPreset? Find(string? id) =>
+ All.FirstOrDefault(item => string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase));
+
+ public IList Arguments(string? extra) {
+ if (Id == CustomId)
+ return SplitArgs(extra);
+
+ if (string.IsNullOrWhiteSpace(extra))
+ return Args.ToList();
+
+ return [..Args, extra.Trim()];
+ }
+
+ public static List SplitArgs(string? text) {
+ var args = new List();
+
+ if (string.IsNullOrWhiteSpace(text))
+ return args;
+
+ var current = new StringBuilder();
+ var quote = '\0';
+
+ foreach (var ch in text.Trim()) {
+ if (quote == '\0' && ch is '"' or '\'') {
+ quote = ch;
+
+ continue;
+ }
+
+ if (quote != '\0' && ch == quote) {
+ quote = '\0';
+
+ continue;
+ }
+
+ if (quote == '\0' && char.IsWhiteSpace(ch)) {
+ if (current.Length > 0) {
+ args.Add(current.ToString());
+ current.Clear();
+ }
+
+ continue;
+ }
+
+ current.Append(ch);
+ }
+
+ if (current.Length > 0)
+ args.Add(current.ToString());
+
+ return args;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudKubeconfig.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudKubeconfig.cs
new file mode 100644
index 0000000..9eca1a6
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/CloudKubeconfig.cs
@@ -0,0 +1,186 @@
+using System.Diagnostics;
+using MaksIT.Results;
+
+
+namespace MaksIT.ClusterConsole.Client.KubeConfig;
+
+public sealed record CloudFetchRequest(
+ string PresetId,
+ string Cluster,
+ string? Location,
+ string? Project,
+ string KubeconfigPath);
+
+public sealed record CloudCliCommand(string FileName, IReadOnlyList Arguments);
+
+///
+/// Official CLI commands that write a full kubeconfig context: API server, CA, and exec login.
+/// Provider CLIs, including kubelogin and doctl, are downloaded into the app data folder.
+/// A host install still cannot be started from that sandbox.
+///
+public static class CloudKubeconfig {
+ public static Result Build(CloudFetchRequest request) {
+ var preset = CloudExecPreset.Find(request.PresetId);
+
+ if (preset is null || !preset.CanFetch)
+ return Result.BadRequest(null, "This provider has no kubeconfig command.");
+
+ if (string.IsNullOrWhiteSpace(request.Cluster))
+ return Result.BadRequest(null, preset.ClusterLabel + " is required.");
+
+ if (preset.LocationLabel.Length > 0 && string.IsNullOrWhiteSpace(request.Location))
+ return Result.BadRequest(null, preset.LocationLabel + " is required.");
+
+ if (preset.ProjectLabel.Length > 0 && string.IsNullOrWhiteSpace(request.Project))
+ return Result.BadRequest(null, preset.ProjectLabel + " is required.");
+
+ if (string.IsNullOrWhiteSpace(request.KubeconfigPath))
+ return Result.BadRequest(null, "Kubeconfig path is required.");
+
+ var cluster = request.Cluster.Trim();
+ var location = request.Location?.Trim() ?? "";
+ var project = request.Project?.Trim() ?? "";
+ var path = request.KubeconfigPath;
+
+ CloudCliCommand command = preset.Id switch {
+ "google" => new(
+ "gcloud",
+ ["container", "clusters", "get-credentials", cluster, "--location", location, "--project", project]),
+ "azure" => new(
+ "az",
+ ["aks", "get-credentials", "--resource-group", project, "--name", cluster, "--file", path]),
+ "amazon" => new(
+ "aws",
+ ["eks", "update-kubeconfig", "--name", cluster, "--region", location, "--kubeconfig", path]),
+ "oracle" => new(
+ "oci",
+ ["ce", "cluster", "create-kubeconfig", "--cluster-id", cluster, "--file", path, "--region", location, "--token-version", "2.0.0"]),
+ "digitalocean" => new(
+ "doctl",
+ ["kubernetes", "cluster", "kubeconfig", "save", cluster]),
+ "ibm" => new(
+ "ibmcloud",
+ ["ks", "cluster", "config", "--cluster", cluster]),
+ _ => null!
+ };
+
+ if (command is null)
+ return Result.BadRequest(null, "This provider has no kubeconfig command.");
+
+ return Result.Ok(command);
+ }
+
+ public static async Task> RunAsync(
+ CloudCliCommand command,
+ string kubeconfigPath,
+ CancellationToken cancellationToken = default) {
+ CloudCliSandbox.ApplyPath();
+ var directory = Path.GetDirectoryName(kubeconfigPath);
+
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ var fileName = ResolveExecutable(command.FileName);
+ var start = new ProcessStartInfo {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ start.Environment["KUBECONFIG"] = kubeconfigPath;
+
+ if (OperatingSystem.IsWindows()
+ && (fileName.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)
+ || fileName.EndsWith(".bat", StringComparison.OrdinalIgnoreCase))) {
+ start.FileName = "cmd.exe";
+ start.ArgumentList.Add("/d");
+ start.ArgumentList.Add("/s");
+ start.ArgumentList.Add("/c");
+ start.ArgumentList.Add(fileName);
+ }
+ else {
+ start.FileName = fileName;
+ }
+
+ foreach (var argument in command.Arguments)
+ start.ArgumentList.Add(argument);
+
+ using var process = new Process { StartInfo = start };
+
+ try {
+ if (!process.Start())
+ return Result.ServiceUnavailable(null, "Could not start " + command.FileName + ".");
+ }
+ catch (Exception ex) {
+ return Result.ServiceUnavailable(null, command.FileName + " did not start. " + ex.Message);
+ }
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromMinutes(3));
+
+ try {
+ await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) {
+ try {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (Exception) {
+ // The process may already have exited.
+ }
+
+ try {
+ await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
+ }
+ catch (Exception) {
+ // The pipes close when the process is killed.
+ }
+
+ return Result.RequestTimeout(null, command.FileName + " did not finish within 3 minutes.");
+ }
+
+ var stdout = (await stdoutTask.ConfigureAwait(false)).Trim();
+ var stderr = (await stderrTask.ConfigureAwait(false)).Trim();
+
+ if (process.ExitCode != 0) {
+ var detail = stderr.Length > 0 ? stderr : stdout;
+
+ return Result.BadRequest(null, command.FileName + " failed. " + detail);
+ }
+
+ return Result.Ok(stdout.Length > 0 ? stdout : "Context written.");
+ }
+
+ private static string ResolveExecutable(string fileName) {
+ if (Path.IsPathRooted(fileName) && File.Exists(fileName))
+ return fileName;
+
+ var path = Environment.GetEnvironmentVariable("PATH");
+
+ if (string.IsNullOrWhiteSpace(path))
+ return fileName;
+
+ var extensions = OperatingSystem.IsWindows()
+ ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").Split(';', StringSplitOptions.RemoveEmptyEntries)
+ : new[] { "" };
+
+ foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) {
+ var plain = Path.Combine(dir.Trim(), fileName);
+
+ if (File.Exists(plain))
+ return plain;
+
+ foreach (var extension in extensions) {
+ var candidate = plain + extension;
+
+ if (File.Exists(candidate))
+ return candidate;
+ }
+ }
+
+ return fileName;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeAuthKind.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeAuthKind.cs
index 847aaa1..11bcd58 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeAuthKind.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeAuthKind.cs
@@ -9,6 +9,7 @@ public sealed class KubeAuthKind : Enumeration {
public static readonly KubeAuthKind Cert = new(2, "cert");
public static readonly KubeAuthKind K3sData = new(3, "k3sdata");
public static readonly KubeAuthKind Basic = new(4, "basic");
+ public static readonly KubeAuthKind Exec = new(5, "exec");
private KubeAuthKind(int id, string name) : base(id, name) { }
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigEditor.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigEditor.cs
index df51dc6..491ddf2 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigEditor.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigEditor.cs
@@ -277,6 +277,8 @@ internal static class KubeConfigEditor {
}
private static void ApplyCredentials(UserCredentials creds, KubeConnectionRequest request) {
+ ClearCredentials(creds);
+
if (request.AuthKind == KubeAuthKind.Cert) {
ApplyClientCert(creds, request);
@@ -286,9 +288,6 @@ internal static class KubeConfigEditor {
if (request.AuthKind == KubeAuthKind.K3sData) {
creds.ClientCertificateData = NormalizeData(request.ClientCertData ?? "");
creds.ClientKeyData = NormalizeData(request.ClientKeyData ?? "");
- creds.ClientCertificate = null;
- creds.ClientKey = null;
- creds.Token = null;
return;
}
@@ -296,7 +295,19 @@ internal static class KubeConfigEditor {
if (request.AuthKind == KubeAuthKind.Basic) {
creds.UserName = request.BasicUser;
creds.Password = request.BasicPassword;
- creds.Token = null;
+
+ return;
+ }
+
+ if (request.AuthKind == KubeAuthKind.Exec) {
+ var preset = CloudExecPreset.Find(request.ExecPreset) ?? CloudExecPreset.Custom;
+ creds.ExternalExecution = new ExternalExecution {
+ ApiVersion = "client.authentication.k8s.io/v1beta1",
+ Command = request.ExecCommand,
+ Arguments = preset.Arguments(request.ExecArgument),
+ ProvideClusterInfo = request.ExecProvideClusterInfo,
+ InstallHint = request.ExecInstallHint ?? ""
+ };
return;
}
@@ -304,6 +315,18 @@ internal static class KubeConfigEditor {
creds.Token = request.Token;
}
+ private static void ClearCredentials(UserCredentials creds) {
+ creds.Token = null;
+ creds.UserName = null;
+ creds.Password = null;
+ creds.ClientCertificate = null;
+ creds.ClientCertificateData = null;
+ creds.ClientKey = null;
+ creds.ClientKeyData = null;
+ creds.AuthProvider = null;
+ creds.ExternalExecution = null;
+ }
+
private static void ApplyClientCert(UserCredentials creds, KubeConnectionRequest request) {
if (request.EmbedClientCerts) {
if (!string.IsNullOrWhiteSpace(request.ClientCertFile))
@@ -381,6 +404,9 @@ internal static class KubeConfigEditor {
if (!string.IsNullOrWhiteSpace(creds.UserName))
return "Auth: basic (username: " + creds.UserName + ")";
+ if (creds.ExternalExecution is { } exec && !string.IsNullOrWhiteSpace(exec.Command))
+ return "Auth: exec " + exec.Command;
+
if (creds.ExternalExecution is not null)
return "Auth: exec plugin";
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigService.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigService.cs
index 9db4e7a..7de549b 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigService.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConfigService.cs
@@ -255,6 +255,18 @@ public sealed class KubeConfigService : IKubeConfigService {
return null;
}
+ if (request.AuthKind == KubeAuthKind.Exec) {
+ if (string.IsNullOrWhiteSpace(request.ExecCommand))
+ return "Exec command is required.";
+
+ var preset = CloudExecPreset.Find(request.ExecPreset);
+
+ if (preset?.ArgumentRequired == true && string.IsNullOrWhiteSpace(request.ExecArgument))
+ return preset.ArgumentLabel + " is required.";
+
+ return null;
+ }
+
return "Unknown auth type.";
}
}
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConnectionModels.cs b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConnectionModels.cs
index 8dc65c6..63734c2 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConnectionModels.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfig/KubeConnectionModels.cs
@@ -48,5 +48,15 @@ public sealed class KubeConnectionRequest {
public string? BasicPassword { get; init; }
+ public string? ExecPreset { get; init; }
+
+ public string? ExecCommand { get; init; }
+
+ public string? ExecArgument { get; init; }
+
+ public bool ExecProvideClusterInfo { get; init; }
+
+ public string? ExecInstallHint { get; init; }
+
public bool UseAfterAdd { get; init; } = true;
}
diff --git a/src/MaksIT.ClusterConsole.Client/MaksIT.ClusterConsole.Client.csproj b/src/MaksIT.ClusterConsole.Client/MaksIT.ClusterConsole.Client.csproj
index 130101f..b3b0efb 100644
--- a/src/MaksIT.ClusterConsole.Client/MaksIT.ClusterConsole.Client.csproj
+++ b/src/MaksIT.ClusterConsole.Client/MaksIT.ClusterConsole.Client.csproj
@@ -12,6 +12,8 @@
+
+
diff --git a/src/MaksIT.ClusterConsole.Client/S3/S3Layout.cs b/src/MaksIT.ClusterConsole.Client/S3/S3Layout.cs
new file mode 100644
index 0000000..ae1c347
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/S3/S3Layout.cs
@@ -0,0 +1,335 @@
+namespace MaksIT.ClusterConsole.Client.S3;
+
+///
+/// Backup-target URLs, object keys, and one-folder listings.
+///
+public static class S3Layout {
+ public const int PreviewLimit = 512 * 1024;
+
+ public const long CopyLimit = 5L * 1024 * 1024 * 1024;
+
+ public static bool TryParseUrl(string? url, out string bucket, out string region, out string prefix) {
+ bucket = "";
+ region = "";
+ prefix = "";
+
+ if (string.IsNullOrWhiteSpace(url))
+ return false;
+
+ var text = url.Trim();
+
+ if (!text.StartsWith("s3://", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var rest = text[5..];
+ var at = rest.IndexOf('@');
+
+ if (at <= 0)
+ return false;
+
+ bucket = rest[..at];
+ var after = rest[(at + 1)..];
+ var slash = after.IndexOf('/');
+
+ if (slash < 0) {
+ region = after;
+ }
+ else {
+ region = after[..slash];
+ prefix = after[(slash + 1)..].Trim('/');
+ var query = prefix.IndexOf('?');
+
+ if (query >= 0)
+ prefix = prefix[..query].Trim('/');
+ }
+
+ return IsBucket(bucket) && IsRegion(region) && IsPrefix(prefix);
+ }
+
+ public static string FormatUrl(string bucket, string region, string prefix) {
+ var path = prefix.Trim().Trim('/');
+ var root = "s3://" + bucket.Trim() + "@" + region.Trim() + "/";
+
+ return path.Length == 0 ? root : root + path;
+ }
+
+ public static string FirstEndpoint(string? endpoints) {
+ if (string.IsNullOrWhiteSpace(endpoints))
+ return "";
+
+ var first = endpoints.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
+
+ return string.IsNullOrWhiteSpace(first) ? "" : first.TrimEnd('/');
+ }
+
+ public static bool PathStyle(string endpoints, string virtualHosted) {
+ var custom = FirstEndpoint(endpoints).Length > 0;
+
+ if (custom)
+ return !virtualHosted.Trim().Equals("true", StringComparison.OrdinalIgnoreCase);
+
+ return virtualHosted.Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool IsResourceName(string? name) {
+ if (string.IsNullOrWhiteSpace(name) || name.Length > 63)
+ return false;
+
+ if (!char.IsAsciiLetterOrDigit(name[0]) || !char.IsAsciiLetterOrDigit(name[^1]))
+ return false;
+
+ foreach (var character in name) {
+ if (char.IsAsciiLetter(character) && !char.IsAsciiLetterLower(character))
+ return false;
+
+ if (!char.IsAsciiLetterOrDigit(character) && character != '-')
+ return false;
+ }
+
+ return true;
+ }
+
+ public static bool IsBucket(string? bucket) {
+ if (string.IsNullOrWhiteSpace(bucket) || bucket.Length is < 3 or > 63)
+ return false;
+
+ if (bucket.Contains("..", StringComparison.Ordinal) || bucket.StartsWith('.') || bucket.EndsWith('.') || bucket.StartsWith('-') || bucket.EndsWith('-'))
+ return false;
+
+ foreach (var character in bucket) {
+ if (char.IsAsciiLetter(character) && !char.IsAsciiLetterLower(character))
+ return false;
+
+ if (!char.IsAsciiLetterOrDigit(character) && character is not '.' and not '-')
+ return false;
+ }
+
+ return true;
+ }
+
+ public static bool IsRegion(string? region) {
+ if (string.IsNullOrWhiteSpace(region) || region.Length > 64)
+ return false;
+
+ foreach (var character in region) {
+ if (!char.IsAsciiLetterOrDigit(character) && character != '-')
+ return false;
+ }
+
+ return true;
+ }
+
+ public static bool IsPrefix(string? prefix) {
+ if (string.IsNullOrWhiteSpace(prefix))
+ return true;
+
+ if (prefix.Contains('\\') || prefix.Contains("//", StringComparison.Ordinal))
+ return false;
+
+ var text = prefix.Trim().Trim('/');
+
+ if (text.Length == 0)
+ return true;
+
+ foreach (var part in text.Split('/')) {
+ if (part.Length == 0 || part is "." or "..")
+ return false;
+ }
+
+ return true;
+ }
+
+ public static bool IsFolderName(string? name) {
+ if (string.IsNullOrWhiteSpace(name))
+ return false;
+
+ var text = name.Trim();
+
+ return !text.Contains('/') && !text.Contains('\\') && text is not "." and not "..";
+ }
+
+ public static string NormalizePrefix(string? prefix) {
+ if (string.IsNullOrWhiteSpace(prefix))
+ return "";
+
+ var text = prefix.Replace('\\', '/').Trim().Trim('/');
+
+ return text.Length == 0 ? "" : text + "/";
+ }
+
+ public static string Parent(string? prefix) {
+ var text = NormalizePrefix(prefix).TrimEnd('/');
+
+ if (text.Length == 0)
+ return "";
+
+ var slash = text.LastIndexOf('/');
+
+ return slash < 0 ? "" : text[..(slash + 1)];
+ }
+
+ public static string Child(string? prefix, string name) =>
+ NormalizePrefix(prefix) + name.Trim().Trim('/');
+
+ public static string Display(string? prefix) =>
+ NormalizePrefix(prefix).TrimEnd('/');
+
+ public static string Sibling(string key, string newName, bool folder) {
+ var trimmed = key.TrimEnd('/');
+ var slash = trimmed.LastIndexOf('/');
+ var parent = slash < 0 ? "" : trimmed[..(slash + 1)];
+
+ return parent + newName.Trim().Trim('/') + (folder ? "/" : "");
+ }
+
+ public static string CopyName(string name, bool folder) {
+ if (folder)
+ return name + "-copy";
+
+ var dot = name.LastIndexOf('.');
+
+ if (dot <= 0)
+ return name + "-copy";
+
+ return name[..dot] + "-copy" + name[dot..];
+ }
+
+ public static bool ContainsPrefix(string parent, string child) {
+ var root = NormalizePrefix(parent);
+ var next = NormalizePrefix(child);
+
+ if (root.Length == 0)
+ return false;
+
+ return next.StartsWith(root, StringComparison.Ordinal) && next.Length > root.Length;
+ }
+
+ public static long PartSize(long size) {
+ const long preferred = 64L * 1024 * 1024;
+ const long maxParts = 10000;
+ const long minimum = 5L * 1024 * 1024;
+
+ if (size <= 0)
+ return preferred;
+
+ var parts = (size + preferred - 1) / preferred;
+
+ if (parts <= maxParts)
+ return preferred;
+
+ var sized = (size + maxParts - 1) / maxParts;
+
+ return sized < minimum ? minimum : sized;
+ }
+
+ public static string UploadKey(string? prefix, string relative) {
+ var clean = relative.Replace('\\', '/').TrimStart('/');
+
+ return NormalizePrefix(prefix) + clean;
+ }
+
+ public static string SafeSegment(string name) {
+ var invalid = Path.GetInvalidFileNameChars();
+ var buffer = name.ToCharArray();
+
+ for (var i = 0; i < buffer.Length; i++) {
+ if (invalid.Contains(buffer[i]))
+ buffer[i] = '_';
+ }
+
+ var text = new string(buffer).Trim();
+
+ return text.Length == 0 ? "object" : text;
+ }
+
+ public static IReadOnlyList Buckets(IReadOnlyList listed, string? configured) {
+ var items = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (var bucket in listed) {
+ if (string.IsNullOrWhiteSpace(bucket.Name) || !seen.Add(bucket.Name))
+ continue;
+
+ items.Add(bucket);
+ }
+
+ if (!string.IsNullOrWhiteSpace(configured) && seen.Add(configured))
+ items.Add(new S3Bucket(configured, null));
+
+ items.Sort(static (left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
+
+ return items;
+ }
+
+ public static IReadOnlyList MarkBackups(IReadOnlyList buckets, IEnumerable backupNames) {
+ var names = new HashSet(StringComparer.Ordinal);
+
+ foreach (var name in backupNames) {
+ if (!string.IsNullOrWhiteSpace(name))
+ names.Add(name);
+ }
+
+ var marked = new List(buckets.Count);
+
+ foreach (var bucket in buckets)
+ marked.Add(names.Contains(bucket.Name) ? bucket with { Backup = true } : bucket);
+
+ return marked;
+ }
+
+ public static IReadOnlyList List(string? prefix, IReadOnlyList folders, IReadOnlyList objects) {
+ var root = NormalizePrefix(prefix);
+ var seen = new HashSet(StringComparer.Ordinal);
+ var entries = new List();
+
+ foreach (var folder in folders) {
+ if (!TryName(root, folder, out var name))
+ continue;
+
+ if (!seen.Add(name))
+ continue;
+
+ entries.Add(new S3Entry(name, NormalizePrefix(folder), true, 0, null));
+ }
+
+ foreach (var item in objects) {
+ if (item.Key == root || !TryName(root, item.Key, out var name))
+ continue;
+
+ var folder = item.Key.EndsWith('/');
+
+ if (folder && !seen.Add(name))
+ continue;
+
+ if (!folder && !seen.Add("file:" + name))
+ continue;
+
+ entries.Add(new S3Entry(name, folder ? NormalizePrefix(item.Key) : item.Key, folder, item.Size, item.Modified));
+ }
+
+ entries.Sort(static (left, right) => {
+ if (left.IsDirectory != right.IsDirectory)
+ return left.IsDirectory ? -1 : 1;
+
+ return string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase);
+ });
+
+ return entries;
+ }
+
+ private static bool TryName(string root, string key, out string name) {
+ name = "";
+
+ if (!key.StartsWith(root, StringComparison.Ordinal))
+ return false;
+
+ var rest = key[root.Length..].TrimEnd('/');
+
+ if (rest.Length == 0 || rest.Contains('/'))
+ return false;
+
+ name = rest;
+
+ return true;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Client/S3/S3Models.cs b/src/MaksIT.ClusterConsole.Client/S3/S3Models.cs
new file mode 100644
index 0000000..6b4be83
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/S3/S3Models.cs
@@ -0,0 +1,71 @@
+using System.Globalization;
+using MaksIT.ClusterConsole.Client.Cluster;
+
+
+namespace MaksIT.ClusterConsole.Client.S3;
+
+///
+/// One S3 location behind a Longhorn backup target.
+///
+public sealed record S3Target(
+ string Name,
+ string Namespace,
+ string Bucket,
+ string Region,
+ string Prefix,
+ string Endpoint,
+ string AccessKey,
+ string SecretKey,
+ bool PathStyle,
+ string Certificate,
+ string CredentialSecret,
+ string PollInterval) {
+ public bool IsDefault =>
+ string.Equals(Name, "default", StringComparison.Ordinal);
+
+ public string Caption {
+ get {
+ var where = string.IsNullOrEmpty(Prefix) ? Bucket : Bucket + "/" + Prefix;
+
+ return Name + " · " + where;
+ }
+ }
+}
+
+///
+/// One bucket the credentials can see on this endpoint.
+/// Backup is set when a Longhorn backup target uses the bucket.
+///
+public sealed record S3Bucket(string Name, DateTimeOffset? Created) {
+ public bool Backup { get; init; }
+
+ public string Caption =>
+ Backup ? Name + " · Longhorn" : Name;
+}
+
+///
+/// One object or folder in a bucket listing.
+///
+public sealed record S3Entry(string Name, string Key, bool IsDirectory, long Size, DateTimeOffset? Modified) {
+ public string SizeText =>
+ IsDirectory ? "" : KubeQuantity.FormatBytesCompact(Size);
+
+ public string ModifiedText =>
+ Modified is null ? "" : Modified.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
+
+ public long ModifiedSort =>
+ Modified?.UtcTicks ?? 0;
+
+ public string KindText =>
+ IsDirectory ? "Folder" : "File";
+}
+
+///
+/// An object returned by a bucket listing, before folder names are applied.
+///
+public readonly record struct S3ObjectInfo(string Key, long Size, DateTimeOffset? Modified);
+
+///
+/// Text loaded from an object for the preview editor.
+///
+public sealed record S3TextFile(string Text, bool Binary);
diff --git a/src/MaksIT.ClusterConsole.Client/S3/S3Storage.cs b/src/MaksIT.ClusterConsole.Client/S3/S3Storage.cs
new file mode 100644
index 0000000..0804f13
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/S3/S3Storage.cs
@@ -0,0 +1,684 @@
+using System.Net;
+using System.Text;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Amazon;
+using Amazon.S3;
+using Amazon.Runtime;
+using Amazon.S3.Model;
+using Amazon.S3.Transfer;
+using MaksIT.Results;
+
+
+namespace MaksIT.ClusterConsole.Client.S3;
+
+///
+/// Lists every bucket on an endpoint, then lists and changes objects in the open bucket.
+/// The client matches a path-style S3 endpoint: checksums are sent only when the operation requires them.
+///
+public static class S3Storage {
+ public static Task>> ListBucketsAsync(
+ S3Target target,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ var buckets = new List();
+ string? continuation = null;
+ var pages = 0;
+
+ do {
+ var page = await client.ListBucketsAsync(new ListBucketsRequest {
+ ContinuationToken = continuation
+ }, token).ConfigureAwait(false);
+
+ if (page.Buckets is not null) {
+ foreach (var bucket in page.Buckets) {
+ if (string.IsNullOrWhiteSpace(bucket.BucketName))
+ continue;
+
+ DateTimeOffset? created = bucket.CreationDate is { } when
+ ? new DateTimeOffset(DateTime.SpecifyKind(when, DateTimeKind.Utc))
+ : null;
+ buckets.Add(new S3Bucket(bucket.BucketName, created));
+ }
+ }
+
+ var next = page.ContinuationToken;
+ pages++;
+
+ if (string.IsNullOrEmpty(next) || next == continuation || pages >= 50)
+ break;
+
+ continuation = next;
+ }
+ while (true);
+
+ return Result>.Ok(buckets);
+ }, cancellationToken);
+
+ public static Task CreateBucketAsync(
+ S3Target target,
+ string name,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ var bucket = name.Trim();
+
+ if (!S3Layout.IsBucket(bucket))
+ return Result.BadRequest("Enter a bucket name with 3 to 63 lowercase letters, numbers, dots, or hyphens.");
+
+ var request = new PutBucketRequest {
+ BucketName = bucket
+ };
+ var custom = S3Layout.FirstEndpoint(target.Endpoint).Length > 0;
+
+ if (!custom
+ && !string.IsNullOrWhiteSpace(target.Region)
+ && !target.Region.Equals("us-east-1", StringComparison.OrdinalIgnoreCase))
+ request.BucketRegionName = target.Region;
+
+ await client.PutBucketAsync(request, token).ConfigureAwait(false);
+
+ return Result.Ok();
+ }, cancellationToken);
+
+ public static Task DeleteBucketAsync(
+ S3Target target,
+ string name,
+ Action? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ var bucket = name.Trim();
+ var cleared = await DeleteObjectsAsync(client, bucket, "", progress, token).ConfigureAwait(false);
+
+ if (!cleared.IsSuccess)
+ return Refuse(cleared);
+
+ progress?.Invoke("Deleting bucket " + bucket);
+ await client.DeleteBucketAsync(bucket, token).ConfigureAwait(false);
+
+ return Result.Ok();
+ }, cancellationToken);
+
+ public static Task>> ListAsync(
+ S3Target target,
+ string? prefix,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ var root = S3Layout.NormalizePrefix(prefix);
+ var folders = new List();
+ var objects = new List();
+ string? continuation = null;
+
+ do {
+ var page = await client.ListObjectsV2Async(new ListObjectsV2Request {
+ BucketName = target.Bucket,
+ Prefix = root,
+ Delimiter = "/",
+ ContinuationToken = continuation
+ }, token).ConfigureAwait(false);
+
+ if (page.CommonPrefixes is not null)
+ folders.AddRange(page.CommonPrefixes);
+
+ if (page.S3Objects is not null) {
+ foreach (var item in page.S3Objects)
+ objects.Add(Info(item));
+ }
+
+ continuation = page.IsTruncated == true ? page.NextContinuationToken : null;
+ }
+ while (!string.IsNullOrEmpty(continuation));
+
+ return Result>.Ok(S3Layout.List(root, folders, objects));
+ }, cancellationToken);
+
+ public static Task> ReadTextAsync(
+ S3Target target,
+ string key,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ using var response = await client.GetObjectAsync(new GetObjectRequest {
+ BucketName = target.Bucket,
+ Key = key,
+ ByteRange = new ByteRange(0, S3Layout.PreviewLimit - 1)
+ }, token).ConfigureAwait(false);
+ await using var stream = response.ResponseStream;
+ var buffer = new byte[S3Layout.PreviewLimit];
+ var read = 0;
+
+ while (read < buffer.Length) {
+ var got = await stream.ReadAsync(buffer.AsMemory(read, buffer.Length - read), token).ConfigureAwait(false);
+
+ if (got == 0)
+ break;
+
+ read += got;
+ }
+
+ var binary = buffer.AsSpan(0, read).Contains((byte)0);
+
+ return Result.Ok(new S3TextFile(binary ? "" : Encoding.UTF8.GetString(buffer, 0, read), binary));
+ }, cancellationToken);
+
+ public static Task WriteTextAsync(
+ S3Target target,
+ string key,
+ string text,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ using var body = new MemoryStream(Encoding.UTF8.GetBytes(text));
+ await client.PutObjectAsync(new PutObjectRequest {
+ BucketName = target.Bucket,
+ Key = key,
+ InputStream = body
+ }, token).ConfigureAwait(false);
+
+ return Result.Ok();
+ }, cancellationToken);
+
+ public static Task CreateFolderAsync(
+ S3Target target,
+ string prefix,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ using var empty = new MemoryStream([]);
+ await client.PutObjectAsync(new PutObjectRequest {
+ BucketName = target.Bucket,
+ Key = S3Layout.NormalizePrefix(prefix),
+ InputStream = empty
+ }, token).ConfigureAwait(false);
+
+ return Result.Ok();
+ }, cancellationToken);
+
+ public static Task DeleteObjectAsync(
+ S3Target target,
+ string key,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ await client.DeleteObjectAsync(target.Bucket, key, token).ConfigureAwait(false);
+
+ return Result.Ok();
+ }, cancellationToken);
+
+ public static Task> DeletePrefixAsync(
+ S3Target target,
+ string prefix,
+ Action? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, (client, token) => DeleteObjectsAsync(client, target.Bucket, S3Layout.NormalizePrefix(prefix), progress, token), cancellationToken);
+
+ public static Task UploadAsync(
+ S3Target target,
+ string key,
+ string path,
+ Action? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Transfer(target, async (transfer, token) => {
+ var request = new TransferUtilityUploadRequest {
+ BucketName = target.Bucket,
+ Key = key,
+ FilePath = path
+ };
+ request.UploadProgressEvent += (_, args) => progress?.Invoke("Uploading " + Leaf(key) + " · " + args.PercentDone + "%");
+ await transfer.UploadAsync(request, token).ConfigureAwait(false);
+
+ return Result.Ok();
+ }, cancellationToken);
+
+ public static Task DownloadAsync(
+ S3Target target,
+ string key,
+ string path,
+ Action? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Transfer(target, async (transfer, token) => {
+ var directory = Path.GetDirectoryName(path);
+
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ var request = new TransferUtilityDownloadRequest {
+ BucketName = target.Bucket,
+ Key = key,
+ FilePath = path
+ };
+ request.WriteObjectProgressEvent += (_, args) => progress?.Invoke("Downloading " + Leaf(key) + " · " + args.PercentDone + "%");
+ await transfer.DownloadAsync(request, token).ConfigureAwait(false);
+
+ return Result.Ok();
+ }, cancellationToken);
+
+ public static Task> DownloadPrefixAsync(
+ S3Target target,
+ string prefix,
+ string directory,
+ Action? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ var root = S3Layout.NormalizePrefix(prefix);
+ var listed = await ListKeysAsync(client, target.Bucket, root, token).ConfigureAwait(false);
+
+ if (!listed.IsSuccess || listed.Value is null)
+ return new Result(0, false, new List(listed.Messages), listed.StatusCode);
+
+ var files = listed.Value.Where(item => !item.Key.EndsWith('/')).ToList();
+ using var transfer = new TransferUtility(client);
+
+ for (var index = 0; index < files.Count; index++) {
+ var item = files[index];
+ var relative = item.Key[root.Length..];
+ var path = LocalPath(directory, relative);
+ var folder = Path.GetDirectoryName(path);
+
+ if (!string.IsNullOrEmpty(folder))
+ Directory.CreateDirectory(folder);
+
+ progress?.Invoke("Downloading " + (index + 1) + " of " + files.Count + " · " + Leaf(item.Key));
+ await transfer.DownloadAsync(path, target.Bucket, item.Key, token).ConfigureAwait(false);
+ }
+
+ return Result.Ok(files.Count);
+ }, cancellationToken);
+
+ public static Task CopyObjectAsync(
+ S3Target target,
+ string sourceKey,
+ string destinationKey,
+ long size,
+ Action? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, (client, token) => CopyOneAsync(client, target.Bucket, sourceKey, destinationKey, size, progress, token), cancellationToken);
+
+ public static Task> CopyPrefixAsync(
+ S3Target target,
+ string sourcePrefix,
+ string destinationPrefix,
+ Action? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Guard(target, async (client, token) => {
+ var source = S3Layout.NormalizePrefix(sourcePrefix);
+ var destination = S3Layout.NormalizePrefix(destinationPrefix);
+
+ if (source == destination)
+ return Result.BadRequest(0, "Choose a different folder.");
+
+ if (S3Layout.ContainsPrefix(source, destination))
+ return Result.BadRequest(0, "The new folder is inside the folder being copied.");
+
+ var listed = await ListKeysAsync(client, target.Bucket, source, token).ConfigureAwait(false);
+
+ if (!listed.IsSuccess || listed.Value is null)
+ return new Result(0, false, new List(listed.Messages), listed.StatusCode);
+
+ var copied = 0;
+
+ foreach (var item in listed.Value) {
+ if (!item.Key.StartsWith(source, StringComparison.Ordinal))
+ continue;
+
+ var destinationKey = destination + item.Key[source.Length..];
+ progress?.Invoke("Copying " + (copied + 1) + " of " + listed.Value.Count + " · " + Leaf(item.Key));
+ var done = await CopyOneAsync(client, target.Bucket, item.Key, destinationKey, item.Size, null, token).ConfigureAwait(false);
+
+ if (!done.IsSuccess)
+ return new Result(copied, false, new List(done.Messages), done.StatusCode);
+
+ copied++;
+ }
+
+ return Result.Ok(copied);
+ }, cancellationToken);
+
+ private static async Task CopyOneAsync(
+ AmazonS3Client client,
+ string bucket,
+ string sourceKey,
+ string destinationKey,
+ long size,
+ Action? progress,
+ CancellationToken cancellationToken) {
+ if (sourceKey == destinationKey)
+ return Result.BadRequest("Choose a different name.");
+
+ if (size >= S3Layout.CopyLimit)
+ return await CopyLargeAsync(client, bucket, sourceKey, destinationKey, size, progress, cancellationToken).ConfigureAwait(false);
+
+ progress?.Invoke("Copying " + Leaf(sourceKey));
+ await client.CopyObjectAsync(new CopyObjectRequest {
+ SourceBucket = bucket,
+ SourceKey = sourceKey,
+ DestinationBucket = bucket,
+ DestinationKey = destinationKey,
+ MetadataDirective = S3MetadataDirective.COPY
+ }, cancellationToken).ConfigureAwait(false);
+
+ return Result.Ok();
+ }
+
+ private static async Task CopyLargeAsync(
+ AmazonS3Client client,
+ string bucket,
+ string sourceKey,
+ string destinationKey,
+ long size,
+ Action? progress,
+ CancellationToken cancellationToken) {
+ var started = await client.InitiateMultipartUploadAsync(new InitiateMultipartUploadRequest {
+ BucketName = bucket,
+ Key = destinationKey
+ }, cancellationToken).ConfigureAwait(false);
+ var uploadId = started.UploadId;
+
+ try {
+ var partSize = S3Layout.PartSize(size);
+ var tags = new List();
+ var part = 1;
+ long offset = 0;
+
+ while (offset < size) {
+ var last = Math.Min(offset + partSize, size) - 1;
+ progress?.Invoke("Copying " + Leaf(sourceKey) + " · part " + part);
+ var copied = await client.CopyPartAsync(new CopyPartRequest {
+ SourceBucket = bucket,
+ SourceKey = sourceKey,
+ DestinationBucket = bucket,
+ DestinationKey = destinationKey,
+ UploadId = uploadId,
+ PartNumber = part,
+ FirstByte = offset,
+ LastByte = last
+ }, cancellationToken).ConfigureAwait(false);
+ tags.Add(new PartETag(copied));
+ offset = last + 1;
+ part++;
+ }
+
+ await client.CompleteMultipartUploadAsync(new CompleteMultipartUploadRequest {
+ BucketName = bucket,
+ Key = destinationKey,
+ UploadId = uploadId,
+ PartETags = tags
+ }, cancellationToken).ConfigureAwait(false);
+
+ return Result.Ok();
+ }
+ catch {
+ try {
+ await client.AbortMultipartUploadAsync(bucket, destinationKey, uploadId, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception) {
+ // The copy error is the one to report.
+ }
+
+ throw;
+ }
+ }
+
+ private static async Task> DeleteObjectsAsync(
+ AmazonS3Client client,
+ string bucket,
+ string prefix,
+ Action? progress,
+ CancellationToken cancellationToken) {
+ var deleted = 0;
+ string? continuation = null;
+
+ do {
+ var page = await client.ListObjectsV2Async(new ListObjectsV2Request {
+ BucketName = bucket,
+ Prefix = prefix,
+ ContinuationToken = continuation
+ }, cancellationToken).ConfigureAwait(false);
+ var keys = page.S3Objects?.Select(item => item.Key).Where(key => !string.IsNullOrEmpty(key)).ToList() ?? [];
+
+ if (keys.Count > 0) {
+ progress?.Invoke("Deleting " + keys.Count + " objects…");
+ var removed = await DeleteKeysAsync(client, bucket, keys, cancellationToken).ConfigureAwait(false);
+
+ if (!removed.IsSuccess)
+ return new Result(deleted, false, new List(removed.Messages), removed.StatusCode);
+
+ deleted += keys.Count;
+ }
+
+ continuation = page.IsTruncated == true ? page.NextContinuationToken : null;
+ }
+ while (!string.IsNullOrEmpty(continuation));
+
+ return Result.Ok(deleted);
+ }
+
+ private static async Task>> ListKeysAsync(
+ AmazonS3Client client,
+ string bucket,
+ string prefix,
+ CancellationToken cancellationToken) {
+ var objects = new List();
+ string? continuation = null;
+
+ do {
+ var page = await client.ListObjectsV2Async(new ListObjectsV2Request {
+ BucketName = bucket,
+ Prefix = prefix,
+ ContinuationToken = continuation
+ }, cancellationToken).ConfigureAwait(false);
+
+ if (page.S3Objects is not null) {
+ foreach (var item in page.S3Objects)
+ objects.Add(Info(item));
+ }
+
+ continuation = page.IsTruncated == true ? page.NextContinuationToken : null;
+ }
+ while (!string.IsNullOrEmpty(continuation));
+
+ return Result>.Ok(objects);
+ }
+
+ private static async Task DeleteKeysAsync(
+ AmazonS3Client client,
+ string bucket,
+ IReadOnlyList keys,
+ CancellationToken cancellationToken) {
+ var request = new DeleteObjectsRequest {
+ BucketName = bucket,
+ Quiet = true
+ };
+
+ foreach (var key in keys)
+ request.AddKey(key);
+
+ var response = await client.DeleteObjectsAsync(request, cancellationToken).ConfigureAwait(false);
+ var error = response.DeleteErrors?.FirstOrDefault();
+
+ if (error is not null)
+ return Result.BadRequest(error.Key + ": " + error.Message);
+
+ return Result.Ok();
+ }
+
+ private static async Task Guard(
+ S3Target target,
+ Func> work,
+ CancellationToken cancellationToken) {
+ try {
+ using var client = Create(target);
+
+ return await work(client, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException) {
+ return Fail(ex);
+ }
+ }
+
+ private static async Task> Guard(
+ S3Target target,
+ Func>> work,
+ CancellationToken cancellationToken) {
+ try {
+ using var client = Create(target);
+
+ return await work(client, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException) {
+ return Fail(ex);
+ }
+ }
+
+ private static async Task Transfer(
+ S3Target target,
+ Func> work,
+ CancellationToken cancellationToken) {
+ try {
+ using var client = Create(target);
+ using var transfer = new TransferUtility(client);
+
+ return await work(transfer, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException) {
+ return Fail(ex);
+ }
+ }
+
+ private static AmazonS3Client Create(S3Target target) {
+ var endpoint = S3Layout.FirstEndpoint(target.Endpoint);
+
+ if (endpoint.Length > 0
+ && !endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("The endpoint must start with http:// or https://.");
+
+ if (string.IsNullOrWhiteSpace(target.AccessKey) || string.IsNullOrWhiteSpace(target.SecretKey))
+ throw new InvalidOperationException("This target has no access key or secret. Edit the target and save both.");
+
+ var config = new AmazonS3Config {
+ ForcePathStyle = target.PathStyle,
+ Timeout = TimeSpan.FromMinutes(5),
+ MaxErrorRetry = 2,
+ RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED,
+ ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED
+ };
+
+ if (endpoint.Length > 0) {
+ config.ServiceURL = endpoint;
+ config.AuthenticationRegion = string.IsNullOrWhiteSpace(target.Region) ? "us-east-1" : target.Region;
+ }
+ else {
+ config.RegionEndpoint = RegionEndpoint.GetBySystemName(target.Region);
+ }
+
+ if (!string.IsNullOrWhiteSpace(target.Certificate)) {
+ try {
+ config.HttpClientFactory = new PinnedCertificateFactory(target.Certificate);
+ }
+ catch (Exception ex) {
+ throw new InvalidOperationException("The certificate could not be read. Paste the PEM text. " + ex.Message);
+ }
+ }
+
+ return new AmazonS3Client(new BasicAWSCredentials(target.AccessKey, target.SecretKey), config);
+ }
+
+ private static S3ObjectInfo Info(S3Object item) =>
+ new(item.Key, item.Size ?? 0, item.LastModified is { } modified ? new DateTimeOffset(DateTime.SpecifyKind(modified, DateTimeKind.Utc)) : null);
+
+ private static string Leaf(string key) {
+ var trimmed = key.TrimEnd('/');
+ var slash = trimmed.LastIndexOf('/');
+
+ return slash < 0 ? trimmed : trimmed[(slash + 1)..];
+ }
+
+ private static string LocalPath(string directory, string relative) {
+ var path = directory;
+
+ foreach (var part in relative.Split('/', StringSplitOptions.RemoveEmptyEntries))
+ path = Path.Combine(path, S3Layout.SafeSegment(part));
+
+ return path;
+ }
+
+ private static Result Refuse(Result result) {
+ var message = result.Messages.Count == 0 ? "The request failed." : string.Join("; ", result.Messages);
+
+ if (result.StatusCode == HttpStatusCode.NotFound)
+ return Result.NotFound(message);
+
+ if (result.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized)
+ return Result.Forbidden(message);
+
+ if ((int)result.StatusCode >= 500)
+ return Result.InternalServerError(message);
+
+ return Result.BadRequest(message);
+ }
+
+ private static Result Fail(Exception ex) {
+ if (ex is InvalidOperationException invalid)
+ return Result.BadRequest(invalid.Message);
+
+ if (ex is AmazonS3Exception s3) {
+ var message = string.IsNullOrWhiteSpace(s3.Message) ? "S3 refused the request." : s3.Message;
+
+ if (s3.StatusCode == HttpStatusCode.NotFound)
+ return Result.NotFound(message);
+
+ if (s3.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized)
+ return Result.Forbidden(message);
+
+ if ((int)s3.StatusCode >= 500)
+ return Result.InternalServerError(message);
+
+ return Result.BadRequest(message);
+ }
+
+ if (ex is AmazonClientException client)
+ return Result.BadRequest(string.IsNullOrWhiteSpace(client.Message) ? "S3 could not be reached." : client.Message);
+
+ return Result.InternalServerError(ex.Message);
+ }
+
+ private static Result Fail(Exception ex) {
+ var failed = Fail(ex);
+
+ return new Result(default, false, new List(failed.Messages), failed.StatusCode);
+ }
+
+ private sealed class PinnedCertificateFactory : HttpClientFactory {
+ private readonly X509Certificate2Collection _extra = [];
+
+ public PinnedCertificateFactory(string pem) =>
+ _extra.ImportFromPem(pem);
+
+ public override string GetConfigUniqueString(IClientConfig clientConfig) =>
+ _extra.Count == 0 ? "none" : _extra[0].Thumbprint;
+
+ public override HttpClient CreateHttpClient(IClientConfig clientConfig) {
+ var handler = new HttpClientHandler {
+ ServerCertificateCustomValidationCallback = (_, certificate, chain, errors) => Trusts(certificate, chain, errors)
+ };
+
+ return new HttpClient(handler);
+ }
+
+ private bool Trusts(X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors errors) {
+ if (errors == SslPolicyErrors.None)
+ return true;
+
+ if (certificate is null)
+ return false;
+
+ foreach (var extra in _extra) {
+ if (string.Equals(certificate.GetCertHashString(), extra.GetCertHashString(), StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+
+ chain ??= new X509Chain();
+ chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
+ chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+ chain.ChainPolicy.CustomTrustStore.AddRange(_extra);
+
+ return chain.Build(certificate);
+ }
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatContext.cs b/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatContext.cs
index d40f594..4dd6dad 100644
--- a/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatContext.cs
+++ b/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatContext.cs
@@ -27,7 +27,8 @@ public sealed record ClusterChatContext(
Diagnose problems and fix them with tools. Do not invent objects, logs, or events.
Read first. A broad health question is valid. Prefer get_logs with an explicit container on multi-container pods.
Do not repeat a read you already have. Write the diagnosis before you propose a repair.
- Change the cluster only by calling restart_workload, delete_pod, scale_workload, or apply_manifest.
+ Change the cluster only by calling restart_workload, delete_pod, scale_workload, apply_manifest, detach_longhorn_volume, or attach_longhorn_volume.
+ A Longhorn volume that is degraded or faulted and still attached, or a failed or stopped replica of that volume, beside a detached volume for the same PVC or restored from that volume's backup, is a leftover. Detach the leftover volume, then attach the new volume to a ready Longhorn node. Do not delete the new volume. If detach leaves it attached, detach it again with force.
Before a write, name the object, the cause, and what will change.
After the operator approves or rejects, continue and answer the original question. Do not stop at the repair.
Do not claim a change was applied until the tool result says it succeeded.
@@ -40,7 +41,8 @@ public sealed record ClusterChatContext(
You are the SRE assistant inside {AppInfo.ProductName}, a Kubernetes desktop console.
Diagnose problems using the provided UI context and tools. Do not invent objects, logs, or events.
If data is missing, call a tool. Prefer get_logs with an explicit container on multi-container pods.
- You can only read the cluster. Do not claim you restarted, scaled, deleted, or applied YAML.
+ You can only read the cluster. Do not claim you restarted, scaled, deleted, applied YAML, or attached a volume.
+ A Longhorn volume that is degraded or faulted and still attached, or a failed or stopped replica of that volume, beside a detached volume for the same PVC or restored from that volume's backup, is a leftover. The fix is to detach the leftover volume, then attach the new volume to a ready Longhorn node. Do not delete the new volume. If detach leaves it attached, detach it again with force.
Be concise. Name the object, the failing container, and the most likely cause.
""";
diff --git a/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatTools.cs b/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatTools.cs
index 7e88405..77ab501 100644
--- a/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatTools.cs
+++ b/src/MaksIT.ClusterConsole.Shared/Chat/ClusterChatTools.cs
@@ -64,11 +64,27 @@ public sealed class ClusterChatTools(ClusterWorkspace workspace) {
"apply_manifest",
"Apply one YAML manifest. Status and server metadata are stripped before apply.",
ObjectSchema(
- ("yaml", "Full YAML document for one object", true)))
+ ("yaml", "Full YAML document for one object", true))),
+ Tool(
+ "detach_longhorn_volume",
+ "Detach a Longhorn volume. Use this on a degraded or faulted leftover, including one left by a backup, before attaching the new volume. namespace is the Longhorn volume namespace, usually longhorn-system. Set force to true when a normal detach leaves the volume attached.",
+ ObjectSchema(
+ ("name", "Longhorn volume name", true),
+ ("namespace", "Longhorn volume namespace. Defaults to longhorn-system.", false),
+ ("force", "true to drop every attachment ticket, including a CSI ticket. Default false.", false))),
+ Tool(
+ "attach_longhorn_volume",
+ "Attach a detached Longhorn volume to a Ready Longhorn node. Detach a degraded leftover for the same PVC, or the volume this one was restored from, first. Do not delete the new volume.",
+ ObjectSchema(
+ ("name", "Longhorn volume name", true),
+ ("node", "Ready Longhorn node name", true),
+ ("namespace", "Longhorn volume namespace. Defaults to longhorn-system.", false),
+ ("disableFrontend", "true for maintenance attach. Required for ReadWriteMany. Default false.", false)))
];
public static bool IsMutating(string name) =>
- name is "restart_workload" or "delete_pod" or "scale_workload" or "apply_manifest";
+ name is "restart_workload" or "delete_pod" or "scale_workload" or "apply_manifest"
+ or "attach_longhorn_volume" or "detach_longhorn_volume";
public static JsonObject? PrepareManifest(string yaml) {
var document = YamlFormatter.ToJsonObject(yaml);
@@ -87,6 +103,8 @@ public sealed class ClusterChatTools(ClusterWorkspace workspace) {
"delete_pod" => $"Delete Pod/{resourceName}{where}. The controller can recreate it.",
"scale_workload" => $"Scale {kind}/{resourceName}{where} to {Arg(args, "replicas") ?? "?"} replicas.",
"apply_manifest" => DescribeManifest(args),
+ "detach_longhorn_volume" => $"Detach Longhorn volume {resourceName}{where}.",
+ "attach_longhorn_volume" => $"Attach Longhorn volume {resourceName}{where} to {Arg(args, "node") ?? "?"}.",
_ => name
};
}
@@ -95,6 +113,12 @@ public sealed class ClusterChatTools(ClusterWorkspace workspace) {
if (name == "scale_workload" && TryReplicas(args, out var replicas, out _))
return $"replicas: {replicas}";
+ if (name == "attach_longhorn_volume")
+ return "node: " + (Arg(args, "node") ?? "");
+
+ if (name == "detach_longhorn_volume")
+ return "force: " + (Arg(args, "force") ?? "false");
+
if (name != "apply_manifest")
return "";
@@ -134,6 +158,8 @@ public sealed class ClusterChatTools(ClusterWorkspace workspace) {
"delete_pod" => await DeletePodAsync(args, context, cancellationToken).ConfigureAwait(false),
"scale_workload" => await ScaleWorkloadAsync(args, context, cancellationToken).ConfigureAwait(false),
"apply_manifest" => await ApplyManifestAsync(args, cancellationToken).ConfigureAwait(false),
+ "detach_longhorn_volume" => await DetachLonghornAsync(args, cancellationToken).ConfigureAwait(false),
+ "attach_longhorn_volume" => await AttachLonghornAsync(args, cancellationToken).ConfigureAwait(false),
_ => $"Unknown tool '{name}'."
};
@@ -398,6 +424,53 @@ public sealed class ClusterChatTools(ClusterWorkspace workspace) {
return $"Apply {kind}/{name}{where}.";
}
+ private async Task DetachLonghornAsync(JsonObject args, CancellationToken cancellationToken) {
+ if (workspace.Session is null)
+ return "Not connected.";
+
+ var name = Arg(args, "name");
+
+ if (string.IsNullOrWhiteSpace(name))
+ return "name is required.";
+
+ var ns = Arg(args, "namespace");
+
+ if (string.IsNullOrWhiteSpace(ns))
+ ns = "longhorn-system";
+
+ var force = string.Equals(Arg(args, "force"), "true", StringComparison.OrdinalIgnoreCase);
+ var result = await workspace.Session.DetachLonghornVolumeAsync(name, ns, force, cancellationToken)
+ .ConfigureAwait(false);
+
+ return result.IsSuccess
+ ? $"Detached Longhorn volume {name}."
+ : string.Join("; ", result.Messages);
+ }
+
+ private async Task AttachLonghornAsync(JsonObject args, CancellationToken cancellationToken) {
+ if (workspace.Session is null)
+ return "Not connected.";
+
+ var name = Arg(args, "name");
+ var node = Arg(args, "node");
+
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(node))
+ return "name and node are required. node must be a Ready Longhorn node.";
+
+ var ns = Arg(args, "namespace");
+
+ if (string.IsNullOrWhiteSpace(ns))
+ ns = "longhorn-system";
+
+ var disable = string.Equals(Arg(args, "disableFrontend"), "true", StringComparison.OrdinalIgnoreCase);
+ var result = await workspace.Session.AttachLonghornVolumeAsync(name, ns, node, disable, cancellationToken)
+ .ConfigureAwait(false);
+
+ return result.IsSuccess
+ ? $"Attached Longhorn volume {name} to {node}."
+ : string.Join("; ", result.Messages);
+ }
+
private static bool TryReplicas(JsonObject args, out int replicas, out string error) {
replicas = 0;
error = "scale_workload replicas must be an integer from 0 to 100.";
diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs b/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs
index 0b660f8..1bef563 100644
--- a/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs
@@ -42,7 +42,9 @@ public static class ClusterIssues {
IEnumerable pods,
DateTimeOffset? utcNow = null,
IEnumerable? services = null,
- IEnumerable? claims = null) {
+ IEnumerable? claims = null,
+ IEnumerable? volumes = null,
+ IEnumerable? replicas = null) {
var now = utcNow ?? DateTimeOffset.UtcNow;
var podByUid = pods
.Select(p => (Uid: JsonPath.Uid(p), Pod: p))
@@ -61,6 +63,13 @@ public static class ClusterIssues {
foreach (var claim in claims ?? [])
warnings.AddRange(ClaimWarnings(claim, now));
+ foreach (var issue in LonghornVolume.Issues(volumes ?? [], now, replicas)) {
+ if (issue.Severity == "Error")
+ errors.Add(issue);
+ else
+ warnings.Add(issue);
+ }
+
foreach (var issue in EventIssues(events, podByUid, now)) {
if (issue.Severity == "Error")
errors.Add(issue);
diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterIssueReader.cs b/src/MaksIT.ClusterConsole.Shared/ClusterIssueReader.cs
index 10474de..a831df9 100644
--- a/src/MaksIT.ClusterConsole.Shared/ClusterIssueReader.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ClusterIssueReader.cs
@@ -1,3 +1,4 @@
+using System.Text.Json.Nodes;
using MaksIT.Results;
using MaksIT.ClusterConsole.Client.Cluster;
@@ -16,7 +17,8 @@ public static class ClusterIssueReader {
var podsTask = ClusterObjectList.LoadAsync(session, "pods", cancellationToken);
var servicesTask = ClusterObjectList.LoadAsync(session, "services", cancellationToken);
var claimsTask = ClusterObjectList.LoadAsync(session, "persistentvolumeclaims", cancellationToken);
- await Task.WhenAll(nodesTask, eventsTask, podsTask, servicesTask, claimsTask).ConfigureAwait(false);
+ var longhornTask = session.HasApiGroupAsync("longhorn.io", cancellationToken);
+ await Task.WhenAll(nodesTask, eventsTask, podsTask, servicesTask, claimsTask, longhornTask).ConfigureAwait(false);
var nodes = nodesTask.Result;
var events = eventsTask.Result;
@@ -31,11 +33,31 @@ public static class ClusterIssueReader {
nodes.Messages.Concat(events.Messages).ToList(),
nodes.StatusCode);
+ IReadOnlyList volumes = [];
+ IReadOnlyList replicas = [];
+ var longhorn = longhornTask.Result;
+
+ if (longhorn.IsSuccess && longhorn.Value) {
+ var volumeRef = ResourceCatalog.Find(ResourceCatalog.LonghornVolumesId)!.ToRef();
+ var replicaRef = new ResourceRef("longhorn.io", "v1beta2", "replicas", "Replica", true);
+ var volumeTask = session.ListAsync(volumeRef, Configuration.AllNamespaces, cancellationToken);
+ var replicaTask = session.ListAsync(replicaRef, Configuration.AllNamespaces, cancellationToken);
+ await Task.WhenAll(volumeTask, replicaTask).ConfigureAwait(false);
+
+ if (volumeTask.Result.IsSuccess && volumeTask.Result.Value is not null)
+ volumes = volumeTask.Result.Value;
+
+ if (replicaTask.Result.IsSuccess && replicaTask.Result.Value is not null)
+ replicas = replicaTask.Result.Value;
+ }
+
return Result.Ok(ClusterIssues.Collect(
nodes.Value ?? [],
events.Value ?? [],
pods.Value ?? [],
services: services.IsSuccess ? services.Value : [],
- claims: claims.IsSuccess ? claims.Value : []));
+ claims: claims.IsSuccess ? claims.Value : [],
+ volumes: volumes,
+ replicas: replicas));
}
}
diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
index 89eb7fc..36c7772 100644
--- a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
@@ -275,7 +275,8 @@ public sealed partial class ClusterWorkspace {
ResourceCatalog.Helm,
ResourceCatalog.HelmReleasesDescriptor),
Special(ResourceCatalog.DaprSidecarsId, "Sidecars", ResourceCatalog.Dapr),
- Special(ResourceCatalog.DaprControlPlaneId, "Control plane", ResourceCatalog.Dapr)
+ Special(ResourceCatalog.DaprControlPlaneId, "Control plane", ResourceCatalog.Dapr),
+ Special(ResourceCatalog.S3BrowserId, "S3 Browser", ResourceCatalog.Longhorn)
};
items.AddRange(descriptors.Select(d => new NavigatorItem {
diff --git a/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs b/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs
index 1457cef..8aceb58 100644
--- a/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Globalization;
using System.Text.Json.Nodes;
@@ -55,27 +56,88 @@ public sealed class ConfigurationFileService {
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
+ QuarantineIfCorrupt(FilePath);
var root = ReadRoot(File.Exists(FilePath) ? FilePath : null) ?? [];
root["Configuration"] = JsonSerializer.SerializeToNode(configuration, SerializerOptions);
- File.WriteAllText(FilePath, root.ToJsonString(SerializerOptions));
+ ReplaceFile(FilePath, root.ToJsonString(SerializerOptions));
_current = configuration;
}
+ ///
+ /// Moves an unreadable settings file aside so a truncated write cannot crash startup or a file watcher.
+ ///
+ public static bool QuarantineIfCorrupt(string path) {
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ return false;
+
+ try {
+ using var document = JsonDocument.Parse(File.ReadAllText(path));
+ _ = document.RootElement;
+
+ return false;
+ }
+ catch (JsonException) {
+ var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
+ var dest = path + ".corrupt-" + stamp;
+ var n = 0;
+
+ while (File.Exists(dest)) {
+ n++;
+ dest = path + ".corrupt-" + stamp + "-" + n.ToString(CultureInfo.InvariantCulture);
+ }
+
+ File.Move(path, dest);
+
+ return true;
+ }
+ }
+
private Configuration LoadFromDisk() {
var path = ResolveReadPath();
if (path is null)
return new Configuration();
- using var document = JsonDocument.Parse(File.ReadAllText(path));
+ string text;
- if (!document.RootElement.TryGetProperty("Configuration", out var value))
+ try {
+ text = File.ReadAllText(path);
+ }
+ catch (IOException) {
return new Configuration();
+ }
- var configuration = JsonSerializer.Deserialize(value.GetRawText(), SerializerOptions) ?? new Configuration();
- configuration.EnsureDefaults();
+ JsonDocument document;
- return configuration;
+ try {
+ document = JsonDocument.Parse(text);
+ }
+ catch (JsonException) {
+ if (string.Equals(path, FilePath, StringComparison.OrdinalIgnoreCase))
+ QuarantineIfCorrupt(path);
+
+ return new Configuration();
+ }
+
+ using (document) {
+ if (!document.RootElement.TryGetProperty("Configuration", out var value))
+ return new Configuration();
+
+ var configuration = JsonSerializer.Deserialize(value.GetRawText(), SerializerOptions) ?? new Configuration();
+ configuration.EnsureDefaults();
+
+ return configuration;
+ }
+ }
+
+ private static void ReplaceFile(string path, string text) {
+ var temp = path + ".tmp";
+ File.WriteAllText(temp, text);
+
+ if (File.Exists(path))
+ File.Replace(temp, path, null);
+ else
+ File.Move(temp, path);
}
private static JsonObject? ReadRoot(string? path) {
diff --git a/src/MaksIT.ClusterConsole.Shared/ErrorReport.cs b/src/MaksIT.ClusterConsole.Shared/ErrorReport.cs
index 4d1c4b4..67b858b 100644
--- a/src/MaksIT.ClusterConsole.Shared/ErrorReport.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ErrorReport.cs
@@ -36,6 +36,21 @@ public static class ErrorReport {
return false;
}
+ ///
+ /// AddJsonFile reloads on a thread-pool callback. A truncated settings file throws there,
+ /// and nothing observes that task, so the finalizer reports it as a crash.
+ ///
+ public static bool IsConfigurationReloadFailure(Exception exception) {
+ ArgumentNullException.ThrowIfNull(exception);
+
+ foreach (var current in Walk(exception)) {
+ if (current.Message.Contains("Failed to load configuration from file", StringComparison.Ordinal))
+ return true;
+ }
+
+ return false;
+ }
+
private static string Version() {
var version = Assembly.GetEntryAssembly()?.GetName().Version;
diff --git a/src/MaksIT.ClusterConsole.Shared/JsonPath.cs b/src/MaksIT.ClusterConsole.Shared/JsonPath.cs
index a50c0ad..91939cb 100644
--- a/src/MaksIT.ClusterConsole.Shared/JsonPath.cs
+++ b/src/MaksIT.ClusterConsole.Shared/JsonPath.cs
@@ -25,8 +25,12 @@ public static class JsonPath {
if (path == "pv.claim")
return VolumeClaim(root as JsonObject);
- if (path == "longhorn.size")
- return LonghornSize(root as JsonObject);
+ if (path.StartsWith("longhorn.backup.", StringComparison.Ordinal))
+ return LonghornBackup.Column(root as JsonObject, path);
+
+ if (path.StartsWith("longhorn.", StringComparison.Ordinal)
+ && path is not "longhorn.scheduling" and not "longhorn.disks")
+ return LonghornVolume.Column(root as JsonObject, path);
if (path == "longhorn.scheduling")
return LonghornScheduling(root as JsonObject);
@@ -418,12 +422,6 @@ public static class JsonPath {
return roles.Count == 0 ? "worker" : string.Join(",", roles);
}
- private static string LonghornSize(JsonObject? item) {
- var bytes = ReadBytes(item?["spec"]?["size"]);
-
- return bytes <= 0 ? "" : KubeQuantity.FormatBytesCompact(bytes);
- }
-
private static string LonghornScheduling(JsonObject? item) {
var node = item?["spec"]?["allowScheduling"];
diff --git a/src/MaksIT.ClusterConsole.Shared/LonghornBackup.cs b/src/MaksIT.ClusterConsole.Shared/LonghornBackup.cs
new file mode 100644
index 0000000..1dac2f3
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Shared/LonghornBackup.cs
@@ -0,0 +1,729 @@
+using System.Globalization;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using MaksIT.ClusterConsole.Client.Cluster;
+
+
+
+namespace MaksIT.ClusterConsole.Shared;
+
+///
+/// Longhorn backup rows, backup-job rows, and the cron schedule those jobs store.
+///
+public static class LonghornBackup {
+ public const string LabelPrefix = "recurring-job.longhorn.io/";
+
+ public static readonly IReadOnlyList MinuteChoices = Minutes();
+
+ public static readonly IReadOnlyList HourChoices = Hours();
+
+ public static readonly IReadOnlyList DayChoices = Days();
+
+ public static readonly IReadOnlyList MonthChoices = Months();
+
+ public static readonly IReadOnlyList WeekdayChoices = Weekdays();
+
+ public static CronFields Daily { get; } = new("0", "2", "*", "*", "*");
+
+ public static CronFields Hourly { get; } = new("0", "*", "*", "*", "*");
+
+ public static CronFields Weekly { get; } = new("0", "2", "*", "*", "0");
+
+ public static string Column(JsonObject? item, string path) =>
+ path switch {
+ "longhorn.backup.state" => State(item),
+ "longhorn.backup.volume" => VolumeName(item),
+ "longhorn.backup.last" => JsonPath.Text(item?["status"]?["lastBackupName"]),
+ "longhorn.backup.lastAt" => When(item?["status"]?["lastBackupAt"]),
+ "longhorn.backup.created" => Created(item),
+ "longhorn.backup.size" => Size(item?["status"]?["size"] ?? item?["status"]?["volumeSize"]),
+ "longhorn.backup.target" => JsonPath.Text(item?["spec"]?["backupTargetName"]),
+ "longhorn.backup.schedule" => Describe(JsonPath.Text(item?["spec"]?["cron"])),
+ "longhorn.backup.retain" => JsonPath.Text(item?["spec"]?["retain"]),
+ "longhorn.backup.groups" => Groups(item),
+ "longhorn.backup.task" => TaskLabel(item),
+ "longhorn.backup.runState" => "",
+ "longhorn.backup.lastSuccess" => "",
+ _ => ""
+ };
+
+ public static IReadOnlyDictionary Tips(JsonObject? item) {
+ var error = Message(item);
+
+ if (error.Length == 0)
+ return new Dictionary(StringComparer.Ordinal);
+
+ return new Dictionary(StringComparer.Ordinal) {
+ ["State"] = error
+ };
+ }
+
+ public static readonly IReadOnlyList Tasks = [
+ new("backup", "Backup", "Use Backup to copy a volume into the backup target on this schedule. Restore that copy if the disk or the cluster is gone. Retain is how many copies to keep."),
+ new("snapshot", "Snapshot", "Use Snapshot for a local point in time on the volume. It is the fast rollback while the volume is still there. It is not stored in the backup target. Retain is how many snapshots to keep."),
+ new("snapshot-delete", "Snapshot Delete", "Use Snapshot Delete to remove local snapshots once a volume has more than Retain. It removes user snapshots and system snapshots past that count."),
+ new("snapshot-cleanup", "Snapshot Cleanup", "Use Snapshot Cleanup to purge snapshots Longhorn has already marked removable, including snapshots left by the system. Retain is not used."),
+ new("filesystem-trim", "Filesystem Trim", "Use Filesystem Trim to tell the filesystem to release blocks that files no longer use, so Longhorn can reclaim that space. It does not take a snapshot or a backup. Retain is not used."),
+ new("system-backup", "System Backup", "Use System Backup to copy the Longhorn configuration to the backup target, so Longhorn can be rebuilt on another cluster. It does not copy application pods. Volume data still comes from volume backups. Volume backup policy chooses whether this job also takes those volume backups.")
+ ];
+
+ public static readonly IReadOnlyList Policies = [
+ new("if-not-present", "If not present", "Take a volume backup only when that volume does not already have one."),
+ new("always", "Always", "Take a volume backup every time this job runs."),
+ new("disabled", "Disabled", "Copy only the Longhorn configuration. Do not take volume backups.")
+ ];
+
+ public const string ForceHint = "Force create still runs when cleaning up the older copies failed.";
+
+ public static bool IsBackupJob(JsonObject item) {
+ var task = JsonPath.Text(item["spec"]?["task"]);
+
+ return task is "backup" or "backup-force-create";
+ }
+
+ public static string BaseTask(string stored) =>
+ stored switch {
+ "backup-force-create" => "backup",
+ "snapshot-force-create" => "snapshot",
+ _ => stored
+ };
+
+ public static bool IsForceTask(string stored) =>
+ stored is "backup-force-create" or "snapshot-force-create";
+
+ public static string StoredTask(string task, bool force) {
+ if (force && task == "backup")
+ return "backup-force-create";
+
+ if (force && task == "snapshot")
+ return "snapshot-force-create";
+
+ return task;
+ }
+
+ public static RecurringTask? FindTask(string id) {
+ foreach (var task in Tasks) {
+ if (task.Id == id)
+ return task;
+ }
+
+ return null;
+ }
+
+ public static BackupPolicy? FindPolicy(string id) {
+ foreach (var policy in Policies) {
+ if (policy.Id == id)
+ return policy;
+ }
+
+ return null;
+ }
+
+ public static bool VolumeHasJob(JsonObject volume, string job) {
+ var value = JsonPath.Text(volume["metadata"]?["labels"]?[LabelPrefix + job]);
+
+ if (value.Length == 0)
+ return false;
+
+ if (value.Equals("disabled", StringComparison.OrdinalIgnoreCase) || value.Equals("false", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return true;
+ }
+
+ public static string LabelPatch(string? clearJob, string? enableJob) {
+ var labels = new List();
+
+ if (!string.IsNullOrWhiteSpace(clearJob) && !string.Equals(clearJob, enableJob, StringComparison.Ordinal))
+ labels.Add("\"" + LabelPrefix + clearJob + "\":null");
+
+ if (!string.IsNullOrWhiteSpace(enableJob))
+ labels.Add("\"" + LabelPrefix + enableJob + "\":\"enabled\"");
+
+ return "{\"metadata\":{\"labels\":{" + string.Join(',', labels) + "}}}";
+ }
+
+ public static JsonObject JobDocument(
+ string name,
+ string ns,
+ string cron,
+ int retain,
+ string task,
+ IReadOnlyList groups,
+ int concurrency,
+ IReadOnlyDictionary? labels = null,
+ IReadOnlyDictionary? parameters = null) {
+ var spec = new JsonObject {
+ ["name"] = name,
+ ["task"] = task,
+ ["cron"] = cron,
+ ["retain"] = retain,
+ ["concurrency"] = concurrency < 1 ? 1 : concurrency
+ };
+
+ if (groups.Count > 0) {
+ var list = new JsonArray();
+
+ foreach (var group in groups)
+ list.Add(group);
+
+ spec["groups"] = list;
+ }
+
+ if (labels is { Count: > 0 }) {
+ var map = new JsonObject();
+
+ foreach (var (key, value) in labels)
+ map[key] = value;
+
+ spec["labels"] = map;
+ }
+
+ if (parameters is { Count: > 0 }) {
+ var map = new JsonObject();
+
+ foreach (var (key, value) in parameters)
+ map[key] = value;
+
+ spec["parameters"] = map;
+ }
+
+ return new JsonObject {
+ ["apiVersion"] = "longhorn.io/v1beta2",
+ ["kind"] = "RecurringJob",
+ ["metadata"] = new JsonObject {
+ ["name"] = name,
+ ["namespace"] = ns
+ },
+ ["spec"] = spec
+ };
+ }
+
+ public static IReadOnlyList GroupsOf(JsonObject item) {
+ if (item["spec"]?["groups"] is not JsonArray groups)
+ return [];
+
+ var names = new List();
+
+ foreach (var group in groups) {
+ var name = JsonPath.Text(group);
+
+ if (name.Length > 0)
+ names.Add(name);
+ }
+
+ return names;
+ }
+
+ public static IReadOnlyDictionary Runs(
+ IReadOnlyList cronJobs,
+ IReadOnlyList jobs) {
+ var runs = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var cron in cronJobs)
+ NoteSuccess(runs, JsonPath.Namespace(cron), JsonPath.Name(cron), JsonPath.Text(cron["status"]?["lastSuccessfulTime"]));
+
+ foreach (var job in jobs) {
+ var owner = CronJobOwner(job);
+
+ if (owner.Length == 0)
+ continue;
+
+ var acc = Account(runs, JsonPath.Namespace(job), owner);
+
+ if (InProgress(job)) {
+ acc.Active++;
+
+ continue;
+ }
+
+ if (Succeeded(job)) {
+ NoteSuccess(runs, JsonPath.Namespace(job), owner, JsonPath.Text(job["status"]?["completionTime"]));
+
+ continue;
+ }
+
+ if (!Failed(job) || !JsonPath.TryTimestamp(FinishedAt(job), out var when))
+ continue;
+
+ if (acc.HasFailure && when <= acc.FailureAt)
+ continue;
+
+ acc.HasFailure = true;
+ acc.FailureAt = when;
+ acc.Failure = FailureMessage(job);
+ }
+
+ return runs.ToDictionary(pair => pair.Key, pair => Describe(pair.Value), StringComparer.Ordinal);
+ }
+
+ public static string RunKey(string? ns, string name) =>
+ (ns ?? "") + "/" + name;
+
+ private static void NoteSuccess(Dictionary runs, string? ns, string name, string timestamp) {
+ if (name.Length == 0 || !JsonPath.TryTimestamp(timestamp, out var when))
+ return;
+
+ var acc = Account(runs, ns, name);
+
+ if (acc.HasSuccess && when <= acc.LastSuccess)
+ return;
+
+ acc.HasSuccess = true;
+ acc.LastSuccess = when;
+ }
+
+ private static RunAcc Account(Dictionary runs, string? ns, string name) {
+ var key = RunKey(ns, name);
+
+ if (runs.TryGetValue(key, out var acc))
+ return acc;
+
+ acc = new RunAcc();
+ runs[key] = acc;
+
+ return acc;
+ }
+
+ private static RecurringRun Describe(RunAcc acc) {
+ var last = acc.HasSuccess
+ ? acc.LastSuccess.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + "Z"
+ : "";
+
+ if (acc.Active > 0) {
+ var detail = acc.Active == 1 ? "A run is in progress." : acc.Active + " runs are in progress.";
+
+ return new RecurringRun("Running", detail, last);
+ }
+
+ if (acc.HasFailure && (!acc.HasSuccess || acc.FailureAt > acc.LastSuccess))
+ return new RecurringRun("Failed", acc.Failure.Length > 0 ? acc.Failure : "The latest run failed.", last);
+
+ return new RecurringRun("Idle", "", last);
+ }
+
+ private static bool InProgress(JsonObject job) =>
+ Count(job["status"]?["active"]) > 0 || (!Succeeded(job) && !Failed(job));
+
+ private static bool Succeeded(JsonObject job) =>
+ Count(job["status"]?["succeeded"]) > 0;
+
+ private static bool Failed(JsonObject job) {
+ if (job["status"]?["conditions"] is not JsonArray conditions)
+ return false;
+
+ foreach (var condition in conditions) {
+ if (condition is not JsonObject item)
+ continue;
+
+ if (JsonPath.Text(item["type"]).Equals("Failed", StringComparison.Ordinal) && JsonPath.Text(item["status"]).Equals("True", StringComparison.Ordinal))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static string FailureMessage(JsonObject job) {
+ if (job["status"]?["conditions"] is not JsonArray conditions)
+ return "The latest run failed.";
+
+ foreach (var condition in conditions) {
+ if (condition is not JsonObject item)
+ continue;
+
+ if (!JsonPath.Text(item["type"]).Equals("Failed", StringComparison.Ordinal))
+ continue;
+
+ var message = JsonPath.Text(item["message"]);
+
+ if (message.Length > 0)
+ return message;
+ }
+
+ return "The latest run failed.";
+ }
+
+ private static string FinishedAt(JsonObject job) {
+ var completion = JsonPath.Text(job["status"]?["completionTime"]);
+
+ if (completion.Length > 0)
+ return completion;
+
+ var start = JsonPath.Text(job["status"]?["startTime"]);
+
+ if (start.Length > 0)
+ return start;
+
+ return JsonPath.Text(job["metadata"]?["creationTimestamp"]);
+ }
+
+ private static int Count(JsonNode? node) {
+ if (node is JsonValue value) {
+ if (value.TryGetValue(out var count))
+ return count;
+
+ if (value.TryGetValue(out var wide))
+ return wide > int.MaxValue ? int.MaxValue : (int)wide;
+ }
+
+ return int.TryParse(JsonPath.Text(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : 0;
+ }
+
+ private sealed class RunAcc {
+ public DateTimeOffset LastSuccess;
+ public bool HasSuccess;
+ public int Active;
+ public DateTimeOffset FailureAt;
+ public bool HasFailure;
+ public string Failure = "";
+ }
+
+ private static string CronJobOwner(JsonObject job) {
+ if (job["metadata"]?["ownerReferences"] is not JsonArray owners)
+ return "";
+
+ foreach (var owner in owners) {
+ if (owner is not JsonObject item)
+ continue;
+
+ if (JsonPath.Text(item["kind"]).Equals("CronJob", StringComparison.Ordinal))
+ return JsonPath.Text(item["name"]);
+ }
+
+ return "";
+ }
+
+ public static IReadOnlyDictionary MapOf(JsonObject item, string field) {
+ if (item["spec"]?[field] is not JsonObject map)
+ return new Dictionary