Skip to content

Commit 28fbd20

Browse files
authored
feat: pod file browser (#298)
1 parent 23b04d7 commit 28fbd20

File tree

14 files changed

+845
-15
lines changed

14 files changed

+845
-15
lines changed

pkg/cluster/prometheus.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ var discoveryLabels = []client.MatchingLabels{
2020
{
2121
"app.kubernetes.io/instance": "vm",
2222
},
23+
{
24+
"app.kubernetes.io/part-of": "kube-prometheus-stack",
25+
},
2326
}
2427

2528
func discoveryPrometheusURL(kc *kube.K8sClient) string {

pkg/handlers/resources/pod_handler.go

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@ package resources
33
import (
44
"encoding/json"
55
"fmt"
6+
"mime"
67
"net/http"
8+
"path/filepath"
9+
"sort"
10+
"strings"
711
"time"
812

913
"github.com/gin-gonic/gin"
1014
"github.com/samber/lo"
1115
"github.com/zxh326/kite/pkg/cluster"
16+
"github.com/zxh326/kite/pkg/common"
17+
"github.com/zxh326/kite/pkg/kube"
18+
"github.com/zxh326/kite/pkg/model"
19+
"github.com/zxh326/kite/pkg/rbac"
1220
corev1 "k8s.io/api/core/v1"
1321
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1422
"k8s.io/apimachinery/pkg/watch"
@@ -169,9 +177,241 @@ func (h *PodHandler) List(c *gin.Context) {
169177
func (h *PodHandler) registerCustomRoutes(group *gin.RouterGroup) {
170178
// watch pods in namespace (or _all)
171179
group.GET("/:namespace/watch", h.Watch)
180+
filesGroup := group.Group("/:namespace/:name/files")
181+
filesGroup.Use(func(c *gin.Context) {
182+
user := c.MustGet("user").(model.User)
183+
cs := c.MustGet("cluster").(*cluster.ClientSet)
184+
namespace := c.Param("namespace")
185+
if !rbac.CanAccess(user, "pods", string(common.VerbExec), cs.Name, namespace) {
186+
c.JSON(http.StatusForbidden, gin.H{"error": rbac.NoAccess(user.Key(), string(common.VerbExec), "pods", namespace, cs.Name)})
187+
c.Abort()
188+
return
189+
}
190+
c.Next()
191+
})
192+
filesGroup.GET("", h.ListFiles)
193+
filesGroup.GET("/preview", h.PreviewFile)
194+
filesGroup.GET("/download", h.DownloadFile)
195+
filesGroup.PUT("/upload", h.UploadFile)
196+
}
197+
198+
type FileInfo struct {
199+
Name string `json:"name"`
200+
IsDir bool `json:"isDir"`
201+
Size string `json:"size"`
202+
ModTime string `json:"modTime"`
203+
Mode string `json:"mode"`
204+
UID string `json:"uid,omitempty"`
205+
GID string `json:"gid,omitempty"`
206+
}
207+
208+
func (h *PodHandler) ListFiles(c *gin.Context) {
209+
namespace := c.Param("namespace")
210+
podName := c.Param("name")
211+
container := c.Query("container")
212+
path := c.Query("path")
213+
if path == "" {
214+
path = "/"
215+
}
216+
cs := c.MustGet("cluster").(*cluster.ClientSet)
217+
cmd := []string{"ls", "-lah", "--full-time", path}
218+
stdout, stderr, err := cs.K8sClient.ExecCommandBuffered(c.Request.Context(), namespace, podName, container, cmd)
219+
if err != nil {
220+
if strings.Contains(err.Error(), "not found") || strings.Contains(stderr, "not found") {
221+
c.JSON(http.StatusBadRequest, gin.H{
222+
"error": fmt.Sprintf("File browsing is not supported for %s container (missing 'ls' command)", container),
223+
})
224+
return
225+
}
226+
c.JSON(http.StatusOK, nil)
227+
return
228+
}
229+
230+
files := parseLsOutput(stdout)
231+
c.JSON(http.StatusOK, files)
232+
}
233+
234+
func parseLsOutput(output string) []FileInfo {
235+
lines := strings.Split(output, "\n")
236+
files := make([]FileInfo, 0)
237+
for _, line := range lines {
238+
if strings.HasPrefix(line, "total") || strings.TrimSpace(line) == "" {
239+
continue
240+
}
241+
parts := strings.Fields(line)
242+
if len(parts) < 9 {
243+
continue
244+
}
245+
246+
mode := parts[0]
247+
isDir := strings.HasPrefix(mode, "d")
248+
249+
uid := parts[2]
250+
gid := parts[3]
251+
size := parts[4]
252+
253+
rawDate := strings.Join(parts[5:7], " ")
254+
modTime := rawDate
255+
name := strings.Join(parts[8:], " ")
256+
// Skip . and ..
257+
if name == "." || name == ".." {
258+
continue
259+
}
260+
files = append(files, FileInfo{
261+
Name: name,
262+
IsDir: isDir,
263+
Size: size,
264+
ModTime: modTime,
265+
Mode: mode,
266+
UID: uid,
267+
GID: gid,
268+
})
269+
}
270+
sort.Slice(files, func(i, j int) bool {
271+
// Directories first
272+
if files[i].IsDir && !files[j].IsDir {
273+
return true
274+
}
275+
if !files[i].IsDir && files[j].IsDir {
276+
return false
277+
}
278+
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
279+
})
280+
return files
281+
}
282+
283+
func (h *PodHandler) PreviewFile(c *gin.Context) {
284+
namespace := c.Param("namespace")
285+
podName := c.Param("name")
286+
container := c.Query("container")
287+
path := c.Query("path")
288+
cs := c.MustGet("cluster").(*cluster.ClientSet)
289+
if path == "" {
290+
c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"})
291+
return
292+
}
293+
if strings.Contains(path, "->") {
294+
path = strings.TrimSpace(strings.SplitN(path, "->", 2)[0])
295+
}
296+
297+
cmd := []string{"cat", path}
298+
299+
contentType := mime.TypeByExtension(filepath.Ext(path))
300+
if contentType == "" {
301+
contentType = "text/plain; charset=utf-8"
302+
}
303+
c.Header("Content-Type", contentType)
304+
c.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(path)))
305+
306+
err := cs.K8sClient.ExecCommand(c.Request.Context(), kube.ExecOptions{
307+
Namespace: namespace,
308+
PodName: podName,
309+
ContainerName: container,
310+
Command: cmd,
311+
Stdout: c.Writer,
312+
Stderr: nil,
313+
TTY: false,
314+
})
315+
316+
if err != nil {
317+
klog.Errorf("Failed to preview file: %v", err)
318+
}
319+
}
320+
321+
func (h *PodHandler) DownloadFile(c *gin.Context) {
322+
namespace := c.Param("namespace")
323+
podName := c.Param("name")
324+
container := c.Query("container")
325+
path := c.Query("path")
326+
327+
cs := c.MustGet("cluster").(*cluster.ClientSet)
328+
329+
if path == "" {
330+
c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"})
331+
return
332+
}
333+
if strings.Contains(path, "->") {
334+
path = strings.TrimSpace(strings.SplitN(path, "->", 2)[0])
335+
}
336+
_, _, err := cs.K8sClient.ExecCommandBuffered(c.Request.Context(), namespace, podName, container, []string{"test", "-d", path})
337+
isDir := err == nil
338+
339+
var cmd []string
340+
if isDir {
341+
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar\"", filepath.Base(path)))
342+
c.Header("Content-Type", "application/x-tar")
343+
cmd = []string{"tar", "cf", "-", path}
344+
} else {
345+
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path)))
346+
c.Header("Content-Type", "application/octet-stream")
347+
cmd = []string{"cat", path}
348+
}
349+
350+
err = cs.K8sClient.ExecCommand(c.Request.Context(), kube.ExecOptions{
351+
Namespace: namespace,
352+
PodName: podName,
353+
ContainerName: container,
354+
Command: cmd,
355+
Stdout: c.Writer,
356+
Stderr: nil,
357+
TTY: false,
358+
})
359+
360+
if err != nil {
361+
klog.Errorf("Failed to download file: %v", err)
362+
}
363+
}
364+
365+
func (h *PodHandler) UploadFile(c *gin.Context) {
366+
namespace := c.Param("namespace")
367+
podName := c.Param("name")
368+
container := c.Query("container")
369+
path := c.Query("path")
370+
cs := c.MustGet("cluster").(*cluster.ClientSet)
371+
if path == "" {
372+
c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"})
373+
return
374+
}
375+
376+
file, header, err := c.Request.FormFile("file")
377+
if err != nil {
378+
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to get file from request"})
379+
return
380+
}
381+
defer func() {
382+
if err := file.Close(); err != nil {
383+
klog.Errorf("failed to close uploaded file: %v", err)
384+
}
385+
}()
386+
387+
filename := filepath.Base(header.Filename)
388+
if filename == "." || filename == ".." || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
389+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
390+
return
391+
}
392+
393+
destPath := filepath.Join(path, header.Filename)
394+
cmd := []string{"tee", destPath}
395+
396+
err = cs.K8sClient.ExecCommand(c.Request.Context(), kube.ExecOptions{
397+
Namespace: namespace,
398+
PodName: podName,
399+
ContainerName: container,
400+
Command: cmd,
401+
Stdin: file, // Stream file content directly
402+
Stdout: nil,
403+
Stderr: nil,
404+
TTY: false,
405+
})
406+
407+
if err != nil {
408+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to upload file: %v", err)})
409+
return
410+
}
411+
412+
c.JSON(http.StatusOK, gin.H{"message": "file uploaded successfully"})
172413
}
173414

174-
// writeSSE writes a single SSE event with the given name and payload
175415
func writeSSE(c *gin.Context, event string, payload any) error {
176416
c.Writer.Header().Set("Content-Type", "text/event-stream")
177417
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package resources
2+
3+
import "testing"
4+
5+
func TestLsFileParsing(t *testing.T) {
6+
output := `
7+
total 68K
8+
dr-xr-xr-x 1 root root 4.0K 2025-12-10 16:22:19 +0000 .
9+
dr-xr-xr-x 1 root root 4.0K 2025-12-10 16:22:19 +0000 ..
10+
drwxr-xr-x 1 root root 4.0K 2025-10-02 12:23:51 +0000 bin
11+
drwxr-xr-x 5 root root 360 2025-12-10 16:22:19 +0000 dev
12+
drwxr-xr-x 1 root root 4.0K 2025-12-10 16:22:19 +0000 etc
13+
drwxr-xr-x 2 root root 4.0K 2025-05-30 12:13:44 +0000 home
14+
drwxr-xr-x 1 root root 4.0K 2025-05-30 12:13:44 +0000 lib
15+
drwxr-xr-x 5 root root 4.0K 2025-05-30 12:13:44 +0000 media
16+
drwxr-xr-x 2 root root 4.0K 2025-05-30 12:13:44 +0000 mnt
17+
drwxr-xr-x 1 root root 4.0K 2025-10-02 12:30:48 +0000 opt
18+
dr-xr-xr-x 185 root root 0 2025-12-10 16:22:19 +0000 proc
19+
drwx------ 2 root root 4.0K 2025-05-30 12:13:44 +0000 root
20+
drwxr-xr-x 1 root root 4.0K 2025-12-10 16:22:19 +0000 run
21+
drwxr-xr-x 1 root root 4.0K 2025-10-02 12:23:47 +0000 sbin
22+
drwxr-xr-x 2 root root 4.0K 2025-05-30 12:13:44 +0000 srv
23+
dr-xr-xr-x 13 root root 0 2025-12-10 16:21:42 +0000 sys
24+
drwxrwxrwt 2 root root 4.0K 2025-05-30 12:13:44 +0000 tmp
25+
drwxr-xr-x 1 root root 4.0K 2025-05-30 12:13:44 +0000 usr
26+
drwxr-xr-x 1 root root 4.0K 2025-05-30 12:13:44 +0000 var
27+
`
28+
files := parseLsOutput(output)
29+
expectedFileCount := 17
30+
if len(files) != expectedFileCount {
31+
t.Fatalf("expected %d files, got %d", expectedFileCount, len(files))
32+
}
33+
34+
varFile := files[16]
35+
if varFile.Name != "var" {
36+
t.Errorf("expected file name 'var', got '%s'", varFile.Name)
37+
}
38+
if varFile.IsDir != true {
39+
t.Errorf("expected 'var' to be a directory")
40+
}
41+
if varFile.Size != "4.0K" {
42+
t.Errorf("expected 'var' size to be 4.0K, got %s", varFile.Size)
43+
}
44+
if varFile.Mode != "drwxr-xr-x" {
45+
t.Errorf("expected 'var' mode to be 'drwxr-xr-x', got '%s'", varFile.Mode)
46+
}
47+
if varFile.ModTime != "2025-05-30 12:13:44" {
48+
t.Errorf("expected 'var' mod time to be '2025-05-30 12:13:44', got '%s'", varFile.ModTime)
49+
}
50+
if varFile.UID != "root" {
51+
t.Errorf("expected 'var' uid to be 'root', got '%s'", varFile.UID)
52+
}
53+
if varFile.GID != "root" {
54+
t.Errorf("expected 'var' gid to be 'root', got '%s'", varFile.GID)
55+
}
56+
}

pkg/kube/client.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ func NewClient(config *rest.Config) (*K8sClient, error) {
119119
}, nil
120120
}
121121

122-
func (k *K8sClient) Stop(name string) {
122+
func (c *K8sClient) Stop(name string) {
123123
klog.Infof("Stopping K8s client for %s", name)
124-
k.cancel()
124+
c.cancel()
125125
}
126126

127127
// GetScheme returns the runtime scheme used by the client

0 commit comments

Comments
 (0)