基于client-go实现pod 交互式terminal
后端实现逻辑(golang)
| package main |
| |
| import ( |
| "errors" |
| "fmt" |
| "github.com/gin-gonic/gin" |
| "github.com/gorilla/websocket" |
| corev1 "k8s.io/api/core/v1" |
| "k8s.io/client-go/kubernetes" |
| "k8s.io/client-go/kubernetes/scheme" |
| "k8s.io/client-go/rest" |
| "k8s.io/client-go/tools/clientcmd" |
| "k8s.io/client-go/tools/remotecommand" |
| "log" |
| "net/http" |
| "strconv" |
| "strings" |
| "unicode/utf8" |
| ) |
| |
| |
| func initialClientSet(path string) (*kubernetes.Clientset, *rest.Config, error) { |
| config, err := clientcmd.BuildConfigFromFlags("", path) |
| if err != nil { |
| log.Fatal(err) |
| } |
| |
| ClientSet, err := kubernetes.NewForConfig(config) |
| if err != nil { |
| log.Fatal(err) |
| return nil, nil, err |
| } |
| return ClientSet, config, err |
| } |
| |
| func initialWS(c *gin.Context) (*websocket.Conn, error) { |
| var upgrader = websocket.Upgrader{ |
| ReadBufferSize: 1024, |
| WriteBufferSize: 1024, |
| CheckOrigin: func(r *http.Request) bool { |
| return true |
| }, |
| } |
| |
| ws, err := upgrader.Upgrade(c.Writer, c.Request, nil) |
| if err != nil { |
| fmt.Println(err) |
| return nil, err |
| } |
| return ws, err |
| } |
| |
| |
| type streamHandler struct { |
| ws *websocket.Conn |
| inputMsg chan []byte |
| resizeEvent chan remotecommand.TerminalSize |
| } |
| |
| |
| func (handler *streamHandler) Next() *remotecommand.TerminalSize { |
| resize := <-handler.resizeEvent |
| return &resize |
| } |
| |
| |
| func (handler *streamHandler) Read(p []byte) (size int, err error) { |
| data, ok := <-handler.inputMsg |
| if !ok { |
| return 0, errors.New("I/O data reading failed") |
| } |
| copy(p, data) |
| return len(data), nil |
| } |
| |
| |
| func (handler *streamHandler) Write(p []byte) (int, error) { |
| |
| if !utf8.Valid(p) { |
| bufStr := string(p) |
| buf := make([]rune, 0, len(bufStr)) |
| for _, r := range bufStr { |
| if r == utf8.RuneError { |
| buf = append(buf, []rune("@")...) |
| } else { |
| buf = append(buf, r) |
| } |
| } |
| p = []byte(string(buf)) |
| } |
| err := handler.ws.WriteMessage(websocket.TextMessage, p) |
| return len(p), err |
| } |
| |
| |
| func ToInt(str string) int { |
| data, err := strconv.Atoi(str) |
| if err != nil { |
| fmt.Println(err) |
| } |
| return data |
| } |
| |
| |
| func executeTask(handler *streamHandler) { |
| for { |
| _, msg, err := handler.ws.ReadMessage() |
| if err != nil { |
| return |
| } |
| |
| if string(msg) == "ping" { |
| continue |
| } |
| |
| if strings.Contains(string(msg), "resize") { |
| resizeSlice := strings.Split(string(msg), ":") |
| rows, _ := strconv.Atoi(resizeSlice[1]) |
| cols, _ := strconv.Atoi(resizeSlice[2]) |
| handler.resizeEvent <- remotecommand.TerminalSize{ |
| Width: uint16(cols), |
| Height: uint16(rows), |
| } |
| continue |
| } |
| handler.inputMsg <- msg |
| } |
| } |
| |
| func podTerminal(c *gin.Context) { |
| podName := c.Query("podName") |
| namespace := c.Query("namespace") |
| containerName := c.Query("containerName") |
| cols := c.Query("cols") |
| rows := c.Query("rows") |
| |
| ClientSet, config, err := initialClientSet("./kube/config") |
| if err != nil { |
| return |
| } |
| |
| |
| req := ClientSet.CoreV1().RESTClient().Post(). |
| Resource("pods"). |
| Name(podName). |
| Namespace(namespace). |
| SubResource("exec"). |
| VersionedParams(&corev1.PodExecOptions{ |
| Container: containerName, |
| Command: []string{"bash"}, |
| Stdin: true, |
| Stdout: true, |
| Stderr: true, |
| TTY: true, |
| }, scheme.ParameterCodec) |
| |
| |
| exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) |
| if err != nil { |
| log.Println(err) |
| return |
| } |
| |
| ws, err := initialWS(c) |
| defer func() { |
| ws.Close() |
| if err := recover(); err != nil { |
| log.Println(err) |
| } |
| }() |
| |
| handler := &streamHandler{ |
| ws: ws, |
| inputMsg: make(chan []byte, 1024), |
| resizeEvent: make(chan remotecommand.TerminalSize, 1), |
| } |
| |
| handler.resizeEvent <- remotecommand.TerminalSize{Width: uint16(ToInt(cols)), Height: uint16(ToInt(rows))} |
| |
| |
| go executeTask(handler) |
| |
| if err := exec.Stream(remotecommand.StreamOptions{ |
| Stdin: handler, |
| Stdout: handler, |
| Stderr: handler, |
| Tty: true, |
| TerminalSizeQueue: handler, |
| }); err != nil { |
| ws.Close() |
| return |
| } |
| |
| stopTimer := time.NewTimer(time.Minute * 30) |
| for { |
| select { |
| case <-stopTimer.C: |
| return |
| } |
| } |
| } |
前端实现逻辑(vue3)
| <template> |
| <div id="terminal"></div> |
| </template> |
| |
| <script> |
| import { Terminal } from 'xterm' |
| import { FitAddon } from 'xterm-addon-fit' |
| import { AttachAddon } from 'xterm-addon-attach' |
| import {onBeforeUnmount, onMounted } from "vue" |
| import 'xterm/css/xterm.css' |
| export default { |
| name: 'web-terminal', |
| setup(){ |
| //初始化ws连接 |
| let ws = new WebSocket("ws://localhost:9090?podName=xxxx&namespace=xxxx&containerName=xxx") |
| ws.onopen = ()=>{ |
| console.log(Date(), 'onopen') |
| heartCheck.start() |
| } |
| ws.onclose = ()=>{ |
| console.log(Date(), 'onclose') |
| heartCheck.stop() |
| } |
| ws.onerror = ()=> { |
| console.log(Date(), 'onerror') |
| } |
| |
| //心跳检查 |
| const heartCheck = { |
| timeout: 5000, // 5s发一次心跳 |
| //关闭心跳检查 |
| stop: function() { |
| clearInterval(this.timer) |
| }, |
| //开启心跳检查 |
| start: function() { |
| this.timer = setInterval(function() { |
| if (ws !== null && ws.readyState === 1) { |
| ws.send('ping') |
| } |
| }, this.timeout) |
| } |
| } |
| //页面挂载后初始化terminal功能 |
| onMounted(()=>{ |
| let webTerminal = document.getElementById('terminal') |
| let terminal = new Terminal( |
| { |
| fontSize: 16 |
| } |
| ) |
| let fitAddon = new FitAddon() |
| terminal.loadAddon(fitAddon) |
| terminal.open(webTerminal) |
| try { |
| fitAddon.fit() |
| } catch (e) { |
| console.error(e) |
| } |
| |
| //加载attach插件,通过ws实现web终端与远程终端进行实时交互 |
| terminal.loadAddon(new AttachAddon(ws)) |
| |
| //增加滚轮事件监听,用于调整web终端字体大小 |
| webTerminal.addEventListener("wheel", (e) => { |
| if (e.ctrlKey) { |
| e.preventDefault() |
| if (e.deltaY < 0) { |
| terminal.options.fontSize = ++self.fontSize |
| } else { |
| terminal.options.fontSize = --self.fontSize |
| } |
| try { fitAddon.fit() } catch (e) {/**/} |
| if (ws !== null && ws.readyState === 1) { |
| ws.send(`resize:${terminal.rows}:${terminal.cols}`) |
| } |
| } |
| |
| //为window添加窗口大小调整事件,用于实时调整终端窗口 |
| window.addEventListener('resize', () => { |
| webTerminal.style.height = document.documentElement.clientHeight + 'px' |
| try { fitAddon.fit() } catch (e) {/**/} |
| if (ws !== null && ws.readyState === 1) { |
| ws.send(`resize:${terminal.rows}:${terminal.cols}`) |
| } |
| }) |
| }) |
| |
| onBeforeUnmount(()=>{ |
| if (ws !== null) { |
| ws.close() |
| } |
| if (terminal !== null) { |
| terminal.dispose() |
| } |
| }) |
| }) |
| |
| } |
| } |
| </script> |
| |
| <style> |
| #terminal{ |
| position: absolute; |
| top: 0; right: 0; bottom: 0; left: 0; |
| } |
| </style> |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 零经验选手,Compose 一天开发一款小游戏!
· 因为Apifox不支持离线,我果断选择了Apipost!
· 通过 API 将Deepseek响应流式内容输出到前端