mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
7e81e1c65c
Persist upgrade jobs and progress across backend restarts; preserve Compose identity and database mounts, isolate backups, and fail safely on uncertain recreation. Add frontend recovery handling and lifecycle/permission regressions.
475 lines
15 KiB
Go
475 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-backend/internal/http/response"
|
|
"go-backend/internal/panelupgrade"
|
|
)
|
|
|
|
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 {
|
|
JobID string `json:"jobId"`
|
|
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 { return panelupgrade.ValidateVersion(value) }
|
|
|
|
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 {
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
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 err := exec.CommandContext(ctx, "docker", "--version").Run(); err != nil {
|
|
reasons = append(reasons, "docker CLI不可用")
|
|
}
|
|
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 err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
|
|
reasons = append(reasons, "docker compose不可用")
|
|
}
|
|
if _, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Identity(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 (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 (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(io.LimitReader(r.Body, 4097))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(body) > 4096 {
|
|
return fmt.Errorf("request too large")
|
|
}
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
return nil
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(req); err != nil {
|
|
return err
|
|
}
|
|
var extra any
|
|
if decoder.Decode(&extra) != io.EOF {
|
|
return fmt.Errorf("unexpected trailing JSON")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
if err := validateUpgradeVersion(version); err != nil {
|
|
response.WriteJSON(w, response.ErrDefault("版本号格式无效"))
|
|
return
|
|
}
|
|
composeData, err := os.ReadFile(exec.composePath())
|
|
if err != nil {
|
|
response.WriteJSON(w, response.ErrDefault("读取部署配置失败"))
|
|
return
|
|
}
|
|
composeAsset := exec.selectComposeAsset(composeData)
|
|
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.ErrDefault("下载目标 Compose 失败"))
|
|
return
|
|
}
|
|
// A detached helper must not be canceled when the browser disconnects.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
job, imageID, err := (panelupgrade.Manager{Dir: exec.deployDir, Backend: exec.backendContainer}).Start(ctx, version, newCompose)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
|
return
|
|
}
|
|
response.WriteJSON(w, response.OK(systemUpgradeRunData{JobID: job.ID, Version: version, Channel: channel, ComposeAsset: composeAsset, HelperContainer: job.HelperContainer, BackendImageID: imageID, Message: systemUpgradeMessage}))
|
|
}
|
|
|
|
func (h *Handler) systemUpgradeStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
|
return
|
|
}
|
|
var req struct {
|
|
JobID string `json:"jobId"`
|
|
}
|
|
defer r.Body.Close()
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 4097))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); err != nil && err != io.EOF {
|
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
|
return
|
|
}
|
|
var extra any
|
|
if decoder.Decode(&extra) != io.EOF {
|
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
|
return
|
|
}
|
|
e := newSystemUpgradeExecutor()
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
job, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Status(ctx, req.JobID)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.ErrDefault("无法读取升级任务状态"))
|
|
return
|
|
}
|
|
response.WriteJSON(w, response.OK(struct {
|
|
CurrentVersion string `json:"currentVersion"`
|
|
Job *panelupgrade.Job `json:"job"`
|
|
}{currentPanelVersion(), job}))
|
|
}
|