diff --git a/go.mod b/go.mod index 6b6813c5a1..219c9b7654 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 43d9dd1434..11d2f2df94 100644 --- a/go.sum +++ b/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= diff --git a/models/devcontainer/devcontainer.go b/models/devcontainer/devcontainer.go index 06f3231da1..26fc91ffaa 100644 --- a/models/devcontainer/devcontainer.go +++ b/models/devcontainer/devcontainer.go @@ -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)) } diff --git a/models/devcontainer/devcontainer_json.go b/models/devcontainer/devcontainer_json.go index 35edbdd61c..8ea8139217 100644 --- a/models/devcontainer/devcontainer_json.go +++ b/models/devcontainer/devcontainer_json.go @@ -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 } diff --git a/models/devcontainer/errors/error.go b/models/devcontainer/errors/error.go index f2cbcc8d01..4b935d6509 100644 --- a/models/devcontainer/errors/error.go +++ b/models/devcontainer/errors/error.go @@ -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) } diff --git a/models/migrations/devstar_v1_0/dv2.go b/models/migrations/devstar_v1_0/dv2.go index e15ee7f2bd..065d247c39 100644 --- a/models/migrations/devstar_v1_0/dv2.go +++ b/models/migrations/devstar_v1_0/dv2.go @@ -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(), } } diff --git a/modules/docker/docker_api.go b/modules/docker/docker_api.go index 0142b8f383..fff724524b 100644 --- a/modules/docker/docker_api.go +++ b/modules/docker/docker_api.go @@ -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 } diff --git a/modules/docker/docker_types.go b/modules/docker/docker_types.go new file mode 100644 index 0000000000..ed1498a1c9 --- /dev/null +++ b/modules/docker/docker_types.go @@ -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 +} diff --git a/modules/setting/server.go b/modules/setting/server.go index f069ec8179..1b8a0ab74a 100644 --- a/modules/setting/server.go +++ b/modules/setting/server.go @@ -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 diff --git a/routers/web/devcontainer/devcontainer.go b/routers/web/devcontainer/devcontainer.go index b766408862..d4b80069b3 100644 --- a/routers/web/devcontainer/devcontainer.go +++ b/routers/web/devcontainer/devcontainer.go @@ -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() +} diff --git a/routers/web/web.go b/routers/web/web.go index 8df97bb220..99565ddddd 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) }, diff --git a/services/devcontainer/devcontainer.go b/services/devcontainer/devcontainer.go index 9d3f936dfd..49a667d273 100644 --- a/services/devcontainer/devcontainer.go +++ b/services/devcontainer/devcontainer.go @@ -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 -} diff --git a/services/devcontainer/devcontainer_api.go b/services/devcontainer/devcontainer_api.go index ceb4a04bae..422834e51f 100644 --- a/services/devcontainer/devcontainer_api.go +++ b/services/devcontainer/devcontainer_api.go @@ -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, diff --git a/services/devcontainer/devcontainer_json.go b/services/devcontainer/devcontainer_json.go new file mode 100644 index 0000000000..27d75ff8a9 --- /dev/null +++ b/services/devcontainer/devcontainer_json.go @@ -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)) +} diff --git a/services/devcontainer/devcontainer_list.go b/services/devcontainer/devcontainer_list.go index 8b175f01a4..7a86faf996 100644 --- a/services/devcontainer/devcontainer_list.go +++ b/services/devcontainer/devcontainer_list.go @@ -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(), } diff --git a/services/devcontainer/devcontainer_type.go b/services/devcontainer/devcontainer_type.go index 1910ce8066..f872fb2091 100644 --- a/services/devcontainer/devcontainer_type.go +++ b/services/devcontainer/devcontainer_type.go @@ -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 diff --git a/services/devcontainer/docker_agent.go b/services/devcontainer/docker_agent.go index 4f6981029a..82936b3c09 100644 --- a/services/devcontainer/docker_agent.go +++ b/services/devcontainer/docker_agent.go @@ -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 } diff --git a/services/devcontainer/k8s_agent.go b/services/devcontainer/k8s_agent.go index 1359be7570..f42395eeb5 100644 --- a/services/devcontainer/k8s_agent.go +++ b/services/devcontainer/k8s_agent.go @@ -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) diff --git a/services/runner/runner.go b/services/runner/runner.go index 019612bd6e..c846c6eb18 100644 --- a/services/runner/runner.go +++ b/services/runner/runner.go @@ -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) } diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl index 760d3bfa2c..8ddb2ab822 100644 --- a/templates/base/alert.tmpl +++ b/templates/base/alert.tmpl @@ -9,7 +9,7 @@ {{end}} {{if .Flash.InfoMsg}} -
+

{{.Flash.InfoMsg | SanitizeHTML}}

{{end}} diff --git a/templates/repo/devcontainer/details.tmpl b/templates/repo/devcontainer/details.tmpl index e1868af197..6963627d90 100644 --- a/templates/repo/devcontainer/details.tmpl +++ b/templates/repo/devcontainer/details.tmpl @@ -8,45 +8,37 @@
{{if not .HasValidDevContainerJSON}} -
- {{svg "octicon-container" 48}} -

{{ctx.Locale.Tr "repo.dev_container_empty"}}

-
- -
-
+
+ {{svg "octicon-container" 48}} +

{{ctx.Locale.Tr "repo.dev_container_empty"}}

+
+ +
+
{{else}} -
-
-

Dev Container Configuration

-
-
-
- -
-
- -
-
-
- -
-
-
- -
- + +
+
+
+ +
Edit
+
+ +
+ +
+ +
+ {{end}} -
@@ -59,11 +51,15 @@
{{svg "octicon-trash" 14}}{{ctx.Locale.Tr "repo.dev_container_control.delete"}}
{{svg "octicon-database"}}{{ctx.Locale.Tr "repo.dev_container_control.update"}}
{{svg "octicon-code" 14}}open with WebTerminal
-
{{svg "octicon-code" 14}}open with VSCode
+
{{svg "octicon-code" 14}}open with VSCode
{{else if .HasValidDevContainerJSON}}
- {{svg "octicon-terminal" 14 "tw-mr-2"}} {{ctx.Locale.Tr "repo.dev_container_control.create"}} + {{if not .isCreatingDevcontainer}} + {{svg "octicon-terminal" 14 "tw-mr-2"}} Create Dev Container + {{else}} +
{{svg "octicon-terminal" 14 "tw-mr-2"}} Running
+ {{end}}
{{end}} {{if not .HasValidDevContainerJSON}} @@ -73,6 +69,7 @@
+ @@ -158,4 +155,6 @@ + + {{template "base/footer" .}} diff --git a/web_src/js/components/RepoDevcontainerView.vue b/web_src/js/components/RepoDevcontainerView.vue new file mode 100644 index 0000000000..cb411a1edb --- /dev/null +++ b/web_src/js/components/RepoDevcontainerView.vue @@ -0,0 +1,576 @@ + + + + + diff --git a/web_src/js/index.js b/web_src/js/index.js index e7058a0d87..99226afc81 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -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,