diff --git a/internal/service/system_update.go b/internal/service/system_update.go index c00108b..dbebe46 100644 --- a/internal/service/system_update.go +++ b/internal/service/system_update.go @@ -1,13 +1,9 @@ package service import ( - "bytes" "context" "errors" "os" - "os/exec" - "regexp" - "runtime" "strings" "sync" "time" @@ -138,82 +134,6 @@ func (s *SystemUpdateService) finishWithoutTask(status SystemUpdateStatus) { s.mu.Unlock() } -func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus { - status := s.baseStatus(ctx) - now := time.Now().Format(time.RFC3339) - status.CheckedAt = &now - - customCommand := s.rawUpdateCommand(ctx) - dockerPath, err := exec.LookPath("docker") - if err != nil { - if customCommand != "" { - status.CanApply = true - status.Message = "当前环境无法检查 Docker;将执行自定义更新命令" - status.Details = err.Error() - return status - } - status.Message = "当前镜像内未安装 docker CLI,无法自动拉取并重启 Docker 镜像" - return status - } - if runtime.GOOS != "windows" { - if _, err := os.Stat("/var/run/docker.sock"); err != nil { - if customCommand != "" { - status.CanApply = true - status.Message = "未检测到 Docker socket;将执行自定义更新命令" - status.Details = err.Error() - return status - } - status.Message = "未检测到 /var/run/docker.sock,请挂载 Docker socket 后再使用热更新" - status.Details = err.Error() - return status - } - } - - checkCtx, cancel := context.WithTimeout(ctx, systemUpdateCheckTimeout) - defer cancel() - if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "version", "--format", "{{.Server.Version}}"); err != nil { - if customCommand != "" { - status.CanApply = true - status.Message = "无法连接 Docker 引擎;将执行自定义更新命令" - status.Details = strings.TrimSpace(out + "\n" + err.Error()) - return status - } - status.Message = "无法连接 Docker 引擎,请检查 Docker socket 权限" - status.Details = strings.TrimSpace(out + "\n" + err.Error()) - return status - } - status.DockerAvailable = true - - containerID := currentContainerID() - status.ContainerID = containerID - if containerID == "" { - status.Message = "无法识别当前容器 ID;请配置自定义更新命令" - if customCommand != "" { - status.CanApply = true - status.Message = "无法识别当前容器 ID;将执行自定义更新命令" - } - return status - } - - if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "inspect", containerID, "--format", "{{.Name}}|{{.Image}}"); err == nil { - name, imageID := parseContainerInspectLine(out) - status.ContainerName = name - status.CurrentImageID = imageID - } - if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "image", "inspect", status.Image, "--format", "{{json .RepoDigests}}"); err == nil { - status.LocalDigest = firstDockerDigest(out) - } - if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "manifest", "inspect", "--verbose", status.Image); err == nil { - status.RemoteDigest = firstDockerDigest(out) - } else if status.Details == "" { - status.Details = strings.TrimSpace(out + "\n" + err.Error()) - } - status.UpdateAvailable = compareDockerDigests(status.LocalDigest, status.RemoteDigest) - status.CanApply = true - status.Message = systemUpdateCheckMessage(status) - return status -} - func (s *SystemUpdateService) runUpdate(parent context.Context, status SystemUpdateStatus, task *TaskHandle) { runCtx, cancel := context.WithTimeout(parent, systemUpdateRunTimeout) defer cancel() @@ -293,154 +213,6 @@ func (s *SystemUpdateService) setting(ctx context.Context, key, fallback string) return strings.TrimSpace(fallback) } -func (s *SystemUpdateService) updateCommand(ctx context.Context, status SystemUpdateStatus) string { - custom := s.rawUpdateCommand(ctx) - if strings.TrimSpace(custom) != "" { - return renderSystemUpdateCommand(custom, status) - } - return renderSystemUpdateCommand(defaultSystemUpdateCommand(), status) -} - -func (s *SystemUpdateService) rawUpdateCommand(ctx context.Context) string { - return s.setting(ctx, SystemUpdateCommandSettingKey, os.Getenv("MEDIASTATION_UPDATE_COMMAND")) -} - -func defaultSystemUpdateCommand() string { - return "docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {{watchtower_image}} --run-once --cleanup {{container}}" -} - -func renderSystemUpdateCommand(template string, status SystemUpdateStatus) string { - replacements := map[string]string{ - "{{image}}": shellQuote(status.Image), - "{{watchtower_image}}": shellQuote(firstNonEmpty(status.WatchtowerImage, DefaultSystemUpdateWatchtowerImage)), - "{{container}}": shellQuote(firstNonEmpty(status.ContainerName, status.ContainerID)), - "{{container_id}}": shellQuote(status.ContainerID), - "{{container_name}}": shellQuote(status.ContainerName), - } - out := template - for marker, value := range replacements { - out = strings.ReplaceAll(out, marker, value) - } - return strings.TrimSpace(out) -} - -func runSystemUpdateCommand(ctx context.Context, name string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, name, args...) - var buf bytes.Buffer - cmd.Stdout = &buf - cmd.Stderr = &buf - err := cmd.Run() - return buf.String(), err -} - -func runSystemUpdateShell(ctx context.Context, command string) (string, error) { - if strings.TrimSpace(command) == "" { - return "", errors.New("empty update command") - } - if runtime.GOOS == "windows" { - return runSystemUpdateCommand(ctx, "cmd", "/C", command) - } - return runSystemUpdateCommand(ctx, "/bin/sh", "-c", command) -} - -func currentContainerID() string { - if value := strings.TrimSpace(os.Getenv("HOSTNAME")); looksLikeContainerID(value) { - return value - } - host, err := os.Hostname() - if err != nil { - return "" - } - host = strings.TrimSpace(host) - if looksLikeContainerID(host) { - return host - } - return "" -} - -func looksLikeContainerID(value string) bool { - if len(value) < 12 { - return false - } - for _, r := range value { - if (r >= 'a' && r <= 'f') || (r >= '0' && r <= '9') { - continue - } - return false - } - return true -} - -func parseContainerInspectLine(raw string) (string, string) { - line := strings.TrimSpace(raw) - parts := strings.SplitN(line, "|", 2) - if len(parts) != 2 { - return strings.TrimPrefix(line, "/"), "" - } - return strings.TrimPrefix(strings.TrimSpace(parts[0]), "/"), strings.TrimSpace(parts[1]) -} - -var dockerDigestPattern = regexp.MustCompile(`sha256:[a-fA-F0-9]{64}`) - -func firstDockerDigest(raw string) string { - match := dockerDigestPattern.FindString(raw) - return strings.ToLower(strings.TrimSpace(match)) -} - -func compareDockerDigests(localDigest, remoteDigest string) *bool { - localDigest = strings.ToLower(strings.TrimSpace(localDigest)) - remoteDigest = strings.ToLower(strings.TrimSpace(remoteDigest)) - if localDigest == "" || remoteDigest == "" { - return nil - } - available := localDigest != remoteDigest - return &available -} - -func systemUpdateCheckMessage(status SystemUpdateStatus) string { - if status.UpdateAvailable == nil { - return "已连接 Docker,可执行更新;当前环境无法精确比较本地与远端镜像摘要" - } - if *status.UpdateAvailable { - return "检测到远端镜像摘要与本地不同,可以执行更新" - } - return "当前本地镜像摘要与远端一致" -} - -func systemUpdateOutputDetails(output string) []string { - output = strings.TrimSpace(output) - if output == "" { - return nil - } - lines := strings.Split(output, "\n") - if len(lines) > 12 { - lines = lines[len(lines)-12:] - } - for i := range lines { - lines[i] = strings.TrimRight(lines[i], "\r") - } - return lines -} - -func redactSystemUpdateCommand(command string) string { - command = strings.TrimSpace(command) - if len(command) > 500 { - return command[:500] + "..." - } - return command -} - -func shellQuote(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "''" - } - if runtime.GOOS == "windows" { - return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` - } - return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" -} - func cloneSystemUpdateStatus(status SystemUpdateStatus) SystemUpdateStatus { if status.UpdateAvailable != nil { v := *status.UpdateAvailable diff --git a/internal/service/system_update_command.go b/internal/service/system_update_command.go new file mode 100644 index 0000000..c681c33 --- /dev/null +++ b/internal/service/system_update_command.go @@ -0,0 +1,95 @@ +package service + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "runtime" + "strings" +) + +func (s *SystemUpdateService) updateCommand(ctx context.Context, status SystemUpdateStatus) string { + custom := s.rawUpdateCommand(ctx) + if strings.TrimSpace(custom) != "" { + return renderSystemUpdateCommand(custom, status) + } + return renderSystemUpdateCommand(defaultSystemUpdateCommand(), status) +} + +func (s *SystemUpdateService) rawUpdateCommand(ctx context.Context) string { + return s.setting(ctx, SystemUpdateCommandSettingKey, os.Getenv("MEDIASTATION_UPDATE_COMMAND")) +} + +func defaultSystemUpdateCommand() string { + return "docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {{watchtower_image}} --run-once --cleanup {{container}}" +} + +func renderSystemUpdateCommand(template string, status SystemUpdateStatus) string { + replacements := map[string]string{ + "{{image}}": shellQuote(status.Image), + "{{watchtower_image}}": shellQuote(firstNonEmpty(status.WatchtowerImage, DefaultSystemUpdateWatchtowerImage)), + "{{container}}": shellQuote(firstNonEmpty(status.ContainerName, status.ContainerID)), + "{{container_id}}": shellQuote(status.ContainerID), + "{{container_name}}": shellQuote(status.ContainerName), + } + out := template + for marker, value := range replacements { + out = strings.ReplaceAll(out, marker, value) + } + return strings.TrimSpace(out) +} + +func runSystemUpdateCommand(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + err := cmd.Run() + return buf.String(), err +} + +func runSystemUpdateShell(ctx context.Context, command string) (string, error) { + if strings.TrimSpace(command) == "" { + return "", errors.New("empty update command") + } + if runtime.GOOS == "windows" { + return runSystemUpdateCommand(ctx, "cmd", "/C", command) + } + return runSystemUpdateCommand(ctx, "/bin/sh", "-c", command) +} + +func systemUpdateOutputDetails(output string) []string { + output = strings.TrimSpace(output) + if output == "" { + return nil + } + lines := strings.Split(output, "\n") + if len(lines) > 12 { + lines = lines[len(lines)-12:] + } + for i := range lines { + lines[i] = strings.TrimRight(lines[i], "\r") + } + return lines +} + +func redactSystemUpdateCommand(command string) string { + command = strings.TrimSpace(command) + if len(command) > 500 { + return command[:500] + "..." + } + return command +} + +func shellQuote(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "''" + } + if runtime.GOOS == "windows" { + return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` + } + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} diff --git a/internal/service/system_update_docker.go b/internal/service/system_update_docker.go new file mode 100644 index 0000000..889b667 --- /dev/null +++ b/internal/service/system_update_docker.go @@ -0,0 +1,173 @@ +package service + +import ( + "context" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + "time" +) + +func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus { + status := s.baseStatus(ctx) + now := time.Now().Format(time.RFC3339) + status.CheckedAt = &now + + customCommand := s.rawUpdateCommand(ctx) + dockerPath, err := exec.LookPath("docker") + if err != nil { + return systemUpdateCustomFallback(status, systemUpdateFallback{ + command: customCommand, + customMessage: "当前环境无法检查 Docker;将执行自定义更新命令", + customDetails: err.Error(), + defaultMessage: "当前镜像内未安装 docker CLI,无法自动拉取并重启 Docker 镜像", + }) + } + if runtime.GOOS != "windows" { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + return systemUpdateCustomFallback(status, systemUpdateFallback{ + command: customCommand, + customMessage: "未检测到 Docker socket;将执行自定义更新命令", + customDetails: err.Error(), + defaultMessage: "未检测到 /var/run/docker.sock,请挂载 Docker socket 后再使用热更新", + defaultDetails: err.Error(), + }) + } + } + + checkCtx, cancel := context.WithTimeout(ctx, systemUpdateCheckTimeout) + defer cancel() + if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "version", "--format", "{{.Server.Version}}"); err != nil { + details := strings.TrimSpace(out + "\n" + err.Error()) + return systemUpdateCustomFallback(status, systemUpdateFallback{ + command: customCommand, + customMessage: "无法连接 Docker 引擎;将执行自定义更新命令", + customDetails: details, + defaultMessage: "无法连接 Docker 引擎,请检查 Docker socket 权限", + defaultDetails: details, + }) + } + status.DockerAvailable = true + + containerID := currentContainerID() + status.ContainerID = containerID + if containerID == "" { + status.Message = "无法识别当前容器 ID;请配置自定义更新命令" + if customCommand != "" { + status.CanApply = true + status.Message = "无法识别当前容器 ID;将执行自定义更新命令" + } + return status + } + + populateSystemUpdateDockerMetadata(checkCtx, dockerPath, &status) + status.UpdateAvailable = compareDockerDigests(status.LocalDigest, status.RemoteDigest) + status.CanApply = true + status.Message = systemUpdateCheckMessage(status) + return status +} + +type systemUpdateFallback struct { + command string + customMessage string + customDetails string + defaultMessage string + defaultDetails string +} + +func systemUpdateCustomFallback(status SystemUpdateStatus, fallback systemUpdateFallback) SystemUpdateStatus { + if fallback.command != "" { + status.CanApply = true + status.Message = fallback.customMessage + status.Details = fallback.customDetails + return status + } + status.Message = fallback.defaultMessage + status.Details = fallback.defaultDetails + return status +} + +func populateSystemUpdateDockerMetadata(ctx context.Context, dockerPath string, status *SystemUpdateStatus) { + if status == nil { + return + } + if out, err := runSystemUpdateCommand(ctx, dockerPath, "inspect", status.ContainerID, "--format", "{{.Name}}|{{.Image}}"); err == nil { + name, imageID := parseContainerInspectLine(out) + status.ContainerName = name + status.CurrentImageID = imageID + } + if out, err := runSystemUpdateCommand(ctx, dockerPath, "image", "inspect", status.Image, "--format", "{{json .RepoDigests}}"); err == nil { + status.LocalDigest = firstDockerDigest(out) + } + if out, err := runSystemUpdateCommand(ctx, dockerPath, "manifest", "inspect", "--verbose", status.Image); err == nil { + status.RemoteDigest = firstDockerDigest(out) + } else if status.Details == "" { + status.Details = strings.TrimSpace(out + "\n" + err.Error()) + } +} + +func currentContainerID() string { + if value := strings.TrimSpace(os.Getenv("HOSTNAME")); looksLikeContainerID(value) { + return value + } + host, err := os.Hostname() + if err != nil { + return "" + } + host = strings.TrimSpace(host) + if looksLikeContainerID(host) { + return host + } + return "" +} + +func looksLikeContainerID(value string) bool { + if len(value) < 12 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'f') || (r >= '0' && r <= '9') { + continue + } + return false + } + return true +} + +func parseContainerInspectLine(raw string) (string, string) { + line := strings.TrimSpace(raw) + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + return strings.TrimPrefix(line, "/"), "" + } + return strings.TrimPrefix(strings.TrimSpace(parts[0]), "/"), strings.TrimSpace(parts[1]) +} + +var dockerDigestPattern = regexp.MustCompile(`sha256:[a-fA-F0-9]{64}`) + +func firstDockerDigest(raw string) string { + match := dockerDigestPattern.FindString(raw) + return strings.ToLower(strings.TrimSpace(match)) +} + +func compareDockerDigests(localDigest, remoteDigest string) *bool { + localDigest = strings.ToLower(strings.TrimSpace(localDigest)) + remoteDigest = strings.ToLower(strings.TrimSpace(remoteDigest)) + if localDigest == "" || remoteDigest == "" { + return nil + } + available := localDigest != remoteDigest + return &available +} + +func systemUpdateCheckMessage(status SystemUpdateStatus) string { + if status.UpdateAvailable == nil { + return "已连接 Docker,可执行更新;当前环境无法精确比较本地与远端镜像摘要" + } + if *status.UpdateAvailable { + return "检测到远端镜像摘要与本地不同,可以执行更新" + } + return "当前本地镜像摘要与远端一致" +} diff --git a/internal/service/system_update_test.go b/internal/service/system_update_test.go index 9886a25..e4eb983 100644 --- a/internal/service/system_update_test.go +++ b/internal/service/system_update_test.go @@ -65,6 +65,29 @@ func TestDockerDigestHelpers(t *testing.T) { } } +func TestSystemUpdateCustomFallback(t *testing.T) { + status := systemUpdateCustomFallback(SystemUpdateStatus{}, systemUpdateFallback{ + command: "echo update", + customMessage: "custom", + customDetails: "custom details", + defaultMessage: "default", + defaultDetails: "default details", + }) + if !status.CanApply || status.Message != "custom" || status.Details != "custom details" { + t.Fatalf("custom fallback = %#v", status) + } + + status = systemUpdateCustomFallback(SystemUpdateStatus{}, systemUpdateFallback{ + customMessage: "custom", + customDetails: "custom details", + defaultMessage: "default", + defaultDetails: "default details", + }) + if status.CanApply || status.Message != "default" || status.Details != "default details" { + t.Fatalf("default fallback = %#v", status) + } +} + func TestSystemUpdateOutputDetailsKeepsTail(t *testing.T) { lines := make([]string, 0, 14) for i := 0; i < 14; i++ {