Files
docker-compose-updater/internal/docker/updater.go
T
ilovintit cea9b941cf
Build and Push / build (push) Failing after 13m20s
Initial commit: docker-compose-updater
Go 项目,包含:
- 服务端 updater:两阶段协议,ECDSA 签名验证,AES-GCM 加密
- 发送端 dcu-send:Gitea Action CLI
- internal/auth:加解密/签名/会话管理
- internal/docker:Docker CLI 容器查找/拉取/重建
- action/:Gitea Action 定义
- deploy/Dockerfile:多阶段构建
- .gitea/workflows/build.yaml:CI/CD
2026-06-08 15:16:46 +08:00

284 lines
8.0 KiB
Go

// Package docker 封装 Docker CLI 调用,负责查找、拉取和重建容器。
package docker
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
const (
composeProjectLabel = "com.docker.compose.project"
composeServiceLabel = "com.docker.compose.service"
)
// Updater 封装 Docker 容器更新操作。
type Updater struct {
pullTimeout time.Duration
restartTimeout time.Duration
}
// NewUpdater 创建 Updater 实例。
func NewUpdater(pullTimeout, restartTimeout time.Duration) *Updater {
return &Updater{
pullTimeout: pullTimeout,
restartTimeout: restartTimeout,
}
}
// ContainerInfo 容器信息摘要。
type ContainerInfo struct {
ID string `json:"ID"`
Name string `json:"Name"`
Image string `json:"Image"`
Project string `json:"Project"`
Service string `json:"Service"`
}
// containerInspect 对应 docker inspect 的部分字段。
type containerInspect struct {
ID string `json:"Id"`
Name string `json:"Name"`
Config struct {
Image string `json:"Image"`
Cmd []string `json:"Cmd"`
Entrypoint []string `json:"Entrypoint"`
Env []string `json:"Env"`
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
Labels map[string]string `json:"Labels"`
WorkingDir string `json:"WorkingDir"`
User string `json:"User"`
Hostname string `json:"Hostname"`
} `json:"Config"`
HostConfig struct {
NetworkMode string `json:"NetworkMode"`
Privileged bool `json:"Privileged"`
RestartPolicy struct {
Name string `json:"Name"`
} `json:"RestartPolicy"`
PortBindings map[string][]struct {
HostPort string `json:"HostPort"`
} `json:"PortBindings"`
Binds []string `json:"Binds"`
Links []string `json:"Links"`
ExtraHosts []string `json:"ExtraHosts"`
} `json:"HostConfig"`
Mounts []struct {
Type string `json:"Type"`
Source string `json:"Source"`
Target string `json:"Target"`
} `json:"Mounts"`
NetworkSettings struct {
Networks map[string]struct {
Aliases []string `json:"Aliases"`
} `json:"Networks"`
} `json:"NetworkSettings"`
}
// FindContainerByLabels 通过 compose project + service 标签查找容器。
func (u *Updater) FindContainerByLabels(project, service string) (*ContainerInfo, error) {
args := []string{
"ps",
"--filter", fmt.Sprintf("label=%s=%s", composeProjectLabel, project),
"--filter", fmt.Sprintf("label=%s=%s", composeServiceLabel, service),
"--format", "{{.ID}}\t{{.Image}}\t{{.Names}}",
"--latest",
}
out, err := u.docker(args...)
if err != nil {
return nil, fmt.Errorf("find container: %w", err)
}
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) == 0 || lines[0] == "" {
return nil, fmt.Errorf("container not found: project=%s service=%s", project, service)
}
parts := strings.SplitN(lines[0], "\t", 3)
if len(parts) < 3 {
return nil, fmt.Errorf("unexpected docker ps output: %s", out)
}
return &ContainerInfo{
ID: parts[0],
Image: parts[1],
Name: parts[2],
Project: project,
Service: service,
}, nil
}
// PullImage 拉取指定镜像。
func (u *Updater) PullImage(imageName string) error {
_, err := u.docker("pull", imageName)
return err
}
// RecreateContainer 重建容器:拉取 → 重命名 → 创建 → 启动 → 清理。
// 返回新容器 ID。镜像无变化时跳过重建。
func (u *Updater) RecreateContainer(project, service string) (string, error) {
info, err := u.FindContainerByLabels(project, service)
if err != nil {
return "", err
}
// 拉取新镜像
if err := u.PullImage(info.Image); err != nil {
return "", fmt.Errorf("pull: %w", err)
}
// 检查镜像是否有变化
oldImageID, err := u.getContainerImageID(info.ID)
if err != nil {
return "", fmt.Errorf("old image ID: %w", err)
}
newImageID, err := u.getImageID(info.Image)
if err != nil {
return "", fmt.Errorf("new image ID: %w", err)
}
if oldImageID == newImageID {
return info.ID, nil
}
// 检查旧容器配置
inspectJSON, err := u.inspectContainer(info.ID)
if err != nil {
return "", fmt.Errorf("inspect: %w", err)
}
oldName := strings.TrimPrefix(inspectJSON.Name, "/")
oldRenamed := oldName + "-old-" + time.Now().Format("150405")
// 重命名旧容器
if _, err := u.docker("rename", info.ID, oldRenamed); err != nil {
return "", fmt.Errorf("rename old: %w", err)
}
// 创建新容器
runArgs := u.buildRunArgs(inspectJSON, info.Image, oldName)
createOut, err := u.docker(runArgs...)
if err != nil {
_, _ = u.docker("rename", info.ID, oldName) // 恢复
return "", fmt.Errorf("create: %w", err)
}
newID := strings.TrimSpace(createOut)
// 删除旧容器
if _, err := u.docker("rm", "-f", info.ID); err != nil {
fmt.Printf("warning: remove old %s: %v\n", info.ID[:12], err)
}
return newID, nil
}
// RestartContainer 仅重启(会拉取最新镜像后再重建)。
func (u *Updater) RestartContainer(project, service string) (string, error) {
_, err := u.FindContainerByLabels(project, service)
if err != nil {
return "", err
}
return u.RecreateContainer(project, service)
}
func (u *Updater) getContainerImageID(containerID string) (string, error) {
return u.inspectField(containerID, "{{.Image}}")
}
func (u *Updater) getImageID(imageName string) (string, error) {
out, err := u.docker("image", "inspect", "--format", "{{.Id}}", imageName)
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
func (u *Updater) inspectField(containerID, format string) (string, error) {
out, err := u.docker("inspect", "--format", format, containerID)
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
func (u *Updater) inspectContainer(containerID string) (*containerInspect, error) {
out, err := u.docker("inspect", containerID)
if err != nil {
return nil, err
}
var containers []containerInspect
if err := json.Unmarshal([]byte(out), &containers); err != nil {
return nil, fmt.Errorf("parse inspect: %w", err)
}
if len(containers) == 0 {
return nil, fmt.Errorf("container not found: %s", containerID)
}
return &containers[0], nil
}
// buildRunArgs 从旧容器配置构建 docker run 参数。
func (u *Updater) buildRunArgs(inspect *containerInspect, imageName, containerName string) []string {
args := []string{"run", "-d"}
args = append(args, "--name", containerName)
rp := inspect.HostConfig.RestartPolicy.Name
if rp == "" {
rp = "unless-stopped"
}
args = append(args, "--restart", rp)
for _, env := range inspect.Config.Env {
args = append(args, "-e", env)
}
for _, m := range inspect.Mounts {
if m.Source == "" {
continue
}
args = append(args, "-v", fmt.Sprintf("%s:%s", m.Source, m.Target))
}
for port, bindings := range inspect.HostConfig.PortBindings {
for _, b := range bindings {
if b.HostPort == "" {
args = append(args, "-p", port)
} else {
args = append(args, "-p", fmt.Sprintf("%s:%s", b.HostPort, port))
}
}
}
if nm := inspect.HostConfig.NetworkMode; nm != "" && nm != "default" {
args = append(args, "--network", string(nm))
}
for _, h := range inspect.HostConfig.ExtraHosts {
args = append(args, "--add-host", h)
}
for k, v := range inspect.Config.Labels {
args = append(args, "-l", fmt.Sprintf("%s=%s", k, v))
}
if wd := inspect.Config.WorkingDir; wd != "" {
args = append(args, "-w", wd)
}
if u := inspect.Config.User; u != "" {
args = append(args, "-u", u)
}
if hn := inspect.Config.Hostname; hn != "" {
args = append(args, "--hostname", hn)
}
if inspect.HostConfig.Privileged {
args = append(args, "--privileged")
}
for _, link := range inspect.HostConfig.Links {
args = append(args, "--link", link)
}
args = append(args, imageName)
return args
}
// docker 执行 docker CLI 命令。
func (u *Updater) docker(args ...string) (string, error) {
cmd := exec.Command("docker", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("docker %s: %v\nstderr: %s",
strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return stdout.String(), nil
}