Files
devstar/routers/web/devcontainer/devcontainer.go
2025-08-11 11:29:50 +08:00

641 lines
19 KiB
Go

package devcontainer
import (
clictx "context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/models/db"
devcontainer_models "code.gitea.io/gitea/models/devcontainer"
"code.gitea.io/gitea/modules/base"
docker_module "code.gitea.io/gitea/modules/docker"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
devcontainer_service "code.gitea.io/gitea/services/devcontainer"
)
const (
tplGetRepoDevcontainerDetail base.TplName = "repo/devcontainer/details"
)
// 获取仓库 Dev Container 详细信息
// GET /{username}/{reponame}/dev-container
func GetRepoDevContainerDetails(ctx *context.Context) {
// 1. 查询当前 Repo 已有的 Dev Container 信息
opts := &devcontainer_service.RepoDevcontainerOptions{
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
}
ctx.Data["isAdmin"] = false
if ctx.Doer.IsAdmin {
ctx.Data["isAdmin"] = true
ctx.Data["canRead"] = true
} else {
canRead, _ := devcontainer_service.CanCreateDevcontainer(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID)
ctx.Data["canRead"] = canRead
isAdmin, _ := devcontainer_service.IsOwner(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID)
ctx.Data["isAdmin"] = isAdmin
}
//ctx.Repo.RepoLink == ctx.Repo.Repository.Link()
devContainerMetadata, err := devcontainer_service.GetRepoDevcontainerDetails(ctx, opts)
hasDevContainer := err == nil && devContainerMetadata.DevContainerId > 0
ctx.Data["HasDevContainer"] = hasDevContainer
ctx.Data["HasDevContainerSetting"] = false
if hasDevContainer {
ctx.Data["DevContainer"] = devContainerMetadata
}
// 2. 检查当前仓库的当前分支是否存在有效的 /.devcontainer/devcontainer.json
isValidRepoDevcontainerJson := isValidRepoDevcontainerJsonFile(ctx)
hasDockerfile := isValidRepoDevcontainerDockerfile(ctx)
if !hasDevContainer && !isValidRepoDevcontainerJson {
ctx.Flash.Error(ctx.Tr("repo.dev_container_invalid_config_prompt"), true)
}
ctx.Data["HasDockerfile"] = false
if hasDockerfile {
ctx.Data["HasDockerfile"] = true
}
// 从devcontainer.json文件提取image字段解析成仓库地址、命名空间、镜像名
devcontainerJson, err := devcontainer_service.GetDevcontainerJsonModel(ctx, ctx.Repo.Repository)
if err == nil {
imageName := devcontainerJson.Image
registry, namespace, repo, tag := ParseImageName(imageName)
log.Info("%v %v", repo, tag)
ctx.Data["RepositoryAddress"] = registry
ctx.Data["RepositoryUsername"] = namespace
ctx.Data["ImageName"] = "dev-" + ctx.Repo.Repository.Name + ":latest"
}
ctx.Data["TerminalParams"] = "user=" + ctx.Doer.Name + "&repo=" + ctx.Repo.Repository.Name + "&repoid=" + strconv.FormatInt(ctx.Repo.Repository.ID, 10) + "&userid=" + strconv.FormatInt(ctx.Doer.ID, 10)
if setting.Devcontainer.Agent == setting.KUBERNETES || setting.Devcontainer.Agent == "k8s" {
// 获取WebSSH服务端口
webTerminalURL, err := devcontainer_service.GetWebTerminalURL(ctx, devContainerMetadata.DevContainerName)
if err == nil {
ctx.Data["WebSSHUrl"] = webTerminalURL
}
} else {
ctx.Data["WebSSHUrl"] = "http://localhost:3000/assets/terminal/index.html?type=docker&" + fmt.Sprintf("%v", ctx.Data["TerminalParams"])
}
terminalURL, err := devcontainer_service.Get_IDE_TerminalURL(ctx, &devContainerMetadata)
if err == nil {
ctx.Data["VSCodeUrl"] = "vscode" + terminalURL
ctx.Data["CursorUrl"] = "cursor" + terminalURL
ctx.Data["WindsurfUrl"] = "windsurf" + terminalURL
}
//存在devcontainer.json读取信息展示
if isValidRepoDevcontainerJson {
fileContent, err := devcontainer_service.GetDevcontainerJsonString(ctx, ctx.Repo.Repository)
if err == nil {
ctx.Data["FileContent"] = fileContent
}
}
// 3. 携带数据渲染页面,返回
ctx.Data["Title"] = ctx.Locale.Tr("repo.dev_container")
ctx.Data["PageIsRepoDevcontainerDetails"] = true
ctx.Data["HasValidDevContainerJSON"] = isValidRepoDevcontainerJson
ctx.Data["Repository"] = ctx.Repo.Repository
ctx.Data["ContextUser"] = ctx.Doer
ctx.Data["CreateDevcontainerSettingUrl"] = "/" + ctx.ContextUser.Name + "/" + ctx.Repo.Repository.Name + "/dev-container/createConfiguration"
ctx.Data["EditDevcontainerConfigurationUrl"] = ctx.Repo.RepoLink + "/_edit/" + ctx.Repo.Repository.DefaultBranch + "/.devcontainer/devcontainer.json"
ctx.Data["TreeNames"] = []string{".devcontainer", "devcontainer.json"}
ctx.Data["TreePaths"] = []string{".devcontainer", ".devcontainer/devcontainer.json"}
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
ctx.Data["SaveMethods"] = []string{"Container", "DockerFile"}
ctx.Data["SaveMethod"] = "Container"
ctx.HTML(http.StatusOK, tplGetRepoDevcontainerDetail)
}
func CreateRepoDevContainerConfiguration(ctx *context.Context) {
if !ctx.Doer.IsAdmin {
ctx.Flash.Error("permisson denied", true)
return
}
devcontainer_service.CreateDevcontainerJSON(ctx, ctx.Repo.Repository, ctx.Doer)
ctx.Redirect(path.Join(ctx.Repo.RepoLink, "/dev-container"))
}
func ParseImageName(imageName string) (registry, namespace, repo, tag string) {
// 分离仓库地址和命名空间
parts := strings.Split(imageName, "/")
if len(parts) == 3 {
registry = parts[0]
namespace = parts[1]
repo = parts[2]
} else if len(parts) == 2 {
registry = parts[0]
repo = parts[1]
} else {
repo = imageName
}
// 分离标签
parts = strings.Split(repo, ":")
if len(parts) > 1 {
tag = parts[1]
repo = parts[0]
} else {
tag = "latest"
}
if registry == "" {
registry = "docker.io"
}
return registry, namespace, repo, tag
}
// 创建仓库 Dev Container
func CreateRepoDevContainer(ctx *context.Context) {
if !isUserDevcontainerAlreadyInRepository(ctx) {
opts := &devcontainer_service.CreateRepoDevcontainerOptions{
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
}
devcontainer_service.CreateRepoDevcontainer(ctx, opts)
}
ctx.Redirect(ctx.Repo.RepoLink + "/dev-container")
}
// 辅助判断当前仓库的当前分支是否存在有效的 /.devcontainer/devcontainer.json
func isValidRepoDevcontainerJsonFile(ctx *context.Context) bool {
// 1. 仓库非空,且非 Archived 状态
if ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsArchived {
return false
}
// 2. 当前分支的目录 .devcontainer 下存在 devcontainer.json 文件
fileDevcontainerJsonExists, err := ctx.Repo.FileExists(".devcontainer/devcontainer.json", ctx.Repo.BranchName)
if err != nil || !fileDevcontainerJsonExists {
return false
}
// 3. TODO: DevContainer 格式正确
return true
}
// 辅助判断当前仓库的当前分支是否存在有效的 /.devcontainer/Dockerfile
func isValidRepoDevcontainerDockerfile(ctx *context.Context) bool {
// 1. 仓库非空,且非 Archived 状态
if ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsArchived {
return false
}
// 2. 当前分支的目录 .devcontainer 下存在 devcontainer.json 文件
dockerfilePath, err := devcontainer_service.GetDockerfilePath(ctx, ctx.Repo.Repository)
if err != nil {
return false
}
dockerfileExists, err := ctx.Repo.FileExists(".devcontainer/"+dockerfilePath, ctx.Repo.BranchName)
if err != nil || !dockerfileExists {
return false
}
// 3. TODO: DevContainer 格式正确
return true
}
// 辅助判断当前用户在当前仓库是否已有 Dev Container
func isUserDevcontainerAlreadyInRepository(ctx *context.Context) bool {
opts := &devcontainer_service.RepoDevcontainerOptions{
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
}
devcontainerDetails, _ := devcontainer_service.GetRepoDevcontainerDetails(ctx, opts)
return devcontainerDetails.DevContainerId > 0
}
func UpdateRepoDevContainerForCurrentActor(ctx *context.Context) {
if !ctx.Doer.IsAdmin {
ctx.Flash.Error("permisson denied", true)
return
}
opt := &devcontainer_service.RepoDevcontainerOptions{
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
}
devContainerMetadata, _ := devcontainer_service.GetRepoDevcontainerDetails(ctx, opt)
// 取得参数
body, _ := io.ReadAll(ctx.Req.Body)
log.Info(string(body))
var updateInfo devcontainer_service.UpdateInfo
err := json.Unmarshal(body, &updateInfo)
// 保存容器功能使用弹窗显示错误信息
if err != nil {
log.Info("保存容器参数反序列化失败:", err)
ctx.JSON(400, map[string]string{"message": "输入错误"})
return
}
opts := &devcontainer_service.UpdateDevcontainerOptions{
ImageName: updateInfo.ImageName,
PassWord: updateInfo.PassWord,
RepositoryAddress: updateInfo.RepositoryAddress,
RepositoryUsername: updateInfo.RepositoryUsername,
DevContainerName: devContainerMetadata.DevContainerName,
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
SaveMethod: updateInfo.SaveMethod,
}
err = devcontainer_service.UpdateDevcontainerAPIService(ctx, opts)
if err != nil {
ctx.JSON(500, map[string]string{"message": err.Error()})
return
}
ctx.JSON(http.StatusOK, map[string]string{"redirect": ctx.Repo.RepoLink + "/dev-container", "message": "成功"})
}
// 删除仓库 当前用户 Dev Container
func DeleteRepoDevContainerForCurrentActor(ctx *context.Context) {
if isUserDevcontainerAlreadyInRepository(ctx) {
opts := &devcontainer_service.RepoDevcontainerOptions{
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
}
err := devcontainer_service.DeleteRepoDevcontainer(ctx, opts)
if err != nil {
log.Warn("failed to delete devContainer with option{%v}: %v", opts, err.Error())
ctx.Flash.Error(ctx.Tr("repo.dev_container_control.deletion_failed_for_user", ctx.Doer.Name))
} else {
ctx.Flash.Success(ctx.Tr("repo.dev_container_control.deletion_success_for_user", ctx.Doer.Name))
}
}
ctx.JSONRedirect(ctx.Repo.RepoLink + "/dev-container")
}
type OutputResponse struct {
CurrentJob struct {
IP string `json:"ip"`
Port string `json:"port"`
Title string `json:"title"`
Detail string `json:"detail"`
Steps []*ViewJobStep `json:"steps"`
} `json:"currentDevcontainer"`
}
type ViewJobStep struct {
Summary string `json:"summary"`
Duration string `json:"duration"`
Status string `json:"status"`
Logs []ViewStepLogLine `json:"logs"`
}
type ViewStepLogLine struct {
Index int64 `json:"index"`
Message string `json:"message"`
Timestamp float64 `json:"timestamp"`
}
func GetContainerOutput(ctx *context.Context) {
// 设置 CORS 响应头
ctx.Resp.Header().Set("Access-Control-Allow-Origin", "*")
ctx.Resp.Header().Set("Access-Control-Allow-Methods", "*")
ctx.Resp.Header().Set("Access-Control-Allow-Headers", "*")
query := ctx.Req.URL.Query()
var paramInfo = ParamInfo{
RepoID: query.Get("repo"),
UserID: query.Get("user"),
}
var devContainerOutput []devcontainer_models.DevcontainerOutput
dbEngine := db.GetEngine(*ctx)
var status string
_, err := db.GetEngine(ctx).
Table("devcontainer").
Select("devcontainer_status").
Where("user_id = ? AND repo_id = ?", paramInfo.UserID, paramInfo.RepoID).
Get(&status)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
var containerName string
_, err = db.GetEngine(ctx).
Table("devcontainer").
Select("name").
Where("user_id = ? AND repo_id = ?", paramInfo.UserID, paramInfo.RepoID).
Get(&containerName)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
err = dbEngine.Table("devcontainer_output").
Where("user_id = ? AND repo_id = ?", paramInfo.UserID, paramInfo.RepoID).
Find(&devContainerOutput)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
if len(devContainerOutput) > 0 {
resp := &OutputResponse{}
resp.CurrentJob.Title = ctx.Repo.Repository.Name + " Devcontainer Info"
resp.CurrentJob.Detail = status
if status == "4" {
// 获取WebSSH服务端口
webTerminalURL, err := devcontainer_service.GetWebTerminalURL(ctx, containerName)
if err == nil {
log.Info("URL解析失败: %v", err)
}
// 解析URL
u, err := url.Parse(webTerminalURL)
if err != nil {
log.Info("URL解析失败: %v", err)
}
// 分离主机和端口
terminalHost, terminalPort, err := net.SplitHostPort(u.Host)
resp.CurrentJob.IP = terminalHost
resp.CurrentJob.Port = terminalPort
if err != nil {
log.Info("URL解析失败: %v", err)
}
}
for _, item := range devContainerOutput {
logLines := []ViewStepLogLine{}
logLines = append(logLines, ViewStepLogLine{
Index: 1,
Message: item.Output,
})
resp.CurrentJob.Steps = append(resp.CurrentJob.Steps, &ViewJobStep{
Summary: item.Command,
Status: item.Status,
Logs: logLines,
})
}
ctx.JSON(http.StatusOK, resp)
return
} else {
resp := &OutputResponse{}
ctx.JSON(http.StatusOK, resp)
}
}
func RestartContainer(ctx *context.Context) {
opt := &devcontainer_service.RepoDevcontainerOptions{
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
}
devContainerMetadata, _ := devcontainer_service.GetRepoDevcontainerDetails(ctx, opt)
err := devcontainer_service.RestartDevcontainer(*ctx, &devContainerMetadata)
if err != nil {
ctx.Flash.Error("fail to restart container")
}
ctx.JSON(http.StatusOK, map[string]string{})
}
func StopContainer(ctx *context.Context) {
opt := &devcontainer_service.RepoDevcontainerOptions{
Actor: ctx.Doer,
Repository: ctx.Repo.Repository,
}
devContainerMetadata, _ := devcontainer_service.GetRepoDevcontainerDetails(ctx, opt)
err := devcontainer_service.StopDevcontainer(ctx, &devContainerMetadata)
if err != nil {
ctx.Flash.Error("fail to stop container")
}
ctx.JSON(http.StatusOK, map[string]string{})
}
type ParamInfo struct {
RepoID string
UserID string
}
func GetCommand(ctx *context.Context) {
query := ctx.Req.URL.Query()
var commandInfo = ParamInfo{
RepoID: query.Get("repo"),
UserID: query.Get("user"),
}
dbEngine := db.GetEngine(*ctx)
var status uint16
var containerName string
var containerID uint16
var work_dir string
var cmd string
var flag uint16
//当前状态
_, err := dbEngine.
Table("devcontainer").
Select("devcontainer_status, id, name, devcontainer_work_dir").
Where("user_id = ? AND repo_id = ?", commandInfo.UserID, commandInfo.RepoID).
Get(&status, &containerID, &containerName, &work_dir)
if err != nil {
log.Info("Error: %v\n", err)
}
flag = status
switch status {
case 0:
if containerID > 0 {
flag = 1
}
case 1:
devContainerJson, err := devcontainer_service.GetDevcontainerJsonModel(ctx, ctx.Repo.Repository)
if err != nil {
log.Info("Error : %v\n", err)
break
}
buffer, err := docker_module.ImageExists(devContainerJson.Image)
if err != nil {
log.Info("Error : %v\n", err)
break
}
if buffer {
flag = 2
}
case 2:
// 创建docker client
cliCtx := clictx.Background()
cli, err := docker_module.CreateDockerClient(&cliCtx)
if err != nil {
break
}
defer cli.Close()
containerID, err := docker_module.GetContainerID(cli, containerName)
containerStatus, err := docker_module.GetContainerStatus(cli, containerID)
if containerStatus == "running" {
flag = 3
}
case 3:
// 创建docker client
cliCtx := clictx.Background()
cli, err := docker_module.CreateDockerClient(&cliCtx)
if err != nil {
break
}
defer cli.Close()
containerID, err := docker_module.GetContainerID(cli, containerName)
dirStatus, err := docker_module.CheckDirExists(cli, containerID, work_dir)
if dirStatus {
flag = 4
}
case 4:
cmd = "docker exec -it --workdir " + work_dir + " " + containerName + " /bin/bash -c \"echo 'Successfully connected to the container';bash\"\n"
default:
log.Info("other status")
}
if flag != status {
//下一条指令
_, err = dbEngine.Table("devcontainer_output").
Select("command").
Where("user_id = ? AND repo_id = ? AND list_id = ?", commandInfo.UserID, commandInfo.RepoID, flag).
Get(&cmd)
if err != nil {
log.Info("Error : %v\n", err)
}
_, err = dbEngine.Table("devcontainer").
Where("user_id = ? AND repo_id = ? ", commandInfo.UserID, commandInfo.RepoID).
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: flag})
if err != nil {
log.Info("err %v", err)
}
}
// 设置 CORS 响应头
ctx.Resp.Header().Set("Access-Control-Allow-Origin", "*")
ctx.Resp.Header().Set("Access-Control-Allow-Methods", "*")
ctx.Resp.Header().Set("Access-Control-Allow-Headers", "*")
ctx.JSON(http.StatusOK, map[string]string{"command": cmd, "status": fmt.Sprint(flag)})
}
func GetTerminalToken(ctx *context.Context) {
query := ctx.Req.URL.Query()
var containerName string
_, err := db.GetEngine(ctx).
Table("devcontainer").
Select("name").
Where("user_id = ? AND repo_id = ?", query.Get("user"), query.Get("repo")).
Get(&containerName)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
if containerName == "" {
ctx.Error(404, "Service not found")
}
// 获取WebSSH服务端口
webTerminalURL, err := devcontainer_service.GetWebTerminalURL(ctx, containerName)
if err == nil {
log.Info("URL解析失败: %v", err)
}
// 解析URL
u, err := url.Parse(webTerminalURL)
if err != nil {
log.Info("URL解析失败: %v", err)
return
}
// 分离主机和端口
terminalHost, terminalPort, err := net.SplitHostPort(u.Host)
if err != nil {
log.Info("URL解析失败: %v", err)
return
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("http://" + terminalHost + ":" + terminalPort + "/token")
if err != nil {
log.Error("Failed to connect terminal: %v", err)
ctx.Error(http.StatusInternalServerError, "Failed to connect terminal")
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Error("Failed to read response body: %v", err)
ctx.Error(http.StatusInternalServerError, "Failed to read response body")
return
}
ctx.Resp.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
ctx.Status(resp.StatusCode)
_, _ = ctx.Write(body)
}
func GetContainerStatus(ctx *context.Context) {
var status string
var id int
var containerName string
var flag uint16
log.Info("GetContainerStatus %v 666 %v", ctx.Doer.ID, ctx.Repo.Repository.ID)
_, err := db.GetEngine(ctx).
Table("devcontainer").
Select("devcontainer_status, id, name").
Where("user_id = ? AND repo_id = ?", ctx.Doer.ID, ctx.Repo.Repository.ID).
Get(&status, &id, &containerName)
if err != nil {
log.Info("Error: %v\n", err)
}
if id == 0 {
status = "-"
}
switch status {
case "5":
// 创建docker client
cliCtx := clictx.Background()
cli, err := docker_module.CreateDockerClient(&cliCtx)
if err != nil {
break
}
defer cli.Close()
containerID, err := docker_module.GetContainerID(cli, containerName)
containerStatus, err := docker_module.GetContainerStatus(cli, containerID)
if containerStatus == "running" {
flag = 4
}
case "6":
// 创建docker client
cliCtx := clictx.Background()
cli, err := docker_module.CreateDockerClient(&cliCtx)
if err != nil {
break
}
defer cli.Close()
containerID, err := docker_module.GetContainerID(cli, containerName)
containerStatus, err := docker_module.GetContainerStatus(cli, containerID)
if containerStatus == "exited" {
flag = 8
}
default:
log.Info("other status")
}
if fmt.Sprintf("%d", flag) != status {
_, err = db.GetEngine(ctx).Table("devcontainer").
Where("user_id = ? AND repo_id = ? ", ctx.Doer.ID, ctx.Repo.Repository.ID).
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: flag})
if err != nil {
log.Info("err %v", err)
}
}
ctx.JSON(http.StatusOK, map[string]string{"status": status})
}