diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go index eed88a03..11212977 100644 --- a/cmd/ace/wire_gen.go +++ b/cmd/ace/wire_gen.go @@ -385,7 +385,7 @@ func initAce() (*app.Ace, func(), error) { cleanup() return nil, nil, err } - containerUsecase := biz.NewContainerUsecase(containerRepo, settingRepo) + containerUsecase := biz.NewContainerUsecase(locale, containerRepo, settingRepo, taskRepo) containerService, err := service.NewContainerService(containerUsecase) if err != nil { cleanup() @@ -407,7 +407,7 @@ func initAce() (*app.Ace, func(), error) { cleanup() return nil, nil, err } - containerImageUsecase := biz.NewContainerImageUsecase(containerImageRepo, settingRepo) + containerImageUsecase := biz.NewContainerImageUsecase(locale, containerImageRepo, settingRepo, taskRepo) containerImageService, err := service.NewContainerImageService(containerImageUsecase) if err != nil { cleanup() diff --git a/go.mod b/go.mod index 7ef7d9d9..61c7e9e0 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/coreos/go-systemd/v22 v22.7.0 github.com/creack/pty v1.1.24 github.com/dchest/captcha v1.1.0 + github.com/distribution/reference v0.6.0 github.com/expr-lang/expr v1.17.8 github.com/fsnotify/fsnotify v1.10.1 github.com/go-chi/chi/v5 v5.3.1 @@ -80,7 +81,6 @@ require ( github.com/boombuler/barcode v1.1.0 // indirect github.com/boyter/go-string v1.0.5 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/internal/biz/container.go b/internal/biz/container.go index 4beeccb9..4b9316f2 100644 --- a/internal/biz/container.go +++ b/internal/biz/container.go @@ -4,6 +4,8 @@ import ( "slices" "strings" + "github.com/leonelquinteros/gotext" + "github.com/acepanel/panel/v3/internal/request" "github.com/acepanel/panel/v3/pkg/types" ) @@ -27,10 +29,17 @@ type ContainerRepo interface { type ContainerUsecase struct { repo ContainerRepo setting SettingRepo + task TaskRepo + t *gotext.Locale } -func NewContainerUsecase(repo ContainerRepo, setting SettingRepo) *ContainerUsecase { - return &ContainerUsecase{repo: repo, setting: setting} +func NewContainerUsecase(t *gotext.Locale, containerRepo ContainerRepo, settingRepo SettingRepo, taskRepo TaskRepo) *ContainerUsecase { + return &ContainerUsecase{ + repo: containerRepo, + setting: settingRepo, + task: taskRepo, + t: t, + } } func (uc *ContainerUsecase) ListAll() ([]types.Container, error) { @@ -62,6 +71,29 @@ func (uc *ContainerUsecase) Create(req *request.ContainerCreate) (string, error) return uc.repo.Create(sock, req) } +func (uc *ContainerUsecase) CreateBackground(req *request.ContainerCreate) error { + shell, err := containerRunShell(containerSock(uc.setting), req) + if err != nil { + return err + } + + task := new(Task) + key := "" + if req.Name != "" { + key = "container:create:" + req.Name + } + task.Key = key + target := req.Name + if target == "" { + target = req.Image + } + task.Name = uc.t.Get("Create container %s", target) + task.Status = TaskStatusWaiting + task.Shell = shell + + return uc.task.Push(task) +} + // Update 删除旧容器后按新配置重建同名容器 func (uc *ContainerUsecase) Update(id string, req *request.ContainerCreate) (string, error) { sock := containerSock(uc.setting) @@ -71,6 +103,33 @@ func (uc *ContainerUsecase) Update(id string, req *request.ContainerCreate) (str return uc.repo.Create(sock, req) } +func (uc *ContainerUsecase) UpdateBackground(id string, req *request.ContainerCreate) error { + sock := containerSock(uc.setting) + runShell, err := containerRunShell(sock, req) + if err != nil { + return err + } + + shell := strings.Join([]string{ + "set -e", + dockerCommand(sock, "image", "inspect", req.Image) + " >/dev/null 2>&1 || " + dockerCommand(sock, "pull", req.Image), + dockerCommand(sock, "rm", "--force", id), + runShell, + }, "\n") + target := req.Name + if target == "" { + target = req.Image + } + + task := new(Task) + task.Key = "container:update:" + id + task.Name = uc.t.Get("Update container %s", target) + task.Status = TaskStatusWaiting + task.Shell = shell + + return uc.task.Push(task) +} + func (uc *ContainerUsecase) Remove(id string) error { sock := containerSock(uc.setting) return uc.repo.Remove(sock, id) diff --git a/internal/biz/container_command.go b/internal/biz/container_command.go new file mode 100644 index 00000000..2a04137c --- /dev/null +++ b/internal/biz/container_command.go @@ -0,0 +1,136 @@ +package biz + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/distribution/reference" + "github.com/libtnb/utils/str" + + "github.com/acepanel/panel/v3/internal/request" +) + +func containerImagePullShell(sock string, req *request.ContainerImagePull) (string, string, error) { + if !req.Auth { + return dockerCommand(sock, "pull", req.Name), "", nil + } + + named, err := reference.ParseNormalizedNamed(req.Name) + if err != nil { + return "", "", fmt.Errorf("invalid image reference: %w", err) + } + + configDir := filepath.Join(os.TempDir(), "ace-docker-task-"+str.Random(16)) + cleanup := "rm -rf " + shellQuote(configDir) + shell := strings.Join([]string{ + "set -e", + "mkdir -p " + shellQuote(configDir), + "trap " + shellQuote(cleanup) + " EXIT", + "printf %s " + shellQuote(req.Password) + " | " + dockerCommand(sock, "--config", configDir, "login", "--username", req.Username, "--password-stdin", reference.Domain(named)), + dockerCommand(sock, "--config", configDir, "pull", req.Name), + }, "\n") + + return shell, cleanup, nil +} + +func containerRunShell(sock string, req *request.ContainerCreate) (string, error) { + args := []string{"run", "--detach"} + + if req.Name != "" { + args = append(args, "--name", req.Name) + } + if req.Network != "" { + args = append(args, "--network", req.Network) + } + if req.PublishAllPorts { + args = append(args, "--publish-all") + } else { + for _, port := range req.Ports { + if port.ContainerStart < 1 || port.ContainerEnd > 65535 || port.ContainerStart > port.ContainerEnd || + port.HostStart < 1 || port.HostEnd > 65535 || port.HostStart > port.HostEnd { + return "", errors.New("port range is invalid") + } + if port.ContainerEnd-port.ContainerStart != port.HostEnd-port.HostStart { + return "", errors.New("container port and host port count do not match") + } + + for offset := uint(0); offset <= port.HostEnd-port.HostStart; offset++ { + host := "" + if port.Host.IsValid() { + host = port.Host.String() + ":" + if port.Host.Is6() { + host = "[" + port.Host.String() + "]:" + } + } + mapping := fmt.Sprintf("%s%d:%d/%s", host, port.HostStart+offset, port.ContainerStart+offset, port.Protocol) + args = append(args, "--publish", mapping) + } + } + } + + for _, volume := range req.Volumes { + args = append(args, "--volume", strings.Join([]string{volume.Host, volume.Container, volume.Mode}, ":")) + } + for _, env := range req.Env { + args = append(args, "--env", env.Key+"="+env.Value) + } + for _, label := range req.Labels { + args = append(args, "--label", label.Key+"="+label.Value) + } + + if len(req.Entrypoint) > 0 { + args = append(args, "--entrypoint", req.Entrypoint[0]) + } + if req.RestartPolicy != "" { + restartPolicy := req.RestartPolicy + if restartPolicy == "on-failure" { + restartPolicy += ":5" + } + args = append(args, "--restart", restartPolicy) + } + if req.AutoRemove { + args = append(args, "--rm") + } + if req.Privileged { + args = append(args, "--privileged") + } + if req.OpenStdin { + args = append(args, "--interactive") + } + if req.Tty { + args = append(args, "--tty") + } + if req.CPUShares > 0 { + args = append(args, "--cpu-shares", strconv.FormatInt(req.CPUShares, 10)) + } + if req.CPUs > 0 { + args = append(args, "--cpus", strconv.FormatFloat(req.CPUs, 'f', -1, 64)) + } + if req.Memory > 0 { + args = append(args, "--memory", strconv.FormatInt(req.Memory, 10)+"m") + } + + args = append(args, req.Image) + if len(req.Entrypoint) > 1 { + args = append(args, req.Entrypoint[1:]...) + } + args = append(args, req.Command...) + + return dockerCommand(sock, args...), nil +} + +func dockerCommand(sock string, args ...string) string { + args = append([]string{"docker", "--host", sock}, args...) + for i := range args { + args[i] = shellQuote(args[i]) + } + return strings.Join(args, " ") +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} diff --git a/internal/biz/container_image.go b/internal/biz/container_image.go index 6fb39ea0..dc9ee3c0 100644 --- a/internal/biz/container_image.go +++ b/internal/biz/container_image.go @@ -1,6 +1,8 @@ package biz import ( + "github.com/leonelquinteros/gotext" + "github.com/acepanel/panel/v3/internal/request" "github.com/acepanel/panel/v3/pkg/types" ) @@ -16,10 +18,17 @@ type ContainerImageRepo interface { type ContainerImageUsecase struct { repo ContainerImageRepo setting SettingRepo + task TaskRepo + t *gotext.Locale } -func NewContainerImageUsecase(repo ContainerImageRepo, setting SettingRepo) *ContainerImageUsecase { - return &ContainerImageUsecase{repo: repo, setting: setting} +func NewContainerImageUsecase(t *gotext.Locale, containerImageRepo ContainerImageRepo, settingRepo SettingRepo, taskRepo TaskRepo) *ContainerImageUsecase { + return &ContainerImageUsecase{ + repo: containerImageRepo, + setting: settingRepo, + task: taskRepo, + t: t, + } } func (uc *ContainerImageUsecase) List() ([]types.ContainerImage, error) { @@ -37,6 +46,22 @@ func (uc *ContainerImageUsecase) Pull(req *request.ContainerImagePull) error { return uc.repo.Pull(sock, req) } +func (uc *ContainerImageUsecase) PullBackground(req *request.ContainerImagePull) error { + shell, cancelShell, err := containerImagePullShell(containerSock(uc.setting), req) + if err != nil { + return err + } + + task := new(Task) + task.Key = "container:image:pull:" + req.Name + task.Name = uc.t.Get("Pull image %s", req.Name) + task.Status = TaskStatusWaiting + task.Shell = shell + task.CancelShell = cancelShell + + return uc.task.Push(task) +} + func (uc *ContainerImageUsecase) Remove(id string) error { sock := containerSock(uc.setting) return uc.repo.Remove(sock, id) diff --git a/internal/data/container.go b/internal/data/container.go index e0b06906..f261f812 100644 --- a/internal/data/container.go +++ b/internal/data/container.go @@ -190,7 +190,7 @@ func (r *containerRepo) Create(sock string, req *request.ContainerCreate) (strin } // 设置资源限制 hostConfig.CPUShares = req.CPUShares - hostConfig.NanoCPUs = req.CPUs * 1e9 + hostConfig.NanoCPUs = int64(req.CPUs * 1e9) hostConfig.Memory = req.Memory * 1024 * 1024 hostConfig.MemorySwap = 0 diff --git a/internal/request/container.go b/internal/request/container.go index 35013edb..2c9975b0 100644 --- a/internal/request/container.go +++ b/internal/request/container.go @@ -14,6 +14,7 @@ type ContainerRename struct { type ContainerCreate struct { Name string `form:"name" json:"name"` Image string `form:"image" json:"image" validate:"required"` + Background bool `form:"background" json:"background"` Ports []types.ContainerPort `form:"ports" json:"ports"` Network string `form:"network" json:"network"` Volumes []types.ContainerContainerVolume `form:"volumes" json:"volumes"` @@ -28,6 +29,6 @@ type ContainerCreate struct { PublishAllPorts bool `form:"publish_all_ports" json:"publish_all_ports"` Tty bool `form:"tty" json:"tty"` CPUShares int64 `form:"cpu_shares" json:"cpu_shares"` - CPUs int64 `form:"cpus" json:"cpus"` + CPUs float64 `form:"cpus" json:"cpus"` Memory int64 `form:"memory" json:"memory"` } diff --git a/internal/request/container_image.go b/internal/request/container_image.go index 19aac06c..189f8cb8 100644 --- a/internal/request/container_image.go +++ b/internal/request/container_image.go @@ -5,8 +5,9 @@ type ContainerImageID struct { } type ContainerImagePull struct { - Name string `form:"name" json:"name" validate:"required"` - Auth bool `form:"auth" json:"auth"` - Username string `form:"username" json:"username" validate:"required_if:Auth,true"` - Password string `form:"password" json:"password" validate:"required_if:Auth,true"` + Name string `form:"name" json:"name" validate:"required"` + Background bool `form:"background" json:"background"` + Auth bool `form:"auth" json:"auth"` + Username string `form:"username" json:"username" validate:"required_if:Auth,true"` + Password string `form:"password" json:"password" validate:"required_if:Auth,true"` } diff --git a/internal/service/container.go b/internal/service/container.go index 18aea659..1953af65 100644 --- a/internal/service/container.go +++ b/internal/service/container.go @@ -64,7 +64,17 @@ func (s *ContainerService) Update(w http.ResponseWriter, r *http.Request) { return } - id, err := s.containerRepo.Update(chi.URLParam(r, "id"), req) + idParam := chi.URLParam(r, "id") + if req.Background { + if err = s.containerRepo.UpdateBackground(idParam, req); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + Success(w, nil) + return + } + + id, err := s.containerRepo.Update(idParam, req) if err != nil { Error(w, http.StatusInternalServerError, "%v", err) return @@ -80,6 +90,15 @@ func (s *ContainerService) Create(w http.ResponseWriter, r *http.Request) { return } + if req.Background { + if err = s.containerRepo.CreateBackground(req); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + Success(w, nil) + return + } + id, err := s.containerRepo.Create(req) if err != nil { Error(w, http.StatusInternalServerError, "%v", err) diff --git a/internal/service/container_image.go b/internal/service/container_image.go index c4cf144c..67852c41 100644 --- a/internal/service/container_image.go +++ b/internal/service/container_image.go @@ -57,6 +57,15 @@ func (s *ContainerImageService) Pull(w http.ResponseWriter, r *http.Request) { return } + if req.Background { + if err = s.containerImageRepo.PullBackground(req); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + Success(w, nil) + return + } + if err = s.containerImageRepo.Pull(req); err != nil { Error(w, http.StatusInternalServerError, "%v", err) return diff --git a/pkg/shell/exec.go b/pkg/shell/exec.go index 693e5811..28ce8ab7 100644 --- a/pkg/shell/exec.go +++ b/pkg/shell/exec.go @@ -56,7 +56,7 @@ func ExecWithLog(ctx context.Context, shell string, logFile string) error { if ctx.Err() != nil { return ctx.Err() } - return fmt.Errorf("run %s failed, err: %w", shell, err) + return fmt.Errorf("run shell failed: %w", err) } return nil diff --git a/web/src/views/container/ContainerCreate.vue b/web/src/views/container/ContainerCreate.vue index d47cc94f..869cc049 100644 --- a/web/src/views/container/ContainerCreate.vue +++ b/web/src/views/container/ContainerCreate.vue @@ -107,16 +107,23 @@ const getNetworks = () => { } // 创建/更新容器 -const createContainer = () => { +const createContainer = (background = false) => { doSubmit.value = true + const config = { ...createModel, background } const req = isEdit.value - ? container.containerUpdate(props.editId!, createModel) - : container.containerCreate(createModel) + ? container.containerUpdate(props.editId!, config) + : container.containerCreate(config) useRequest(req) .onSuccess(() => { - window.$message.success( - isEdit.value ? $gettext('Updated successfully') : $gettext('Created successfully'), - ) + if (background) { + window.$message.success( + $gettext('Task submitted, please check progress in background tasks'), + ) + } else { + window.$message.success( + isEdit.value ? $gettext('Updated successfully') : $gettext('Created successfully'), + ) + } show.value = false }) .onComplete(() => { @@ -129,6 +136,14 @@ const onPullSuccess = () => { createContainer() } +const onPullBackground = () => { + createContainer(true) +} + +const onPullCancel = () => { + doSubmit.value = false +} + // 提交处理 const handleSubmit = () => { if (!createModel.image) { @@ -646,5 +661,7 @@ watch(show, (val) => { v-model:show="showPullModal" :image="createModel.image" @success="onPullSuccess" + @background="onPullBackground" + @cancel="onPullCancel" /> diff --git a/web/src/views/container/ImagePullModal.vue b/web/src/views/container/ImagePullModal.vue index 7d92489c..1fbeaf6c 100644 --- a/web/src/views/container/ImagePullModal.vue +++ b/web/src/views/container/ImagePullModal.vue @@ -13,15 +13,26 @@ const props = defineProps<{ const emit = defineEmits<{ success: [] + background: [] cancel: [] }>() +const background = ref(false) const isPulling = ref(false) const pullProgress = ref>(new Map()) const pullStatus = ref('') const pullError = ref('') let pullWs: WebSocket | null = null +const closePullSocket = () => { + if (!pullWs) return + pullWs.onmessage = null + pullWs.onclose = null + pullWs.onerror = null + pullWs.close() + pullWs = null +} + // 计算总体拉取进度 const totalProgress = computed(() => { const layers = Array.from(pullProgress.value.values()) @@ -38,6 +49,8 @@ const totalProgress = computed(() => { // 拉取镜像 const pullImage = () => { + closePullSocket() + isPulling.value = true pullProgress.value = new Map() pullStatus.value = $gettext('Connecting...') @@ -95,12 +108,19 @@ const pullImage = () => { }) } +const handleSubmit = () => { + if (background.value) { + show.value = false + emit('background') + return + } + + pullImage() +} + // 取消拉取 const cancelPull = () => { - if (pullWs) { - pullWs.close() - pullWs = null - } + closePullSocket() resetState() show.value = false emit('cancel') @@ -117,27 +137,21 @@ const resetState = () => { watch(show, (val) => { if (val) { resetState() - pullImage() + background.value = false } else { - if (pullWs) { - pullWs.close() - pullWs = null - } + closePullSocket() } }) onUnmounted(() => { - if (pullWs) { - pullWs.close() - pullWs = null - } + closePullSocket() }) + + + + + + + + {{ $gettext('Foreground') }} + {{ $gettext('Background') }} + + + + {{ $gettext('Cancel') }} + {{ $gettext('Submit') }} + + diff --git a/web/src/views/container/ImageView.vue b/web/src/views/container/ImageView.vue index 60bb9c7f..73b5194a 100644 --- a/web/src/views/container/ImageView.vue +++ b/web/src/views/container/ImageView.vue @@ -12,11 +12,13 @@ const { confirmDelete } = useConfirm() const pullModel = ref({ name: '', + background: false, auth: false, username: '', password: '', }) const pullModal = ref(false) +const pullSubmitting = ref(false) const pruneLoading = ref(false) const selectedRowKeys = ref([]) @@ -27,6 +29,15 @@ const pullStatus = ref('') const pullError = ref('') let pullWs: WebSocket | null = null +const closePullSocket = () => { + if (!pullWs) return + pullWs.onmessage = null + pullWs.onclose = null + pullWs.onerror = null + pullWs.close() + pullWs = null +} + // 计算总体拉取进度 const totalProgress = computed(() => { const layers = Array.from(pullProgress.value.values()) @@ -146,11 +157,9 @@ const handleBulkDelete = async () => { // 取消拉取 const cancelPull = () => { - if (pullWs) { - pullWs.close() - pullWs = null - } + closePullSocket() resetState() + pullModal.value = false } // 重置拉取状态 @@ -168,6 +177,23 @@ const handlePull = () => { return } + if (pullModel.value.background) { + pullSubmitting.value = true + useRequest(container.imagePull({ ...pullModel.value, background: true })) + .onSuccess(() => { + pullModal.value = false + window.$message.success( + $gettext('Task submitted, please check progress in background tasks'), + ) + }) + .onComplete(() => { + pullSubmitting.value = false + }) + return + } + + closePullSocket() + isPulling.value = true pullProgress.value = new Map() pullStatus.value = $gettext('Connecting...') @@ -233,11 +259,9 @@ const handlePull = () => { watch(pullModal, (val) => { if (val) { resetState() + pullModel.value.background = false } else { - if (pullWs) { - pullWs.close() - pullWs = null - } + closePullSocket() } }) @@ -246,7 +270,7 @@ onMounted(() => { }) onUnmounted(() => { - cancelPull() + closePullSocket() }) @@ -304,8 +328,8 @@ onUnmounted(() => { size="huge" :bordered="false" :segmented="false" - :mask-closable="!isPulling" - :closable="!isPulling" + :mask-closable="!isPulling && !pullSubmitting" + :closable="!isPulling && !pullSubmitting" >