feat: Docker 操作支持后台任务

This commit is contained in:
耗子
2026-08-12 17:10:54 +08:00
parent e0bc1f18e7
commit 17cbb1ad2a
14 changed files with 380 additions and 47 deletions
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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
+61 -2
View File
@@ -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)
+136
View File
@@ -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, "'", `'"'"'`) + "'"
}
+27 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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"`
}
+5 -4
View File
@@ -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"`
}
+20 -1
View File
@@ -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)
+9
View File
@@ -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
+1 -1
View File
@@ -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
+23 -6
View File
@@ -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"
/>
</template>
+44 -14
View File
@@ -13,15 +13,26 @@ const props = defineProps<{
const emit = defineEmits<{
success: []
background: []
cancel: []
}>()
const background = ref(false)
const isPulling = ref(false)
const pullProgress = ref<Map<string, any>>(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()
})
</script>
<template>
<n-modal
v-model:show="show"
:title="$gettext('Pulling Image')"
:title="$gettext('Pull Image')"
preset="card"
style="width: 60vw"
size="medium"
@@ -201,5 +215,21 @@ onUnmounted(() => {
</n-flex>
</template>
</n-result>
<n-form v-else>
<n-form-item :label="$gettext('Image Name')">
<n-input :value="props.image" readonly />
</n-form-item>
<n-form-item :label="$gettext('Execution Mode')">
<n-radio-group v-model:value="background">
<n-radio-button :value="false">{{ $gettext('Foreground') }}</n-radio-button>
<n-radio-button :value="true">{{ $gettext('Background') }}</n-radio-button>
</n-radio-group>
</n-form-item>
<n-flex justify="end">
<n-button @click="cancelPull">{{ $gettext('Cancel') }}</n-button>
<n-button type="primary" @click="handleSubmit">{{ $gettext('Submit') }}</n-button>
</n-flex>
</n-form>
</n-modal>
</template>
+48 -12
View File
@@ -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<any>([])
@@ -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()
})
</script>
@@ -304,8 +328,8 @@ onUnmounted(() => {
size="huge"
:bordered="false"
:segmented="false"
:mask-closable="!isPulling"
:closable="!isPulling"
:mask-closable="!isPulling && !pullSubmitting"
:closable="!isPulling && !pullSubmitting"
>
<!-- 拉取进度 -->
<template v-if="isPulling || pullProgress.size > 0">
@@ -379,6 +403,12 @@ onUnmounted(() => {
<n-form-item path="auth" :label="$gettext('Authentication')">
<n-switch v-model:value="pullModel.auth" />
</n-form-item>
<n-form-item path="background" :label="$gettext('Execution Mode')">
<n-radio-group v-model:value="pullModel.background">
<n-radio-button :value="false">{{ $gettext('Foreground') }}</n-radio-button>
<n-radio-button :value="true">{{ $gettext('Background') }}</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item v-if="pullModel.auth" path="username" :label="$gettext('Username')">
<n-input
v-model:value="pullModel.username"
@@ -397,7 +427,13 @@ onUnmounted(() => {
/>
</n-form-item>
</n-form>
<n-button type="info" block :loading="loading" :disabled="loading" @click="handlePull">
<n-button
type="info"
block
:loading="pullSubmitting"
:disabled="pullSubmitting"
@click="handlePull"
>
{{ $gettext('Submit') }}
</n-button>
</template>