完善了webterminal功能

This commit is contained in:
panshuxiao
2025-05-28 01:30:51 +08:00
repo.diff.parent 475b1d0c22
repo.diff.commit 72a526c65a
repo.diff.stats_desc%!(EXTRA int=3, int=377, int=22)

repo.diff.view_file

@@ -19,6 +19,7 @@ package devcontainer
import (
"context"
"strconv"
"strings"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
@@ -46,6 +47,7 @@ type DevcontainerAppReconciler struct {
// +kubebuilder:rbac:groups=devcontainer.devstar.cn,resources=devcontainerapps/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=create;delete;get;list;watch
// +kubebuilder:rbac:groups="",resources=services,verbs=create;delete;get;list;watch
// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;delete
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
@@ -69,6 +71,44 @@ func (r *DevcontainerAppReconciler) Reconcile(ctx context.Context, req ctrl.Requ
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 添加 finalizer 处理逻辑
finalizerName := "devcontainer.devstar.cn/finalizer"
// 检查对象是否正在被删除
if !app.ObjectMeta.DeletionTimestamp.IsZero() {
// 对象正在被删除 - 处理 finalizer
if k8s_sigs_controller_runtime_utils.ContainsFinalizer(app, finalizerName) {
// 执行清理操作
logger.Info("Cleaning up resources before deletion", "name", app.Name)
// 查找并删除关联的 PVC
if err := r.cleanupPersistentVolumeClaims(ctx, app); err != nil {
logger.Error(err, "Failed to clean up PVCs")
return ctrl.Result{}, err
}
// 删除完成后移除 finalizer
k8s_sigs_controller_runtime_utils.RemoveFinalizer(app, finalizerName)
if err := r.Update(ctx, app); err != nil {
logger.Error(err, "Failed to remove finalizer")
return ctrl.Result{}, err
}
}
// 已标记为删除且处理完成,允许继续删除流程
return ctrl.Result{}, nil
}
// 如果对象不包含 finalizer就添加它
if !k8s_sigs_controller_runtime_utils.ContainsFinalizer(app, finalizerName) {
logger.Info("Adding finalizer", "name", app.Name)
k8s_sigs_controller_runtime_utils.AddFinalizer(app, finalizerName)
if err := r.Update(ctx, app); err != nil {
logger.Error(err, "Failed to add finalizer")
return ctrl.Result{}, err
}
}
// 检查停止容器的注解
if desiredReplicas, exists := app.Annotations["devstar.io/desiredReplicas"]; exists && desiredReplicas == "0" {
logger.Info("DevContainer stop requested via annotation", "name", app.Name)
@@ -353,6 +393,52 @@ func (r *DevcontainerAppReconciler) Reconcile(ctx context.Context, req ctrl.Requ
return ctrl.Result{}, nil
}
// cleanupPersistentVolumeClaims 查找并删除与 DevcontainerApp 关联的所有 PVC
func (r *DevcontainerAppReconciler) cleanupPersistentVolumeClaims(ctx context.Context, app *devcontainer_v1.DevcontainerApp) error {
logger := log.FromContext(ctx)
// 查找关联的 PVC
pvcList := &core_v1.PersistentVolumeClaimList{}
// 按标签筛选
labelSelector := client.MatchingLabels{
"app": app.Name,
}
if err := r.List(ctx, pvcList, client.InNamespace(app.Namespace), labelSelector); err != nil {
return err
}
// 如果按标签没找到,尝试按名称模式查找
if len(pvcList.Items) == 0 {
if err := r.List(ctx, pvcList, client.InNamespace(app.Namespace)); err != nil {
return err
}
// 筛选出名称包含 DevcontainerApp 名称的 PVC
var filteredItems []core_v1.PersistentVolumeClaim
for _, pvc := range pvcList.Items {
// StatefulSet PVC 命名格式通常为: <volumeClaimTemplate名称>-<StatefulSet名称>-<序号>
// 检查是否包含 app 名称作为名称的一部分
if strings.Contains(pvc.Name, app.Name+"-") {
filteredItems = append(filteredItems, pvc)
logger.Info("Found PVC to delete", "name", pvc.Name)
}
}
pvcList.Items = filteredItems
}
// 删除找到的 PVC
for i := range pvcList.Items {
logger.Info("Deleting PVC", "name", pvcList.Items[i].Name)
if err := r.Delete(ctx, &pvcList.Items[i]); err != nil && !errors.IsNotFound(err) {
logger.Error(err, "Failed to delete PVC", "name", pvcList.Items[i].Name)
return err
}
}
return nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *DevcontainerAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).

repo.diff.view_file

@@ -409,9 +409,25 @@ func GetWebTerminalURL(ctx context.Context, devcontainerName string) (string, er
log.Error("GetWebTerminalURL: 加载配置文件失败: %v", err)
return "", err
}
// 检查是否启用了基于路径的访问方式
domain := cfg.Section("server").Key("DOMAIN").Value()
log.Info("GetWebTerminalURL: 生成 ttyd URL: http://%s:%d/", domain, ttydNodePort)
return fmt.Sprintf("http://%s:%d/", domain, ttydNodePort), nil
scheme := "https"
// 从容器名称中提取用户名和仓库名
parts := strings.Split(devcontainerName, "-")
if len(parts) >= 2 {
username := parts[0]
repoName := parts[1]
// 构建访问路径
path := fmt.Sprintf("/%s/%s/dev-container-webterminal", username, repoName)
terminalURL := fmt.Sprintf("%s://%s%s", scheme, domain, path)
log.Info("GetWebTerminalURL: 使用 Ingress 路径方式生成 ttyd URL: %s", terminalURL)
return terminalURL, nil
}
}
// 如果没有找到ttyd端口记录详细的调试信息
@@ -444,26 +460,12 @@ func GetWebTerminalURL(ctx context.Context, devcontainerName string) (string, er
}
func Get_IDE_TerminalURL(ctx *gitea_context.Context, devcontainer *RepoDevContainer) (string, error) {
var access_token string
defalut_ctx := context.Background()
// 获取端口号
cli, err := docker.CreateDockerClient(&defalut_ctx)
if err != nil {
return "", err
}
defer cli.Close()
containerID, err := docker.GetContainerID(cli, devcontainer.DevContainerName)
if err != nil {
return "", err
}
port, err := docker.GetMappedPort(cli, containerID, "22")
if err != nil {
return "", err
}
// 检查session
// 检查 session 中是否已存在 token
if ctx.Session.Get("access_token") != nil {
access_token = ctx.Session.Get("access_token").(string)
} else {
// 生成token
// 生成 token
token := &auth_model.AccessToken{
UID: devcontainer.UserId,
Name: "terminal_login_token",
@@ -486,8 +488,67 @@ func Get_IDE_TerminalURL(ctx *gitea_context.Context, devcontainer *RepoDevContai
}
ctx.Session.Set("terminal_login_token", token.Token)
access_token = token.Token
}
// 根据不同的代理类型获取 SSH 端口
var port string
switch setting.Devcontainer.Agent {
case setting.KUBERNETES, "k8s":
// 创建 K8s 客户端
apiRequestContext := ctx.Req.Context()
k8sClient, err := devcontainer_k8s_agent_module.GetKubernetesClient(&apiRequestContext)
if err != nil {
log.Error("Get_IDE_TerminalURL: 创建 K8s 客户端失败: %v", err)
return "", err
}
// 获取 DevcontainerApp 资源
opts := &devcontainer_k8s_agent_module.GetDevcontainerOptions{
GetOptions: metav1.GetOptions{},
Name: devcontainer.DevContainerName,
Namespace: setting.Devcontainer.Namespace,
Wait: false,
}
log.Info("Get_IDE_TerminalURL: 从 K8s 获取 DevcontainerApp %s, namespace=%s",
devcontainer.DevContainerName, setting.Devcontainer.Namespace)
devcontainerApp, err := devcontainer_k8s_agent_module.GetDevcontainer(&apiRequestContext, k8sClient, opts)
if err != nil {
log.Error("Get_IDE_TerminalURL: 获取 DevcontainerApp 失败: %v", err)
return "", err
}
// 使用 NodePort 作为 SSH 端口
port = fmt.Sprintf("%d", devcontainerApp.Status.NodePortAssigned)
log.Info("Get_IDE_TerminalURL: K8s 环境使用 NodePort %s 作为 SSH 端口", port)
case setting.DOCKER:
// 原有 Docker 处理逻辑
defalut_ctx := context.Background()
cli, err := docker.CreateDockerClient(&defalut_ctx)
if err != nil {
return "", err
}
defer cli.Close()
containerID, err := docker.GetContainerID(cli, devcontainer.DevContainerName)
if err != nil {
return "", err
}
mappedPort, err := docker.GetMappedPort(cli, containerID, "22")
if err != nil {
return "", err
}
port = mappedPort
default:
return "", fmt.Errorf("不支持的 DevContainer Agent 类型: %s", setting.Devcontainer.Agent)
}
// 构建并返回 URL
return "://mengning.devstar/" +
"openProject?host=" + devcontainer.DevContainerHost +
"&port=" + port +
@@ -495,7 +556,6 @@ func Get_IDE_TerminalURL(ctx *gitea_context.Context, devcontainer *RepoDevContai
"&path=" + devcontainer.DevContainerWorkDir +
"&access_token=" + access_token +
"&devstar_username=" + devcontainer.RepoOwnerName, nil
}
func AddPublicKeyToAllRunningDevContainer(ctx context.Context, user *user_model.User, publicKey string) error {

repo.diff.view_file

@@ -3,6 +3,7 @@ package devcontainer
import (
"context"
"fmt"
"strings"
"time"
"code.gitea.io/gitea/models/db"
@@ -16,9 +17,14 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/devcontainer/errors"
"code.gitea.io/gitea/services/devstar_cloud_provider"
networkingv1 "k8s.io/api/networking/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
var k8sGroupVersionResource = schema.GroupVersionResource{
@@ -105,6 +111,32 @@ func AssignDevcontainerDeletion2K8sOperator(ctx *context.Context, devcontainersL
return err
}
// 获取标准 Kubernetes 客户端,用于删除 Ingress
stdClient, err := getStandardKubernetesClient()
if err != nil {
log.Warn("AssignDevcontainerDeletion2K8sOperator: 获取标准 K8s 客户端失败: %v", err)
// 继续执行,不阻止主流程
} else {
// 先删除与 DevContainer 相关的 Ingress 资源
for _, devcontainer := range *devcontainersList {
ingressName := fmt.Sprintf("%s-ttyd-ingress", devcontainer.Name)
log.Info("AssignDevcontainerDeletion2K8sOperator: 删除 Ingress %s", ingressName)
err := stdClient.NetworkingV1().Ingresses(setting.Devcontainer.Namespace).Delete(*ctx, ingressName, metav1.DeleteOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
// Ingress 已经不存在,视为正常情况
log.Info("AssignDevcontainerDeletion2K8sOperator: Ingress %s 不存在,跳过删除", ingressName)
} else {
log.Warn("AssignDevcontainerDeletion2K8sOperator: 删除 Ingress %s 失败: %v", ingressName, err)
// 继续执行,不阻止主流程
}
} else {
log.Info("AssignDevcontainerDeletion2K8sOperator: 成功删除 Ingress %s", ingressName)
}
}
}
// 2. 调用 modules 层 k8s Agent执行删除资源
opts := &devcontainer_dto.DeleteDevcontainerOptions{
DeleteOptions: metav1.DeleteOptions{},
@@ -120,11 +152,20 @@ func AssignDevcontainerDeletion2K8sOperator(ctx *context.Context, devcontainersL
for _, devcontainer := range *devcontainersList {
opts.Name = devcontainer.Name
_ = devcontainer_k8s_agent_module.DeleteDevcontainer(ctx, client, opts)
// 删除SSH端口的NAT规则
devcontainerNatRuleDescription := setting.DEVCONTAINER_CLOUD_NAT_RULE_DESCRIPTION_PREFIX + devcontainer.Name
err := devstar_cloud_provider.DeleteNATRulePort(devcontainerNatRuleDescription)
if err != nil {
log.Warn("[Cloud NAT DELETION Error]: " + err.Error())
}
// 删除ttyd端口的NAT规则
ttydNatRuleDescription := setting.DEVCONTAINER_CLOUD_NAT_RULE_DESCRIPTION_PREFIX + devcontainer.Name + "-ttyd"
err = devstar_cloud_provider.DeleteNATRulePort(ttydNatRuleDescription)
if err != nil {
log.Warn("[Cloud ttyd NAT DELETION Error]: " + err.Error())
}
}
return nil
}
@@ -317,11 +358,43 @@ func AssignDevcontainerCreation2K8sOperator(ctx *context.Context, newDevContaine
} else {
log.Info("AssignDevcontainerCreation2K8sOperator: NAT port mapping created successfully")
}
// 为ttyd端口创建NAT映射 - 查找extraPorts中的ttyd端口
for _, portInfo := range devcontainerInCluster.Status.ExtraPortsAssigned {
if portInfo.Name == "ttyd" || strings.Contains(portInfo.Name, "ttyd") ||
portInfo.Name == "port-7681" || portInfo.ContainerPort == 7681 {
// 找到ttyd端口创建NAT映射
ttydPrivatePort := uint64(portInfo.NodePort)
ttydPublicPort := ttydPrivatePort
ttydNatRuleDescription := setting.DEVCONTAINER_CLOUD_NAT_RULE_DESCRIPTION_PREFIX + newDevContainer.Name + "-ttyd"
log.Info("AssignDevcontainerCreation2K8sOperator: Creating NAT port mapping for ttyd: private=%d, public=%d, desc=%s",
ttydPrivatePort, ttydPublicPort, ttydNatRuleDescription)
err = devstar_cloud_provider.CreateNATRulePort(ttydPrivatePort, ttydPublicPort, ttydNatRuleDescription)
if err != nil {
log.Error("AssignDevcontainerCreation2K8sOperator: Failed to create NAT port mapping for ttyd: %v", err)
} else {
log.Info("AssignDevcontainerCreation2K8sOperator: NAT port mapping for ttyd created successfully")
}
break
}
}
}
log.Info("DevContainer created in cluster - Name: %s, NodePort: %d",
devcontainerInCluster.Name,
devcontainerInCluster.Status.NodePortAssigned)
// 为 ttyd 服务创建 Ingress
err = createDevContainerTTYDIngress(ctx, devcontainerInCluster.Name)
if err != nil {
log.Warn("AssignDevcontainerCreation2K8sOperator: 创建 ttyd Ingress 失败: %v", err)
// 不阻止主流程,只记录警告
} else {
log.Info("AssignDevcontainerCreation2K8sOperator: 创建 ttyd Ingress 成功")
}
// 4. 层层返回 nil自动提交数据库事务完成 DevContainer 创建
return nil
}
@@ -490,11 +563,29 @@ func updateNodePortAsync(containerName string, namespace string, userId, repoId
log.Info("updateNodePortAsync: NAT port mapping created successfully")
}
// 记录 ttyd 端口信息到日志
// 记录 ttyd 端口信息到日志并创建NAT映射
if len(devcontainer.Status.ExtraPortsAssigned) > 0 {
for _, portInfo := range devcontainer.Status.ExtraPortsAssigned {
log.Info("Found extra port for %s: name=%s, nodePort=%d, containerPort=%d",
containerName, portInfo.Name, portInfo.NodePort, portInfo.ContainerPort)
// 为ttyd端口创建NAT映射
if portInfo.Name == "ttyd" || strings.Contains(portInfo.Name, "ttyd") ||
portInfo.Name == "port-7681" || portInfo.ContainerPort == 7681 {
ttydPrivatePort := uint64(portInfo.NodePort)
ttydPublicPort := ttydPrivatePort
ttydNatRuleDescription := setting.DEVCONTAINER_CLOUD_NAT_RULE_DESCRIPTION_PREFIX + containerName + "-ttyd"
log.Info("updateNodePortAsync: Creating NAT port mapping for ttyd: private=%d, public=%d, desc=%s",
ttydPrivatePort, ttydPublicPort, ttydNatRuleDescription)
err := devstar_cloud_provider.CreateNATRulePort(ttydPrivatePort, ttydPublicPort, ttydNatRuleDescription)
if err != nil {
log.Error("updateNodePortAsync: Failed to create NAT port mapping for ttyd: %v", err)
} else {
log.Info("updateNodePortAsync: NAT port mapping for ttyd created successfully")
}
}
}
}
@@ -523,3 +614,121 @@ func updateNodePortAsync(containerName string, namespace string, userId, repoId
log.Warn("updateNodePortAsync: Failed to retrieve real NodePort after multiple attempts")
}
// 创建 DevContainer ttyd 服务的 Ingress 资源
func createDevContainerTTYDIngress(ctx *context.Context, devcontainerName string) error {
log.Info("createDevContainerTTYDIngress: 开始为 %s 创建 ttyd Ingress", devcontainerName)
// 获取标准 Kubernetes 客户端
stdClient, err := getStandardKubernetesClient()
if err != nil {
log.Error("createDevContainerTTYDIngress: 获取 K8s 客户端失败: %v", err)
return err
}
// 从配置中读取域名
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
if err != nil {
log.Error("createDevContainerTTYDIngress: 加载配置文件失败: %v", err)
return err
}
domain := cfg.Section("server").Key("DOMAIN").Value()
// 从容器名称中提取用户名和仓库名
parts := strings.Split(devcontainerName, "-")
var username, repoName string
if len(parts) >= 2 {
username = parts[0]
repoName = parts[1]
} else {
username = "unknown"
repoName = "unknown"
}
// 构建 PATH 和 Ingress 配置
path := fmt.Sprintf("/%s/%s/dev-container-webterminal", username, repoName)
ingressName := fmt.Sprintf("%s-ttyd-ingress", devcontainerName)
serviceName := fmt.Sprintf("%s-svc", devcontainerName)
// 创建 Ingress 资源
pathType := networkingv1.PathTypePrefix
ingressClassName := "nginx"
ingress := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: ingressName,
Namespace: setting.Devcontainer.Namespace,
Annotations: map[string]string{
"nginx.ingress.kubernetes.io/proxy-send-timeout": "3600",
"nginx.ingress.kubernetes.io/proxy-read-timeout": "3600",
"nginx.ingress.kubernetes.io/websocket-services": serviceName,
"nginx.ingress.kubernetes.io/proxy-http-version": "1.1",
"nginx.ingress.kubernetes.io/rewrite-target": "/",
"nginx.ingress.kubernetes.io/configuration-snippet": `
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $remote_addr;
`,
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: &ingressClassName,
Rules: []networkingv1.IngressRule{
{
Host: domain,
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: path,
PathType: &pathType,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: serviceName,
Port: networkingv1.ServiceBackendPort{
Number: 7681,
},
},
},
},
},
},
},
},
},
},
}
// 创建 Ingress 资源
_, err = stdClient.NetworkingV1().Ingresses(setting.Devcontainer.Namespace).Create(*ctx, ingress, metav1.CreateOptions{})
if err != nil {
log.Error("createDevContainerTTYDIngress: 创建 Ingress 失败: %v", err)
return err
}
log.Info("createDevContainerTTYDIngress: 成功创建 Ingress %sttyd 可通过 https://%s%s 访问", ingressName, domain, path)
return nil
}
// 获取标准 Kubernetes 客户端
func getStandardKubernetesClient() (*kubernetes.Clientset, error) {
// 使用与 GetKubernetesClient 相同的逻辑获取配置
config, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
if err != nil {
// 如果集群外配置失败,尝试集群内配置
log.Warn("Failed to obtain Kubernetes config outside of cluster: %v", err)
config, err = rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("获取 K8s 配置失败 (集群内外均失败): %v", err)
}
}
// 创建标准客户端
stdClient, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("创建标准 K8s 客户端失败: %v", err)
}
return stdClient, nil
}