Files
devstar/routers/web/devcontainer/devcontainer.go
xinitx 4858414c6b !87 Devcontaienr页面中改为终端样式
* 恢复合并时误删的/logo router
* change terminal
* change terminal
* Merge   main
* fix api bug
* feature-permission
2025-07-27 04:40:19 +00:00

571 lines
18 KiB
Go

package devcontainer
import (
"encoding/json"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/models/db"
devcontainer_models "code.gitea.io/gitea/models/devcontainer"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log"
"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,
}
var devContainerOutput []devcontainer_models.DevcontainerOutput
dbEngine := db.GetEngine(*ctx)
err := dbEngine.Table("devcontainer_output").
Where("user_id = ? AND repo_id = ?", ctx.Doer.ID, ctx.Repo.Repository.ID).
Find(&devContainerOutput)
ctx.Data["isCreatingDevcontainer"] = false
ctx.Data["InitializedContainer"] = true
if err == nil && len(devContainerOutput) > 0 {
ctx.Data["isCreatingDevcontainer"] = true
}
var created bool = false
for _, item := range devContainerOutput {
if item.ListId > 0 && item.Status == "success" {
created = true
}
if item.Status != "success" {
ctx.Data["InitializedContainer"] = false
}
}
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 && created
ctx.Data["HasDevContainer"] = hasDevContainer
ctx.Data["HasDevContainerSetting"] = false
if hasDevContainer {
ctx.Data["DevContainer"] = devContainerMetadata
}
ctx.Data["PullImage"] = false
ctx.Data["CreateContainer"] = false
ctx.Data["Init"] = false
ctx.Data["Running"] = false
ctx.Data["RestartOrStop"] = false
ctx.Data["Save"] = false
switch devContainerMetadata.DevContainerStatus {
case 1:
ctx.Data["PullImage"] = true
case 2:
ctx.Data["CreateContainer"] = true
case 3:
ctx.Data["Init"] = true
case 4:
ctx.Data["Running"] = true
case 5:
ctx.Data["Restart"] = true
case 6:
ctx.Data["Stop"] = true
case 7:
ctx.Data["Save"] = true
default:
log.Info("unknown status")
}
// 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"
}
// 获取WebSSH服务端口
webTerminalURL, err := devcontainer_service.GetWebTerminalURL(ctx, devContainerMetadata.DevContainerName)
if err == nil {
ctx.Data["WebSSHUrl"] = webTerminalURL
}
// 解析URL
u, err := url.Parse(webTerminalURL)
if err != nil {
log.Info("URL解析失败: %v", err)
}
// 分离主机和端口
terminalHost, terminalPort, err := net.SplitHostPort(u.Host)
if err != nil {
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)
log.Info("URL解析失败: %v", err)
} else {
ctx.Data["TerminalParams"] = "ip=" + terminalHost + "&port=" + terminalPort + "&user=" + ctx.Doer.Name + "&repo=" + ctx.Repo.Repository.Name + "&repoid=" + strconv.FormatInt(ctx.Repo.Repository.ID, 10) + "&userid=" + strconv.FormatInt(ctx.Doer.ID, 10)
log.Info("TerminalParams: %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) {
// 设置 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 commandInfo = ParamInfo{
RepoID: query.Get("repo"),
UserID: query.Get("user"),
}
var status string
_, err := db.GetEngine(ctx).
Table("devcontainer").
Select("devcontainer_status").
Where("user_id = ? AND repo_id = ?", commandInfo.UserID, commandInfo.RepoID).
Get(&status)
if status == "4" {
var scriptContent []byte
_, err = os.Stat("devcontainer_after_init.sh")
if os.IsNotExist(err) {
_, err = os.Stat("/app/gitea/devcontainer_after_init.sh")
if os.IsNotExist(err) {
return
} else {
scriptContent, err = os.ReadFile("/app/gitea/devcontainer_after_init.sh")
if err != nil {
return
}
}
} else {
scriptContent, err = os.ReadFile("devcontainer_after_init.sh")
if err != nil {
return
}
}
ctx.JSON(http.StatusOK, map[string]string{"command": string(scriptContent)})
return
}
ctx.JSON(http.StatusOK, map[string]string{"command": "ls"})
}
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)
}