新增功能:node shell 新增功能:Custom Resource Definitions增加查看yaml的按钮 fixed: Terminal 中 Stream 改成 StreamWithContext 防止资源泄露 fixed: 终端默认使用sh(因为很多镜像没有bash但是一定有sh) (#255)

* 新增功能:Custom Resource Definitions增加查看yaml的按钮

* fixed: Terminal 中 Stream 改成 StreamWithContext 防止资源泄露

* fixed: 终端默认使用sh(因为很多镜像没有bash但是一定有sh)

* 新增功能:node shell

* fixed: node shell按钮增加权限控制
This commit is contained in:
awol2005ex
2024-08-05 16:56:25 +08:00
committed by GitHub
parent f35375d4e7
commit 12588713ce
13 changed files with 596 additions and 39 deletions

View File

@@ -623,6 +623,8 @@ func Install(parent iris.Party) {
sp.Get("/:name/apigroups/{group:path}", handler.ListApiGroupResources())
sp.Get("/:name/namespaces", handler.ListNamespace())
sp.Get("/:name/terminal/session", handler.TerminalSessionHandler())
//node shell
sp.Get("/:name/node_terminal/session", handler.NodeTerminalSessionHandler())
sp.Get("/:name/logging/session", handler.LoggingHandler())
sp.Get("/:name/repos", handler.ListClusterRepos())
sp.Get("/:name/repos/detail", handler.ListClusterReposDetail())

View File

@@ -59,3 +59,45 @@ func (h *Handler) TerminalSessionHandler() iris.Handler {
ctx.Values().Set("data", resp)
}
}
//node shell
func (h *Handler) NodeTerminalSessionHandler() iris.Handler {
return func(ctx *context.Context) {
nodeName := ctx.URLParam("nodeName")
sessionID, err := terminal.GenTerminalSessionId()
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Values().Set("message", err)
return
}
clusterName := ctx.Params().GetString("name")
c, err := h.clusterService.Get(clusterName, common.DBOptions{})
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Values().Set("message", err)
return
}
k := kubernetes.NewKubernetes(c)
conf, err := k.Config()
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Values().Set("message", err)
return
}
client, err := k.Client()
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Values().Set("message", err)
return
}
terminal.TerminalSessions.Set(sessionID, terminal.TerminalSession{
Id: sessionID,
Bound: make(chan error),
SizeChan: make(chan remotecommand.TerminalSize),
})
go terminal.WaitForNodeShellTerminal(client, conf, nodeName, sessionID)
resp := TerminalResponse{ID: sessionID}
ctx.Values().Set("data", resp)
}
}

View File

@@ -1,6 +1,7 @@
package terminal
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -18,6 +19,8 @@ import (
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
"k8s.io/utils/ptr"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const END_OF_TRANSMISSION = "\u0004"
@@ -244,8 +247,8 @@ func startProcess(k8sClient kubernetes.Interface, cfg *rest.Config, cmd []string
if err != nil {
return err
}
err = exec.Stream(remotecommand.StreamOptions{
ctx :=context.Background()
err = exec.StreamWithContext(ctx,remotecommand.StreamOptions{
Stdin: ptyHandler,
Stdout: ptyHandler,
Stderr: ptyHandler,
@@ -311,3 +314,139 @@ func WaitForTerminal(k8sClient kubernetes.Interface, cfg *rest.Config, namespace
TerminalSessions.Close(sessionId, 1, "Process exited")
}
}
//node shell
func WaitForNodeShellTerminal(k8sClient kubernetes.Interface, cfg *rest.Config,nodeName string, sessionId string) {
select {
case <-TerminalSessions.Get(sessionId).Bound:
close(TerminalSessions.Get(sessionId).Bound)
err := startNodeShellProcess(k8sClient, cfg, nodeName, TerminalSessions.Get(sessionId))
if err != nil {
TerminalSessions.Close(sessionId, 2, err.Error())
return
}
TerminalSessions.Close(sessionId, 1, "Process exited")
}
}
func startNodeShellProcess(k8sClient kubernetes.Interface, cfg *rest.Config, nodeName string, ptyHandler PtyHandler) error {
/*创建特权容器*/
containerName := "node-shell"
podName :="node-"+nodeName+"-shell"
namespace :="default"
image := "alpine:latest"
ctx :=context.Background()
getpod ,err :=k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
FieldSelector :"metadata.name="+podName+",metadata.namespace="+namespace,
})
if err != nil {
return err
}
//没有pod则创建
if len(getpod.Items) == 0 {
container := &v1.Container{
Name: containerName,
Image: image,
ImagePullPolicy: v1.PullIfNotPresent,
Stdin: true,
StdinOnce: true,
TTY: true,
Command: []string{"/bin/sh"},
SecurityContext: &v1.SecurityContext{
Privileged : ptr.To(true),
},
//挂载hostPath到容器中
VolumeMounts: []v1.VolumeMount{
{
Name: "host-root",
MountPath: "/host",
},
},
}
pod := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: podName,
Namespace: namespace,
},
Spec: v1.PodSpec{
Containers: []v1.Container{*container},
NodeName: nodeName,
RestartPolicy: v1.RestartPolicyNever,
HostNetwork: true,
HostPID: true,
Tolerations: []v1.Toleration{
{
Key: "CriticalAddonsOnly",
Operator: v1.TolerationOpExists,
},
{
Key: "NoExecute",
Operator: v1.TolerationOpExists,
},
},
//挂载hostPath到容器中
Volumes: []v1.Volume{
v1.Volume{
Name: "host-root",
VolumeSource: v1.VolumeSource{
HostPath: &v1.HostPathVolumeSource{
Path: "/",
},
},
},
},
},
}
_, err = k8sClient.CoreV1().Pods(namespace).Create(context.TODO(), pod, metav1.CreateOptions{
});
if err != nil {
return err
}
//等待pod创建完成
time.Sleep(5 * time.Second)
}
cmd :=[]string{"sh"}
req := k8sClient.CoreV1().RESTClient().Post().
Resource("pods").
Name(podName).
Namespace(namespace).
SubResource("exec")
req.VersionedParams(&v1.PodExecOptions{
Container: containerName,
Command: cmd,
Stdin: true,
Stdout: true,
Stderr: true,
TTY: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(cfg, "POST", req.URL())
if err != nil {
return err
}
err = exec.StreamWithContext(ctx,remotecommand.StreamOptions{
Stdin: ptyHandler,
Stdout: ptyHandler,
Stderr: ptyHandler,
TerminalSizeQueue: ptyHandler,
Tty: true,
})
if err != nil {
return err
}
return nil
}

View File

@@ -147,6 +147,26 @@ export default {
this.$router.push({ path: "/nodes/detail/" + row.metadata.name, query: { yamlShow: "true" } })
}
},
{
label: "Shell",
icon: "iconfont iconline-terminalzhongduan",
disabled: () => {
return !checkPermissions({
scope:'namespace',
apiGroup: "",
resource: "pods/exec",
verb: "create"
})
},
click: (row) => {
let routeUrl = this.$router.resolve({ path: "/node_terminal" , query: {
cluster: this.clusterName,
namespace: row.metadata.namespace,
nodeName: row.metadata.name,
} })
window.open(routeUrl.href, "_blank")
}
},
]
}
},

View File

@@ -0,0 +1,88 @@
<template>
<div style="background-color: #1f2224" >
<el-row>
<div style="float: right;margin-top: 15px;margin-bottom: 5px;margin-right: 30px">
<span style="font-size: 20px; color: white">node path / is mounted to shell /host</span>
</div>
</el-row>
<el-row>
<div>
<iframe :key="isRefresh" :src="terminal.url" :style="{'height': height}" style="width: 100%;border: 0"></iframe>
</div>
</el-row>
</div>
</template>
<script>
import KoFormItem from "@/components/ko-form-item/index"
import { isLogin } from "@/api/auth"
export default {
name: "NodeTerminal",
components: { KoFormItem },
data() {
return {
height: "",
isRefresh: false,
terminal: {
cluster: "",
nodeName:""
},
}
},
methods: {
getHeight() {
this.height = document.body.clientHeight - 51 + "px"
},
getTerminalUrl() {
return `${process.env.VUE_APP_TERMINAL_PATH}/node_shell?cluster=${this.terminal.cluster}&nodeName=${this.terminal.nodeName}`
},
pullingSession() {
this.timer = setInterval(() => {
isLogin().then(data => {
this.items = data.data;
}).catch(() => {
this.$message.error(this.$t('commons.login.expires'))
clearInterval(this.timer)
})
}, 5000)
},
},
created() {
this.terminal = {
cluster: this.$route.query.cluster,
nodeName: this.$route.query.nodeName
}
this.terminal.url = this.getTerminalUrl()
this.getHeight()
this.pullingSession()
},
}
</script>
<style lang="scss" scoped>
.terminalOption {
float: left;
margin-left: 5px;
margin-top: 10px;
margin-bottom: 10px;
}
.spanClass {
margin-left: 20px;
color: white;
}
.interval {
margin-left: 10px;
}
::v-deep .scrollbar {
.el-scrollbar__thumb {
background-color: black;
}
.el-table__body-wrapper::-webkit-scrollbar {
width: 3px;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<layout-content header="Custom Resource">
<complex-table :data="data" @search="search" :selects.sync="selects" v-loading="loading"
:pagination-config="paginationConfig" :search-config="searchConfig">
:pagination-config="paginationConfig" :search-config="searchConfig">
<template #header>
<el-button type="primary" size="small" :disabled="selects.length===0" @click="onDelete()"
v-has-permissions="{scope:'namespace',apiGroup:'apiextensions.k8s.io',resource:'customresourcedefinitions',verb:'delete'}">
<el-button type="primary" size="small" :disabled="selects.length === 0" @click="onDelete()"
v-has-permissions="{ scope: 'namespace', apiGroup: 'apiextensions.k8s.io', resource: 'customresourcedefinitions', verb: 'delete' }">
{{ $t("commons.button.delete") }}
</el-button>
</template>
@@ -15,14 +15,20 @@
<el-table-column label="Kind" show-overflow-tooltip prop="kind">
</el-table-column>
<el-table-column :label="$t('commons.table.name')" show-overflow-tooltip prop="metadata.name">
<template v-slot:default="{ row }">
<el-link @click="openEditPage(row, '0')" class="iconfont iconhuaban88"><span>
{{ row.metadata.name }}
</span></el-link>
</template>
</el-table-column>
<el-table-column v-if="show" :label="$t('business.namespace.namespace')" prop="metadata.namespace">
<template v-slot:default="{row}">
<template v-slot:default="{ row }">
{{ row.metadata.namespace }}
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.created_time')" prop="metadata.creationTimestamp" fix>
<template v-slot:default="{row}">
<template v-slot:default="{ row }">
{{ row.metadata.creationTimestamp | age }}
</template>
</el-table-column>
@@ -34,13 +40,13 @@
<script>
import LayoutContent from "@/components/layout/LayoutContent"
import ComplexTable from "@/components/complex-table"
import {downloadYaml} from "@/utils/actions"
import { downloadYaml } from "@/utils/actions"
import KoTableOperations from "@/components/ko-table-operations"
import {
deleteResource, getResource,
listResourceByGroup
} from "@/api/customresourcedefinitions"
import {checkPermissions} from "@/utils/permission"
import { checkPermissions } from "@/utils/permission"
export default {
name: "CRList",
@@ -54,7 +60,7 @@ export default {
default: "Cluster"
}
},
data () {
data() {
return {
data: [],
selects: [],
@@ -66,19 +72,7 @@ export default {
label: this.$t("commons.button.edit_yaml"),
icon: "el-icon-edit",
click: (row) => {
this.$router.push({
name: "CustomResourceEdit",
params: {
name: row.metadata.name,
cluster: this.cluster,
version: this.version,
group: this.group,
names: this.names,
},
query: {
namespace: row.metadata.namespace
}
})
this.openEditPage(row, '1')
},
disabled: () => {
return !checkPermissions({
@@ -89,6 +83,13 @@ export default {
})
}
},
{
label: this.$t("commons.button.view_yaml"),
icon: "el-icon-view",
click: (row) => {
this.openEditPage(row, '0')
}
},
{
label: this.$t("commons.button.download_yaml"),
icon: "el-icon-download",
@@ -123,7 +124,7 @@ export default {
}
},
methods: {
search (resetPage) {
search(resetPage) {
this.loading = true
if (resetPage) {
this.paginationConfig.currentPage = 1
@@ -134,14 +135,14 @@ export default {
this.paginationConfig.total = res.total
})
},
onDelete (row) {
onDelete(row) {
this.$confirm(
this.$t("commons.confirm_message.delete"),
this.$t("commons.message_box.prompt"), {
confirmButtonText: this.$t("commons.button.confirm"),
cancelButtonText: this.$t("commons.button.cancel"),
type: "warning",
}).then(() => {
confirmButtonText: this.$t("commons.button.confirm"),
cancelButtonText: this.$t("commons.button.cancel"),
type: "warning",
}).then(() => {
this.ps = []
if (row) {
this.ps.push(deleteResource(this.cluster, this.version, this.group, this.names, row.metadata.namespace.row.metadata.name))
@@ -167,8 +168,24 @@ export default {
}
})
},
openEditPage(row, editable) {
this.$router.push({
name: "CustomResourceEdit",
params: {
name: row.metadata.name,
cluster: this.cluster,
version: this.version,
group: this.group,
names: this.names,
editable: editable
},
query: {
namespace: row.metadata.namespace
}
})
}
},
created () {
created() {
this.cluster = this.$route.query.cluster
this.show = this.scope === "Namespaced"
this.search()
@@ -176,6 +193,4 @@ export default {
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<template>
<layout-content :header="$t('commons.button.edit')" :back-to="{name: 'CustomResourceDefinitions'}" v-loading="loading">
<layout-content :header="editable=='1'?$t('commons.button.edit'):$t('commons.button.view_yaml')" :back-to="{name: 'CustomResourceDefinitions'}" v-loading="loading">
<yaml-editor :value="yaml" :is-edit="true" ref="yaml_editor"></yaml-editor>
<div class="bottom-button">
<div class="bottom-button" v-if="editable=='1'">
<el-button @click="onCancel()">{{ $t("commons.button.cancel") }}</el-button>
<el-button v-loading="loading" @click="onSubmit" type="primary">
{{ $t("commons.button.submit") }}
@@ -22,7 +22,8 @@ export default {
name: String,
names: String,
version: String,
group: String
group: String,
editable: String
},
data () {
return {

View File

@@ -3,8 +3,8 @@
<el-row>
<div class="terminalOption" v-if="terminal.type ==='terminal'">
<el-radio-group size="mini" @change="changeConditions()" v-model="shell">
<el-radio-button label="bash"></el-radio-button>
<el-radio-button label="sh"></el-radio-button>
<el-radio-button label="bash"></el-radio-button>
</el-radio-group>
</div>
<div class="terminalOption">
@@ -102,7 +102,7 @@ export default {
data() {
return {
height: "",
shell: "bash",
shell: "sh",
isRefresh: false,
follow: true,
/*是否查看上次失败日志*/

View File

@@ -151,7 +151,7 @@ const Clusters = {
},
},
{
path: "/resource/:group/:names/:version/:name/edit",
path: "/resource/:group/:names/:version/:name/edit/:editable",
component: () => import("@/business/custom-resource/cr/edit"),
hidden: true,
props: true,

View File

@@ -0,0 +1,16 @@
import terminals from "@/business/cluster/nodes/terminal"
const Terminal = {
path: "/node_terminal?:cluster/:nodeName",
hidden: true,
component: terminals,
name: "NodeTerminal",
children: [
{
path: "/node_terminal",
component: () => import("@/business/cluster/nodes/terminal"),
},
]
}
export default Terminal

View File

@@ -1,6 +1,7 @@
import {Routes} from '@angular/router';
import {AppComponent} from "./app.component";
import {TerminalComponent} from "./terminal/terminal.component";
import {NodeShellTerminalComponent} from "./terminal/node_terminal.component";
import {LoggingComponent} from "./logging/logging.component";
@@ -10,6 +11,7 @@ export const routes: Routes = [
{path: '', redirectTo: 'app', pathMatch: 'full'},
{path: 'app', component: TerminalComponent},
{path: 'logging', component: LoggingComponent},
{path: 'node_shell', component: NodeShellTerminalComponent},
{path: '*', redirectTo: '', pathMatch: 'full'},
]
},

View File

@@ -0,0 +1,225 @@
import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import {Terminal} from 'xterm';
import {FitAddon} from 'xterm-addon-fit';
import {ReplaySubject, Subject} from "rxjs";
import {ShellFrame, SJSCloseEvent, SJSMessageEvent} from "./terminal";
import {ActivatedRoute, Router} from "@angular/router";
import {debounce} from 'lodash';
import {takeUntil} from "rxjs/operators";
import {TerminalService} from "./terminal.service";
declare let SockJS: any;
@Component({
selector: 'app-node-terminal',
templateUrl: './terminal.component.html',
styleUrls: ['./terminal.component.css']
})
export class NodeShellTerminalComponent implements AfterViewInit {
@ViewChild('anchor', {static: true}) anchorRef: ElementRef;
term: Terminal;
nodeName: string;
clusterName: string;
private connecting_: boolean
private connectionClosed_: boolean
private conn_: WebSocket
private connected_ = false;
private debouncedFit_: Function
private connSubject_ = new ReplaySubject<ShellFrame>(100)
private incommingMessage$_ = new Subject<ShellFrame>()
// private readonly endpoint_=
private readonly unsubscribe_ = new Subject<void>()
private readonly keyEvent$_ = new ReplaySubject<KeyboardEvent>(2)
constructor(private activatedRoute_: ActivatedRoute,
private readonly cdr_: ChangeDetectorRef,
private _router: Router,
private terminalService: TerminalService
) {
this.clusterName = this.activatedRoute_.snapshot.queryParams["cluster"]
this.nodeName = this.activatedRoute_.snapshot.queryParams["nodeName"]
}
ngAfterViewInit(): void {
if (this.nodeName) {
this.setupConnection()
} else {
alert("please set param: nodeName name ")
}
}
ngOnDestroy(): void {
this.unsubscribe_.next();
this.unsubscribe_.complete();
if (this.conn_) {
this.conn_.close();
}
if (this.connSubject_) {
this.connSubject_.complete();
}
if (this.term) {
this.term.dispose();
}
this.incommingMessage$_.complete();
}
disconnect(): void {
if (this.conn_) {
this.conn_.close();
}
if (this.connSubject_) {
this.connSubject_.complete();
this.connSubject_ = new ReplaySubject<ShellFrame>(100);
}
if (this.term) {
this.term.dispose();
}
this.incommingMessage$_.complete();
this.incommingMessage$_ = new Subject<ShellFrame>();
}
initTerm(): void {
if (this.connSubject_) {
this.connSubject_.complete()
this.connSubject_ = new ReplaySubject<ShellFrame>(100);
}
if (this.term) {
this.term.dispose()
}
this.term = new Terminal({
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
bellStyle: 'sound',
cursorBlink: true,
});
const fitAddon = new FitAddon();
this.term.loadAddon(fitAddon);
this.term.open(this.anchorRef.nativeElement);
this.debouncedFit_ = debounce(() => {
fitAddon.fit()
this.cdr_.markForCheck();
}, 100)
this.debouncedFit_();
window.addEventListener('resize', () => this.debouncedFit_())
this.connSubject_.pipe(takeUntil(this.unsubscribe_)).subscribe(frame => {
this.handleConnectionMessage(frame);
})
this.term.onData(this.onTerminalSendingString.bind(this))
this.term.onResize(this.onTerminalResize.bind(this))
this.term.onKey(event => {
this.keyEvent$_.next(event.domEvent)
})
this.cdr_.markForCheck()
}
private onConnectionOpen(sessionId: string): void {
const startData = {Op: 'bind', SessionID: sessionId};
this.conn_.send(JSON.stringify(startData));
this.connSubject_.next(startData);
this.connected_ = true;
this.connecting_ = false;
this.connectionClosed_ = false;
// Make sure the terminal is with correct display size.
this.onTerminalResize();
// Focus on connection
this.term.focus();
this.cdr_.markForCheck();
}
private async setupConnection(): Promise<void> {
if (!(this.nodeName && !this.connecting_)) {
return;
}
this.connecting_ = true;
this.connectionClosed_ = false;
try {
const {data} = await this.terminalService.createNodeShellTerminalSession(this.clusterName, this.nodeName).toPromise()
const id = data.id
this.conn_ = new SockJS(`/kubepi/api/v1/ws/terminal/sockjs?${id}`);
this.conn_.onopen = this.onConnectionOpen.bind(this, id);
this.conn_.onmessage = this.onConnectionMessage.bind(this);
this.conn_.onclose = this.onConnectionClose.bind(this);
this.initTerm()
this.cdr_.markForCheck();
} catch (e:any) {
this.term.write(e.error.message)
}
}
private handleConnectionMessage(frame: ShellFrame): void {
if (frame.Op === 'stdout') {
if (frame.Data)
this.term.write(frame.Data);
}
if (frame.Op === 'toast') {
alert(frame.Data)
}
this.incommingMessage$_.next(frame);
this.cdr_.markForCheck();
}
private onConnectionMessage(evt: SJSMessageEvent): void {
const msg = JSON.parse(evt.data);
this.connSubject_.next(msg);
}
private onConnectionClose(_evt?: SJSCloseEvent): void {
this.term.write("\n\n******** The connection failed. Unsupported, interrupted or timed out. ********")
if (!this.connected_) {
return;
}
this.conn_.close();
this.connected_ = false;
this.connecting_ = false;
this.connectionClosed_ = true;
// alert(_evt?.reason)
this.cdr_.markForCheck();
}
private onTerminalSendingString(str: string): void {
if (this.connected_) {
this.conn_.send(JSON.stringify({
Op: 'stdin',
Data: str,
Cols: this.term.cols,
Rows: this.term.rows,
}))
}
}
private onTerminalResize(): void {
if (this.connected_) {
this.conn_.send(
JSON.stringify({
Op: 'resize',
Cols: this.term.cols,
Rows: this.term.rows,
})
);
}
}
}

View File

@@ -21,4 +21,11 @@ export class TerminalService {
}()
return this.http.get<any>(url)
}
createNodeShellTerminalSession(clusterName: string, nodeName: string): Observable<any> {
const url = function () {
let baseUrl = `/kubepi/api/v1/clusters/${clusterName}/node_terminal/session?nodeName=${nodeName}`
return baseUrl
}()
return this.http.get<any>(url)
}
}