Files
panel/pkg/docker/command.go
T
耗子andClaude Opus 5.5 3e663f6ec1 feat(container): 容器表单补全主机名等参数并修复网络与启动问题
- 补齐后端已支持但表单未暴露的主机名、静态 IP、网络别名、DNS、额外 Hosts、工作目录、用户、Init、只读、tmpfs、共享内存、ulimit、停止信号与超时、健康检查、能力、安全选项、设备、内核参数,编辑时完整回填,删除重建不再丢配置;新增网络与高级设置标签页
- 静态 IP 改写入 IPAMConfig 并支持 IPv6,原先写在只读的 IPAddress 上被 Docker 忽略
- 设备权限与容器路径缺省时对齐 docker run 默认值,健康检查 NONE 转为 --no-healthcheck,非法 DNS 直接报错
- 共享内存单位改为 MB、健康检查时长改为秒,与内存等字段一致
- 网络下拉改按名称传值,网络名同时作为 NetworkMode 与端点 key,修复选 host 网络后无法启动
- 启动从 data 层移到 biz 层,启动失败时返回错误,不再提示创建成功

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:15:00 +08:00

237 lines
6.8 KiB
Go

package docker
import (
"cmp"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/distribution/reference"
"github.com/libtnb/utils/str"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/shell"
)
// ImagePullShell 生成拉取镜像的命令,需要认证时返回配套的清理命令
func ImagePullShell(sock string, req *request.ContainerImagePull) (string, string, error) {
if !req.Auth {
return Command(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 " + shell.Quote(configDir)
shell := strings.Join([]string{
"set -e",
"mkdir -p " + shell.Quote(configDir),
"trap " + shell.Quote(cleanup) + " EXIT",
"printf %s " + shell.Quote(req.Password) + " | " + Command(sock, "--config", configDir, "login", "--username", req.Username, "--password-stdin", reference.Domain(named)),
Command(sock, "--config", configDir, "pull", req.Name),
}, "\n")
return shell, cleanup, nil
}
// RunShell 生成后台创建容器的命令
func RunShell(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.Hostname != "" {
args = append(args, "--hostname", req.Hostname)
}
if req.WorkingDir != "" {
args = append(args, "--workdir", req.WorkingDir)
}
if req.User != "" {
args = append(args, "--user", req.User)
}
for _, alias := range req.NetworkAliases {
args = append(args, "--network-alias", alias)
}
if req.StaticIP != "" {
flag := "--ip"
if strings.Contains(req.StaticIP, ":") {
flag = "--ip6"
}
args = append(args, flag, req.StaticIP)
}
for _, dns := range req.DNS {
args = append(args, "--dns", dns)
}
for _, host := range req.ExtraHosts {
args = append(args, "--add-host", host)
}
for _, capability := range req.CapAdd {
args = append(args, "--cap-add", capability)
}
for _, capability := range req.CapDrop {
args = append(args, "--cap-drop", capability)
}
for _, device := range req.Devices {
args = append(args, "--device", mountSpec(device.Host, cmp.Or(device.Container, device.Host), device.Permissions))
}
for _, option := range req.SecurityOpt {
args = append(args, "--security-opt", option)
}
for _, sysctl := range req.Sysctls {
args = append(args, "--sysctl", sysctl.Key+"="+sysctl.Value)
}
for _, ulimit := range req.Ulimits {
args = append(args, "--ulimit", fmt.Sprintf("%s=%d:%d", ulimit.Name, ulimit.Soft, ulimit.Hard))
}
for _, tmpfs := range req.Tmpfs {
value := tmpfs.Key
if tmpfs.Value != "" {
value += ":" + tmpfs.Value
}
args = append(args, "--tmpfs", value)
}
if req.ShmSize > 0 {
args = append(args, "--shm-size", strconv.FormatInt(req.ShmSize, 10)+"m")
}
if req.Init {
args = append(args, "--init")
}
if req.StopSignal != "" {
args = append(args, "--stop-signal", req.StopSignal)
}
if req.StopTimeout > 0 {
args = append(args, "--stop-timeout", strconv.Itoa(req.StopTimeout))
}
if req.ReadonlyRootfs {
args = append(args, "--read-only")
}
if hc := req.Healthcheck; hc != nil && len(hc.Test) > 0 && hc.Test[0] == "NONE" {
args = append(args, "--no-healthcheck")
} else if hc != nil {
// Test 为空表示沿用镜像的检查命令,只覆盖时长与次数
if len(hc.Test) > 0 {
test := hc.Test
if test[0] == "CMD" || test[0] == "CMD-SHELL" {
test = test[1:]
}
args = append(args, "--health-cmd", strings.Join(test, " "))
}
if hc.Interval > 0 {
args = append(args, "--health-interval", strconv.Itoa(hc.Interval)+"s")
}
if hc.Timeout > 0 {
args = append(args, "--health-timeout", strconv.Itoa(hc.Timeout)+"s")
}
if hc.StartPeriod > 0 {
args = append(args, "--health-start-period", strconv.Itoa(hc.StartPeriod)+"s")
}
if hc.Retries > 0 {
args = append(args, "--health-retries", strconv.Itoa(hc.Retries))
}
}
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", mountSpec(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 Command(sock, args...), nil
}
// mountSpec 拼接 docker 的 源:目标[:选项] 形式,
// 选项为空时不能留下尾随冒号,否则 docker 报 empty section between colons
func mountSpec(source, target, option string) string {
spec := source + ":" + target
if option != "" {
spec += ":" + option
}
return spec
}
// Command 拼接指向指定套接字的 docker 命令,各参数均做转义
func Command(sock string, args ...string) string {
args = append([]string{"docker", "--host", sock}, args...)
for i := range args {
args[i] = shell.Quote(args[i])
}
return strings.Join(args, " ")
}