Files
flvx/go-backend/internal/http/handler/system_upgrade.go
T
2026-05-15 23:16:14 +08:00

658 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"go-backend/internal/http/response"
)
const (
panelDeployDirEnv = "PANEL_DEPLOY_DIR"
panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER"
defaultPanelDeployDir = "/opt/flvx-panel"
defaultPanelBackendName = "flux-panel-backend"
dockerSocketPath = "/var/run/docker.sock"
maxSystemUpgradeComposeAssetBytes = 1 << 20
systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启"
systemUpgradeConflictError = "已有面板升级任务执行中"
)
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`)
var systemUpgradeReleaseBaseURL = githubHTMLBase
var systemUpgradeAPIBaseURL = githubAPIBase
var systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
return client.Get(url)
}
type systemUpgradeExecutor struct {
deployDir string
backendContainer string
}
type systemUpgradeCapabilityData struct {
Capable bool `json:"capable"`
Reasons []string `json:"reasons"`
DeployDir string `json:"deployDir"`
BackendContainer string `json:"backendContainer"`
}
type systemUpgradeReleaseData struct {
Version string `json:"version"`
Name string `json:"name"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
Channel string `json:"channel"`
}
type systemUpgradeVersionData struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
Channel string `json:"channel"`
Reason string `json:"reason,omitempty"`
Capability systemUpgradeCapabilityData `json:"capability"`
}
type systemUpgradeCheckData struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
Channel string `json:"channel"`
Capability systemUpgradeCapabilityData `json:"capability"`
Releases []systemUpgradeReleaseData `json:"releases"`
}
type systemUpgradeRunData struct {
Version string `json:"version"`
Channel string `json:"channel"`
ComposeAsset string `json:"composeAsset"`
HelperContainer string `json:"helperContainer"`
BackendImageID string `json:"backendImageId"`
Message string `json:"message"`
}
type systemUpgradeRequest struct {
Version string `json:"version"`
Channel string `json:"channel"`
}
func newSystemUpgradeExecutor() *systemUpgradeExecutor {
deployDir := strings.TrimSpace(os.Getenv(panelDeployDirEnv))
if deployDir == "" {
deployDir = defaultPanelDeployDir
}
backendContainer := strings.TrimSpace(os.Getenv(panelBackendContainerEnv))
if backendContainer == "" {
backendContainer = defaultPanelBackendName
}
return &systemUpgradeExecutor{deployDir: deployDir, backendContainer: backendContainer}
}
func currentPanelVersion() string {
version := strings.TrimSpace(os.Getenv("FLUX_VERSION"))
if version == "" {
return "dev"
}
return version
}
func validateBackendContainerName(value string) error {
if value == "" {
return fmt.Errorf("backend container name is empty")
}
if !safeBackendContainerPattern.MatchString(value) {
return fmt.Errorf("unsafe backend container name: %s", value)
}
return nil
}
func validateUpgradeVersion(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("upgrade version is empty")
}
for _, r := range value {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("unsafe upgrade version: contains control character")
}
}
return nil
}
func (e *systemUpgradeExecutor) composePath() string {
return filepath.Join(e.deployDir, "docker-compose.yml")
}
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
reasons := make([]string, 0)
if !filepath.IsAbs(e.deployDir) {
reasons = append(reasons, "部署目录必须是绝对路径")
}
if err := validateBackendContainerName(e.backendContainer); err != nil {
reasons = append(reasons, err.Error())
}
if out, err := exec.CommandContext(ctx, "docker", "--version").CombinedOutput(); err != nil {
reasons = append(reasons, fmt.Sprintf("docker CLI不可用: %v: %s", err, strings.TrimSpace(string(out))))
}
if info, err := os.Stat(dockerSocketPath); err != nil {
reasons = append(reasons, "docker socket不可用: "+err.Error())
} else if info.IsDir() {
reasons = append(reasons, "docker socket路径不是文件")
}
if info, err := os.Stat(e.composePath()); err != nil {
reasons = append(reasons, "部署docker-compose.yml不可用: "+err.Error())
} else if info.IsDir() {
reasons = append(reasons, "部署docker-compose.yml不是文件")
}
if info, err := os.Stat(e.envPath()); err != nil {
reasons = append(reasons, "部署.env不可用: "+err.Error())
} else if info.IsDir() {
reasons = append(reasons, "部署.env不是文件")
}
if out, err := exec.CommandContext(ctx, "docker", "compose", "version").CombinedOutput(); err != nil {
reasons = append(reasons, fmt.Sprintf("docker compose不可用: %v: %s", err, strings.TrimSpace(string(out))))
}
if _, err := e.currentBackendImage(ctx); err != nil {
reasons = append(reasons, err.Error())
}
return systemUpgradeCapabilityData{
Capable: len(reasons) == 0,
Reasons: reasons,
DeployDir: e.deployDir,
BackendContainer: e.backendContainer,
}
}
func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
if enableIPv6ComposePattern.Match(current) {
return "docker-compose-v6.yml"
}
return "docker-compose-v4.yml"
}
func (e *systemUpgradeExecutor) helperScript() string {
return `set -eu
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
cd "$PANEL_DEPLOY_DIR"
echo "" > "$LOGFILE"
log "开始面板升级"
log "工作目录: $(pwd)"
if [ ! -f docker-compose.yml ]; then
log "错误: docker-compose.yml 不存在"
exit 1
fi
if [ ! -f .env ]; then
log "错误: .env 不存在"
exit 1
fi
log "拉取新镜像..."
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 拉取镜像失败"
exit 1
fi
log "等待旧容器释放资源..."
sleep 3
log "重启服务(force-recreate)..."
if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 重启服务失败"
exit 1
fi
log "升级完成"
`
}
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
if err := validateBackendContainerName(e.backendContainer); err != nil {
return nil, err
}
return []string{
"run", "-d", "--rm", "--name", helperName,
"--volumes-from", e.backendContainer,
"-v", dockerSocketPath + ":" + dockerSocketPath,
"-e", panelDeployDirEnv + "=" + e.deployDir,
"--entrypoint", "/bin/sh", imageID,
"-c", e.helperScript(),
}, nil
}
func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
if err := validateUpgradeVersion(version); err != nil {
return err
}
mode, err := fileModeOrDefault(envPath, 0o600)
if err != nil {
return err
}
data, err := os.ReadFile(envPath)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
replaced := false
for i, line := range lines {
if strings.HasPrefix(line, "FLUX_VERSION=") {
lines[i] = "FLUX_VERSION=" + version
replaced = true
}
}
if !replaced {
trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n")
if trimmed == "" {
trimmed = "FLUX_VERSION=" + version
} else {
trimmed += "\nFLUX_VERSION=" + version
}
return writeFileWithMode(envPath, []byte(trimmed+"\n"), mode)
}
content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
return writeFileWithMode(envPath, []byte(content), mode)
}
func (e *systemUpgradeExecutor) backupFile(path string) (string, error) {
mode, err := fileModeOrDefault(path, 0o600)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
backupPath := path + ".upgrade.bak"
if err := writeFileWithMode(backupPath, data, mode); err != nil {
return "", err
}
return backupPath, nil
}
func (e *systemUpgradeExecutor) restoreBackup(path string) error {
backupPath := path + ".upgrade.bak"
mode, err := fileModeOrDefault(backupPath, 0o600)
if err != nil {
return err
}
data, err := os.ReadFile(backupPath)
if err != nil {
return err
}
return writeFileWithMode(path, data, mode)
}
func (e *systemUpgradeExecutor) restoreUpgradeBackups(paths ...string) error {
var errs []string
for _, path := range paths {
if err := e.restoreBackup(path); err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
if len(bytes.TrimSpace(data)) == 0 {
return fmt.Errorf("compose asset is empty")
}
mode, err := fileModeOrDefault(path, 0o644)
if err != nil {
return err
}
return writeFileWithMode(path, data, mode)
}
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
func (h *Handler) fetchSystemUpgradeReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
url := fmt.Sprintf("%s/repos/%s/releases?per_page=%d", strings.TrimRight(systemUpgradeAPIBaseURL, "/"), githubRepo, perPage)
if enabled, proxyURL := h.getGithubProxyConfig(); enabled {
url = fmt.Sprintf("%s/%s", proxyURL, url)
}
resp, err := systemUpgradeHTTPGet(client, url)
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := h.fetchSystemUpgradeReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fallback, nil
}
return 0, err
}
return info.Mode().Perm(), nil
}
func writeFileWithMode(path string, data []byte, mode os.FileMode) error {
if err := os.WriteFile(path, data, mode); err != nil {
return err
}
return os.Chmod(path, mode)
}
func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
if err := validateBackendContainerName(e.backendContainer); err != nil {
return "", err
}
out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer).CombinedOutput()
if err != nil {
return "", fmt.Errorf("inspect backend image failed: %v: %s", err, strings.TrimSpace(string(out)))
}
imageID := strings.TrimSpace(string(out))
if imageID == "" {
return "", fmt.Errorf("backend image id is empty")
}
return imageID, nil
}
func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helperName string) (string, error) {
args, err := e.buildHelperRunArgs(imageID, helperName)
if err != nil {
return "", err
}
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("start helper failed: %v: %s", err, strings.TrimSpace(string(out)))
}
containerID := strings.TrimSpace(string(out))
if containerID == "" {
containerID = helperName
}
return containerID, nil
}
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
url := h.buildSystemUpgradeDownloadURL(version, filename)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := systemUpgradeHTTPGet(client, url)
if err != nil {
return nil, fmt.Errorf("下载%s失败: %v", filename, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("下载%s返回 %d: %s", filename, resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSystemUpgradeComposeAssetBytes+1))
if err != nil {
return nil, fmt.Errorf("读取%s失败: %v", filename, err)
}
if len(body) > maxSystemUpgradeComposeAssetBytes {
return nil, fmt.Errorf("下载%s过大", filename)
}
if len(bytes.TrimSpace(body)) == 0 {
return nil, fmt.Errorf("下载%s内容为空", filename)
}
return body, nil
}
func releasesForChannel(releases []githubRelease, channel string) []systemUpgradeReleaseData {
channel = normalizeReleaseChannel(channel)
items := make([]systemUpgradeReleaseData, 0, len(releases))
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
itemChannel := releaseChannelFromTag(tag)
if itemChannel != channel {
continue
}
items = append(items, systemUpgradeReleaseData{
Version: tag,
Name: r.Name,
PublishedAt: r.PublishedAt,
Prerelease: itemChannel == releaseChannelDev,
Channel: itemChannel,
})
}
return items
}
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
return err
}
if len(bytes.TrimSpace(body)) == 0 {
return nil
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
return decoder.Decode(req)
}
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
data := systemUpgradeVersionData{
CurrentVersion: current,
LatestVersion: latest,
HasUpdate: latest != "" && latest != current,
Channel: channel,
Capability: capability,
}
if lookupErr != nil {
data.LatestVersion = ""
data.HasUpdate = false
data.Reason = lookupErr.Error()
}
return data
}
func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
channel := releaseChannelStable
current := currentPanelVersion()
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel)
response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability)))
}
func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req systemUpgradeRequest
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
current := currentPanelVersion()
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
githubReleases, err := h.fetchSystemUpgradeReleases(50)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
}
releases := releasesForChannel(githubReleases, channel)
latest := ""
if len(releases) > 0 {
latest = releases[0].Version
}
response.WriteJSON(w, response.OK(systemUpgradeCheckData{
CurrentVersion: current,
LatestVersion: latest,
HasUpdate: latest != "" && latest != current,
Channel: channel,
Capability: capability,
Releases: releases,
}))
}
func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.systemUpgradeMu.TryLock() {
response.WriteJSON(w, response.ErrDefault(systemUpgradeConflictError))
return
}
defer h.systemUpgradeMu.Unlock()
var req systemUpgradeRequest
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
if !capability.Capable {
response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; ")))
return
}
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = h.resolveSystemUpgradeLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
imageID, err := exec.currentBackendImage(r.Context())
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
composePath := exec.composePath()
envPath := exec.envPath()
composeData, err := os.ReadFile(composePath)
if err != nil {
response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error()))
return
}
composeAsset := exec.selectComposeAsset(composeData)
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if _, err := exec.backupFile(composePath); err != nil {
response.WriteJSON(w, response.Err(-2, "备份compose失败: "+err.Error()))
return
}
if _, err := exec.backupFile(envPath); err != nil {
response.WriteJSON(w, response.Err(-2, "备份.env失败: "+err.Error()))
return
}
if err := exec.replaceCompose(composePath, newCompose); err != nil {
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
}
response.WriteJSON(w, response.Err(-2, "替换compose失败: "+err.Error()))
return
}
if err := exec.updateEnvVersion(envPath, version); err != nil {
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
}
response.WriteJSON(w, response.Err(-2, "更新版本配置失败: "+err.Error()))
return
}
helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
helperContainer, err := exec.startHelper(r.Context(), imageID, helperName)
if err != nil {
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(systemUpgradeRunData{
Version: version,
Channel: channel,
ComposeAsset: composeAsset,
HelperContainer: helperContainer,
BackendImageID: imageID,
Message: systemUpgradeMessage,
}))
}