mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
fix(panel): make self-upgrades durable and observable (#572)
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.
This commit is contained in:
@@ -63,6 +63,11 @@ jobs:
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test panel upgrade lifecycle and permissions
|
||||
run: |
|
||||
go test ./internal/panelupgrade -count=1
|
||||
go test ./internal/http/handler ./tests/contract -run 'SystemUpgrade|SelectComposeAsset|DownloadReleaseAsset|UpgradeBackup|RestoreBackup' -count=1
|
||||
|
||||
backend-postgres-contract:
|
||||
name: Go Backend PostgreSQL Contract
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -12,9 +12,17 @@ import (
|
||||
|
||||
"go-backend/internal/app"
|
||||
"go-backend/internal/config"
|
||||
"go-backend/internal/panelupgrade"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "panel-upgrade-helper" {
|
||||
if panelupgrade.RunHelper(os.Args[2]) != nil {
|
||||
log.Print("panel upgrade helper stopped; inspect durable job status")
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
cfg := config.FromEnv()
|
||||
if cfg.JWTSecret == "" {
|
||||
log.Println("warning: JWT_SECRET is empty")
|
||||
|
||||
@@ -186,6 +186,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
|
||||
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
|
||||
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
|
||||
mux.HandleFunc("/api/v1/system/upgrade/status", h.systemUpgradeStatus)
|
||||
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/panelupgrade"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -75,6 +76,7 @@ type systemUpgradeCheckData struct {
|
||||
}
|
||||
|
||||
type systemUpgradeRunData struct {
|
||||
JobID string `json:"jobId"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
ComposeAsset string `json:"composeAsset"`
|
||||
@@ -118,17 +120,7 @@ func validateBackendContainerName(value string) error {
|
||||
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 validateUpgradeVersion(value string) error { return panelupgrade.ValidateVersion(value) }
|
||||
|
||||
func (e *systemUpgradeExecutor) composePath() string {
|
||||
return filepath.Join(e.deployDir, "docker-compose.yml")
|
||||
@@ -136,6 +128,8 @@ func (e *systemUpgradeExecutor) composePath() string {
|
||||
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, "部署目录必须是绝对路径")
|
||||
@@ -143,8 +137,8 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
|
||||
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 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())
|
||||
@@ -161,10 +155,10 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
|
||||
} 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 := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
|
||||
reasons = append(reasons, "docker compose不可用")
|
||||
}
|
||||
if _, err := e.currentBackendImage(ctx); err != nil {
|
||||
if _, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Identity(ctx); err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
|
||||
@@ -183,144 +177,6 @@ func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
|
||||
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)
|
||||
@@ -383,55 +239,6 @@ func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (st
|
||||
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}
|
||||
@@ -485,16 +292,26 @@ func releasesForChannel(releases []githubRelease, channel string) []systemUpgrad
|
||||
|
||||
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
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()
|
||||
return decoder.Decode(req)
|
||||
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 {
|
||||
@@ -595,63 +412,63 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
imageID, err := exec.currentBackendImage(r.Context())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
|
||||
if err := validateUpgradeVersion(version); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("版本号格式无效"))
|
||||
return
|
||||
}
|
||||
|
||||
composePath := exec.composePath()
|
||||
envPath := exec.envPath()
|
||||
composeData, err := os.ReadFile(composePath)
|
||||
composeData, err := os.ReadFile(exec.composePath())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error()))
|
||||
response.WriteJSON(w, response.ErrDefault("读取部署配置失败"))
|
||||
return
|
||||
}
|
||||
composeAsset := exec.selectComposeAsset(composeData)
|
||||
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
response.WriteJSON(w, response.ErrDefault("下载目标 Compose 失败"))
|
||||
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)
|
||||
// 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 {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
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}))
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(systemUpgradeRunData{
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
ComposeAsset: composeAsset,
|
||||
HelperContainer: 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}))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -128,149 +127,12 @@ func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionRejectsUnsafeValue(t *testing.T) {
|
||||
for _, version := range []string{"", "2.1.9\nJWT_SECRET=bad", "2.1.9\rbad", "2.1.9\x00bad", "2.1.9\x1fbad"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
original := []byte("JWT_SECRET=test\n")
|
||||
if err := os.WriteFile(envPath, original, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err == nil {
|
||||
t.Fatal("expected unsafe version to fail validation")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != string(original) {
|
||||
t.Fatalf("env content changed to %q, want %q", string(data), string(original))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAcceptsVersionLabels(t *testing.T) {
|
||||
for _, version := range []string{"2.1.9", "2.1.9-beta14", "v-test"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionPreservesFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("env mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
|
||||
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
|
||||
t.Fatal("expected unsafe container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
args, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
|
||||
if err != nil {
|
||||
t.Fatalf("buildHelperRunArgs() error = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"run", "-d", "--rm", "--name", "flvx-upgrade-helper",
|
||||
"--volumes-from", "flux-panel-backend",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
|
||||
"--entrypoint", "/bin/sh", "sha256:abc",
|
||||
"-c", exec.helperScript(),
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsRejectsUnsafeBackendContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend;rm -rf /"}
|
||||
if _, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper"); err == nil {
|
||||
t.Fatal("expected unsafe backend container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
|
||||
@@ -350,64 +212,6 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeBackupUsesStablePathAndRestoreRestoresOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "docker-compose.yml")
|
||||
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
backupPath, err := exec.backupFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if backupPath != path+".upgrade.bak" {
|
||||
t.Fatalf("backup path = %q, want %q", backupPath, path+".upgrade.bak")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mutated"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "original" {
|
||||
t.Fatalf("restored content = %q, want original", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackupPreservesOriginalFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(path, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if _, err := exec.backupFile(path); err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("restored mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
|
||||
var payload systemUpgradeRequest
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Panel upgrade safety and recovery
|
||||
|
||||
The helper is a subcommand in the **currently running image**, dispatched before
|
||||
application/database initialization. This fix must first be installed through the
|
||||
normal administrator deployment procedure on older releases lacking the helper.
|
||||
It cannot retroactively repair an already-running legacy upgrade helper.
|
||||
|
||||
Supported deployments have one `docker-compose.yml`, the original Compose project
|
||||
labels, and a writable host bind mount containing that file and `.env`. The
|
||||
helper mounts that directory at its **host absolute path** and passes explicit
|
||||
project, project-directory, env-file and compose-file options. Multiple Compose
|
||||
files, mismatched mounts/resources, or changed database settings fail closed.
|
||||
|
||||
Jobs are under `<deployment>/.panel-upgrades/<jobId>/` (directory 0700, files
|
||||
0600). `*.original` contains immutable per-job configuration snapshots, not a live
|
||||
database backup. `events.log` contains safe phase messages only. Legacy
|
||||
`.upgrade.bak` files and `upgrade.log` are never changed. Helper containers remain
|
||||
for diagnosis; they are intentionally not automatically removed. Operators may
|
||||
remove completed helpers after retaining the job's files.
|
||||
|
||||
The `latest` pointer plus filesystem lock prevents simultaneous jobs across API
|
||||
processes and backend replacement. Failed validation/pull leaves live files
|
||||
untouched. A failed configuration application attempts to restore both files
|
||||
before any recreate. Once `restarting` is recorded, failures require recovery:
|
||||
no automatic database rollback, image rollback, or container rollback is safe.
|
||||
The helper has a 30 minute overall deadline, including a maximum five minute
|
||||
post-recreate verification window. Docker client timeout during recreation can
|
||||
leave daemon work in flight; it is always treated as uncertain, not retried.
|
||||
|
||||
## Operator recovery after an interrupted/uncertain job
|
||||
|
||||
There is intentionally no remotely callable “clear lock” endpoint. Do not delete
|
||||
job directories or rewrite status JSON to force another attempt.
|
||||
|
||||
1. Work on the Docker host and identify the job from `latest`, `status.json` and
|
||||
`plan.json`. Preserve the entire job directory, live Compose/env files, and
|
||||
container inspection information in a private location. These files can
|
||||
contain credentials. Never paste `.env` or raw Compose config into public logs.
|
||||
2. Restore Docker daemon access if necessary. Inspect the exact helper container
|
||||
named by the job. If it is running, let it finish or explicitly stop it as an
|
||||
administrator. Verify it has stopped. If launch was uncertain, establish that
|
||||
no matching helper exists and no Docker create/up operation remains in flight.
|
||||
Do not clear the job while any of these facts are uncertain.
|
||||
3. Inspect the original project's backend and frontend container IDs, images,
|
||||
mounts and health. Check the actual database and application logs privately.
|
||||
After any attempted recreation, assume migrations may already have occurred.
|
||||
Preserve a current database snapshot before further manual deployment work.
|
||||
Use the normal documented deployment procedure to bring both services to a
|
||||
consistent, healthy state. Do not copy an old database over the current one or
|
||||
blindly redeploy an older image. Configuration snapshots alone cannot undo a
|
||||
schema migration.
|
||||
4. Only after verifying no helper/daemon work remains and both services and their
|
||||
database are consistent, archive the `latest` pointer under a unique filename
|
||||
in `.panel-upgrades/` (for example `resolved-<jobId>.pointer`). Do this while
|
||||
the API is stopped, or hold an exclusive OS flock on `.panel-upgrades/lock`,
|
||||
so no other start can race this manual recovery. Keep the job directory and
|
||||
archived pointer. This permits a new job without changing recovery evidence.
|
||||
5. Restart the API if stopped and reload the browser settings page so it discovers
|
||||
the current `latest` task instead of following the resolved task ID. No current
|
||||
job is expected. A fresh start creates a new ID and new backups, never
|
||||
overwriting old evidence.
|
||||
|
||||
A lost helper, unavailable daemon, or stale status is displayed as
|
||||
`recovery_required` while retaining the active lock. This is intentionally a
|
||||
conservative derived status, so a helper that is still completing work can report
|
||||
its actual durable result without racing a status request.
|
||||
@@ -0,0 +1,311 @@
|
||||
package panelupgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const helperTimeout = 30 * time.Minute
|
||||
|
||||
var verificationTimeout = 5 * time.Minute
|
||||
|
||||
type composeConfig struct {
|
||||
FullServices map[string]map[string]any `json:"-"`
|
||||
Services map[string]struct {
|
||||
Image string `json:"image"`
|
||||
ContainerName string `json:"container_name"`
|
||||
Volumes []map[string]any `json:"volumes"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
} `json:"services"`
|
||||
Volumes map[string]any `json:"volumes"`
|
||||
Networks map[string]any `json:"networks"`
|
||||
}
|
||||
|
||||
func composeArgs(p Plan, dir string, args ...string) []string {
|
||||
return append([]string{"compose", "--project-name", p.Project, "--project-directory", p.HostDir, "--env-file", filepath.Join(dir, ".env"), "-f", filepath.Join(dir, "docker-compose.yml")}, args...)
|
||||
}
|
||||
func config(ctx context.Context, p Plan, dir string) (composeConfig, error) {
|
||||
var c composeConfig
|
||||
b, e := docker(ctx, composeArgs(p, dir, "config", "--format", "json")...)
|
||||
if e != nil {
|
||||
return c, e
|
||||
}
|
||||
e = json.Unmarshal(b, &c)
|
||||
if e == nil {
|
||||
var raw struct {
|
||||
Services map[string]map[string]any `json:"services"`
|
||||
}
|
||||
e = json.Unmarshal(b, &raw)
|
||||
c.FullServices = raw.Services
|
||||
}
|
||||
return c, e
|
||||
}
|
||||
func validateConfig(old, next composeConfig, p Plan) error {
|
||||
// Compare complete normalized services, not a projection that can silently drop
|
||||
// host bindings, commands, security options, networks or dependency changes.
|
||||
if old.FullServices == nil || next.FullServices == nil {
|
||||
return errors.New("missing full Compose configuration")
|
||||
}
|
||||
raw, _ := json.Marshal(old.FullServices)
|
||||
var expected map[string]map[string]any
|
||||
if json.Unmarshal(raw, &expected) != nil {
|
||||
return errors.New("invalid configuration")
|
||||
}
|
||||
for _, service := range []string{"backend", "frontend"} {
|
||||
if expected[service] == nil || next.FullServices[service] == nil {
|
||||
return errors.New("missing service")
|
||||
}
|
||||
expected[service]["image"] = next.FullServices[service]["image"]
|
||||
}
|
||||
if env, ok := expected["backend"]["environment"].(map[string]any); ok {
|
||||
env["FLUX_VERSION"] = p.Job.TargetVersion
|
||||
} else {
|
||||
return errors.New("missing backend environment")
|
||||
}
|
||||
if !reflect.DeepEqual(expected, next.FullServices) {
|
||||
return errors.New("unsupported service configuration change; manual upgrade required")
|
||||
}
|
||||
|
||||
for service, image := range map[string]string{"backend": "ghcr.io/sagit-chu/flux-panel-backend:", "frontend": "ghcr.io/sagit-chu/vite-frontend:"} {
|
||||
a, ok := old.Services[service]
|
||||
b, present := next.Services[service]
|
||||
if !ok || !present || b.Image != image+p.Job.TargetVersion || a.ContainerName != b.ContainerName || !reflect.DeepEqual(a.Volumes, b.Volumes) {
|
||||
return errors.New("service image, container identity or persistent mounts do not match the supported deployment")
|
||||
}
|
||||
}
|
||||
if next.Services["backend"].ContainerName != p.Backend || next.Services["backend"].Environment["FLUX_VERSION"] != p.Job.TargetVersion {
|
||||
return errors.New("backend identity/version mismatch")
|
||||
}
|
||||
|
||||
for service := range next.Services {
|
||||
if service != "backend" && service != "frontend" && service != "postgres" {
|
||||
return errors.New("unsupported service")
|
||||
}
|
||||
}
|
||||
for key, value := range old.Services["backend"].Environment {
|
||||
if key != "FLUX_VERSION" && next.Services["backend"].Environment[key] != value {
|
||||
return errors.New("backend environment changed; manual upgrade required")
|
||||
}
|
||||
}
|
||||
for key, value := range next.Services["backend"].Environment {
|
||||
if key != "FLUX_VERSION" && old.Services["backend"].Environment[key] != value {
|
||||
return errors.New("backend environment changed; manual upgrade required")
|
||||
}
|
||||
}
|
||||
// Database configuration and shared resource identities are not upgrade targets.
|
||||
if !reflect.DeepEqual(old.Services["postgres"], next.Services["postgres"]) || !reflect.DeepEqual(old.Volumes, next.Volumes) || !reflect.DeepEqual(old.Networks, next.Networks) {
|
||||
return errors.New("database or shared resource configuration changed; manual upgrade required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLive proves the normalized source configuration still describes the running
|
||||
// backend. In particular, Compose must not attach a fresh database volume by accident.
|
||||
func validateLive(ctx context.Context, p Plan, c composeConfig) error {
|
||||
live, err := inspect(ctx, p.Backend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if live.Image != p.Image || !live.State.Running || live.Config.Labels["com.docker.compose.project"] != p.Project || live.Config.Labels["com.docker.compose.service"] != "backend" {
|
||||
return errors.New("running backend changed")
|
||||
}
|
||||
if len(live.Mounts) != len(c.Services["backend"].Volumes) {
|
||||
return errors.New("unexpected live mounts; manual upgrade required")
|
||||
}
|
||||
for _, v := range c.Services["backend"].Volumes {
|
||||
typ, _ := v["type"].(string)
|
||||
src, _ := v["source"].(string)
|
||||
dst, _ := v["target"].(string)
|
||||
if typ == "volume" {
|
||||
spec, ok := c.Volumes[src].(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("unknown volume")
|
||||
}
|
||||
src, _ = spec["name"].(string)
|
||||
}
|
||||
if src == "" || dst == "" || (typ != "volume" && typ != "bind") {
|
||||
return errors.New("unsupported mount")
|
||||
}
|
||||
found := false
|
||||
for _, mount := range live.Mounts {
|
||||
actual := mount.Source
|
||||
if typ == "volume" {
|
||||
actual = mount.Name
|
||||
}
|
||||
if mount.Type == typ && mount.Destination == dst && actual == src && mount.RW != (v["read_only"] == true) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("live persistent mount does not match Compose")
|
||||
}
|
||||
}
|
||||
env := map[string]string{}
|
||||
for _, v := range live.Config.Env {
|
||||
parts := strings.SplitN(v, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
env[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"DB_TYPE", "DB_PATH", "DATABASE_URL", "PANEL_DEPLOY_DIR", "PANEL_BACKEND_CONTAINER"} {
|
||||
if env[key] != c.Services["backend"].Environment[key] {
|
||||
return errors.New("live database/deployment environment does not match Compose")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunHelper runs before app/database initialization and does not depend on the old backend process.
|
||||
func RunHelper(planPath string) error {
|
||||
b, e := os.ReadFile(planPath)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var p Plan
|
||||
if e = json.Unmarshal(b, &p); e != nil {
|
||||
return e
|
||||
}
|
||||
if !idPattern.MatchString(p.Job.ID) || ValidateVersion(p.Job.TargetVersion) != nil || !projectPattern.MatchString(p.Project) {
|
||||
return errors.New("invalid plan")
|
||||
}
|
||||
m := Manager{Dir: p.HostDir, Backend: p.Backend}
|
||||
dir := m.jobDir(p.Job.ID)
|
||||
if filepath.Clean(planPath) != filepath.Join(dir, "plan.json") {
|
||||
return errors.New("invalid plan location")
|
||||
}
|
||||
// Reject duplicate helper invocations without affecting the live job.
|
||||
f, e := os.OpenFile(filepath.Join(dir, "helper.started"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
f.Close()
|
||||
j, e := m.read(p.Job.ID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !j.Active {
|
||||
return errors.New("job is not active")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
|
||||
defer cancel()
|
||||
fail := func(msg string) error {
|
||||
if e := m.write(j, "failed", msg); e != nil {
|
||||
return e
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
recovery := func(msg string) error {
|
||||
m.write(j, "recovery_required", msg+";请管理员核对容器、数据库和任务备份后人工恢复,禁止自动回滚或重复升级")
|
||||
return errors.New(msg)
|
||||
}
|
||||
if e = m.write(j, "preparing", "校验部署配置和持久化资源"); e != nil {
|
||||
return e
|
||||
}
|
||||
old, e := config(ctx, p, p.HostDir)
|
||||
if e != nil {
|
||||
return fail("现有 Compose 配置校验失败,线上配置未变更")
|
||||
}
|
||||
next, e := config(ctx, p, dir)
|
||||
if e != nil {
|
||||
return fail("目标 Compose 配置校验失败,线上配置未变更")
|
||||
}
|
||||
if validateConfig(old, next, p) != nil {
|
||||
return fail("目标镜像、服务或持久化资源不匹配,需手动升级")
|
||||
}
|
||||
if validateLive(ctx, p, old) != nil {
|
||||
return fail("运行中后端与部署配置不一致,需手动升级")
|
||||
}
|
||||
if e = m.write(j, "pulling", "正在拉取并检查目标镜像,线上配置尚未变更"); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = docker(ctx, composeArgs(p, dir, "pull", "backend", "frontend")...); e != nil {
|
||||
return fail("镜像拉取失败或超时,线上配置未变更")
|
||||
}
|
||||
targets := map[string]string{}
|
||||
for _, service := range []string{"backend", "frontend"} {
|
||||
out, err := docker(ctx, "image", "inspect", "--format", "{{.Id}}", next.Services[service].Image)
|
||||
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "sha256:") {
|
||||
return fail("目标镜像校验失败,线上配置未变更")
|
||||
}
|
||||
targets[service] = strings.TrimSpace(string(out))
|
||||
}
|
||||
if validateLive(ctx, p, old) != nil {
|
||||
return fail("拉取期间运行中后端发生变化,已安全取消")
|
||||
}
|
||||
// Detect edits since snapshots before replacing either live file.
|
||||
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||
live, err := os.ReadFile(filepath.Join(p.HostDir, name))
|
||||
if err != nil {
|
||||
return fail("读取线上配置失败")
|
||||
}
|
||||
backup, err := os.ReadFile(filepath.Join(dir, name+".original"))
|
||||
if err != nil || string(live) != string(backup) {
|
||||
return fail("线上配置已被修改,本任务已安全取消")
|
||||
}
|
||||
}
|
||||
if e = m.write(j, "applying", "正在应用已校验配置"); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err == nil {
|
||||
err = atomic(filepath.Join(p.HostDir, name), data)
|
||||
}
|
||||
if err != nil {
|
||||
for _, restore := range []string{"docker-compose.yml", ".env"} {
|
||||
original, re := os.ReadFile(filepath.Join(dir, restore+".original"))
|
||||
if re != nil || atomic(filepath.Join(p.HostDir, restore), original) != nil {
|
||||
return recovery("配置恢复失败,尚未执行容器重建")
|
||||
}
|
||||
}
|
||||
return fail("应用配置失败,已恢复原配置,未执行容器重建")
|
||||
}
|
||||
}
|
||||
// Persist the point of no automatic rollback BEFORE Docker can recreate any service.
|
||||
if e = m.write(j, "restarting", "正在重建面板服务"); e != nil {
|
||||
return recovery("无法记录重建状态,已停止执行")
|
||||
}
|
||||
if _, e = docker(ctx, composeArgs(p, p.HostDir, "up", "-d", "--no-deps", "--force-recreate", "--pull", "never", "backend", "frontend")...); e != nil {
|
||||
return recovery("容器重建失败或超时,可能已部分生效")
|
||||
}
|
||||
if e = m.write(j, "verifying", "正在验证服务版本及健康状态"); e != nil {
|
||||
return e
|
||||
}
|
||||
deadline := time.Now().Add(verificationTimeout)
|
||||
for time.Now().Before(deadline) && ctx.Err() == nil {
|
||||
ready := true
|
||||
for _, service := range []string{"backend", "frontend"} {
|
||||
c, err := inspect(ctx, next.Services[service].ContainerName)
|
||||
if err != nil || !c.State.Running || c.Image != targets[service] || c.Config.Labels["com.docker.compose.project"] != p.Project || c.Config.Labels["com.docker.compose.service"] != service {
|
||||
ready = false
|
||||
continue
|
||||
}
|
||||
if c.State.Health != nil && c.State.Health.Status != "healthy" {
|
||||
ready = false
|
||||
}
|
||||
if service == "backend" && c.State.Health == nil {
|
||||
ready = false
|
||||
}
|
||||
}
|
||||
if ready { // Frontend has no Docker healthcheck in the stock template.
|
||||
probeCtx, probeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, probeErr := docker(probeCtx, "exec", next.Services["frontend"].ContainerName, "wget", "-q", "-T", "5", "-O", "/dev/null", "http://127.0.0.1:80/")
|
||||
probeCancel()
|
||||
ready = probeErr == nil
|
||||
}
|
||||
if ready {
|
||||
return m.write(j, "succeeded", "升级完成,目标镜像和服务健康状态已验证")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return recovery("升级后服务健康检查失败或超时")
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Package panelupgrade implements durable, conservative Docker Compose upgrades.
|
||||
package panelupgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var versionPattern = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?$`)
|
||||
var idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
|
||||
var projectPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
func ValidateVersion(v string) error {
|
||||
if !versionPattern.MatchString(v) || len(v) > 128 {
|
||||
return errors.New("invalid release version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
TargetVersion string `json:"targetVersion"`
|
||||
Stage string `json:"stage"`
|
||||
Active bool `json:"active"`
|
||||
RequiresRecovery bool `json:"requiresRecovery"`
|
||||
Message string `json:"message"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
HelperContainer string `json:"helperContainer,omitempty"`
|
||||
BackupDir string `json:"backupDir,omitempty"`
|
||||
}
|
||||
type Plan struct {
|
||||
Job Job
|
||||
DeployDir, HostDir, Project, Backend, Image string
|
||||
}
|
||||
type Manager struct{ Dir, Backend string }
|
||||
|
||||
func (m Manager) root() string { return filepath.Join(m.Dir, ".panel-upgrades") }
|
||||
func (m Manager) jobDir(id string) string { return filepath.Join(m.root(), id) }
|
||||
func atomic(path string, b []byte) error {
|
||||
f, e := os.CreateTemp(filepath.Dir(path), ".write-")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
n := f.Name()
|
||||
defer os.Remove(n)
|
||||
if e = f.Chmod(0600); e == nil {
|
||||
_, e = f.Write(b)
|
||||
}
|
||||
if e == nil {
|
||||
e = f.Sync()
|
||||
}
|
||||
ce := f.Close()
|
||||
if e == nil {
|
||||
e = ce
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = os.Rename(n, path); e != nil {
|
||||
return e
|
||||
}
|
||||
d, e := os.Open(filepath.Dir(path))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
func save(path string, v any) error {
|
||||
b, e := json.Marshal(v)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return atomic(path, b)
|
||||
}
|
||||
func (m Manager) lock() (func(), error) {
|
||||
if e := os.MkdirAll(m.root(), 0700); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
f, e := os.OpenFile(filepath.Join(m.root(), "lock"), os.O_CREATE|os.O_RDWR, 0600)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if e = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
|
||||
f.Close()
|
||||
return nil, errors.New("another upgrade operation is in progress")
|
||||
}
|
||||
return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil
|
||||
}
|
||||
func docker(ctx context.Context, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
b, e := cmd.Output()
|
||||
if e != nil {
|
||||
return nil, errors.New("Docker operation failed (details suppressed to protect credentials)")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
type container struct {
|
||||
Image string
|
||||
Config struct {
|
||||
Labels map[string]string
|
||||
Env []string
|
||||
}
|
||||
State struct {
|
||||
Running bool
|
||||
Health *struct{ Status string }
|
||||
}
|
||||
Mounts []struct {
|
||||
Type, Source, Destination, Name string
|
||||
RW bool
|
||||
}
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, name string) (container, error) {
|
||||
var c []container
|
||||
b, e := docker(ctx, "inspect", name)
|
||||
if e != nil {
|
||||
return container{}, e
|
||||
}
|
||||
if json.Unmarshal(b, &c) != nil || len(c) != 1 {
|
||||
return container{}, errors.New("invalid Docker inspection")
|
||||
}
|
||||
return c[0], nil
|
||||
}
|
||||
func (m Manager) Identity(ctx context.Context) (Plan, error) {
|
||||
p := Plan{DeployDir: m.Dir, Backend: m.Backend}
|
||||
if !filepath.IsAbs(m.Dir) || filepath.Clean(m.Dir) != m.Dir {
|
||||
return p, errors.New("deployment directory must be an absolute clean path")
|
||||
}
|
||||
c, e := inspect(ctx, m.Backend)
|
||||
if e != nil {
|
||||
return p, e
|
||||
}
|
||||
p.Image = c.Image
|
||||
p.Project = c.Config.Labels["com.docker.compose.project"]
|
||||
if !c.State.Running || !projectPattern.MatchString(p.Project) || c.Config.Labels["com.docker.compose.service"] != "backend" || !strings.HasPrefix(p.Image, "sha256:") {
|
||||
return p, errors.New("cannot establish original Compose identity")
|
||||
}
|
||||
for _, v := range c.Mounts {
|
||||
if v.Destination == m.Dir && v.Type == "bind" && v.RW {
|
||||
p.HostDir = v.Source
|
||||
}
|
||||
}
|
||||
if p.HostDir == "/" || !filepath.IsAbs(p.HostDir) || strings.ContainsAny(p.HostDir, ",\n\r") || filepath.Clean(p.HostDir) != p.HostDir {
|
||||
return p, errors.New("deployment directory must be a writable host bind mount")
|
||||
}
|
||||
|
||||
for _, protected := range []string{"/app/paneld", "/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins/docker-compose", "/var/run/docker.sock", "/bin", "/lib", "/etc", "/proc", "/sys", "/dev"} {
|
||||
if p.HostDir == protected || strings.HasPrefix(protected, p.HostDir+"/") || strings.HasPrefix(p.HostDir, protected+"/") {
|
||||
return p, errors.New("host deployment mount would shadow helper runtime paths")
|
||||
}
|
||||
}
|
||||
if c.Config.Labels["com.docker.compose.project.working_dir"] != p.HostDir || c.Config.Labels["com.docker.compose.project.config_files"] != filepath.Join(p.HostDir, "docker-compose.yml") {
|
||||
return p, errors.New("custom or multiple Compose paths require manual upgrade")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func (m Manager) read(id string) (*Job, error) {
|
||||
if !idPattern.MatchString(id) {
|
||||
return nil, errors.New("invalid job ID")
|
||||
}
|
||||
b, e := os.ReadFile(filepath.Join(m.jobDir(id), "status.json"))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var j Job
|
||||
e = json.Unmarshal(b, &j)
|
||||
return &j, e
|
||||
}
|
||||
func (m Manager) Status(ctx context.Context, id string) (*Job, error) {
|
||||
if id == "" {
|
||||
b, e := os.ReadFile(filepath.Join(m.root(), "latest"))
|
||||
if os.IsNotExist(e) {
|
||||
return nil, nil
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
id = string(b)
|
||||
}
|
||||
j, e := m.read(id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !j.Active || time.Now().UnixMilli()-j.UpdatedAt < 90000 {
|
||||
return j, nil
|
||||
}
|
||||
// An unavailable daemon, missing helper, or uncertain launch must never release the task lock.
|
||||
c, e := inspect(ctx, j.HelperContainer)
|
||||
if e == nil && c.State.Running {
|
||||
return j, nil
|
||||
}
|
||||
j.RequiresRecovery = true
|
||||
j.Message = "升级 helper 已停止或无法确认状态。请管理员检查任务备份和容器状态;在确认安全前禁止再次升级。"
|
||||
// This is a derived view: do not race the helper's durable state writes.
|
||||
j.Stage = "recovery_required"
|
||||
return j, nil
|
||||
}
|
||||
func (m Manager) write(j *Job, stage, msg string) error {
|
||||
j.Stage = stage
|
||||
j.Message = msg
|
||||
j.UpdatedAt = time.Now().UnixMilli()
|
||||
j.Active = stage != "succeeded" && stage != "failed"
|
||||
j.RequiresRecovery = stage == "recovery_required"
|
||||
if e := save(filepath.Join(m.jobDir(j.ID), "status.json"), j); e != nil {
|
||||
return e
|
||||
}
|
||||
f, e := os.OpenFile(filepath.Join(m.jobDir(j.ID), "events.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer f.Close()
|
||||
_, e = fmt.Fprintf(f, "%d %s %s\n", j.UpdatedAt, stage, msg)
|
||||
return e
|
||||
}
|
||||
func (m Manager) Start(ctx context.Context, version string, compose []byte) (*Job, string, error) {
|
||||
if e := ValidateVersion(version); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
unlock, e := m.lock()
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
defer unlock()
|
||||
old, e := m.Status(ctx, "")
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
if old != nil && (old.Active || old.RequiresRecovery) {
|
||||
return old, "", errors.New("已有面板升级任务执行中或需要人工恢复")
|
||||
}
|
||||
p, e := m.Identity(ctx)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
if _, e = rand.Read(b); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
id := hex.EncodeToString(b)
|
||||
dir := m.jobDir(id)
|
||||
if e = os.Mkdir(dir, 0700); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
j := Job{ID: id, TargetVersion: version, StartedAt: time.Now().UnixMilli(), HelperContainer: "flvx-upgrade-" + id, BackupDir: filepath.Join(p.HostDir, ".panel-upgrades", id)}
|
||||
p.Job = j
|
||||
if e = m.write(&j, "preparing", "正在准备独立升级任务"); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
if e = atomic(filepath.Join(m.root(), "latest"), []byte(id)); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
fail := func(err error) (*Job, string, error) {
|
||||
m.write(&j, "failed", "准备失败,线上配置未变更")
|
||||
return &j, p.Image, err
|
||||
}
|
||||
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||
data, err := os.ReadFile(filepath.Join(m.Dir, name))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = atomic(filepath.Join(dir, name+".original"), data); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
env, e := os.ReadFile(filepath.Join(dir, ".env.original"))
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
lines := strings.Split(string(env), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "FLUX_VERSION=") || strings.HasPrefix(strings.TrimSpace(line), "export FLUX_VERSION=") {
|
||||
lines[i] = ""
|
||||
}
|
||||
}
|
||||
env = []byte(strings.Join(lines, "\n") + "\nFLUX_VERSION=" + version + "\n")
|
||||
if e = atomic(filepath.Join(dir, ".env"), env); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
if e = atomic(filepath.Join(dir, "docker-compose.yml"), compose); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
p.Job = j
|
||||
if e = save(filepath.Join(dir, "plan.json"), p); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
// No --rm: an exited helper is evidence needed after a daemon/client timeout.
|
||||
args := []string{"run", "-d", "--name", j.HelperContainer, "--label", "io.flvx.upgrade-job=" + id, "--mount", "type=bind,src=" + p.HostDir + ",dst=" + p.HostDir, "--mount", "type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock", "--entrypoint", "/app/paneld", p.Image, "panel-upgrade-helper", filepath.Join(p.HostDir, ".panel-upgrades", id, "plan.json")}
|
||||
if _, e = docker(ctx, args...); e != nil {
|
||||
c, ie := inspect(ctx, j.HelperContainer)
|
||||
if ie != nil || !c.State.Running {
|
||||
return &j, p.Image, errors.New("helper 启动结果不确定;任务已保留,请查询升级状态,勿重复启动")
|
||||
}
|
||||
}
|
||||
return &j, p.Image, nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package panelupgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeDocker(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
d := t.TempDir()
|
||||
if e := os.WriteFile(filepath.Join(d, "docker"), []byte("#!/bin/sh\n"+body), 0700); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Setenv("PATH", d+":"+os.Getenv("PATH"))
|
||||
}
|
||||
func fixture(t *testing.T) (Manager, Plan) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
m := Manager{Dir: dir, Backend: "flux-panel-backend"}
|
||||
id := strings.Repeat("a", 32)
|
||||
os.MkdirAll(m.jobDir(id), 0700)
|
||||
j := Job{ID: id, TargetVersion: "2.5.0", HelperContainer: "helper", StartedAt: time.Now().UnixMilli()}
|
||||
m.write(&j, "preparing", "ready")
|
||||
atomic(filepath.Join(m.root(), "latest"), []byte(id))
|
||||
p := Plan{Job: j, HostDir: dir, DeployDir: "/opt/flvx-panel", Project: "original-project", Backend: m.Backend, Image: "sha256:old"}
|
||||
save(filepath.Join(m.jobDir(id), "plan.json"), p)
|
||||
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||
atomic(filepath.Join(dir, n), []byte("original"))
|
||||
atomic(filepath.Join(m.jobDir(id), n+".original"), []byte("original"))
|
||||
atomic(filepath.Join(m.jobDir(id), n), []byte("target"))
|
||||
}
|
||||
return m, p
|
||||
}
|
||||
func TestVersionRejectsComposeAndPathInjection(t *testing.T) {
|
||||
for _, v := range []string{"latest", "../v1", "1.2.3\nSECRET=x", "1.2.3${TOKEN}", "1.2.3 x", "1.2.3;id"} {
|
||||
if ValidateVersion(v) == nil {
|
||||
t.Fatal(v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"2.5.0-rc10", "v2.5.0", "2.1.9-beta6"} {
|
||||
if ValidateVersion(v) != nil {
|
||||
t.Fatal(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPersistentLockAcrossManagers(t *testing.T) {
|
||||
m, p := fixture(t)
|
||||
fakeDocker(t, "exit 1\n")
|
||||
_, _, e := m.Start(context.Background(), "2.5.0", nil)
|
||||
if e == nil {
|
||||
t.Fatal("accepted concurrent job")
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(m.root(), "latest"))
|
||||
if string(b) != p.Job.ID {
|
||||
t.Fatal("changed active job")
|
||||
}
|
||||
}
|
||||
func TestUncertainHelperNeverUnlocks(t *testing.T) {
|
||||
m, p := fixture(t)
|
||||
j, _ := m.read(p.Job.ID)
|
||||
j.UpdatedAt = 1
|
||||
save(filepath.Join(m.jobDir(j.ID), "status.json"), j)
|
||||
fakeDocker(t, "exit 1\n")
|
||||
j, e := m.Status(context.Background(), "")
|
||||
if e != nil || !j.Active || !j.RequiresRecovery || j.Stage != "recovery_required" {
|
||||
t.Fatalf("%+v %v", j, e)
|
||||
}
|
||||
if _, _, e = m.Start(context.Background(), "2.5.0", nil); e == nil {
|
||||
t.Fatal("uncertain launch unlocked")
|
||||
}
|
||||
}
|
||||
func configs(p Plan) (string, string) {
|
||||
old := composeConfig{}
|
||||
json.Unmarshal([]byte(`{"services":{"backend":{"image":"old","container_name":"flux-panel-backend","environment":{"FLUX_VERSION":"old"}},"frontend":{"image":"old","container_name":"vite-frontend"}}}`), &old)
|
||||
a, _ := json.Marshal(old)
|
||||
next := old
|
||||
next.Services = make(map[string]struct {
|
||||
Image string `json:"image"`
|
||||
ContainerName string `json:"container_name"`
|
||||
Volumes []map[string]any `json:"volumes"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
})
|
||||
for k, v := range old.Services {
|
||||
next.Services[k] = v
|
||||
}
|
||||
v := next.Services["backend"]
|
||||
v.Image = "ghcr.io/sagit-chu/flux-panel-backend:" + p.Job.TargetVersion
|
||||
v.Environment = map[string]string{"FLUX_VERSION": p.Job.TargetVersion}
|
||||
next.Services["backend"] = v
|
||||
v = next.Services["frontend"]
|
||||
v.Image = "ghcr.io/sagit-chu/vite-frontend:" + p.Job.TargetVersion
|
||||
next.Services["frontend"] = v
|
||||
b, _ := json.Marshal(next)
|
||||
return string(a), string(b)
|
||||
}
|
||||
func TestHelperFailureBoundaries(t *testing.T) {
|
||||
for _, stage := range []string{"config", "pull", "apply", "up", "health", "success"} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
m, p := fixture(t)
|
||||
old, next := configs(p)
|
||||
dir := m.jobDir(p.Job.ID)
|
||||
if stage == "apply" {
|
||||
os.Remove(filepath.Join(dir, ".env"))
|
||||
os.Mkdir(filepath.Join(dir, ".env"), 0700)
|
||||
}
|
||||
if stage == "health" {
|
||||
before := verificationTimeout
|
||||
verificationTimeout = 0
|
||||
t.Cleanup(func() { verificationTimeout = before })
|
||||
}
|
||||
script := `case "$*" in
|
||||
*"config --format json"*)
|
||||
case "$*" in *"` + p.Job.ID + `"*) echo '` + next + `';; *) echo '` + old + `';; esac
|
||||
;;
|
||||
*"pull backend frontend"*) `
|
||||
if stage == "pull" {
|
||||
script += "echo SECRET >&2; exit 1"
|
||||
} else {
|
||||
script += "exit 0"
|
||||
}
|
||||
script += `;;
|
||||
"image inspect"*) echo sha256:target;;
|
||||
*"up -d"*) `
|
||||
if stage == "up" {
|
||||
script += "exit 1"
|
||||
} else {
|
||||
script += "touch \"" + filepath.Join(dir, "recreated") + "\"; exit 0"
|
||||
}
|
||||
script += `;;
|
||||
"inspect flux-panel-backend") if [ -f "` + filepath.Join(dir, "recreated") + `" ]; then IMG=sha256:target; else IMG=sha256:old; fi; echo '[{"Image":"'"$IMG"'","State":{"Running":true,"Health":{"Status":"healthy"}},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}}}]';;
|
||||
"inspect vite-frontend") echo '[{"Image":"sha256:target","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"frontend"}}}]';;
|
||||
"exec vite-frontend wget"*) exit 0;;
|
||||
*) exit 1;; esac
|
||||
`
|
||||
if stage == "config" {
|
||||
script = "echo SECRET >&2;exit 1\n"
|
||||
}
|
||||
fakeDocker(t, script)
|
||||
e := RunHelper(filepath.Join(dir, "plan.json"))
|
||||
j, _ := m.read(p.Job.ID)
|
||||
want := "failed"
|
||||
if stage == "up" || stage == "health" {
|
||||
want = "recovery_required"
|
||||
}
|
||||
if stage == "success" {
|
||||
want = "succeeded"
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
if j.Stage != want {
|
||||
t.Fatalf("stage=%s error=%v", j.Stage, e)
|
||||
}
|
||||
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||
b, _ := os.ReadFile(filepath.Join(m.Dir, n))
|
||||
expected := "original"
|
||||
if stage == "up" || stage == "success" || stage == "health" {
|
||||
expected = "target"
|
||||
}
|
||||
if string(b) != expected {
|
||||
t.Fatalf("%s mutated unexpectedly", n)
|
||||
}
|
||||
b, _ = os.ReadFile(filepath.Join(dir, n+".original"))
|
||||
if string(b) != "original" {
|
||||
t.Fatal("backup overwritten")
|
||||
}
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "events.log"))
|
||||
if strings.Contains(string(b), "SECRET") {
|
||||
t.Fatal("secret leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestComposeUsesOriginalHostPathAndProject(t *testing.T) {
|
||||
p := Plan{Project: "my-project", HostDir: "/srv/panel"}
|
||||
s := strings.Join(composeArgs(p, "/srv/panel/.panel-upgrades/job", "pull", "backend", "frontend"), " ")
|
||||
if !strings.Contains(s, "--project-name my-project --project-directory /srv/panel") || strings.Contains(s, "remove-orphans") {
|
||||
t.Fatal(s)
|
||||
}
|
||||
}
|
||||
func TestStatusWithoutJobNeedsNoDocker(t *testing.T) {
|
||||
fakeDocker(t, "exit 99\n")
|
||||
j, e := (Manager{Dir: t.TempDir()}).Status(context.Background(), "")
|
||||
if e != nil || j != nil {
|
||||
t.Fatalf("%v %v", j, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullConfigRejectsUnintendedChanges(t *testing.T) {
|
||||
for _, field := range []string{"ports", "network_mode", "command", "entrypoint", "privileged", "networks"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
_, p := fixture(t)
|
||||
oldJSON, nextJSON := configs(p)
|
||||
var old, next composeConfig
|
||||
json.Unmarshal([]byte(oldJSON), &old)
|
||||
json.Unmarshal([]byte(nextJSON), &next)
|
||||
var a, b struct {
|
||||
Services map[string]map[string]any `json:"services"`
|
||||
}
|
||||
json.Unmarshal([]byte(oldJSON), &a)
|
||||
json.Unmarshal([]byte(nextJSON), &b)
|
||||
old.FullServices = a.Services
|
||||
next.FullServices = b.Services
|
||||
old.FullServices["frontend"][field] = "private-custom-setting"
|
||||
if validateConfig(old, next, p) == nil {
|
||||
t.Fatal("accepted service setting removal: " + field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestLiveRejectsShadowMount(t *testing.T) {
|
||||
_, p := fixture(t)
|
||||
fakeDocker(t, `echo '[{"Image":"sha256:old","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}},"Mounts":[{"Type":"bind","Source":"/srv/db/gost.db","Destination":"/app/data/gost.db","RW":true}]}]'`)
|
||||
oldJSON, _ := configs(p)
|
||||
var old composeConfig
|
||||
json.Unmarshal([]byte(oldJSON), &old)
|
||||
if validateLive(context.Background(), p, old) == nil {
|
||||
t.Fatal("accepted undeclared shadow mount")
|
||||
}
|
||||
}
|
||||
func identityJSON(dir, host, project, files string) string {
|
||||
c := map[string]any{"Image": "sha256:old", "State": map[string]any{"Running": true}, "Config": map[string]any{"Labels": map[string]string{"com.docker.compose.project": project, "com.docker.compose.service": "backend", "com.docker.compose.project.working_dir": host, "com.docker.compose.project.config_files": files}}, "Mounts": []map[string]any{{"Type": "bind", "Source": host, "Destination": dir, "RW": true}}}
|
||||
b, _ := json.Marshal([]any{c})
|
||||
return string(b)
|
||||
}
|
||||
func TestStartUsesHostMountAndKeepsUniqueBackups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := Manager{Dir: dir, Backend: "backend"}
|
||||
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
|
||||
os.WriteFile(filepath.Join(dir, n), []byte("original-secret"), 0600)
|
||||
}
|
||||
args := filepath.Join(t.TempDir(), "args")
|
||||
fakeDocker(t, `case "$1" in inspect) echo '`+identityJSON(dir, "/srv/original-panel", "original-project", "/srv/original-panel/docker-compose.yml")+`';; run) printf '%s\n' "$@" > '`+args+`'; echo container-id;; *) exit 1;; esac`)
|
||||
j, _, e := m.Start(context.Background(), "2.5.0-rc10", []byte("target"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b, _ := os.ReadFile(args)
|
||||
s := string(b)
|
||||
for _, expected := range []string{"type=bind,src=/srv/original-panel,dst=/srv/original-panel", "/app/paneld", "sha256:old", "panel-upgrade-helper"} {
|
||||
if !strings.Contains(s, expected) {
|
||||
t.Fatal(s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "--rm") || strings.Contains(s, "--volumes-from") {
|
||||
t.Fatal(s)
|
||||
}
|
||||
first := j.ID
|
||||
m.write(j, "failed", "safe failure")
|
||||
j, _, e = m.Start(context.Background(), "2.5.1", []byte("target2"))
|
||||
if e != nil || j.ID == first {
|
||||
t.Fatalf("%v %v", j, e)
|
||||
}
|
||||
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
|
||||
b, _ := os.ReadFile(filepath.Join(dir, n))
|
||||
if string(b) != "original-secret" {
|
||||
t.Fatal("live or legacy overwritten")
|
||||
}
|
||||
}
|
||||
b, _ = os.ReadFile(filepath.Join(m.jobDir(first), ".env.original"))
|
||||
if string(b) != "original-secret" {
|
||||
t.Fatal("old job backup changed")
|
||||
}
|
||||
}
|
||||
func TestIdentityFailsClosed(t *testing.T) {
|
||||
for _, host := range []string{"/", "/app", "/usr", "/var/run", "/proc", "relative", "/srv/bad,host"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fakeDocker(t, "echo '"+identityJSON(dir, host, "project", filepath.Join(host, "docker-compose.yml"))+"'\n")
|
||||
if _, e := (Manager{Dir: dir, Backend: "backend"}).Identity(context.Background()); e == nil {
|
||||
t.Fatal(host)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestUncertainRunIsNotRetriedOrUnlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := Manager{Dir: dir, Backend: "backend"}
|
||||
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||
os.WriteFile(filepath.Join(dir, n), []byte("original"), 0600)
|
||||
}
|
||||
calls := filepath.Join(t.TempDir(), "runs")
|
||||
fakeDocker(t, `case "$*" in "inspect backend") echo '`+identityJSON(dir, "/srv/panel", "project", "/srv/panel/docker-compose.yml")+`';; run*) echo run >> '`+calls+`'; exit 1;; *) exit 1;; esac`)
|
||||
j, _, e := m.Start(context.Background(), "2.5.0", []byte("target"))
|
||||
if e == nil || j == nil || !j.Active {
|
||||
t.Fatalf("%v %v", j, e)
|
||||
}
|
||||
_, _, e = m.Start(context.Background(), "2.5.0", nil)
|
||||
if e == nil {
|
||||
t.Fatal("retry accepted")
|
||||
}
|
||||
b, _ := os.ReadFile(calls)
|
||||
if string(b) != "run\n" {
|
||||
t.Fatal("retried uncertain docker run")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestSystemUpgradeStatusRequiresAdminAndReturnsLocalState(t *testing.T) {
|
||||
secret := "upgrade-status-contract-secret"
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PANEL_DEPLOY_DIR", dir)
|
||||
t.Setenv("FLUX_VERSION", "3.0.1-rc10")
|
||||
// A status read with no job must not require Docker or contact GitHub.
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
router, r := setupContractRouter(t, secret)
|
||||
seedContractUser(t, r, 2, "normal_user", 1, 1)
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, token string
|
||||
want int
|
||||
}{{"anonymous", "", 401}, {"normal user", userToken, 403}, {"admin", adminToken, 0}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", tc.token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != tc.want {
|
||||
t.Fatalf("expected code %d, got %d: %s", tc.want, out.Code, out.Msg)
|
||||
}
|
||||
if tc.want == 0 {
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok || data["currentVersion"] != "3.0.1-rc10" || data["job"] != nil {
|
||||
t.Fatalf("unexpected idle status: %#v", out.Data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("idle status should not mutate deployment: entries=%v err=%v", entries, err)
|
||||
}
|
||||
for _, body := range []string{`{"jobId":"../../.env"}`, `{"jobId":`, `{"jobId":123}`} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code == 0 || out.Data != nil || strings.Contains(out.Msg, dir) || strings.Contains(out.Msg, ".env") {
|
||||
t.Fatalf("invalid request must fail without leaking filesystem detail: %#v", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Panel self-upgrade reliability plan
|
||||
|
||||
## Scope
|
||||
|
||||
Fix the panel's Docker Compose self-upgrade flow. The report is from a third-party deployment; this work uses repository analysis and isolated tests, not access to that deployment. Base: `a784350` (`3.0.1-rc10`). No publishing or deployment is included.
|
||||
|
||||
## Confirmed problems
|
||||
|
||||
- The UI stops at helper launch. It cannot distinguish a running, successful, or failed upgrade.
|
||||
- The helper resolves the Compose project and relative bind mounts from `/opt/flvx-panel`, which need not match the original host deployment.
|
||||
- The HTTP mutex ends before the helper. Repeated requests can change the same configuration and overwrite logs and fixed backups.
|
||||
- Active configuration is replaced before images are pulled; helper failures do not restore it.
|
||||
- Successful `compose up` is not an explicit verification of both target images and frontend availability.
|
||||
|
||||
These are code defects and failure risks, not proof of the reported deployment's particular failure.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Identify and validate the existing Compose project, host deployment mount, backend service, and configuration paths before a task can mutate deployment files. Fail safely on unsupported or ambiguous deployment layouts.
|
||||
2. Add a durable task with a unique ID, per-task log and immutable backups. Keep the task lock across the HTTP request, helper execution, and backend replacement. Reconcile stopped helpers conservatively; uncertainty must not permit overlapping upgrades.
|
||||
3. Run a dedicated helper command in the current backend image. Validate a staged Compose configuration and pull images before replacing active files. Use atomic file replacement. Restore configuration only when failure is known to precede service replacement. A partial or uncertain service replacement requires explicit recovery rather than an unsafe automatic database/container rollback.
|
||||
4. Record progress and terminal results locally, with a bounded execution deadline. Verify target services before success. Expose only safe progress messages through an admin status endpoint, never `.env` or arbitrary command output.
|
||||
5. Add frontend progress, reconnect handling, stale-response protection, reload recovery, and repeat-click prevention. A launch acknowledgment is not completion.
|
||||
6. Run focused fault-injection tests, the full backend checks, frontend lint/type/build checks, and UI lifecycle exercises. Document real Docker and PostgreSQL tests that cannot run locally.
|
||||
|
||||
## API contract
|
||||
|
||||
- Existing `POST /api/v1/system/upgrade`: preserve existing response fields and add `jobId`.
|
||||
- New `POST /api/v1/system/upgrade/status`: accept optional `jobId`; return `currentVersion` and nullable `job`.
|
||||
- A job includes `id`, `targetVersion`, `stage`, `active`, `requiresRecovery`, `message`, `startedAt`, `updatedAt`, and optional helper/backup references.
|
||||
- Stages: `preparing`, `pulling`, `applying`, `restarting`, `verifying`, `succeeded`, `failed`, `recovery_required`.
|
||||
- Status reads do not call GitHub. Timestamps use Unix milliseconds. A recovery-required task must not silently permit another upgrade.
|
||||
|
||||
## Required regression coverage
|
||||
|
||||
- Original project names and host paths that differ from the in-container path; invalid/missing labels and mounts; IPv4/IPv6 templates.
|
||||
- Validation and pull failure without changing active config; application failure with safe restoration; partial recreation and verification failures with preserved recovery evidence.
|
||||
- Duplicate requests, interrupted starts, stopped helpers, backend restart, task timeout, and preservation of older backups and logs.
|
||||
- Frontend reload, transient disconnect, success, failure, recovery required, repeated clicks, unmount, and stale responses.
|
||||
- No sensitive environment contents in status responses or browser-visible error messages.
|
||||
|
||||
## Rollout boundary
|
||||
|
||||
The launcher and helper are supplied by the version currently running, not by the target Compose download. Existing rc10 installations do not acquire this fix until the corrected backend image is actually installed. The first transition to the fixed release should use the established host-side upgrade process with the original deployment directory and Compose project. Do not recommend repeated retries of the old web updater or a blind rollback after partial service replacement.
|
||||
|
||||
## Verification record
|
||||
|
||||
Implementation completed in `fix/panel-upgrade-jobs`, based on `a784350`.
|
||||
|
||||
- Backend: full `go test ./...`, `go test -race ./internal/panelupgrade`, `go vet ./...`, native build, and CGO-disabled Linux amd64/arm64 builds passed with Go 1.26.10.
|
||||
- Added an admin/non-admin/anonymous status contract, idle read with no Docker dependency, malformed job-ID rejection, and focused upgrade tests in CI.
|
||||
- Fake-Docker tests cover configuration/pull/recreate/verification failures, partial configuration restoration, immutable task backups, uncertain launch, cross-manager blocking, original project/path, full service-setting preservation, unexpected database mounts, and safe public messages.
|
||||
- Frontend: changed-file ESLint (zero warnings), TypeScript and production build passed with Node 24.22.1. Nine temporary lifecycle replays passed, including two-admin refresh, repeated submissions, unknown transport outcomes, stale replies, unmount, and bounded polling.
|
||||
- Mocked rendered checks passed at 1280×900 and 390×844: confirmation, pulling, an injected 503 disconnect, reconnect, verified completion, reload discovery, and recovery blocking. The pre-existing Switch `classNames` React warning remains; the deliberate 503 was expected. No new framework error overlay or mobile horizontal overflow was found.
|
||||
- Independent review identified and closed full-service configuration preservation, shadow database mounts, and terminal refresh discovery issues. `git diff --check` passed.
|
||||
|
||||
Limitations: no real Docker daemon upgrade was run; local daemon access was unavailable to this task. Fake Docker commands do not prove real Compose/Engine behavior. The PostgreSQL-specific contract was explicitly skipped because `FLVX_POSTGRES_TEST_DSN` was not set. No third-party deployment, SSH, publishing, release, or production restart was performed during implementation.
|
||||
|
||||
The conservative operator recovery procedure is in `go-backend/internal/panelupgrade/README.md`. Full service-setting changes, unsupported deployment layouts, or a possible partial recreation require administrator handling instead of silently changing live settings or automatically rolling back a database. Per-task logs contain safe phase summaries, not raw secret-bearing Docker output.
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
StorageSummaryApiData,
|
||||
SystemUpgradeCheckApiData,
|
||||
SystemUpgradeRunApiData,
|
||||
SystemUpgradeStatusApiData,
|
||||
SystemUpgradeVersionApiData,
|
||||
} from "./types";
|
||||
|
||||
@@ -317,6 +318,13 @@ export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") =>
|
||||
channel,
|
||||
});
|
||||
|
||||
export const getSystemUpgradeStatus = (jobId?: string) =>
|
||||
Network.post<SystemUpgradeStatusApiData>(
|
||||
"/system/upgrade/status",
|
||||
jobId ? { jobId } : {},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
export const runSystemUpgrade = (
|
||||
version?: string,
|
||||
channel: ReleaseChannel = "stable",
|
||||
|
||||
@@ -46,6 +46,7 @@ interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
transportError?: boolean;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
@@ -82,7 +83,12 @@ const Network = {
|
||||
return new Promise(function (resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === "") {
|
||||
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: " - 请先设置面板地址",
|
||||
data: null as T,
|
||||
transportError: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -121,6 +127,7 @@ const Network = {
|
||||
|
||||
resolve({
|
||||
code: -1,
|
||||
transportError: true,
|
||||
msg: errorMessage,
|
||||
data: null as T,
|
||||
});
|
||||
@@ -136,7 +143,12 @@ const Network = {
|
||||
return new Promise(function (resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === "") {
|
||||
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: " - 请先设置面板地址",
|
||||
data: null as T,
|
||||
transportError: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -175,6 +187,7 @@ const Network = {
|
||||
|
||||
resolve({
|
||||
code: -1,
|
||||
transportError: true,
|
||||
msg: errorMessage,
|
||||
data: null as T,
|
||||
});
|
||||
|
||||
@@ -550,7 +550,36 @@ export interface SystemUpgradeCheckApiData extends SystemUpgradeVersionApiData {
|
||||
releases: SystemUpgradeReleaseApiItem[];
|
||||
}
|
||||
|
||||
export type SystemUpgradeStage =
|
||||
| "preparing"
|
||||
| "pulling"
|
||||
| "applying"
|
||||
| "restarting"
|
||||
| "verifying"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "recovery_required";
|
||||
|
||||
export interface SystemUpgradeJob {
|
||||
id: string;
|
||||
targetVersion: string;
|
||||
stage: SystemUpgradeStage;
|
||||
active: boolean;
|
||||
requiresRecovery: boolean;
|
||||
message: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
helperContainer?: string;
|
||||
backupDir?: string;
|
||||
}
|
||||
|
||||
export interface SystemUpgradeStatusApiData {
|
||||
currentVersion: string;
|
||||
job: SystemUpgradeJob | null;
|
||||
}
|
||||
|
||||
export interface SystemUpgradeRunApiData {
|
||||
jobId: string;
|
||||
version: string;
|
||||
channel: "stable" | "dev";
|
||||
composeAsset: string;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getSystemUpgradeStatus, runSystemUpgrade } from "@/api";
|
||||
import {
|
||||
createUpgradeMonitor,
|
||||
initialUpgradeState,
|
||||
upgradeIsBlocked,
|
||||
} from "@/utils/system-upgrade-monitor";
|
||||
|
||||
export function useSystemUpgrade(enabled: boolean) {
|
||||
const [state, setState] = useState(initialUpgradeState);
|
||||
const monitor = useRef<ReturnType<typeof createUpgradeMonitor> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const instance = createUpgradeMonitor(
|
||||
{ status: getSystemUpgradeStatus, run: runSystemUpgrade },
|
||||
setState,
|
||||
);
|
||||
|
||||
monitor.current = instance;
|
||||
instance.refresh();
|
||||
|
||||
return () => {
|
||||
instance.dispose();
|
||||
monitor.current = null;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
blocked: upgradeIsBlocked(state),
|
||||
refresh: () => monitor.current?.refresh(),
|
||||
submit: (version?: string, channel?: "stable" | "dev") =>
|
||||
monitor.current?.submit(version, channel) ?? Promise.resolve(false),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
SystemUpgradeCheckApiData,
|
||||
SystemUpgradeRunApiData,
|
||||
SystemUpgradeReleaseApiItem,
|
||||
SystemUpgradeVersionApiData,
|
||||
} from "@/api/types";
|
||||
@@ -10,6 +9,8 @@ import { useNavigate } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useSystemUpgrade } from "@/hooks/use-system-upgrade";
|
||||
import { upgradeMessage } from "@/utils/system-upgrade-monitor";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -36,7 +37,6 @@ import {
|
||||
getStorageSummary,
|
||||
getSystemUpgradeVersion,
|
||||
checkSystemUpgrade,
|
||||
runSystemUpgrade,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { BackIcon, SettingsIcon } from "@/components/icons";
|
||||
@@ -336,7 +336,9 @@ export default function ConfigPage() {
|
||||
const [systemUpgradeInfo, setSystemUpgradeInfo] =
|
||||
useState<SystemUpgradeVersionApiData | null>(null);
|
||||
const [systemUpgradeChecking, setSystemUpgradeChecking] = useState(false);
|
||||
const [systemUpgradeExecuting, setSystemUpgradeExecuting] = useState(false);
|
||||
const upgrade = useSystemUpgrade(isAdmin());
|
||||
const systemUpgradeExecuting =
|
||||
upgrade.submitting || Boolean(upgrade.job?.active);
|
||||
const [systemUpgradeLoading, setSystemUpgradeLoading] = useState(true);
|
||||
const [systemUpgradeModalOpen, setSystemUpgradeModalOpen] = useState(false);
|
||||
const [systemUpgradeReleases, setSystemUpgradeReleases] = useState<
|
||||
@@ -363,17 +365,38 @@ export default function ConfigPage() {
|
||||
const canTriggerSystemUpgrade = Boolean(
|
||||
!systemUpgradeLoading &&
|
||||
!systemUpgradeChecking &&
|
||||
!systemUpgradeExecuting &&
|
||||
systemUpgradeInfo?.capability.capable !== false,
|
||||
!upgrade.blocked &&
|
||||
systemUpgradeInfo?.capability.capable === true,
|
||||
);
|
||||
const canOpenSystemUpgradeModal = Boolean(
|
||||
systemUpgradeInfo?.capability.capable &&
|
||||
systemUpgradeHasConfirmedUpdate &&
|
||||
!systemUpgradeLoading &&
|
||||
!systemUpgradeChecking &&
|
||||
!systemUpgradeExecuting,
|
||||
!upgrade.blocked,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (upgrade.job?.stage !== "succeeded" || upgrade.uncertain) return;
|
||||
setSystemUpgradeReleases([]);
|
||||
setSystemUpgradeCheckedChannel(null);
|
||||
setSystemUpgradeSelectedVersion("");
|
||||
setSystemUpgradeInfo((previous) =>
|
||||
previous
|
||||
? {
|
||||
...previous,
|
||||
currentVersion: upgrade.currentVersion,
|
||||
hasUpdate: false,
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
}, [
|
||||
upgrade.job?.id,
|
||||
upgrade.job?.stage,
|
||||
upgrade.currentVersion,
|
||||
upgrade.uncertain,
|
||||
]);
|
||||
|
||||
const canGoBack =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.history.state?.idx === "number" &&
|
||||
@@ -572,6 +595,7 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleOpenSystemUpgradeModal = async () => {
|
||||
if (upgrade.blocked) return;
|
||||
if (!canOpenSystemUpgradeModal) {
|
||||
const checked = await handleCheckSystemUpgrade();
|
||||
|
||||
@@ -583,26 +607,15 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleConfirmSystemUpgrade = async () => {
|
||||
setSystemUpgradeExecuting(true);
|
||||
try {
|
||||
const response = await runSystemUpgrade(
|
||||
if (!canTriggerSystemUpgrade) return;
|
||||
const submitted = await upgrade.submit(
|
||||
systemUpgradeSelectedVersion || undefined,
|
||||
updateChannel,
|
||||
);
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
const data = response.data as SystemUpgradeRunApiData;
|
||||
|
||||
if (submitted) {
|
||||
setSystemUpgradeModalOpen(false);
|
||||
setSystemUpgradeSelectedVersion("");
|
||||
toast.success(data.message || "升级已触发,请稍后刷新页面");
|
||||
} else {
|
||||
toast.error(response.msg || "面板升级失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("面板升级失败,请重试");
|
||||
} finally {
|
||||
setSystemUpgradeExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1647,6 +1660,87 @@ export default function ConfigPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="space-y-2 rounded-lg border border-divider bg-background p-4 text-sm"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{upgrade.submitting
|
||||
? "正在提交升级任务..."
|
||||
: upgrade.job
|
||||
? {
|
||||
preparing: "正在准备升级",
|
||||
pulling: "正在拉取镜像",
|
||||
applying: "正在应用升级",
|
||||
restarting: "正在重启服务",
|
||||
verifying: "正在验证运行版本",
|
||||
succeeded: "升级完成,运行版本已验证",
|
||||
failed: "升级失败",
|
||||
recovery_required: "升级中断,需要人工恢复",
|
||||
}[upgrade.job.stage] || "升级状态待确认"
|
||||
: upgrade.checking
|
||||
? "正在恢复升级状态..."
|
||||
: upgrade.uncertain
|
||||
? "升级状态待确认"
|
||||
: "暂无升级任务"}
|
||||
</p>
|
||||
{upgrade.job && (
|
||||
<>
|
||||
<p className="break-words text-default-600">
|
||||
目标版本:{upgrade.job.targetVersion}
|
||||
</p>
|
||||
{upgrade.job.message && (
|
||||
<p className="break-words text-default-600">
|
||||
{upgradeMessage(upgrade.job.message)}
|
||||
</p>
|
||||
)}
|
||||
{upgrade.job.active && (
|
||||
<p className="text-default-500">
|
||||
升级期间请勿重复操作。后端重启可能短暂断开连接,本页面会自动重新连接并验证结果。
|
||||
</p>
|
||||
)}
|
||||
{upgrade.job.stage === "succeeded" && !upgrade.uncertain && (
|
||||
<p className="text-success">
|
||||
后端已验证运行版本 {upgrade.currentVersion}
|
||||
。请刷新页面以加载新版前端。
|
||||
</p>
|
||||
)}
|
||||
{(upgrade.job.requiresRecovery ||
|
||||
upgrade.job.stage === "recovery_required") && (
|
||||
<p className="text-danger">
|
||||
已阻止再次升级。请联系服务器管理员检查升级辅助容器日志和部署目录中的备份,确认服务与数据库状态后按部署文档恢复;不要删除备份或反复触发升级。
|
||||
</p>
|
||||
)}
|
||||
{upgrade.job.stage === "failed" && (
|
||||
<p className="text-danger">
|
||||
请先排查失败原因,再检查更新并重试。
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{upgrade.uncertain && !upgrade.checking && (
|
||||
<p className="text-warning">
|
||||
暂时无法确认升级结果,可能正在重启或连接已中断。这不代表升级失败,请勿重复提交。
|
||||
</p>
|
||||
)}
|
||||
{upgrade.paused && (
|
||||
<p className="text-warning">
|
||||
自动查询已暂停,任务可能仍在运行。请检查服务连接后点击“重新查询状态”;不会重新发起升级。
|
||||
</p>
|
||||
)}
|
||||
{upgrade.notice && (
|
||||
<p className="text-danger">{upgrade.notice}</p>
|
||||
)}
|
||||
<Button
|
||||
isDisabled={upgrade.checking || upgrade.submitting}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={upgrade.refresh}
|
||||
>
|
||||
重新查询状态
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{systemUpgradeLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-divider bg-background px-4 py-3 text-sm text-default-500">
|
||||
<Spinner size="sm" />
|
||||
@@ -1658,7 +1752,9 @@ export default function ConfigPage() {
|
||||
<div>
|
||||
<p className="text-xs text-default-500">当前版本</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{systemUpgradeInfo?.currentVersion || "未获取到版本信息"}
|
||||
{upgrade.currentVersion ||
|
||||
systemUpgradeInfo?.currentVersion ||
|
||||
"未获取到版本信息"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1776,6 +1872,7 @@ export default function ConfigPage() {
|
||||
|
||||
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
isDisabled={upgrade.blocked}
|
||||
isLoading={systemUpgradeChecking}
|
||||
variant="flat"
|
||||
onPress={handleCheckSystemUpgrade}
|
||||
@@ -2078,8 +2175,8 @@ export default function ConfigPage() {
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={systemUpgradeExecuting}
|
||||
isLoading={systemUpgradeExecuting}
|
||||
isDisabled={!canTriggerSystemUpgrade}
|
||||
isLoading={upgrade.submitting}
|
||||
onPress={handleConfirmSystemUpgrade}
|
||||
>
|
||||
确认升级
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
SystemUpgradeJob,
|
||||
SystemUpgradeRunApiData,
|
||||
SystemUpgradeStatusApiData,
|
||||
} from "@/api/types";
|
||||
|
||||
type Response<T> = {
|
||||
code: number;
|
||||
data: T;
|
||||
msg?: string;
|
||||
transportError?: boolean;
|
||||
};
|
||||
export interface UpgradeMonitorState {
|
||||
job: SystemUpgradeJob | null;
|
||||
currentVersion: string;
|
||||
checking: boolean;
|
||||
submitting: boolean;
|
||||
uncertain: boolean;
|
||||
paused: boolean;
|
||||
notice: string;
|
||||
}
|
||||
|
||||
export const initialUpgradeState: UpgradeMonitorState = {
|
||||
job: null,
|
||||
currentVersion: "",
|
||||
checking: true,
|
||||
submitting: false,
|
||||
uncertain: true,
|
||||
paused: false,
|
||||
notice: "",
|
||||
};
|
||||
|
||||
export const upgradeIsBlocked = (state: UpgradeMonitorState) =>
|
||||
state.checking ||
|
||||
state.submitting ||
|
||||
state.uncertain ||
|
||||
Boolean(
|
||||
state.job?.active ||
|
||||
state.job?.requiresRecovery ||
|
||||
state.job?.stage === "recovery_required",
|
||||
);
|
||||
|
||||
// The server supplies sanitized public summaries, never helper logs. Render as
|
||||
// text only; remove control characters and cap length as defense in depth.
|
||||
export const upgradeMessage = (message: string) =>
|
||||
message.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 500);
|
||||
|
||||
// Kept independent of React so lifecycle/disconnect races can be replayed
|
||||
// without adding frontend test infrastructure.
|
||||
export function createUpgradeMonitor(
|
||||
api: {
|
||||
status: (id?: string) => Promise<Response<SystemUpgradeStatusApiData>>;
|
||||
run: (
|
||||
version?: string,
|
||||
channel?: "stable" | "dev",
|
||||
) => Promise<Response<SystemUpgradeRunApiData>>;
|
||||
},
|
||||
publish: (state: UpgradeMonitorState) => void,
|
||||
clock = {
|
||||
now: () => Date.now(),
|
||||
schedule: (fn: () => void, ms: number) => setTimeout(fn, ms),
|
||||
cancel: (timer: ReturnType<typeof setTimeout>) => clearTimeout(timer),
|
||||
},
|
||||
) {
|
||||
let state = { ...initialUpgradeState };
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let busy = false;
|
||||
let deadline = 0;
|
||||
let failures = 0;
|
||||
let expectedJob: string | undefined;
|
||||
let awaitingSubmission = false;
|
||||
let previousJob: string | undefined;
|
||||
const emit = (patch: Partial<UpgradeMonitorState>) => {
|
||||
state = { ...state, ...patch };
|
||||
if (!disposed) publish(state);
|
||||
};
|
||||
const cancelTimer = () => {
|
||||
if (timer !== undefined) clock.cancel(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
const poll = async () => {
|
||||
if (disposed || busy) return;
|
||||
busy = true;
|
||||
const request = generation;
|
||||
|
||||
try {
|
||||
const response = await api.status(expectedJob);
|
||||
|
||||
if (disposed || request !== generation) return;
|
||||
if (response.code !== 0 || !response.data) throw new Error("unavailable");
|
||||
const { job, currentVersion } = response.data;
|
||||
|
||||
// An accepted or interrupted POST must not be mistaken for an idle
|
||||
// server, nor may an older completed job certify the new request.
|
||||
if (
|
||||
(expectedJob && job?.id !== expectedJob) ||
|
||||
(awaitingSubmission && (!job || job.id === previousJob))
|
||||
) {
|
||||
throw new Error("awaiting job");
|
||||
}
|
||||
failures = 0;
|
||||
awaitingSubmission = false;
|
||||
expectedJob = job?.id;
|
||||
emit({
|
||||
job,
|
||||
currentVersion,
|
||||
checking: false,
|
||||
uncertain: false,
|
||||
paused: false,
|
||||
});
|
||||
} catch {
|
||||
if (disposed || request !== generation) return;
|
||||
failures += 1;
|
||||
// Disconnection is not proof of failure. Keep the last known job and
|
||||
// continue blocking submissions until authoritative status is available.
|
||||
emit({ checking: false, uncertain: true });
|
||||
} finally {
|
||||
busy = false;
|
||||
if (
|
||||
!disposed &&
|
||||
request === generation &&
|
||||
(state.uncertain || state.job?.active)
|
||||
) {
|
||||
if (clock.now() >= deadline) {
|
||||
emit({ paused: true });
|
||||
} else {
|
||||
timer = clock.schedule(
|
||||
() => {
|
||||
void poll();
|
||||
},
|
||||
Math.min(15000, 2000 * 2 ** Math.min(failures, 3)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
refresh() {
|
||||
if (disposed || busy || state.submitting) return;
|
||||
cancelTimer();
|
||||
if (
|
||||
!state.uncertain &&
|
||||
!state.job?.active &&
|
||||
!state.job?.requiresRecovery
|
||||
) {
|
||||
expectedJob = undefined;
|
||||
}
|
||||
deadline = clock.now() + 15 * 60 * 1000;
|
||||
failures = 0;
|
||||
emit({ checking: true, paused: false });
|
||||
void poll();
|
||||
},
|
||||
async submit(version?: string, channel?: "stable" | "dev") {
|
||||
if (disposed || busy || upgradeIsBlocked(state)) return false;
|
||||
cancelTimer();
|
||||
const request = ++generation;
|
||||
|
||||
previousJob = state.job?.id;
|
||||
expectedJob = undefined;
|
||||
awaitingSubmission = true;
|
||||
emit({
|
||||
job: null,
|
||||
submitting: true,
|
||||
uncertain: true,
|
||||
paused: false,
|
||||
notice: "",
|
||||
});
|
||||
try {
|
||||
const response = await api.run(version, channel);
|
||||
|
||||
if (disposed || request !== generation) return false;
|
||||
if (response.code === 0 && response.data?.jobId) {
|
||||
expectedJob = response.data.jobId;
|
||||
awaitingSubmission = false;
|
||||
} else if (response.code !== 0 && !response.transportError) {
|
||||
// Definite server rejection: rediscover the authoritative job,
|
||||
// including concurrent submissions from other admin sessions.
|
||||
awaitingSubmission = false;
|
||||
emit({
|
||||
notice: upgradeMessage(
|
||||
response.msg || "升级请求被拒绝,请检查当前任务状态。",
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// The POST may already have started the helper. Reconcile, don't retry.
|
||||
} finally {
|
||||
if (!disposed && request === generation) {
|
||||
emit({ submitting: false });
|
||||
deadline = clock.now() + 15 * 60 * 1000;
|
||||
void poll();
|
||||
}
|
||||
}
|
||||
|
||||
return !disposed && request === generation;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
cancelTimer();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user