!62 json管理和日志输出
* 合并输出按阶段显示 * ttyd初始目录 * 访问数据库放在services层 * 端口指定映射 * vscode链接 * 去掉devstar字符串 * Devcontainer前端页面显示进行了整理优化 * 修复 数据库 bug * 增加容器output
This commit is contained in:
1
go.mod
1
go.mod
@@ -138,6 +138,7 @@ require (
|
||||
github.com/distribution/reference v0.5.0 // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ishidawataru/sctp v0.0.0-20250303034628-ecf9ed6df987 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
)
|
||||
|
||||
|
||||
4
go.sum
4
go.sum
@@ -307,6 +307,8 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
|
||||
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0=
|
||||
github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
@@ -622,6 +624,8 @@ github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH
|
||||
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/ishidawataru/sctp v0.0.0-20250303034628-ecf9ed6df987 h1:pf7+hef676aOjZ9XcvEw5qhdTPPaFVfavOQS+IntVOY=
|
||||
github.com/ishidawataru/sctp v0.0.0-20250303034628-ecf9ed6df987/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v1.14.0 h1:vrbA9Ud87g6JdFWkHTJXppVce58qPIdP7N8y0Ml/A7Q=
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package devstar_devcontainer
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
)
|
||||
|
||||
// DevstarDevcontainer devContainer 关联 代码仓库 和 用户
|
||||
// Devcontainer devContainer 关联 代码仓库 和 用户
|
||||
//
|
||||
// TODO 移除 port 信息,需要前端主动连接时候实时获取最新分配结果,可以考虑给用户API提供选项:是否阻塞等待
|
||||
//
|
||||
// 遵循gonic规则映射数据库表 `devstar_devcontainer`,各字段注解见 https://xorm.io/docs/chapter-02/4.columns/
|
||||
type DevstarDevcontainer struct {
|
||||
// 遵循gonic规则映射数据库表 `devcontainer`,各字段注解见 https://xorm.io/docs/chapter-02/4.columns/
|
||||
type Devcontainer struct {
|
||||
Id int64 `xorm:"BIGINT pk NOT NULL autoincr 'id' comment('主键,devContainerId')"`
|
||||
Name string `xorm:"VARCHAR(64) charset=utf8mb4 collate=utf8mb4_bin UNIQUE NOT NULL 'name' comment('devContainer名称,自动生成')"`
|
||||
DevcontainerHost string `xorm:"VARCHAR(256) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'devcontainer_host' comment('SSH Host')"`
|
||||
@@ -22,6 +22,17 @@ type DevstarDevcontainer struct {
|
||||
UpdatedUnix int64 `xorm:"BIGINT 'updated_unix' comment('更新时间戳')"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(DevstarDevcontainer))
|
||||
type DevcontainerOutput struct {
|
||||
Id int64 `xorm:"BIGINT pk NOT NULL autoincr 'id' comment('主键,devContainerId')"`
|
||||
RepoId int64 `xorm:"BIGINT NOT NULL unique(uniquename) 'repo_id' comment('repository表主键')"`
|
||||
UserId int64 `xorm:"BIGINT NOT NULL unique(uniquename) 'user_id' comment('user表主键')"`
|
||||
Status string `xorm:"VARCHAR(255) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'status' comment('status')"`
|
||||
Output string `xorm:"TEXT 'output' comment('output')"`
|
||||
Command string `xorm:"TEXT 'command' comment('command')"`
|
||||
ListId int64 `xorm:"BIGINT NOT NULL unique(uniquename) 'list_id' comment('list_id')"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Devcontainer))
|
||||
db.RegisterModel(new(DevcontainerOutput))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package devstar_devcontainer
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
@@ -156,7 +156,7 @@ type DevContainerJSON struct {
|
||||
// ⚠ The command is run wherever the source code is located on the host. For cloud services, this is in the cloud.
|
||||
// Note that the array syntax will execute the command without a shell.
|
||||
// You can learn more about formatting string vs array vs object properties.
|
||||
InitializeCommand *interface{} `json:"initializeCommand,omitempty"`
|
||||
InitializeCommand interface{} `json:"initializeCommand,omitempty"`
|
||||
|
||||
// OnCreateCommand is the first of three (along with updateContentCommand and postCreateCommand)
|
||||
// that finalizes container setup when a dev container is created.
|
||||
@@ -276,24 +276,8 @@ type HostRequirementsType struct {
|
||||
|
||||
// Unmarshal 反序列化JSON,返回带有默认值的 DevContainerJSON
|
||||
func Unmarshal(devcontainerJSONContent string) (*DevContainerJSON, error) {
|
||||
// 1. 根据官方文档配置默认值
|
||||
devcontainerJSON := &DevContainerJSON{
|
||||
ForwardPorts: []interface{}{},
|
||||
UpdateRemoteUserUID: true,
|
||||
UserEnvProbe: LoginInteractiveShellUserEnvProbeType,
|
||||
Init: false,
|
||||
Privileged: false,
|
||||
CapAdd: []string{},
|
||||
SecurityOpt: []string{},
|
||||
Build: &DockerBuildType{
|
||||
Context: ".",
|
||||
Options: []string{},
|
||||
},
|
||||
WaitFor: UpdateContentCommand,
|
||||
OtherPortsAttributes: &PortAttributeType{
|
||||
OnAutoForward: OnAutoForwardNotify,
|
||||
},
|
||||
}
|
||||
// 1. 初始化
|
||||
devcontainerJSON := &DevContainerJSON{}
|
||||
|
||||
// 2. JSON 反序列化
|
||||
err := json.Unmarshal([]byte(devcontainerJSONContent), devcontainerJSON)
|
||||
@@ -301,13 +285,6 @@ func Unmarshal(devcontainerJSONContent string) (*DevContainerJSON, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 对于配置的端口的缺省字段进行设置
|
||||
for _, portAttribute := range devcontainerJSON.PortsAttributes {
|
||||
if len(portAttribute.OnAutoForward) == 0 {
|
||||
portAttribute.OnAutoForward = OnAutoForwardNotify
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 返回
|
||||
// 3. 返回
|
||||
return devcontainerJSON, err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrFailedToOperateDevstarDevcontainerDB 错误类型:打开数据库失败
|
||||
type ErrFailedToOperateDevstarDevcontainerDB struct {
|
||||
// ErrFailedToOperateDevcontainerDB 错误类型:打开数据库失败
|
||||
type ErrFailedToOperateDevcontainerDB struct {
|
||||
Action string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (err ErrFailedToOperateDevstarDevcontainerDB) Error() string {
|
||||
func (err ErrFailedToOperateDevcontainerDB) Error() string {
|
||||
return fmt.Sprintf("Failed to %v in DevStar DevContainer DB: %v", err.Action, err.Message)
|
||||
}
|
||||
|
||||
@@ -14,20 +14,20 @@ func InitializeDevContainerDbTables(x *xorm.Engine) error {
|
||||
var err error
|
||||
|
||||
// 1. 初始化 devContainer 表
|
||||
if err = addDBDevStarDevContainer(x); err != nil {
|
||||
if err = addDBDevcontainer(x); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addDBDevStarDevContainer 1. 初始化 devContainer 与 Repository 一对一关系表
|
||||
func addDBDevStarDevContainer(x *xorm.Engine) error {
|
||||
// addDBDevcontainer 1. 初始化 devContainer 与 Repository 一对一关系表
|
||||
func addDBDevcontainer(x *xorm.Engine) error {
|
||||
|
||||
err := x.Sync(new(devcontainer_model.DevstarDevcontainer))
|
||||
err := x.Sync(new(devcontainer_model.Devcontainer))
|
||||
if err != nil {
|
||||
return ErrMigrateDevstarDatabase{
|
||||
Step: "create table 'devstar_devcontainer'",
|
||||
Step: "create table 'devcontainer'",
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -14,9 +14,8 @@ import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/strslice"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
)
|
||||
|
||||
func CreateDockerClient(ctx *context.Context) (*client.Client, error) {
|
||||
@@ -107,6 +106,23 @@ func GetMappedPort(cli *client.Client, containerID string, port string) (string,
|
||||
}
|
||||
return "", fmt.Errorf("容器未开放端口" + port)
|
||||
}
|
||||
|
||||
func GetAllMappedPort(cli *client.Client, containerID string) (string, error) {
|
||||
var result string = "\n"
|
||||
// 获取容器详细信息
|
||||
containerJSON, err := cli.ContainerInspect(context.Background(), containerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 获取端口映射信息
|
||||
portBindings := containerJSON.NetworkSettings.Ports
|
||||
for containerPort, bindings := range portBindings {
|
||||
for _, binding := range bindings {
|
||||
result += fmt.Sprintf("容器端口 %s 映射到主机 %s 端口 %s \n", containerPort, binding.HostIP, binding.HostPort)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func PushImage(dockerHost string, username string, password string, registryUrl string, imageRef string) error {
|
||||
script := "docker " + "-H " + dockerHost + " login -u " + username + " -p " + password + " " + registryUrl + " "
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
@@ -132,9 +148,10 @@ func GetContainerID(cli *client.Client, containerName string) (string, error) {
|
||||
}
|
||||
return containerJSON.ID, nil
|
||||
}
|
||||
func ExecCommandInContainer(cli *client.Client, containerID string, command []string) error {
|
||||
func ExecCommandInContainer(ctx *context.Context, cli *client.Client, containerID string, userID int64, repoID int64, command string) (string, error) {
|
||||
cmdList := []string{"sh", "-c", command}
|
||||
execConfig := types.ExecConfig{
|
||||
Cmd: command,
|
||||
Cmd: cmdList,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
}
|
||||
@@ -142,24 +159,31 @@ func ExecCommandInContainer(cli *client.Client, containerID string, command []st
|
||||
exec, err := cli.ContainerExecCreate(context.Background(), containerID, execConfig)
|
||||
if err != nil {
|
||||
log.Info("创建执行实例失败", err)
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
// 附加到执行实例
|
||||
resp, err := cli.ContainerExecAttach(context.Background(), exec.ID, types.ExecStartCheck{})
|
||||
if err != nil {
|
||||
log.Info("命令附加到执行实例失败", err)
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
defer resp.Close()
|
||||
// 启动执行实例
|
||||
err = cli.ContainerExecStart(context.Background(), exec.ID, types.ExecStartCheck{})
|
||||
if err != nil {
|
||||
log.Info("启动执行实例失败", err)
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
output, err := ioutil.ReadAll(resp.Reader)
|
||||
log.Info("执行命令输出:\n%s\n", output)
|
||||
return nil
|
||||
// 自定义缓冲区
|
||||
var outBuf, errBuf strings.Builder
|
||||
|
||||
// 读取输出
|
||||
_, err = stdcopy.StdCopy(&outBuf, &errBuf, resp.Reader)
|
||||
if err != nil {
|
||||
log.Info("Error reading output for command %v: %v\n", command, err)
|
||||
return "", err
|
||||
}
|
||||
return outBuf.String() + errBuf.String(), nil
|
||||
}
|
||||
func DeleteContainer(cli *client.Client, containerID string) error {
|
||||
// 删除容器
|
||||
@@ -177,7 +201,7 @@ func DeleteContainer(cli *client.Client, containerID string) error {
|
||||
}
|
||||
|
||||
// pullImage 用于拉取指定的 Docker 镜像
|
||||
func PullImage(cli *client.Client, dockerHost string, image string) error {
|
||||
func PullImageSync(cli *client.Client, dockerHost string, image string) error {
|
||||
script := "docker " + "-H " + dockerHost + " pull " + image
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
output, err := cmd.CombinedOutput()
|
||||
@@ -187,19 +211,19 @@ func PullImage(cli *client.Client, dockerHost string, image string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func CreateAndStartContainer(cli *client.Client, image string, command strslice.StrSlice, env []string, binds []string, exposedPorts nat.PortSet, containerName string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
func CreateAndStartContainer(cli *client.Client, opts *CreateDevcontainerOptions) (string, error) {
|
||||
ctx := context.Background()
|
||||
// 创建容器配置
|
||||
config := &container.Config{
|
||||
Image: image,
|
||||
Cmd: command,
|
||||
Env: env,
|
||||
Image: opts.Image,
|
||||
Cmd: opts.CommandList,
|
||||
Env: opts.ContainerEnv,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
OpenStdin: true,
|
||||
ExposedPorts: exposedPorts,
|
||||
ExposedPorts: opts.ForwardPorts,
|
||||
}
|
||||
|
||||
// 设置容器配置
|
||||
@@ -207,20 +231,37 @@ func CreateAndStartContainer(cli *client.Client, image string, command strslice.
|
||||
//Hosts
|
||||
//ExtraHosts: []string{"host.docker.internal:" + host},
|
||||
PublishAllPorts: true,
|
||||
Binds: binds,
|
||||
Binds: nil,
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: "always",
|
||||
},
|
||||
PortBindings: opts.PortBindings,
|
||||
}
|
||||
log.Info("%v", opts.PortBindings)
|
||||
if len(opts.Binds) > 0 {
|
||||
hostConfig.Binds = opts.Binds
|
||||
}
|
||||
// 创建容器
|
||||
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, containerName)
|
||||
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, opts.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Info("fail to create container %v", err)
|
||||
return "", err
|
||||
}
|
||||
// 启动容器
|
||||
err = cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
log.Info("fail to start container %v", err)
|
||||
return "", err
|
||||
}
|
||||
return nil
|
||||
// 获取日志流
|
||||
out, _ := cli.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: false,
|
||||
})
|
||||
defer out.Close()
|
||||
// 5. 解析日志
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
_, _ = stdcopy.StdCopy(&stdoutBuf, &stderrBuf, out)
|
||||
return stdoutBuf.String() + "\n" + stderrBuf.String(), nil
|
||||
}
|
||||
|
||||
25
modules/docker/docker_types.go
Normal file
25
modules/docker/docker_types.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// CreateDevcontainerOptions 定义创建开发容器选项
|
||||
type CreateDevcontainerOptions struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Image string `json:"image"`
|
||||
CommandList []string `json:"command"`
|
||||
ContainerPort uint16 `json:"containerPort"`
|
||||
ServicePort uint16 `json:"servicePort"`
|
||||
SSHPublicKeyList []string `json:"sshPublicKeyList"`
|
||||
GitRepositoryURL string `json:"gitRepositoryURL"`
|
||||
RepoId int64
|
||||
UserId int64
|
||||
ForwardPorts nat.PortSet
|
||||
ContainerEnv []string
|
||||
PostCreateCommand []string
|
||||
Binds []string
|
||||
SystemCommandCount int
|
||||
PortBindings nat.PortMap
|
||||
}
|
||||
@@ -39,8 +39,8 @@ const (
|
||||
LandingPageOrganizations LandingPage = "/explore/organizations"
|
||||
LandingPageLogin LandingPage = "/user/login"
|
||||
|
||||
// LandingPageDevstarDevcontainerRepoLink 是特殊仓库,表示 DevcontainerApp k8s CRD 脚手架工程: devstar.cn/DevcontainerApp
|
||||
LandingPageDevstarDevcontainerRepoLink LandingPage = "/devstar/devstar-devcontainer-kubebuilder-scaffold"
|
||||
// LandingPageDevcontainerRepoLink 是特殊仓库,表示 DevcontainerApp k8s CRD 脚手架工程: devstar.cn/DevcontainerApp
|
||||
LandingPageDevcontainerRepoLink LandingPage = "/devstar/devstar-devcontainer-kubebuilder-scaffold"
|
||||
)
|
||||
|
||||
// Server settings
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
context "code.gitea.io/gitea/services/context"
|
||||
devcontainer_service "code.gitea.io/gitea/services/devcontainer"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
|
||||
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 (
|
||||
@@ -32,9 +28,26 @@ func GetRepoDevContainerDetails(ctx *context.Context) {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
//ctx.Repo.RepoLink == ctx.Repo.Repository.Link()
|
||||
devContainerMetadata, err := devcontainer_service.GetRepoDevcontainerDetails(ctx, opts)
|
||||
hasDevContainer := err == nil && devContainerMetadata.DevContainerId > 0
|
||||
hasDevContainer := err == nil && devContainerMetadata.DevContainerId > 0 && created
|
||||
ctx.Data["HasDevContainer"] = hasDevContainer
|
||||
ctx.Data["HasDevContainerSetting"] = false
|
||||
|
||||
@@ -48,26 +61,35 @@ func GetRepoDevContainerDetails(ctx *context.Context) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.dev_container_invalid_config_prompt"), true)
|
||||
}
|
||||
// 从devcontainer.json文件提取image字段解析成仓库地址、命名空间、镜像名
|
||||
imageName := devcontainer_service.GetDefaultDevcontainerImageFromRepoDevcontainerJSON(ctx, ctx.Repo.Repository)
|
||||
registry, namespace, repo, tag := ParseImageName(imageName)
|
||||
devcontainerJson, err := devcontainer_service.GetDevcontainerJsonModel(ctx, ctx.Repo.Repository)
|
||||
if err == nil {
|
||||
imageName := devcontainerJson.Image
|
||||
registry, namespace, repo, tag := ParseImageName(imageName)
|
||||
ctx.Data["RepositoryAddress"] = registry
|
||||
ctx.Data["RepositoryUsername"] = namespace
|
||||
ctx.Data["ImageName"] = ctx.Repo.Repository.Name + "-" + repo + ":" + tag
|
||||
}
|
||||
|
||||
// 获取WebSSH服务端口
|
||||
webTerminalURL, err := devcontainer_service.GetWebTerminalURL(ctx, devContainerMetadata.DevContainerName)
|
||||
if err == nil {
|
||||
ctx.Data["WebSSHUrl"] = webTerminalURL
|
||||
}
|
||||
vsCodeTerminalURL, err := devcontainer_service.GetVSCodeTerminalURL(ctx, &devContainerMetadata)
|
||||
if err == nil {
|
||||
ctx.Data["VSCodeUrl"] = vsCodeTerminalURL
|
||||
log.Info(vsCodeTerminalURL)
|
||||
}
|
||||
|
||||
//存在devcontainer.json读取信息展示
|
||||
if isValidRepoDevcontainerJson {
|
||||
fileContent, err := devcontainer_service.GetDevcontainerJSONContent(ctx)
|
||||
fileContent, err := devcontainer_service.GetDevcontainerJsonString(ctx, ctx.Repo.Repository)
|
||||
if err == nil {
|
||||
ctx.Data["FileContent"] = fileContent
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 携带数据渲染页面,返回
|
||||
ctx.Data["RepositoryAddress"] = registry
|
||||
ctx.Data["RepositoryUsername"] = namespace
|
||||
ctx.Data["ImageName"] = ctx.Repo.Repository.Name + "-" + repo + ":" + tag
|
||||
ctx.Data["Title"] = ctx.Locale.Tr("repo.dev_container")
|
||||
ctx.Data["PageIsRepoDevcontainerDetails"] = true
|
||||
ctx.Data["HasValidDevContainerJSON"] = isValidRepoDevcontainerJson
|
||||
@@ -82,26 +104,7 @@ func GetRepoDevContainerDetails(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func CreateRepoDevContainerConfiguration(ctx *context.Context) {
|
||||
jsonString := `{"image":"devstar.cn/init/ssh-ttyd:ubuntu-20.05"}`
|
||||
resp, err := files_service.ChangeRepoFiles(db.DefaultContext, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: ".devcontainer/devcontainer.json",
|
||||
ContentReader: bytes.NewReader([]byte(jsonString)),
|
||||
},
|
||||
},
|
||||
OldBranch: "main",
|
||||
NewBranch: "main",
|
||||
Message: "add container configuration",
|
||||
})
|
||||
log.Info(resp.Commit.URL)
|
||||
if err != nil {
|
||||
log.Info("error ChangeRepoFiles:", err)
|
||||
ctx.JSON(500, map[string]string{
|
||||
"message": "error ChangeRepoFiles"})
|
||||
|
||||
}
|
||||
devcontainer_service.CreateDevcontainerJSON(ctx)
|
||||
ctx.Redirect(path.Join(ctx.Repo.RepoLink, "/dev-container"))
|
||||
}
|
||||
func ParseImageName(imageName string) (registry, namespace, repo, tag string) {
|
||||
@@ -139,15 +142,8 @@ func CreateRepoDevContainer(ctx *context.Context) {
|
||||
Actor: ctx.Doer,
|
||||
Repository: ctx.Repo.Repository,
|
||||
}
|
||||
err := devcontainer_service.CreateRepoDevcontainer(ctx, opts)
|
||||
if err != nil {
|
||||
log.Error("failed to create devContainer with option{%v}: %v", opts, err.Error())
|
||||
ctx.Flash.Error(ctx.Tr("repo.dev_container_control.creation_failed_for_user", ctx.Doer.Name))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.dev_container_control.creation_success_for_user", ctx.Doer.Name))
|
||||
}
|
||||
devcontainer_service.CreateRepoDevcontainer(ctx, opts)
|
||||
}
|
||||
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/dev-container")
|
||||
}
|
||||
|
||||
@@ -235,3 +231,64 @@ func DeleteRepoDevContainerForCurrentActor(ctx *context.Context) {
|
||||
}
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/dev-container")
|
||||
}
|
||||
|
||||
type OutputResponse struct {
|
||||
CurrentJob struct {
|
||||
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) {
|
||||
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)
|
||||
|
||||
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 = "success"
|
||||
for _, item := range devContainerOutput {
|
||||
if item.Status != "success" {
|
||||
resp.CurrentJob.Detail = "running"
|
||||
if devContainerOutput[0].Status == "success" && devContainerOutput[1].Status == "success" {
|
||||
resp.CurrentJob.Detail = "created"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
ctx.Done()
|
||||
}
|
||||
|
||||
@@ -1343,6 +1343,7 @@ func registerRoutes(m *web.Router) {
|
||||
|
||||
m.Get("/createConfiguration", devcontainer_web.CreateRepoDevContainerConfiguration)
|
||||
m.Get("/create", devcontainer_web.CreateRepoDevContainer, context.RepoMustNotBeArchived()) // 仓库状态非 Archived 才可以创建 DevContainer
|
||||
m.Get("/output", devcontainer_web.GetContainerOutput)
|
||||
m.Post("/delete", devcontainer_web.DeleteRepoDevContainerForCurrentActor)
|
||||
m.Post("/update", devcontainer_web.UpdateRepoDevContainerForCurrentActor)
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ package devcontainer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,17 +10,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
devcontainer_model "code.gitea.io/gitea/models/devcontainer"
|
||||
devcontainer_models_errors "code.gitea.io/gitea/models/devcontainer/errors"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/docker"
|
||||
git_module "code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
gitea_context "code.gitea.io/gitea/services/context"
|
||||
devcontainer_service_errors "code.gitea.io/gitea/services/devcontainer/errors"
|
||||
@@ -96,7 +91,7 @@ func GetRepoDevcontainerDetails(ctx context.Context, opts *RepoDevcontainerOptio
|
||||
|
||||
// 1. 检查参数是否有效
|
||||
if opts == nil || opts.Actor == nil || opts.Repository == nil {
|
||||
return resultRepoDevcontainerDetail, devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return resultRepoDevcontainerDetail, devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "construct query condition for devContainer user list",
|
||||
Message: "invalid search condition",
|
||||
}
|
||||
@@ -105,45 +100,46 @@ func GetRepoDevcontainerDetails(ctx context.Context, opts *RepoDevcontainerOptio
|
||||
// 2. 查询数据库
|
||||
/*
|
||||
SELECT
|
||||
devstar_devcontainer.id AS devcontainer_id,
|
||||
devstar_devcontainer.name AS devcontainer_name,
|
||||
devstar_devcontainer.devcontainer_host AS devcontainer_host,
|
||||
devstar_devcontainer.devcontainer_port AS devcontainer_port,
|
||||
devstar_devcontainer.devcontainer_username AS devcontainer_username,
|
||||
devstar_devcontainer.devcontainer_work_dir AS devcontainer_work_dir,
|
||||
devstar_devcontainer.repo_id AS repo_id,
|
||||
devcontainer.id AS devcontainer_id,
|
||||
devcontainer.name AS devcontainer_name,
|
||||
devcontainer.devcontainer_host AS devcontainer_host,
|
||||
devcontainer.devcontainer_port AS devcontainer_port,
|
||||
devcontainer.devcontainer_username AS devcontainer_username,
|
||||
devcontainer.devcontainer_work_dir AS devcontainer_work_dir,
|
||||
devcontainer.repo_id AS repo_id,
|
||||
repository.name AS repo_name,
|
||||
repository.owner_name AS repo_owner_name,
|
||||
repository.description AS repo_description,
|
||||
CONCAT('/', repository.owner_name, '/', repository.name) AS repo_link
|
||||
FROM devstar_devcontainer
|
||||
INNER JOIN repository on devstar_devcontainer.repo_id = repository.id
|
||||
FROM devcontainer
|
||||
INNER JOIN repository on devcontainer.repo_id = repository.id
|
||||
WHERE
|
||||
devstar_devcontainer.user_id = #{opts.Actor.ID}
|
||||
devcontainer.user_id = #{opts.Actor.ID}
|
||||
AND
|
||||
devstar_devcontainer.repo_id = #{opts.Repository.ID};
|
||||
devcontainer.repo_id = #{opts.Repository.ID};
|
||||
*/
|
||||
_, err := db.GetEngine(ctx).
|
||||
Table("devstar_devcontainer").
|
||||
Table("devcontainer").
|
||||
Select(""+
|
||||
"devstar_devcontainer.id AS devcontainer_id,"+
|
||||
"devstar_devcontainer.name AS devcontainer_name,"+
|
||||
"devstar_devcontainer.devcontainer_host AS devcontainer_host,"+
|
||||
"devstar_devcontainer.devcontainer_port AS devcontainer_port,"+
|
||||
"devstar_devcontainer.devcontainer_username AS devcontainer_username,"+
|
||||
"devstar_devcontainer.devcontainer_work_dir AS devcontainer_work_dir,"+
|
||||
"devstar_devcontainer.repo_id AS repo_id,"+
|
||||
"devcontainer.id AS devcontainer_id,"+
|
||||
"devcontainer.name AS devcontainer_name,"+
|
||||
"devcontainer.devcontainer_host AS devcontainer_host,"+
|
||||
"devcontainer.devcontainer_port AS devcontainer_port,"+
|
||||
"devcontainer.devcontainer_username AS devcontainer_username,"+
|
||||
"devcontainer.devcontainer_work_dir AS devcontainer_work_dir,"+
|
||||
"devcontainer.repo_id AS repo_id,"+
|
||||
"devcontainer.user_id AS user_id,"+
|
||||
"repository.name AS repo_name,"+
|
||||
"repository.owner_name AS repo_owner_name,"+
|
||||
"repository.description AS repo_description,"+
|
||||
"CONCAT('/', repository.owner_name, '/', repository.name) AS repo_link").
|
||||
Join("INNER", "repository", "devstar_devcontainer.repo_id = repository.id").
|
||||
Where("devstar_devcontainer.user_id = ? AND devstar_devcontainer.repo_id = ?", opts.Actor.ID, opts.Repository.ID).
|
||||
Join("INNER", "repository", "devcontainer.repo_id = repository.id").
|
||||
Where("devcontainer.user_id = ? AND devcontainer.repo_id = ?", opts.Actor.ID, opts.Repository.ID).
|
||||
Get(&resultRepoDevcontainerDetail)
|
||||
|
||||
// 3. 返回
|
||||
if err != nil {
|
||||
return resultRepoDevcontainerDetail, devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return resultRepoDevcontainerDetail, devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("query devcontainer with repo '%v' and username '%v'", opts.Repository.Name, opts.Actor.Name),
|
||||
Message: err.Error(),
|
||||
}
|
||||
@@ -164,9 +160,15 @@ func CreateRepoDevcontainer(ctx context.Context, opts *CreateRepoDevcontainerOpt
|
||||
|
||||
// unixTimestamp is the number of seconds elapsed since January 1, 1970 UTC.
|
||||
unixTimestamp := time.Now().Unix()
|
||||
|
||||
devContainerJson, err := GetDevcontainerJsonModel(ctx, opts.Repository)
|
||||
if err != nil {
|
||||
return devcontainer_service_errors.ErrOperateDevcontainer{
|
||||
Action: "Get DevContainer Error",
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
newDevcontainer := &CreateDevcontainerDTO{
|
||||
DevstarDevcontainer: devcontainer_model.DevstarDevcontainer{
|
||||
Devcontainer: devcontainer_model.Devcontainer{
|
||||
Name: getSanitizedDevcontainerName(username, repoName),
|
||||
DevcontainerHost: setting.Devcontainer.Host,
|
||||
DevcontainerUsername: "root",
|
||||
@@ -176,7 +178,7 @@ func CreateRepoDevcontainer(ctx context.Context, opts *CreateRepoDevcontainerOpt
|
||||
CreatedUnix: unixTimestamp,
|
||||
UpdatedUnix: unixTimestamp,
|
||||
},
|
||||
Image: GetDefaultDevcontainerImageFromRepoDevcontainerJSON(ctx, opts.Repository),
|
||||
Image: devContainerJson.Image,
|
||||
GitRepositoryURL: strings.TrimSuffix(setting.AppURL, "/") + opts.Repository.Link(),
|
||||
}
|
||||
|
||||
@@ -218,24 +220,24 @@ func CreateRepoDevcontainer(ctx context.Context, opts *CreateRepoDevcontainerOpt
|
||||
// }
|
||||
|
||||
// 1. 调用 k8s Operator Agent,创建 DevContainer 资源,同时更新k8s调度器分配的 NodePort
|
||||
err = claimDevcontainerResource(&ctx, newDevcontainer)
|
||||
err = claimDevcontainerResource(&ctx, newDevcontainer, devContainerJson)
|
||||
if err != nil {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("claim resource for Dev Container %v", newDevcontainer),
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
// 2. 根据分配的 NodePort 更新数据库字段
|
||||
rowsAffect, err := db.GetEngine(ctx).
|
||||
Table("devstar_devcontainer").
|
||||
Insert(newDevcontainer.DevstarDevcontainer)
|
||||
Table("devcontainer").
|
||||
Insert(newDevcontainer.Devcontainer)
|
||||
if err != nil {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("insert new DevContainer for user '%s' in repo '%s'", username, repoName),
|
||||
Message: err.Error(),
|
||||
}
|
||||
} else if rowsAffect == 0 {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("insert new DevContainer for user '%s' in repo '%s'", username, repoName),
|
||||
Message: "expected 1 row to be inserted, but got 0",
|
||||
}
|
||||
@@ -317,11 +319,55 @@ func GetWebTerminalURL(ctx context.Context, devcontainerName string) (string, er
|
||||
return "", fmt.Errorf("unknown agent")
|
||||
}
|
||||
}
|
||||
func GetVSCodeTerminalURL(ctx context.Context, devcontainer *RepoDevContainer) (string, error) {
|
||||
cli, err := docker.CreateDockerClient(&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
|
||||
}
|
||||
token := &auth_model.AccessToken{
|
||||
UID: devcontainer.UserId,
|
||||
Name: "vscode_login_token_web",
|
||||
}
|
||||
exist, err := auth_model.AccessTokenByNameExists(ctx, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exist {
|
||||
db.GetEngine(ctx).Table("access_token").Where("uid = ? AND name = ?", devcontainer.UserId, "vscode_login_token_web").Delete()
|
||||
}
|
||||
scope, err := auth_model.AccessTokenScope(strings.Join([]string{"write:user", "write:repository"}, ",")).Normalize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token.Scope = scope
|
||||
err = auth_model.NewAccessToken(db.DefaultContext, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Info("%v------%v", token.Token, token.TokenHash)
|
||||
return "vscode://" + setting.Devcontainer.Host + ":" + port +
|
||||
"/" + "openProject?host=" + devcontainer.DevContainerHost +
|
||||
"&port=" + port +
|
||||
"&username=" + devcontainer.DevContainerUsername +
|
||||
"&path=" + devcontainer.DevContainerWorkDir +
|
||||
"&access_token=" + token.Token +
|
||||
"&devstar_username=" + devcontainer.RepoOwnerName, nil
|
||||
|
||||
}
|
||||
|
||||
// DeleteRepoDevcontainer 按照 仓库 和/或 用户信息删除 DevContainer(s)
|
||||
func DeleteRepoDevcontainer(ctx context.Context, opts *RepoDevcontainerOptions) error {
|
||||
if ctx == nil || opts == nil || (opts.Actor == nil && opts.Repository == nil) {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "construct query parameters",
|
||||
Message: "Invalid parameters",
|
||||
}
|
||||
@@ -335,18 +381,18 @@ func DeleteRepoDevcontainer(ctx context.Context, opts *RepoDevcontainerOptions)
|
||||
if opts.Repository != nil {
|
||||
sqlDevcontainerCondition = sqlDevcontainerCondition.And(builder.Eq{"repo_id": opts.Repository.ID})
|
||||
}
|
||||
var devcontainersList []devcontainer_model.DevstarDevcontainer
|
||||
var devcontainersList []devcontainer_model.Devcontainer
|
||||
|
||||
// 2. 开启事务:先获取 devcontainer列表,后删除
|
||||
dbTransactionErr := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
// 2.1 条件查询: user_id 和/或 repo_id
|
||||
err = db.GetEngine(ctx).
|
||||
Table("devstar_devcontainer").
|
||||
Table("devcontainer").
|
||||
Where(sqlDevcontainerCondition).
|
||||
Find(&devcontainersList)
|
||||
if err != nil {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("find devcontainer(s) with condition '%v'", sqlDevcontainerCondition),
|
||||
Message: err.Error(),
|
||||
}
|
||||
@@ -354,7 +400,7 @@ func DeleteRepoDevcontainer(ctx context.Context, opts *RepoDevcontainerOptions)
|
||||
|
||||
// 2.2 空列表,直接结束事务(由于前一个操作只是查询,所以回滚事务不会导致数据不一致问题)
|
||||
if len(devcontainersList) == 0 {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("find devcontainer(s) with condition '%v'", sqlDevcontainerCondition),
|
||||
Message: "No DevContainer found",
|
||||
}
|
||||
@@ -362,16 +408,19 @@ func DeleteRepoDevcontainer(ctx context.Context, opts *RepoDevcontainerOptions)
|
||||
|
||||
// 2.3 条件删除: user_id 和/或 repo_id
|
||||
_, err = db.GetEngine(ctx).
|
||||
Table("devstar_devcontainer").
|
||||
Table("devcontainer").
|
||||
Where(sqlDevcontainerCondition).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("MARK devcontainer(s) as DELETED with condition '%v'", sqlDevcontainerCondition),
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).
|
||||
Table("devcontainer_output").
|
||||
Where(sqlDevcontainerCondition).
|
||||
Delete()
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -411,7 +460,7 @@ func getSanitizedDevcontainerName(username, repoName string) string {
|
||||
}
|
||||
|
||||
// purgeDevcontainersResource 辅助函数,用于goroutine后台执行,回收DevContainer资源
|
||||
func purgeDevcontainersResource(ctx *context.Context, devcontainersList *[]devcontainer_model.DevstarDevcontainer) error {
|
||||
func purgeDevcontainersResource(ctx *context.Context, devcontainersList *[]devcontainer_model.Devcontainer) error {
|
||||
// 1. 检查 DevContainer 功能是否启用,若禁用,则直接结束,不会真正执行删除操作
|
||||
if !setting.Devcontainer.Enabled {
|
||||
// 如果用户设置禁用 DevContainer,无法删除资源,会直接忽略,而数据库相关记录会继续清空、不会发生回滚
|
||||
@@ -426,7 +475,7 @@ func purgeDevcontainersResource(ctx *context.Context, devcontainersList *[]devco
|
||||
return DeleteDevcontainer(ctx, devcontainersList)
|
||||
default:
|
||||
// 未知 Agent,直接报错
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "dispatch DevContainer deletion",
|
||||
Message: fmt.Sprintf("unknown DevContainer agent `%s`, please check your config files", setting.Devcontainer.Agent),
|
||||
}
|
||||
@@ -434,10 +483,10 @@ func purgeDevcontainersResource(ctx *context.Context, devcontainersList *[]devco
|
||||
}
|
||||
|
||||
// claimDevcontainerResource 分发创建 DevContainer 任务到配置文件指定的执行器
|
||||
func claimDevcontainerResource(ctx *context.Context, newDevContainer *CreateDevcontainerDTO) error {
|
||||
func claimDevcontainerResource(ctx *context.Context, newDevContainer *CreateDevcontainerDTO, devContainerJSON *DevStarJSON) error {
|
||||
// 1. 检查 DevContainer 功能是否启用,若禁用,则直接结束
|
||||
if !setting.Devcontainer.Enabled {
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "Check for DevContainer functionality switch",
|
||||
Message: "DevContainer is disabled globally, please check your configuration files",
|
||||
}
|
||||
@@ -449,123 +498,12 @@ func claimDevcontainerResource(ctx *context.Context, newDevContainer *CreateDevc
|
||||
// k8s Operator
|
||||
return AssignDevcontainerCreation2K8sOperator(ctx, newDevContainer)
|
||||
case setting.DOCKER:
|
||||
return CreateDevcontainer(ctx, newDevContainer)
|
||||
return CreateDevcontainer(ctx, newDevContainer, devContainerJSON)
|
||||
default:
|
||||
// 未知 Agent,直接报错
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_models_errors.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "dispatch DevContainer creation",
|
||||
Message: fmt.Sprintf("unknown DevContainer agent `%s`, please check your config files", setting.Devcontainer.Agent),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultDevcontainerImageFromRepoDevcontainerJSON 从 .devcontainer/devcontainer.json 中获取 devContainer image
|
||||
func GetDefaultDevcontainerImageFromRepoDevcontainerJSON(ctx context.Context, repo *repo_model.Repository) string {
|
||||
|
||||
// 1. 获取默认分支名
|
||||
branchName := repo.DefaultBranch
|
||||
if len(branchName) == 0 {
|
||||
branchName = setting.Devcontainer.DefaultGitBranchName
|
||||
}
|
||||
|
||||
// 2. 打开默认分支
|
||||
gitRepoEntity, err := git_module.OpenRepository(ctx, repo.RepoPath())
|
||||
if err != nil {
|
||||
log.Error("Failed to open repository %s: %v", repo.RepoPath(), err)
|
||||
return setting.Devcontainer.DefaultDevcontainerImageName
|
||||
}
|
||||
defer func(gitRepoEntity *git_module.Repository) {
|
||||
_ = gitRepoEntity.Close()
|
||||
}(gitRepoEntity)
|
||||
|
||||
// 3. 获取分支名称
|
||||
commit, err := gitRepoEntity.GetBranchCommit(branchName)
|
||||
if err != nil {
|
||||
return setting.Devcontainer.DefaultDevcontainerImageName
|
||||
}
|
||||
|
||||
// 4. 读取 .devcontainer/devcontainer.json 文件
|
||||
const maxDevcontainerJSONSize = 10 * 1024 * 1024 // 设置最大允许的文件大小 10MB
|
||||
devcontainerJSONContent, err := commit.GetFileContent(".devcontainer/devcontainer.json", maxDevcontainerJSONSize)
|
||||
if err != nil {
|
||||
log.Error("Failed to get .devcontainer/devcontainer.json file: %v", err)
|
||||
return setting.Devcontainer.DefaultDevcontainerImageName
|
||||
}
|
||||
// 5. 移除注释
|
||||
cleanedContent, err := removeComments(devcontainerJSONContent)
|
||||
if err != nil {
|
||||
log.Error("Failed to remove comments from .devcontainer/devcontainer.json: %v", err)
|
||||
return setting.Devcontainer.DefaultDevcontainerImageName
|
||||
}
|
||||
|
||||
// 5. 解析 JSON
|
||||
devContainerJSON, err := devcontainer_model.Unmarshal(cleanedContent)
|
||||
if err != nil {
|
||||
log.Error("Failed to unmarshal .devcontainer/devcontainer.json: %v", err)
|
||||
return setting.Devcontainer.DefaultDevcontainerImageName
|
||||
}
|
||||
|
||||
// 6. 解析并返回
|
||||
if len(devContainerJSON.Image) == 0 {
|
||||
return setting.Devcontainer.DefaultDevcontainerImageName
|
||||
}
|
||||
return devContainerJSON.Image
|
||||
}
|
||||
func GetDevcontainerJSONContent(ctx *gitea_context.Context) (string, error) {
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(".devcontainer/devcontainer.json")
|
||||
if err != nil {
|
||||
log.Info("Repo.Commit.GetTreeEntryByPath %v", err.Error())
|
||||
return "", err
|
||||
}
|
||||
// No way to edit a directory online.
|
||||
if entry.IsDir() {
|
||||
ctx.NotFound("entry.IsDir", nil)
|
||||
return "", fmt.Errorf(".devcontainer/devcontainer.json entry.IsDir")
|
||||
}
|
||||
|
||||
blob := entry.Blob()
|
||||
if blob.Size() >= setting.UI.MaxDisplayFileSize {
|
||||
ctx.NotFound("blob.Size", err)
|
||||
return "", fmt.Errorf(".devcontainer/devcontainer.json blob.Size overflow")
|
||||
}
|
||||
|
||||
dataRc, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
ctx.NotFound("blob.Data", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer dataRc.Close()
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := util.ReadAtMost(dataRc, buf)
|
||||
buf = buf[:n]
|
||||
|
||||
// Only some file types are editable online as text.
|
||||
if !typesniffer.DetectContentType(buf).IsRepresentableAsText() {
|
||||
ctx.NotFound("typesniffer.IsRepresentableAsText", nil)
|
||||
return "", fmt.Errorf("typesniffer.IsRepresentableAsText")
|
||||
}
|
||||
|
||||
d, _ := io.ReadAll(dataRc)
|
||||
|
||||
buf = append(buf, d...)
|
||||
if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
|
||||
log.Error("ToUTF8: %v", err)
|
||||
return string(buf), nil
|
||||
} else {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 JSON 文件中的注释
|
||||
func removeComments(data string) (string, error) {
|
||||
// 移除单行注释 // ...
|
||||
re := regexp.MustCompile(`//.*`)
|
||||
data = re.ReplaceAllString(data, "")
|
||||
|
||||
// 移除多行注释 /* ... */
|
||||
re = regexp.MustCompile(`/\*.*?\*/`)
|
||||
data = re.ReplaceAllString(data, "")
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ func OpenDevcontainerAPIService(ctx *gitea_web_context.Context, opts *AbstractOp
|
||||
|
||||
// 2. 调用抽象层获取 DevContainer 最新状态(需要根据用户传入的 wait 参数决定是否要阻塞等待 DevContainer 就绪)
|
||||
optsOpenDevcontainer := &OpenDevcontainerAppDispatcherOptions{
|
||||
RepoID: opts.RepoId,
|
||||
UserID: opts.Actor.ID,
|
||||
Name: devcontainerDetails.DevContainerName,
|
||||
Port: devcontainerDetails.DevContainerPort,
|
||||
Wait: opts.Wait,
|
||||
|
||||
367
services/devcontainer/devcontainer_json.go
Normal file
367
services/devcontainer/devcontainer_json.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
devcontainer_model "code.gitea.io/gitea/models/devcontainer"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
gitea_context "code.gitea.io/gitea/services/context"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
type DevStarJSON struct {
|
||||
ForwardPorts nat.PortSet
|
||||
ContainerEnv []string
|
||||
Image string
|
||||
InitializeCommand []string
|
||||
PostCreateCommand []string
|
||||
RunArgs []string
|
||||
}
|
||||
|
||||
func CreateDevcontainerJSON(ctx *gitea_context.Context) {
|
||||
jsonString := `{
|
||||
"image":"mcr.microsoft.com/devcontainers/base:dev-ubuntu-20.04",
|
||||
"forwardPorts": [
|
||||
{
|
||||
"containerPort": 8080,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"containerEnv": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"initializeCommand": "echo \"init\";",
|
||||
"postCreateCommand": [
|
||||
"echo \"created\";",
|
||||
"echo \"test\";"
|
||||
],
|
||||
"runArgs": [
|
||||
"-p",
|
||||
"8888:8888"
|
||||
]
|
||||
}`
|
||||
resp, err := files_service.ChangeRepoFiles(db.DefaultContext, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: ".devcontainer/devcontainer.json",
|
||||
ContentReader: bytes.NewReader([]byte(jsonString)),
|
||||
},
|
||||
},
|
||||
OldBranch: "main",
|
||||
NewBranch: "main",
|
||||
Message: "add container configuration",
|
||||
})
|
||||
log.Info(resp.Commit.URL)
|
||||
if err != nil {
|
||||
log.Info("error ChangeRepoFiles:", err)
|
||||
ctx.JSON(500, map[string]string{
|
||||
"message": "error ChangeRepoFiles"})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateDevcontainerJSON(ctx *gitea_context.Context, jsonString string) error {
|
||||
// 更新devcontainer.json配置文件
|
||||
_, err := files_service.ChangeRepoFiles(db.DefaultContext, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "update",
|
||||
TreePath: ".devcontainer/devcontainer.json",
|
||||
ContentReader: bytes.NewReader([]byte(jsonString)),
|
||||
},
|
||||
},
|
||||
OldBranch: ctx.Repo.Repository.DefaultBranch,
|
||||
Message: "Update container",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新devcontainer.json配置文件失败 %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetImageFromDevcontainerJSON 从 .devcontainer/devcontainer.json 中获取 devContainer image
|
||||
func GetDevcontainerJsonModel(ctx context.Context, repo *repo.Repository) (*DevStarJSON, error) {
|
||||
devcontainerJSONContent, err := GetDevcontainerJsonString(ctx, repo)
|
||||
var devContainerJson *devcontainer_model.DevContainerJSON
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 1. 移除注释
|
||||
cleanedContent, err := removeComments(devcontainerJSONContent)
|
||||
if err != nil {
|
||||
log.Error("Failed to remove comments from .devcontainer/devcontainer.json: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 解析 JSON
|
||||
devContainerJson, err = devcontainer_model.Unmarshal(cleanedContent)
|
||||
if err != nil {
|
||||
log.Error("Failed to unmarshal .devcontainer/devcontainer.json: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Info("%v", devContainerJson)
|
||||
|
||||
devStarJson := &DevStarJSON{
|
||||
Image: devContainerJson.Image,
|
||||
ContainerEnv: ConvertContainerEnv(devContainerJson.ContainerEnv),
|
||||
}
|
||||
|
||||
devStarJson.ForwardPorts, _ = ConvertForwardPorts(devContainerJson.ForwardPorts)
|
||||
devStarJson.InitializeCommand, _ = parseCommand(devContainerJson.InitializeCommand)
|
||||
c1, _ := parseCommand(devContainerJson.OnCreateCommand)
|
||||
c2, _ := parseCommand(devContainerJson.UpdateContentCommand)
|
||||
c3, _ := parseCommand(devContainerJson.PostCreateCommand)
|
||||
c4, _ := parseCommand(devContainerJson.PostStartCommand)
|
||||
c5, _ := parseCommand(devContainerJson.PostAttachCommand)
|
||||
devStarJson.PostCreateCommand = append(devStarJson.PostCreateCommand, c1...)
|
||||
devStarJson.PostCreateCommand = append(devStarJson.PostCreateCommand, c2...)
|
||||
devStarJson.PostCreateCommand = append(devStarJson.PostCreateCommand, c3...)
|
||||
devStarJson.PostCreateCommand = append(devStarJson.PostCreateCommand, c4...)
|
||||
devStarJson.PostCreateCommand = append(devStarJson.PostCreateCommand, c5...)
|
||||
devStarJson.RunArgs = devContainerJson.RunArgs
|
||||
log.Info("%v", devStarJson)
|
||||
return devStarJson, nil
|
||||
}
|
||||
|
||||
func GetDevcontainerJsonString(ctx context.Context, repo *repo.Repository) (string, error) {
|
||||
// 1. 获取默认分支名
|
||||
branchName := repo.DefaultBranch
|
||||
if len(branchName) == 0 {
|
||||
branchName = setting.Devcontainer.DefaultGitBranchName
|
||||
}
|
||||
|
||||
// 2. 打开默认分支
|
||||
gitRepoEntity, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err != nil {
|
||||
log.Error("Failed to open repository %s: %v", repo.RepoPath(), err)
|
||||
return "", err
|
||||
}
|
||||
defer func(gitRepoEntity *git.Repository) {
|
||||
_ = gitRepoEntity.Close()
|
||||
}(gitRepoEntity)
|
||||
|
||||
// 3. 获取分支名称
|
||||
commit, err := gitRepoEntity.GetBranchCommit(branchName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entry, err := commit.GetTreeEntryByPath(".devcontainer/devcontainer.json")
|
||||
if err != nil {
|
||||
log.Info("Repo.Commit.GetTreeEntryByPath %v", err.Error())
|
||||
return "", err
|
||||
}
|
||||
// No way to edit a directory online.
|
||||
if entry.IsDir() {
|
||||
|
||||
return "", fmt.Errorf(".devcontainer/devcontainer.json entry.IsDir")
|
||||
}
|
||||
|
||||
blob := entry.Blob()
|
||||
if blob.Size() >= setting.UI.MaxDisplayFileSize {
|
||||
|
||||
return "", fmt.Errorf(".devcontainer/devcontainer.json blob.Size overflow")
|
||||
}
|
||||
|
||||
dataRc, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer dataRc.Close()
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := util.ReadAtMost(dataRc, buf)
|
||||
buf = buf[:n]
|
||||
|
||||
// Only some file types are editable online as text.
|
||||
if !typesniffer.DetectContentType(buf).IsRepresentableAsText() {
|
||||
|
||||
return "", fmt.Errorf("typesniffer.IsRepresentableAsText")
|
||||
}
|
||||
|
||||
d, _ := io.ReadAll(dataRc)
|
||||
|
||||
buf = append(buf, d...)
|
||||
if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
|
||||
log.Error("ToUTF8: %v", err)
|
||||
return string(buf), nil
|
||||
} else {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 JSON 文件中的注释
|
||||
func removeComments(data string) (string, error) {
|
||||
// 移除单行注释 // ...
|
||||
re := regexp.MustCompile(`//.*`)
|
||||
data = re.ReplaceAllString(data, "")
|
||||
|
||||
// 移除多行注释 /* ... */
|
||||
re = regexp.MustCompile(`/\*.*?\*/`)
|
||||
data = re.ReplaceAllString(data, "")
|
||||
|
||||
return data, nil
|
||||
}
|
||||
func ConvertContainerEnv(envMap map[string]string) []string {
|
||||
var envSlice []string
|
||||
|
||||
for key, value := range envMap {
|
||||
envSlice = append(envSlice, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
return envSlice
|
||||
}
|
||||
|
||||
func parseCommand(cmd interface{}) ([]string, error) {
|
||||
var result []string
|
||||
|
||||
switch v := cmd.(type) {
|
||||
case string: // 直接处理字符串类型
|
||||
result = []string{v}
|
||||
case []interface{}: // 处理数组类型(如 ["echo","hello"])
|
||||
var parts []string
|
||||
for _, item := range v {
|
||||
// 处理数组元素可能是多种类型的情况
|
||||
switch elem := item.(type) {
|
||||
case string:
|
||||
parts = append(parts, elem)
|
||||
case int, float64: // 处理数字自动转换的情况
|
||||
parts = append(parts, fmt.Sprintf("%v", elem))
|
||||
default:
|
||||
return []string{}, fmt.Errorf("unsupported array element type: %T", elem)
|
||||
}
|
||||
}
|
||||
result = parts
|
||||
case map[string]interface{}: // 处理对象类型(如 {"command":"echo","args":["hello"]})
|
||||
// 方式1:序列化成JSON字符串
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return []string{}, fmt.Errorf("failed to marshal object: %v", err)
|
||||
}
|
||||
result = []string{string(jsonBytes)}
|
||||
|
||||
// 方式2:或拼接成 key=value 格式(根据需求选择)
|
||||
// var pairs []string
|
||||
// for key, val := range v {
|
||||
// pairs = append(pairs, fmt.Sprintf("%s=%v", key, val))
|
||||
// }
|
||||
// result = strings.Join(pairs, " ")
|
||||
default:
|
||||
return []string{}, fmt.Errorf("unsupported command type: %T", v)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 转换入口函数
|
||||
func ConvertForwardPorts(input interface{}) (nat.PortSet, error) {
|
||||
portSet := make(nat.PortSet)
|
||||
|
||||
// 类型检查确保是切片
|
||||
rawPorts, ok := input.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("forwardPorts must be an array")
|
||||
}
|
||||
|
||||
// 遍历每个端口定义
|
||||
for _, rawPort := range rawPorts {
|
||||
port, proto, err := parsePortDefinition(rawPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建 nat.Port 并加入集合
|
||||
natPort := nat.Port(fmt.Sprintf("%s/%s", port, proto))
|
||||
portSet[natPort] = struct{}{}
|
||||
}
|
||||
|
||||
return portSet, nil
|
||||
}
|
||||
|
||||
// 处理三种可能的端口定义形式:
|
||||
// 1. 数字: 3000
|
||||
// 2. 字符串: "3000" 或 "3000/udp"
|
||||
// 3. 对象: {"containerPort": 3000, "protocol": "tcp"}
|
||||
func parsePortDefinition(raw interface{}) (string, string, error) {
|
||||
switch v := raw.(type) {
|
||||
case float64: // JSON 数字默认解析为 float64
|
||||
return validatePort(strconv.Itoa(int(v))), "tcp", nil
|
||||
|
||||
case string:
|
||||
return parsePortString(v)
|
||||
|
||||
case map[string]interface{}:
|
||||
return parsePortObject(v)
|
||||
|
||||
default:
|
||||
return "", "", fmt.Errorf("invalid port type: %T", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理字符串形式(支持带协议)
|
||||
func parsePortString(s string) (string, string, error) {
|
||||
proto, port := nat.SplitProtoPort(s)
|
||||
if port == "" {
|
||||
return "", "", fmt.Errorf("invalid port string: %s", s)
|
||||
}
|
||||
return validatePort(port), proto, nil
|
||||
}
|
||||
|
||||
// 处理对象形式
|
||||
func parsePortObject(obj map[string]interface{}) (string, string, error) {
|
||||
// 提取 containerPort
|
||||
rawPort, exists := obj["containerPort"]
|
||||
if !exists {
|
||||
return "", "", fmt.Errorf("missing containerPort field")
|
||||
}
|
||||
|
||||
// 转换端口值
|
||||
var portStr string
|
||||
switch p := rawPort.(type) {
|
||||
case float64:
|
||||
portStr = strconv.Itoa(int(p))
|
||||
case string:
|
||||
portStr = p
|
||||
default:
|
||||
return "", "", fmt.Errorf("invalid containerPort type: %T", rawPort)
|
||||
}
|
||||
|
||||
// 验证端口格式
|
||||
portStr = validatePort(portStr)
|
||||
|
||||
// 获取协议(默认为 tcp)
|
||||
proto := "tcp"
|
||||
if rawProto, exists := obj["protocol"]; exists {
|
||||
if protoStr, ok := rawProto.(string); ok {
|
||||
proto = protoStr
|
||||
}
|
||||
}
|
||||
|
||||
return portStr, proto, nil
|
||||
}
|
||||
|
||||
// 验证端口格式有效性
|
||||
func validatePort(port string) string {
|
||||
// 这里可以添加更严格的验证逻辑
|
||||
// 示例仅处理基本数字格式
|
||||
if _, err := strconv.Atoi(port); err == nil {
|
||||
return port
|
||||
}
|
||||
panic(fmt.Sprintf("invalid port format: %s", port))
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func GetUserDevcontainersList(ctx context.Context, opts *SearchUserDevcontainerL
|
||||
|
||||
// 1. 查询参数预处理
|
||||
if opts == nil || opts.Actor == nil {
|
||||
return resultDevContainerListVO, devcontainer_model.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return resultDevContainerListVO, devcontainer_model.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "construct query condition for devContainer user list",
|
||||
Message: "invalid search condition",
|
||||
}
|
||||
@@ -55,15 +55,15 @@ func GetUserDevcontainersList(ctx context.Context, opts *SearchUserDevcontainerL
|
||||
// 2.1 查询符合条件的记录总数
|
||||
/*
|
||||
SELECT COUNT(*)
|
||||
FROM devstar_devcontainer
|
||||
FROM devcontainer
|
||||
WHERE user_id = #{opts.Actor.ID}
|
||||
*/
|
||||
resultDevContainerListVO.ItemTotalNum, err = db.GetEngine(ctx).
|
||||
Table("devstar_devcontainer").
|
||||
Table("devcontainer").
|
||||
Where(sqlCondition).
|
||||
Count()
|
||||
if err != nil {
|
||||
return devcontainer_model.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_model.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "count devContainer item numbers",
|
||||
Message: err.Error(),
|
||||
}
|
||||
@@ -84,44 +84,44 @@ func GetUserDevcontainersList(ctx context.Context, opts *SearchUserDevcontainerL
|
||||
resultDevContainerListVO.DevContainers = make([]RepoDevContainer, 0, opts.PaginationOptions.PageSize)
|
||||
/*
|
||||
SELECT
|
||||
devstar_devcontainer.id AS devcontainer_id,
|
||||
devstar_devcontainer.name AS devcontainer_name,
|
||||
devstar_devcontainer.devcontainer_host AS devcontainer_host,
|
||||
devstar_devcontainer.devcontainer_username AS devcontainer_username,
|
||||
devstar_devcontainer.devcontainer_work_dir AS devcontainer_work_dir,
|
||||
devstar_devcontainer.repo_id AS repo_id,
|
||||
devcontainer.id AS devcontainer_id,
|
||||
devcontainer.name AS devcontainer_name,
|
||||
devcontainer.devcontainer_host AS devcontainer_host,
|
||||
devcontainer.devcontainer_username AS devcontainer_username,
|
||||
devcontainer.devcontainer_work_dir AS devcontainer_work_dir,
|
||||
devcontainer.repo_id AS repo_id,
|
||||
repository.name AS repo_name,
|
||||
repository.owner_name AS repo_owner_name,
|
||||
repository.description AS repo_description,
|
||||
CONCAT('/', repository.owner_name, '/', repository.name) AS repo_link
|
||||
FROM devstar_devcontainer
|
||||
INNER JOIN repository ON devstar_devcontainer.repo_id = repository.id
|
||||
WHERE devstar_devcontainer.user_id = #{opts.Actor.ID}
|
||||
FROM devcontainer
|
||||
INNER JOIN repository ON devcontainer.repo_id = repository.id
|
||||
WHERE devcontainer.user_id = #{opts.Actor.ID}
|
||||
ORDER BY #{opts.OrderBy.String()}
|
||||
LIMIT #{opts.PaginationOptions.PageSize}
|
||||
OFFSET ( (#{opts.PaginationOptions.Page} - 1) * #{opts.PaginationOptions.PageSize});
|
||||
*/
|
||||
sess := db.GetEngine(ctx).
|
||||
Table("devstar_devcontainer").
|
||||
Table("devcontainer").
|
||||
Select(""+
|
||||
"devstar_devcontainer.id AS devcontainer_id,"+
|
||||
"devstar_devcontainer.name AS devcontainer_name,"+
|
||||
"devstar_devcontainer.devcontainer_host AS devcontainer_host,"+
|
||||
"devstar_devcontainer.devcontainer_username AS devcontainer_username,"+
|
||||
"devstar_devcontainer.devcontainer_work_dir AS devcontainer_work_dir,"+
|
||||
"devstar_devcontainer.repo_id AS repo_id,"+
|
||||
"devcontainer.id AS devcontainer_id,"+
|
||||
"devcontainer.name AS devcontainer_name,"+
|
||||
"devcontainer.devcontainer_host AS devcontainer_host,"+
|
||||
"devcontainer.devcontainer_username AS devcontainer_username,"+
|
||||
"devcontainer.devcontainer_work_dir AS devcontainer_work_dir,"+
|
||||
"devcontainer.repo_id AS repo_id,"+
|
||||
"repository.name AS repo_name,"+
|
||||
"repository.owner_name AS repo_owner_name,"+
|
||||
"repository.description AS repo_description,"+
|
||||
"CONCAT('/', repository.owner_name, '/', repository.name) AS repo_link").
|
||||
Join("INNER", "repository", "devstar_devcontainer.repo_id = repository.id").
|
||||
Join("INNER", "repository", "devcontainer.repo_id = repository.id").
|
||||
Where(sqlCondition).
|
||||
OrderBy(opts.OrderBy.String())
|
||||
err = db.SetSessionPagination(sess, &opts.PaginationOptions).
|
||||
Find(&resultDevContainerListVO.DevContainers)
|
||||
if err != nil {
|
||||
// 查询出错,返回错误信息,结束事务
|
||||
return devcontainer_model.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
return devcontainer_model.ErrFailedToOperateDevcontainerDB{
|
||||
Action: fmt.Sprintf("list devContainer for user '%v' at page %v", opts.Actor.Name, opts.PaginationOptions.Page),
|
||||
Message: err.Error(),
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func GetUserDevcontainersList(ctx context.Context, opts *SearchUserDevcontainerL
|
||||
|
||||
if errDbTransaction != nil {
|
||||
return resultDevContainerListVO,
|
||||
devcontainer_model.ErrFailedToOperateDevstarDevcontainerDB{
|
||||
devcontainer_model.ErrFailedToOperateDevcontainerDB{
|
||||
Action: "query user DevContainer List",
|
||||
Message: errDbTransaction.Error(),
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ type RepoDevContainer struct {
|
||||
DevContainerHost string `json:"devContainerHost" xorm:"devcontainer_host"`
|
||||
DevContainerUsername string `json:"devContainerUsername" xorm:"devcontainer_username"`
|
||||
DevContainerWorkDir string `json:"devContainerWorkDir" xorm:"devcontainer_work_dir"`
|
||||
|
||||
RepoId int64 `json:"repoId" xorm:"repo_id"`
|
||||
RepoName string `json:"repoName" xorm:"repo_name"`
|
||||
UserId int64 `json:"userId" xorm:"user_id"`
|
||||
RepoId int64 `json:"repoId" xorm:"repo_id"`
|
||||
RepoName string `json:"repoName" xorm:"repo_name"`
|
||||
//RepoOwnerID int64 `json:"repo_owner_id" xorm:"repo_owner_id"`
|
||||
RepoOwnerName string `json:"repo_owner_name" xorm:"repo_owner_name"`
|
||||
RepoLink string `json:"repo_link" xorm:"repo_link"`
|
||||
@@ -76,6 +76,8 @@ type OpenDevcontainerAppDispatcherOptions struct {
|
||||
Wait bool `json:"wait"`
|
||||
Port uint16
|
||||
UserPublicKey string
|
||||
RepoID int64
|
||||
UserID int64
|
||||
}
|
||||
|
||||
type UpdateDevcontainerOptions struct {
|
||||
@@ -102,7 +104,7 @@ type AbstractDeleteDevcontainerOptions struct {
|
||||
}
|
||||
|
||||
type CreateDevcontainerDTO struct {
|
||||
devcontainer_model.DevstarDevcontainer
|
||||
devcontainer_model.Devcontainer
|
||||
SSHPublicKeyList []string
|
||||
GitRepositoryURL string
|
||||
Image string
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
devcontainer_model "code.gitea.io/gitea/models/devcontainer"
|
||||
devcontainer_models "code.gitea.io/gitea/models/devcontainer"
|
||||
docker_module "code.gitea.io/gitea/modules/docker"
|
||||
devcontainer_dto "code.gitea.io/gitea/modules/k8s"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
gitea_web_context "code.gitea.io/gitea/services/context"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func CreateDevcontainer(ctx *context.Context, newDevContainer *CreateDevcontainerDTO) error {
|
||||
func CreateDevcontainer(ctx *context.Context, newDevContainer *CreateDevcontainerDTO, devContainerJSON *DevStarJSON) error {
|
||||
log.Info("开始创建容器.....")
|
||||
// 1. 创建docker client
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
@@ -30,51 +30,14 @@ func CreateDevcontainer(ctx *context.Context, newDevContainer *CreateDevcontaine
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
// 2. 创建容器
|
||||
opts := &devcontainer_dto.CreateDevcontainerOptions{
|
||||
Name: newDevContainer.Name,
|
||||
CreateOptions: metav1.CreateOptions{},
|
||||
Image: newDevContainer.Image,
|
||||
CommandList: []string{
|
||||
"sh",
|
||||
"-c",
|
||||
"rm -f /etc/ssh/ssh_host_*; ssh-keygen -A ; service ssh start ; cd ~/ttyd/build; ./ttyd -W bash; tail -f /dev/null;",
|
||||
},
|
||||
SSHPublicKeyList: newDevContainer.SSHPublicKeyList,
|
||||
GitRepositoryURL: newDevContainer.GitRepositoryURL,
|
||||
}
|
||||
dockerHost, err := docker_module.GetDockerSocketPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取docker socket路径失败:%v", err)
|
||||
}
|
||||
// 拉取镜像
|
||||
err = docker_module.PullImage(cli, dockerHost, opts.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("拉取镜像失败:%v", err)
|
||||
}
|
||||
// 创建并启动容器
|
||||
port, err := createAndStartContainer(cli, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 3. 将分配的 NodePort Service 写回 newDevcontainer,供写入数据库进行下一步操作
|
||||
uint16Value, _ := strconv.ParseUint(port, 10, 16)
|
||||
newDevContainer.DevcontainerPort = uint16(uint16Value)
|
||||
log.Info("创建容器成功.....")
|
||||
return nil
|
||||
}
|
||||
func createAndStartContainer(cli *client.Client, opts *devcontainer_dto.CreateDevcontainerOptions) (string, error) {
|
||||
err := docker_module.CreateAndStartContainer(cli, opts.Image, opts.CommandList, nil, nil,
|
||||
nat.PortSet{"22/tcp": struct{}{}, "7681/tcp": struct{}{}}, opts.Name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建或启动容器失败: %v", err)
|
||||
}
|
||||
|
||||
// 将公钥数组转换为字符串
|
||||
keysString := strings.Join(opts.SSHPublicKeyList, "\n")
|
||||
keysString := strings.Join(newDevContainer.SSHPublicKeyList, "\n")
|
||||
// 解析仓库 URL
|
||||
parsedURL, err := url.Parse(opts.GitRepositoryURL)
|
||||
parsedURL, err := url.Parse(newDevContainer.GitRepositoryURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析仓库URL失败: %v", err)
|
||||
log.Info("解析仓库URL失败: %v", err)
|
||||
return err
|
||||
}
|
||||
// 获取ip和端口
|
||||
hostParts := strings.Split(parsedURL.Host, ":")
|
||||
@@ -90,27 +53,88 @@ func createAndStartContainer(cli *client.Client, opts *devcontainer_dto.CreateDe
|
||||
// 生成git仓库的 URL
|
||||
newURL := parsedURL.String()
|
||||
cmd := []string{
|
||||
"sh", "-c",
|
||||
"if [ -z \"${host_docker_internal+x}\" ];then echo \"" + setting.Devcontainer.Host + " host.docker.internal\" | tee -a /etc/hosts; fi; if [ ! -d '/data/workspace' ]; then git clone " + newURL + " /data/workspace && echo \"Git Repository cloned.\"; else echo \"Folder already exists.\"; fi; mkdir -p ~/test; mkdir -p ~/.ssh ; chmod 700 ~/.ssh; echo \"" + keysString + "\" > ~/.ssh/authorized_keys ; chmod 600 ~/.ssh/authorized_keys; ",
|
||||
}
|
||||
// 创建 exec 实例
|
||||
containerID, err := docker_module.GetContainerID(cli, opts.Name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取容器ID:%v", err)
|
||||
}
|
||||
err = docker_module.ExecCommandInContainer(cli, containerID, cmd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("执行命令失败:%v", err)
|
||||
"rm -f /etc/ssh/ssh_host_*; ssh-keygen -A ; service ssh start ;",
|
||||
"if [ -z \"${host_docker_internal+x}\" ];then echo \"" + setting.Devcontainer.Host + " host.docker.internal\" | tee -a /etc/hosts; fi; ",
|
||||
"if [ ! -d '/data/workspace' ]; then git clone " + newURL + " /data/workspace && echo \"Git Repository cloned.\"; else echo \"Folder already exists.\"; fi; ",
|
||||
"mkdir -p ~/test; mkdir -p ~/.ssh ; chmod 700 ~/.ssh; echo \"" + keysString + "\" > ~/.ssh/authorized_keys ; chmod 600 ~/.ssh/authorized_keys; ",
|
||||
"git clone " + "https://devstar.cn/init/ttyd.git" + " /usr/bin/ttyd;",
|
||||
"apt-get update; ",
|
||||
"apt-get install -y build-essential cmake git libjson-c-dev libwebsockets-dev;",
|
||||
"/usr/bin/ttyd/ttyd/ttyd -W -w " + newDevContainer.DevcontainerWorkDir + " bash & ",
|
||||
}
|
||||
|
||||
// 获取端口映射信息
|
||||
v, err := docker_module.GetMappedPort(cli, containerID, "22")
|
||||
if err == nil {
|
||||
return v, nil
|
||||
// 加入22 7681集合
|
||||
natPort22 := nat.Port("22/tcp")
|
||||
natPort7681 := nat.Port("7681/tcp")
|
||||
devContainerJSON.ForwardPorts[natPort22] = struct{}{}
|
||||
devContainerJSON.ForwardPorts[natPort7681] = struct{}{}
|
||||
// 2. 创建容器
|
||||
opts := &docker_module.CreateDevcontainerOptions{
|
||||
Name: newDevContainer.Name,
|
||||
Image: newDevContainer.Image,
|
||||
CommandList: []string{
|
||||
"sh",
|
||||
"-c",
|
||||
strings.Join(devContainerJSON.InitializeCommand, "") + "tail -f /dev/null;",
|
||||
},
|
||||
RepoId: newDevContainer.RepoId,
|
||||
UserId: newDevContainer.UserId,
|
||||
SSHPublicKeyList: newDevContainer.SSHPublicKeyList,
|
||||
GitRepositoryURL: newDevContainer.GitRepositoryURL,
|
||||
ContainerEnv: devContainerJSON.ContainerEnv,
|
||||
PostCreateCommand: append(cmd, devContainerJSON.PostCreateCommand...),
|
||||
ForwardPorts: devContainerJSON.ForwardPorts,
|
||||
SystemCommandCount: 8,
|
||||
}
|
||||
return "", fmt.Errorf("未找到映射的端口")
|
||||
var flag string
|
||||
for _, content := range devContainerJSON.RunArgs {
|
||||
if flag == "-p" {
|
||||
if opts.PortBindings == nil {
|
||||
opts.PortBindings = nat.PortMap{}
|
||||
}
|
||||
// Split the string by ':'
|
||||
parts := strings.Split(content, ":")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
hostPort := strings.TrimSpace(parts[0])
|
||||
containerPortWithProtocol := strings.TrimSpace(parts[1])
|
||||
|
||||
// Split the container port and protocol
|
||||
containerParts := strings.Split(containerPortWithProtocol, "/")
|
||||
var containerProtocol = "tcp"
|
||||
if len(containerParts) == 2 {
|
||||
containerProtocol = containerParts[1]
|
||||
}
|
||||
containerPort := containerParts[0]
|
||||
|
||||
// Create nat.Port and nat.PortBinding
|
||||
port := nat.Port(fmt.Sprintf("%s/%s", containerPort, containerProtocol))
|
||||
portBinding := nat.PortBinding{
|
||||
HostIP: "0.0.0.0",
|
||||
HostPort: hostPort,
|
||||
}
|
||||
// Add to the port map
|
||||
opts.PortBindings[port] = []nat.PortBinding{portBinding}
|
||||
|
||||
}
|
||||
flag = strings.TrimSpace(content)
|
||||
}
|
||||
dockerHost, err := docker_module.GetDockerSocketPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取docker socket路径失败:%v", err)
|
||||
}
|
||||
// 拉取镜像
|
||||
err = PullImageAsyncAndStartContainer(ctx, cli, dockerHost, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建容器失败:%v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func DeleteDevcontainer(ctx *context.Context, devcontainersList *[]devcontainer_model.DevstarDevcontainer) error {
|
||||
|
||||
func DeleteDevcontainer(ctx *context.Context, devcontainersList *[]devcontainer_model.Devcontainer) error {
|
||||
log.Info("开始删除容器...")
|
||||
// 创建docker client
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
@@ -156,7 +180,7 @@ func GetDevcontainer(ctx *context.Context, opts *OpenDevcontainerAppDispatcherOp
|
||||
return 0, err
|
||||
}
|
||||
// 添加公钥
|
||||
err = docker_module.ExecCommandInContainer(cli, containerID, []string{"sh", "-c", fmt.Sprintf("echo '%s' >> ~/.ssh/authorized_keys", opts.UserPublicKey)})
|
||||
_, err = docker_module.ExecCommandInContainer(ctx, cli, containerID, opts.UserID, opts.RepoID, fmt.Sprintf("echo '%s' >> ~/.ssh/authorized_keys", opts.UserPublicKey))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -193,21 +217,216 @@ func SaveDevcontainer(ctx *gitea_web_context.Context, opts *UpdateDevcontainerOp
|
||||
return fmt.Errorf("推送到仓库失败 %v", err)
|
||||
}
|
||||
docker_module.PushImage(dockerHost, opts.RepositoryUsername, opts.PassWord, opts.RepositoryAddress, imageRef)
|
||||
// 更新devcontainer.json配置文件
|
||||
jsonString := `{"image":"` + imageRef + `"}`
|
||||
_, err = files_service.ChangeRepoFiles(db.DefaultContext, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "update",
|
||||
TreePath: ".devcontainer/devcontainer.json",
|
||||
ContentReader: bytes.NewReader([]byte(jsonString)),
|
||||
},
|
||||
},
|
||||
OldBranch: ctx.Repo.Repository.DefaultBranch,
|
||||
Message: "Update container",
|
||||
})
|
||||
devcontainerJson, err := GetDevcontainerJsonString(ctx, opts.Repository)
|
||||
// 定义正则表达式来匹配 image 字段
|
||||
re := regexp.MustCompile(`"image"\s*:\s*"([^"]+)"`)
|
||||
// 使用正则表达式查找并替换 image 字段的值
|
||||
newJSONStr := re.ReplaceAllString(devcontainerJson, `"image": "`+imageRef+`"`)
|
||||
|
||||
return UpdateDevcontainerJSON(ctx, newJSONStr)
|
||||
}
|
||||
|
||||
// pullImage 用于拉取指定的 Docker 镜像
|
||||
func PullImageAsyncAndStartContainer(ctx *context.Context, cli *client.Client, dockerHost string, opts *docker_module.CreateDevcontainerOptions) error {
|
||||
|
||||
script := "docker " + "-H " + dockerHost + " pull " + opts.Image
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
// 获取标准输出和标准错误输出的管道
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新devcontainer.json配置文件 %v", err)
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
} else {
|
||||
|
||||
// 创建扫描器来读取输出
|
||||
stdoutScanner := bufio.NewScanner(stdout)
|
||||
stderrScanner := bufio.NewScanner(stderr)
|
||||
dbEngine := db.GetEngine(*ctx)
|
||||
var pullImageOutput = devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
ListId: 0,
|
||||
Status: "running",
|
||||
UserId: opts.UserId,
|
||||
RepoId: opts.RepoId,
|
||||
Command: "Pull Image",
|
||||
}
|
||||
if _, err := dbEngine.Table("devcontainer_output").Insert(&pullImageOutput); err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err := dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ? ", opts.UserId, opts.RepoId).
|
||||
Get(&pullImageOutput)
|
||||
if err != nil {
|
||||
log.Info("err %v", err)
|
||||
return err
|
||||
}
|
||||
if _, err := dbEngine.Table("devcontainer_output").Insert(&devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
Status: "running",
|
||||
UserId: opts.UserId,
|
||||
RepoId: opts.RepoId,
|
||||
Command: "Initialize Workspace",
|
||||
ListId: 1,
|
||||
}); err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer_output").Insert(&devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
Status: "running",
|
||||
UserId: opts.UserId,
|
||||
RepoId: opts.RepoId,
|
||||
Command: "Initialize DevStar",
|
||||
ListId: 2,
|
||||
})
|
||||
if err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(opts.PostCreateCommand) >= opts.SystemCommandCount {
|
||||
_, err := dbEngine.Table("devcontainer_output").Insert(&devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
Status: "running",
|
||||
UserId: opts.UserId,
|
||||
RepoId: opts.RepoId,
|
||||
Command: "Run postCreateCommand",
|
||||
ListId: 3,
|
||||
})
|
||||
if err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 goroutine 来读取标准输出
|
||||
go func() {
|
||||
var output string
|
||||
var cur int = 0
|
||||
for stdoutScanner.Scan() {
|
||||
output += "\n" + stdoutScanner.Text()
|
||||
cur++
|
||||
if cur%10 == 0 {
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Where("id = ?", pullImageOutput.Id).
|
||||
Update(&devcontainer_models.DevcontainerOutput{
|
||||
Output: output})
|
||||
if err != nil {
|
||||
log.Info("err %v", err)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
for stderrScanner.Scan() {
|
||||
output += "\n" + stderrScanner.Text()
|
||||
cur++
|
||||
if cur%10 == 0 {
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Where("id = ?", pullImageOutput.Id).
|
||||
Update(&devcontainer_models.DevcontainerOutput{
|
||||
Output: output})
|
||||
if err != nil {
|
||||
log.Info("err %v", err)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Where("id = ?", pullImageOutput.Id).
|
||||
Update(&devcontainer_models.DevcontainerOutput{
|
||||
Output: output})
|
||||
if err != nil {
|
||||
log.Info("err %v", err)
|
||||
|
||||
}
|
||||
dbEngine.Table("devcontainer_output").
|
||||
Where("id = ?", pullImageOutput.Id).
|
||||
Update(&devcontainer_models.DevcontainerOutput{Status: "success"})
|
||||
}()
|
||||
|
||||
// 等待命令执行完毕
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
|
||||
if err != nil {
|
||||
log.Info("fail to pull image")
|
||||
} else {
|
||||
// 创建并启动容器
|
||||
output, err := docker_module.CreateAndStartContainer(cli, opts)
|
||||
if err != nil {
|
||||
log.Info("创建或启动容器失败: %v", err)
|
||||
}
|
||||
containerID, err := docker_module.GetContainerID(cli, opts.Name)
|
||||
if err != nil {
|
||||
log.Info("获取容器ID:%v", err)
|
||||
}
|
||||
portInfo, err := docker_module.GetAllMappedPort(cli, containerID)
|
||||
if err != nil {
|
||||
log.Info("创建或启动容器失败:%v", err)
|
||||
}
|
||||
// 存储到数据库
|
||||
if _, err := dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", opts.UserId, opts.RepoId, 1).
|
||||
Update(&devcontainer_models.DevcontainerOutput{
|
||||
Output: output + portInfo,
|
||||
Status: "success",
|
||||
}); err != nil {
|
||||
log.Info("Error storing output for command %v: %v\n", opts.CommandList[2], err)
|
||||
}
|
||||
// 创建 exec 实例
|
||||
|
||||
var buffer string = ""
|
||||
var state int = 2
|
||||
for index, cmd := range opts.PostCreateCommand {
|
||||
|
||||
if index == opts.SystemCommandCount {
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", opts.UserId, opts.RepoId, state).
|
||||
Update(&devcontainer_models.DevcontainerOutput{
|
||||
Status: "success",
|
||||
})
|
||||
if err != nil {
|
||||
log.Info("Error storing output for command %v: %v\n", cmd, err)
|
||||
}
|
||||
buffer = ""
|
||||
state = 3
|
||||
}
|
||||
|
||||
output, err = docker_module.ExecCommandInContainer(ctx, cli, containerID, opts.UserId, opts.RepoId, cmd)
|
||||
buffer += output
|
||||
if err != nil {
|
||||
log.Info("执行命令失败:%v", err)
|
||||
}
|
||||
|
||||
_, err := dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", opts.UserId, opts.RepoId, state).
|
||||
Update(&devcontainer_models.DevcontainerOutput{
|
||||
Output: buffer,
|
||||
})
|
||||
if err != nil {
|
||||
log.Info("Error storing output for command %v: %v\n", cmd, err)
|
||||
}
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", opts.UserId, opts.RepoId, state).
|
||||
Update(&devcontainer_models.DevcontainerOutput{
|
||||
Status: "success",
|
||||
})
|
||||
if err != nil {
|
||||
log.Info("Error storing output for command %v: %v\n", cmd, err)
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func AssignDevcontainerGetting2K8sOperator(ctx *context.Context, opts *OpenDevco
|
||||
// - modules/ 与 k8s API Server 交互密切相关
|
||||
// - services/ 进行了封装,简化用户界面使用
|
||||
|
||||
func AssignDevcontainerDeletion2K8sOperator(ctx *context.Context, devcontainersList *[]devcontainer_model.DevstarDevcontainer) error {
|
||||
func AssignDevcontainerDeletion2K8sOperator(ctx *context.Context, devcontainersList *[]devcontainer_model.Devcontainer) error {
|
||||
|
||||
// 1. 获取 Dynamic Client
|
||||
client, err := devcontainer_k8s_agent_module.GetKubernetesClient(ctx)
|
||||
|
||||
@@ -42,7 +42,7 @@ func RegistRunner(ctx *context.Context, token string) error {
|
||||
return fmt.Errorf("获取docker socket路径失败:%v", err)
|
||||
}
|
||||
// 拉取镜像
|
||||
err = docker_module.PullImage(cli, dockerHost, setting.Runner.Image)
|
||||
err = docker_module.PullImageSync(cli, dockerHost, setting.Runner.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("拉取act_runner镜像失败:%v", err)
|
||||
}
|
||||
@@ -67,7 +67,13 @@ func RegistRunner(ctx *context.Context, token string) error {
|
||||
}
|
||||
containerName := "runner-" + timestamp
|
||||
//创建并启动Runner容器
|
||||
err = docker_module.CreateAndStartContainer(cli, setting.Runner.Image, nil, env, binds, nil, containerName)
|
||||
opts := &docker_module.CreateDevcontainerOptions{
|
||||
Name: containerName,
|
||||
Image: setting.Runner.Image,
|
||||
Binds: binds,
|
||||
ContainerEnv: env,
|
||||
}
|
||||
_, err = docker_module.CreateAndStartContainer(cli, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建并注册Runner失败:%v", err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Flash.InfoMsg}}
|
||||
<div class="ui info message flash-message flash-info">
|
||||
<div class="ui info message flash-message flash-info" id="containerOutput">
|
||||
<p>{{.Flash.InfoMsg | SanitizeHTML}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -8,45 +8,37 @@
|
||||
<!-- 开始:Dev Container 正文内容 - 左侧主展示区 -->
|
||||
<div class="issue-content-left">
|
||||
{{if not .HasValidDevContainerJSON}}
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-container" 48}}
|
||||
<h2>{{ctx.Locale.Tr "repo.dev_container_empty"}}</h2>
|
||||
<form method="get" action="{{.CreateDevcontainerSettingUrl}}" class="ui edit form">
|
||||
<button class="ui primary button" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-container" 48}}
|
||||
<h2>{{ctx.Locale.Tr "repo.dev_container_empty"}}</h2>
|
||||
<form method="get" action="{{.CreateDevcontainerSettingUrl}}" class="ui edit form">
|
||||
<button class="ui primary button" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="issue-title-header">
|
||||
<div class="issue-title">
|
||||
<h1 style="text-align: center">Dev Container Configuration</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui container">
|
||||
|
||||
<form class="ui edit form">
|
||||
<div class="repo-editor-header">
|
||||
<div class="ui breadcrumb field">
|
||||
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
|
||||
{{range $i, $v := .TreeNames}}
|
||||
<div class="breadcrumb-divider">/</div>
|
||||
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
|
||||
{{end}}
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.editor.filename_help"}}">{{svg "octicon-info"}}</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui bottom attached segment tw-p-0">
|
||||
<div class="ui active tab tw-rounded" data-tab="write">
|
||||
<textarea readonly id="edit_area" name="content" >{{.FileContent}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<a href="{{.EditDevcontainerConfigurationUrl}}"><button class="ui primary button">Edit</button></a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="ui container">
|
||||
|
||||
<form class="ui edit form">
|
||||
<div class="repo-editor-header">
|
||||
<div class="ui breadcrumb field">
|
||||
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
|
||||
{{range $i, $v := .TreeNames}}
|
||||
<div class="breadcrumb-divider">/</div>
|
||||
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
<a href="{{.EditDevcontainerConfigurationUrl}}"><div class="ui primary button" style="margin-left: 10px;width: 4em;height: 1em;">Edit</div></a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="repo-devcontainer-view" data-outputLink="{{.Repository.Link}}/dev-container/output"></div>
|
||||
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
<!-- 结束:Dev Container 正文内容 - 左侧主展示区 -->
|
||||
|
||||
@@ -59,11 +51,15 @@
|
||||
<div class="item"><a class="delete-button flex-text-inline" data-modal="#delete-repo-devcontainer-of-user-modal" href="#" data-url="{{.Repository.Link}}/dev-container/delete">{{svg "octicon-trash" 14}}{{ctx.Locale.Tr "repo.dev_container_control.delete"}}</a></div>
|
||||
<div class="item"><a class="delete-button flex-text-inline" style="color:black;" data-modal-id="updatemodal" href="#">{{svg "octicon-database"}}{{ctx.Locale.Tr "repo.dev_container_control.update"}}</a></div>
|
||||
<div class="item"><a class="flex-text-inline" style="color:black;" href="{{.WebSSHUrl}}" target="_blank">{{svg "octicon-code" 14}}open with WebTerminal</a></div>
|
||||
<div class="item"><a class="flex-text-inline" style="color:black;" href="vscode://devstar-open?repo=GIT-URL" target="_blank">{{svg "octicon-code" 14}}open with VSCode</a></div>
|
||||
<div class="item"><a class="flex-text-inline" style="color:black;" onclick="window.location.href = '{{.VSCodeUrl}}'">{{svg "octicon-code" 14}}open with VSCode</a ></div>
|
||||
|
||||
{{else if .HasValidDevContainerJSON}}
|
||||
<div class="item">
|
||||
<a class="button flex-text-inline" href="{{.Repository.Link}}/dev-container/create">{{svg "octicon-terminal" 14 "tw-mr-2"}} {{ctx.Locale.Tr "repo.dev_container_control.create"}}</a>
|
||||
{{if not .isCreatingDevcontainer}}
|
||||
<a class="button flex-text-inline" href="{{.Repository.Link}}/dev-container/create">{{svg "octicon-terminal" 14 "tw-mr-2"}} Create Dev Container</a>
|
||||
{{else}}
|
||||
<div class="button flex-text-inline" disabled>{{svg "octicon-terminal" 14 "tw-mr-2"}} Running</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .HasValidDevContainerJSON}}
|
||||
@@ -73,6 +69,7 @@
|
||||
</div>
|
||||
<!-- 结束:Dev Container 正文内容 - 右侧展示区 -->
|
||||
</div>
|
||||
|
||||
<!-- 结束Dev Container 正文内容 -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,4 +155,6 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{{template "base/footer" .}}
|
||||
|
||||
576
web_src/js/components/RepoDevcontainerView.vue
Normal file
576
web_src/js/components/RepoDevcontainerView.vue
Normal file
@@ -0,0 +1,576 @@
|
||||
<script>
|
||||
import {SvgIcon} from '../svg.js';
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import {createApp} from 'vue';
|
||||
import {toggleElem} from '../utils/dom.js';
|
||||
import {formatDatetime} from '../utils/time.js';
|
||||
import {renderAnsi} from '../render/ansi.js';
|
||||
import {GET, POST, DELETE} from '../modules/fetch.js';
|
||||
|
||||
const sfc = {
|
||||
name: 'RepoDevcontainerView',
|
||||
components: {
|
||||
SvgIcon,
|
||||
ActionRunStatus,
|
||||
},
|
||||
props: {
|
||||
outputLink: String,
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// internal state
|
||||
loading: false,
|
||||
intervalID: null,
|
||||
currentJobStepsStates: [],
|
||||
currentDevcontainer: {
|
||||
title: 'Devcontainer Info',
|
||||
detail: 'No Devcontainer Created yet',
|
||||
steps: [
|
||||
// {
|
||||
// summary: '',
|
||||
// duration: '',
|
||||
// status: '',
|
||||
// logs:{
|
||||
//
|
||||
// },
|
||||
// }
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
// load job data and then auto-reload periodically
|
||||
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
|
||||
|
||||
await this.loadJob();
|
||||
this.intervalID = setInterval(this.loadJob, 3000);
|
||||
},
|
||||
unmounted() {
|
||||
// clear the interval timer when the component is unmounted
|
||||
// even our page is rendered once, not spa style
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID);
|
||||
this.intervalID = null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// show/hide the step logs for a step
|
||||
toggleStepLogs(idx) {
|
||||
this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
|
||||
},
|
||||
async fetchJob() {
|
||||
|
||||
const resp = await GET(this.outputLink);
|
||||
return await resp.json();
|
||||
},
|
||||
|
||||
async loadJob() {
|
||||
if (this.loading) return;
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
let job;
|
||||
try {
|
||||
job = await this.fetchJob();
|
||||
if(!job){
|
||||
clearInterval(this.intervalID);
|
||||
this.intervalID = null;
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) return; // avoid network error while unloading page
|
||||
throw err;
|
||||
}
|
||||
if(this.currentDevcontainer.detail == "running" && job.currentDevcontainer.detail == "created"){
|
||||
location.reload();
|
||||
}
|
||||
// save the state to Vue data, then the UI will be updated
|
||||
this.currentDevcontainer = job.currentDevcontainer;
|
||||
|
||||
// sync the currentJobStepsStates to store the job step states
|
||||
for (let i = 0; i < this.currentDevcontainer.steps.length; i++) {
|
||||
if (!this.currentJobStepsStates[i]) {
|
||||
// initial states for job steps
|
||||
this.currentJobStepsStates[i] = {expanded: false};
|
||||
}
|
||||
}
|
||||
if (this.isDone(this.currentDevcontainer.detail) && this.intervalID) {
|
||||
clearInterval(this.intervalID);
|
||||
this.intervalID = null;
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
isDone(status) {
|
||||
return ['success', 'finished', 'skipped', 'failure', 'cancelled'].includes(status);
|
||||
},
|
||||
|
||||
isExpandable(status) {
|
||||
return ['success', 'finished', 'running', 'failure', 'cancelled'].includes(status);
|
||||
},
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
export default sfc;
|
||||
|
||||
export function initRepositoryDevcontainerView() {
|
||||
const el = document.querySelector('#repo-devcontainer-view');
|
||||
if (!el) return;
|
||||
|
||||
// TODO: the parent element's full height doesn't work well now,
|
||||
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
|
||||
const parentFullHeight = document.querySelector('body > div.full.height');
|
||||
if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
|
||||
const view = createApp(sfc, {
|
||||
outputLink: el.getAttribute('data-outputLink')
|
||||
})
|
||||
view.mount(el);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="ui container action-view-container">
|
||||
<div class="action-view-body">
|
||||
<div class="action-view-right">
|
||||
<div class="job-info-header">
|
||||
<div class="job-info-header-left gt-ellipsis">
|
||||
<h3 class="job-info-header-title gt-ellipsis">
|
||||
{{ currentDevcontainer.title }}
|
||||
</h3>
|
||||
<p class="job-info-header-detail">
|
||||
{{ currentDevcontainer.detail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-step-container" ref="steps" v-if="currentDevcontainer.steps.length">
|
||||
<div class="job-step-section" v-for="(jobStep, i) in currentDevcontainer.steps" :key="i">
|
||||
<div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
|
||||
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
||||
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
|
||||
-->
|
||||
<SvgIcon v-if="currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
|
||||
<SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
|
||||
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
|
||||
|
||||
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
||||
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 使用 v-for 渲染 job-log-line -->
|
||||
<div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded">
|
||||
<div class="job-log-line" v-for="(line, index) in jobStep.logs" :key="index" :id="`jobstep-${i}-${line.index}`">
|
||||
<a class="line-num muted" :href="`#jobstep-${i}-${line.index}`">{{ line.index }}</a>
|
||||
<span class="log-msg">{{ line.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.action-view-body {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ================ */
|
||||
/* action view header */
|
||||
|
||||
.action-view-header {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-info-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-info-summary-title {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.action-info-summary-title-text {
|
||||
font-size: 20px;
|
||||
margin: 0 0 0 8px;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action-commit-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.action-commit-summary {
|
||||
margin-left: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================ */
|
||||
/* action view left */
|
||||
|
||||
.action-view-left {
|
||||
width: 30%;
|
||||
max-width: 400px;
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: var(--color-body);
|
||||
z-index: 2; /* above .job-info-header */
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.action-view-left {
|
||||
position: static; /* can not sticky because multiple jobs would overlap into right view */
|
||||
}
|
||||
}
|
||||
|
||||
.job-artifacts-title {
|
||||
font-size: 18px;
|
||||
margin-top: 16px;
|
||||
padding: 16px 10px 0 20px;
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.job-artifacts-item {
|
||||
margin: 5px 0;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.job-artifacts-list {
|
||||
padding-left: 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.job-artifacts-icon {
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.job-brief-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.job-brief-item {
|
||||
padding: 10px;
|
||||
border-radius: var(--border-radius);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.job-brief-item:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.job-brief-item.selected {
|
||||
font-weight: var(--font-weight-bold);
|
||||
background-color: var(--color-active);
|
||||
}
|
||||
|
||||
.job-brief-item:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.job-brief-item .job-brief-rerun {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.job-brief-item .job-brief-rerun:hover {
|
||||
transform: scale(130%);
|
||||
}
|
||||
|
||||
.job-brief-item .job-brief-item-left {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-brief-item .job-brief-item-left span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.job-brief-item .job-brief-item-left .job-brief-name {
|
||||
display: block;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.job-brief-item .job-brief-item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ================ */
|
||||
/* action view right */
|
||||
|
||||
.action-view-right {
|
||||
flex: 1;
|
||||
color: var(--color-console-fg-subtle);
|
||||
max-height: 100%;
|
||||
width: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-console-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-console-bg);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* begin fomantic button overrides */
|
||||
|
||||
.action-view-right .ui.button,
|
||||
.action-view-right .ui.button:focus {
|
||||
background: transparent;
|
||||
color: var(--color-console-fg-subtle);
|
||||
}
|
||||
|
||||
.action-view-right .ui.button:hover {
|
||||
background: var(--color-console-hover-bg);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.button:active {
|
||||
background: var(--color-console-active-bg);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
/* end fomantic button overrides */
|
||||
|
||||
/* begin fomantic dropdown menu overrides */
|
||||
|
||||
.action-view-right .ui.dropdown .menu {
|
||||
background: var(--color-console-menu-bg);
|
||||
border-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item {
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:active {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-active-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .divider {
|
||||
border-top-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
|
||||
background: var(--color-console-menu-bg);
|
||||
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
/* end fomantic dropdown menu overrides */
|
||||
|
||||
.job-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 60px;
|
||||
z-index: 1; /* above .job-step-container */
|
||||
background: var(--color-console-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.job-info-header:has(+ .job-step-container) {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-title {
|
||||
color: var(--color-console-fg);
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-detail {
|
||||
color: var(--color-console-fg-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-info-header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container {
|
||||
max-height: 100%;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
border-top: 1px solid var(--color-console-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary {
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-duration {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.selected {
|
||||
color: var(--color-console-fg);
|
||||
background-color: var(--color-console-active-bg);
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.action-view-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.action-view-left, .action-view-right {
|
||||
width: 100%;
|
||||
}
|
||||
.action-view-left {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* some elements are not managed by vue, so we need to use global style */
|
||||
.job-status-rotate {
|
||||
animation: job-status-rotate-keyframes 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes job-status-rotate-keyframes {
|
||||
100% {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.job-step-section {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs {
|
||||
font-family: var(--fonts-monospace);
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs .job-log-line {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.job-log-line:hover,
|
||||
.job-log-line:target {
|
||||
background-color: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-log-line:target {
|
||||
scroll-margin-top: 95px;
|
||||
}
|
||||
|
||||
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
||||
.job-log-line .line-num, .log-time-seconds {
|
||||
width: 48px;
|
||||
color: var(--color-text-light-3);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.job-log-line:target > .line-num {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.log-time-seconds {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.job-log-line .log-time,
|
||||
.log-time-stamp {
|
||||
color: var(--color-text-light-3);
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs .job-log-line .log-msg {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
white-space: break-spaces;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* selectors here are intentionally exact to only match fullscreen */
|
||||
|
||||
.full.height > .action-view-right {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-info-header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-step-container {
|
||||
height: calc(100% - 60px);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* TODO: group support
|
||||
|
||||
.job-log-group {
|
||||
|
||||
}
|
||||
.job-log-group-summary {
|
||||
|
||||
}
|
||||
.job-log-list {
|
||||
|
||||
} */
|
||||
</style>
|
||||
@@ -65,6 +65,7 @@ import {initRepoCommentForm, initRepository} from './features/repo-legacy.js';
|
||||
import {initCopyContent} from './features/copycontent.js';
|
||||
import {initCaptcha} from './features/captcha.js';
|
||||
import {initRepositoryActionView} from './components/RepoActionView.vue';
|
||||
import {initRepositoryDevcontainerView} from './components/RepoDevcontainerView.vue';
|
||||
import {initGlobalTooltips} from './modules/tippy.js';
|
||||
import {initGiteaFomantic} from './modules/fomantic.js';
|
||||
import {initSubmitEventPolyfill, onDomReady} from './utils/dom.js';
|
||||
@@ -210,6 +211,7 @@ onDomReady(() => {
|
||||
initRepoWikiForm,
|
||||
initRepository,
|
||||
initRepositoryActionView,
|
||||
initRepositoryDevcontainerView,
|
||||
initRepositorySearch,
|
||||
initRepoContributors,
|
||||
initRepoCodeFrequency,
|
||||
|
||||
Reference in New Issue
Block a user