Files
flvx/docs/superpowers/plans/2026-05-04-panel-self-upgrade.md
T

1317 lines
43 KiB
Markdown

# Panel Self-Upgrade Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an admin-only “panel self-upgrade” flow that checks FLVX GitHub releases and upgrades the backend/frontend Docker Compose deployment from the settings page.
**Architecture:** Keep the upgrade entrypoint inside `go-backend`, but do not let the backend container rebuild itself directly. The backend updates the mounted deployment files, discovers its current image ID through Docker, and launches a detached helper container from that same image. The helper container inherits the deployment bind mount and Docker socket, then runs the fixed `docker compose pull backend frontend` and `docker compose up -d backend frontend` sequence. The frontend adds a new card to `src/pages/config.tsx` that shows capability state, checks updates, and triggers the upgrade with a confirmation modal.
**Tech Stack:** Go 1.25 / net-http handlers / existing release helpers in `upgrade.go` / Docker CLI via multi-stage Dockerfile copy / React 18 / TypeScript / HeroUI bridge / existing `Network.post` API client.
**Execution Constraints:** Do not create a git commit during execution unless the user explicitly asks for one in that implementation session.
---
## File Map
- Create: `go-backend/internal/http/handler/system_upgrade.go`
Responsibility: system upgrade data types, capability checks, `.env` updates, compose asset selection, helper-container launch, and HTTP handlers for `/api/v1/system/version`, `/api/v1/system/check-updates`, and `/api/v1/system/upgrade`.
- Create: `go-backend/internal/http/handler/system_upgrade_test.go`
Responsibility: helper logic unit tests, method-guard tests, and upgrade lock tests.
- Modify: `go-backend/internal/http/handler/handler.go`
Responsibility: add a dedicated mutex field and register new system upgrade routes.
- Modify: `go-backend/internal/http/handler/upgrade.go`
Responsibility: reuse existing release-channel helpers and GitHub proxy URL builder from system upgrade code.
- Modify: `go-backend/Dockerfile`
Responsibility: copy Docker CLI and compose plugin into the runtime image.
- Modify: `docker-compose-v4.yml`
Responsibility: pass `FLUX_VERSION`, `PANEL_DEPLOY_DIR`, `PANEL_BACKEND_CONTAINER`, mount Docker socket, and mount the deployment directory.
- Modify: `docker-compose-v6.yml`
Responsibility: same runtime wiring as v4 while preserving IPv6 network config.
- Modify: `vite-frontend/src/api/types.ts`
Responsibility: add typed response contracts for system upgrade status, release list, and run result.
- Modify: `vite-frontend/src/api/index.ts`
Responsibility: add `getSystemUpgradeVersion`, `checkSystemUpgrade`, and `runSystemUpgrade` wrappers.
- Modify: `vite-frontend/src/pages/config.tsx`
Responsibility: add upgrade card, loading state, release list, capability reason, confirmation modal, and upgrade action.
### Task 1: Build the backend helper core with unit tests
**Files:**
- Create: `go-backend/internal/http/handler/system_upgrade.go`
- Create: `go-backend/internal/http/handler/system_upgrade_test.go`
- [ ] **Step 1: Write the failing helper tests**
```go
package handler
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
compose := []byte("networks:\n gost-network:\n enable_ipv6: true\n")
if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" {
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v6.yml")
}
}
func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
compose := []byte("services:\n backend:\n image: test\n")
if got := exec.selectComposeAsset(compose); got != "docker-compose-v4.yml" {
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v4.yml")
}
}
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 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 := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
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)
}
}
```
- [ ] **Step 2: Run the focused backend tests to verify they fail**
Run:
```bash
go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1
```
Expected: FAIL with errors such as `undefined: systemUpgradeExecutor`, `undefined: validateBackendContainerName`, and `undefined: (*systemUpgradeExecutor).updateEnvVersion`.
- [ ] **Step 3: Write the minimal helper implementation**
Create `go-backend/internal/http/handler/system_upgrade.go` with this initial helper core:
```go
package handler
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
panelDeployDirEnv = "PANEL_DEPLOY_DIR"
panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER"
defaultPanelDeployDir = "/opt/flvx-panel"
defaultPanelBackendName = "flux-panel-backend"
dockerSocketPath = "/var/run/docker.sock"
systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启"
systemUpgradeConflictError = "已有面板升级任务执行中"
)
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
type systemUpgradeExecutor struct {
deployDir string
backendContainer string
}
func newSystemUpgradeExecutor() *systemUpgradeExecutor {
deployDir := strings.TrimSpace(os.Getenv(panelDeployDirEnv))
if deployDir == "" {
deployDir = defaultPanelDeployDir
}
backendContainer := strings.TrimSpace(os.Getenv(panelBackendContainerEnv))
if backendContainer == "" {
backendContainer = defaultPanelBackendName
}
return &systemUpgradeExecutor{
deployDir: deployDir,
backendContainer: backendContainer,
}
}
func validateBackendContainerName(value string) error {
if value == "" {
return fmt.Errorf("backend container name is empty")
}
if !safeBackendContainerPattern.MatchString(value) {
return fmt.Errorf("unsafe backend container name: %s", value)
}
return nil
}
func (e *systemUpgradeExecutor) composePath() string {
return filepath.Join(e.deployDir, "docker-compose.yml")
}
func (e *systemUpgradeExecutor) envPath() string {
return filepath.Join(e.deployDir, ".env")
}
func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
if strings.Contains(string(current), "enable_ipv6: true") {
return "docker-compose-v6.yml"
}
return "docker-compose-v4.yml"
}
func (e *systemUpgradeExecutor) helperScript() string {
return strings.Join([]string{
"set -eu",
`cd "$PANEL_DEPLOY_DIR"`,
"docker compose pull backend frontend",
"sleep 5",
"docker compose up -d backend frontend",
}, "\n")
}
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) []string {
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(),
}
}
func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
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 os.WriteFile(envPath, []byte(trimmed+"\n"), 0o644)
}
content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
return os.WriteFile(envPath, []byte(content), 0o644)
}
```
- [ ] **Step 4: Run the focused backend tests again**
Run:
```bash
go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit only if the user explicitly requested a commit**
```bash
git add go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go
git commit -m "feat: add panel upgrade helper core"
```
### Task 2: Wire backend handlers, capability checks, and upgrade orchestration
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- Modify: `go-backend/internal/http/handler/upgrade.go`
- Modify: `go-backend/internal/http/handler/system_upgrade.go`
- Modify: `go-backend/internal/http/handler/system_upgrade_test.go`
- [ ] **Step 1: Add failing handler tests for method guards and the upgrade lock**
Append these tests to `go-backend/internal/http/handler/system_upgrade_test.go`:
```go
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
rr := httptest.NewRecorder()
h.systemVersion(rr, req)
if !strings.Contains(rr.Body.String(), "请求失败") {
t.Fatalf("expected wrong-method response, got %s", rr.Body.String())
}
}
func TestSystemUpgradeRejectsConcurrentRequests(t *testing.T) {
h := &Handler{}
h.systemUpgradeMu.Lock()
defer h.systemUpgradeMu.Unlock()
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
rr := httptest.NewRecorder()
h.systemUpgrade(rr, req)
if !strings.Contains(rr.Body.String(), systemUpgradeConflictError) {
t.Fatalf("expected conflict message, got %s", rr.Body.String())
}
}
```
- [ ] **Step 2: Run the new backend tests to verify they fail**
Run:
```bash
go test ./internal/http/handler -run 'Test(SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests)' -count=1
```
Expected: FAIL with errors such as `h.systemVersion undefined`, `h.systemUpgrade undefined`, and `Handler has no field or method systemUpgradeMu`.
- [ ] **Step 3: Implement the system upgrade handlers and orchestration**
Update `go-backend/internal/http/handler/handler.go` so `Handler` has a dedicated system-upgrade mutex and route registration:
```go
type Handler struct {
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
metrics *metrics.IngestionService
healthCheck *health.Checker
captchaMu sync.Mutex
captchaTokens map[string]int64
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
jobsWG sync.WaitGroup
systemUpgradeMu sync.Mutex
upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
nodeOnlineRedeployAt map[int64]time.Time
nodeOnlineRedeployQueued map[int64]struct{}
nodeOnlineRedeploying map[int64]struct{}
qualityProber *tunnelQualityProber
}
func (h *Handler) Register(mux *http.ServeMux) {
// existing handlers...
mux.HandleFunc("/api/v1/system/storage", h.storageSummary)
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)
// existing handlers...
}
```
Extend `go-backend/internal/http/handler/system_upgrade.go` with the capability/result types and the minimal orchestration functions:
```go
type systemUpgradeCapability struct {
Capable bool `json:"capable"`
Reason string `json:"reason,omitempty"`
DeployDir string `json:"deployDir,omitempty"`
ComposeFile string `json:"composeFile,omitempty"`
BackendContainer string `json:"backendContainer,omitempty"`
}
type systemUpgradeVersionData struct {
CurrentVersion string `json:"currentVersion"`
Channel string `json:"channel"`
LatestVersion string `json:"latestVersion,omitempty"`
HasUpdate bool `json:"hasUpdate"`
Capable bool `json:"capable"`
Reason string `json:"reason,omitempty"`
DeployDir string `json:"deployDir,omitempty"`
ComposeFile string `json:"composeFile,omitempty"`
BackendContainer string `json:"backendContainer,omitempty"`
}
type systemUpgradeCheckData struct {
CurrentVersion string `json:"currentVersion"`
Channel string `json:"channel"`
LatestVersion string `json:"latestVersion,omitempty"`
HasUpdate bool `json:"hasUpdate"`
Capable bool `json:"capable"`
Reason string `json:"reason,omitempty"`
DeployDir string `json:"deployDir,omitempty"`
ComposeFile string `json:"composeFile,omitempty"`
BackendContainer string `json:"backendContainer,omitempty"`
Releases []systemUpgradeReleaseItem `json:"releases"`
}
type systemUpgradeReleaseItem struct {
Version string `json:"version"`
Name string `json:"name"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
Channel string `json:"channel"`
}
type systemUpgradeRunData struct {
Version string `json:"version"`
Message string `json:"message"`
Commands []string `json:"commands"`
HelperContainerID string `json:"helperContainerId,omitempty"`
}
func currentPanelVersion() string {
version := strings.TrimSpace(os.Getenv("FLUX_VERSION"))
if version == "" {
return "dev"
}
return version
}
func (e *systemUpgradeExecutor) checkCapability(ctx context.Context) systemUpgradeCapability {
capability := systemUpgradeCapability{
DeployDir: e.deployDir,
ComposeFile: e.composePath(),
BackendContainer: e.backendContainer,
}
if e.deployDir == "" || !filepath.IsAbs(e.deployDir) {
capability.Reason = "PANEL_DEPLOY_DIR 必须是已挂载的绝对路径"
return capability
}
if err := validateBackendContainerName(e.backendContainer); err != nil {
capability.Reason = err.Error()
return capability
}
if _, err := exec.LookPath("docker"); err != nil {
capability.Reason = "docker CLI 不可用"
return capability
}
if _, err := os.Stat(dockerSocketPath); err != nil {
capability.Reason = "未挂载 /var/run/docker.sock"
return capability
}
if _, err := os.Stat(capability.ComposeFile); err != nil {
capability.Reason = "未找到 docker-compose.yml"
return capability
}
if _, err := os.Stat(e.envPath()); err != nil {
capability.Reason = "未找到 .env"
return capability
}
cmd := exec.CommandContext(ctx, "docker", "compose", "version")
if out, err := cmd.CombinedOutput(); err != nil {
capability.Reason = fmt.Sprintf("docker compose 不可用: %s", strings.TrimSpace(string(out)))
return capability
}
capability.Capable = true
return capability
}
func (e *systemUpgradeExecutor) backupFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(path+".upgrade.bak", data, 0o644)
}
func (e *systemUpgradeExecutor) replaceComposeFile(composeData []byte) error {
tmpPath := e.composePath() + ".tmp"
if err := os.WriteFile(tmpPath, composeData, 0o644); err != nil {
return err
}
return os.Rename(tmpPath, e.composePath())
}
func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("inspect backend image failed: %s", strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID string) (string, error) {
helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
args := e.buildHelperRunArgs(imageID, helperName)
cmd := exec.CommandContext(ctx, "docker", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("start helper failed: %s", strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
url := h.buildGithubDownloadURL(version, filename)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("download %s failed: %w", filename, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("download %s failed: %d %s", filename, resp.StatusCode, strings.TrimSpace(string(body)))
}
return io.ReadAll(resp.Body)
}
func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
executor := newSystemUpgradeExecutor()
capability := executor.checkCapability(r.Context())
latestVersion, _ := resolveLatestReleaseByChannel(releaseChannelStable)
currentVersion := currentPanelVersion()
response.WriteJSON(w, response.OK(systemUpgradeVersionData{
CurrentVersion: currentVersion,
Channel: releaseChannelStable,
LatestVersion: latestVersion,
HasUpdate: latestVersion != "" && latestVersion != currentVersion,
Capable: capability.Capable,
Reason: capability.Reason,
DeployDir: capability.DeployDir,
ComposeFile: capability.ComposeFile,
BackendContainer: capability.BackendContainer,
}))
}
func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
}
items := make([]systemUpgradeReleaseItem, 0, len(releases))
for _, release := range releases {
if release.Draft {
continue
}
tag := strings.TrimSpace(release.TagName)
if tag == "" || releaseChannelFromTag(tag) != channel {
continue
}
items = append(items, systemUpgradeReleaseItem{
Version: tag,
Name: release.Name,
PublishedAt: release.PublishedAt,
Prerelease: channel == releaseChannelDev,
Channel: channel,
})
}
executor := newSystemUpgradeExecutor()
capability := executor.checkCapability(r.Context())
latestVersion := ""
if len(items) > 0 {
latestVersion = items[0].Version
}
currentVersion := currentPanelVersion()
response.WriteJSON(w, response.OK(systemUpgradeCheckData{
CurrentVersion: currentVersion,
Channel: channel,
LatestVersion: latestVersion,
HasUpdate: latestVersion != "" && latestVersion != currentVersion,
Capable: capability.Capable,
Reason: capability.Reason,
DeployDir: capability.DeployDir,
ComposeFile: capability.ComposeFile,
BackendContainer: capability.BackendContainer,
Releases: items,
}))
}
func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.systemUpgradeMu.TryLock() {
response.WriteJSON(w, response.Err(-2, systemUpgradeConflictError))
return
}
defer h.systemUpgradeMu.Unlock()
var req struct {
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
resolved, err := resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
version = resolved
}
executor := newSystemUpgradeExecutor()
capability := executor.checkCapability(r.Context())
if !capability.Capable {
response.WriteJSON(w, response.Err(-2, capability.Reason))
return
}
currentCompose, err := os.ReadFile(executor.composePath())
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("读取 compose 文件失败: %v", err)))
return
}
assetName := executor.selectComposeAsset(currentCompose)
composeData, err := h.downloadReleaseAsset(version, assetName)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := executor.backupFile(executor.composePath()); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("备份 compose 文件失败: %v", err)))
return
}
if err := executor.backupFile(executor.envPath()); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("备份 .env 失败: %v", err)))
return
}
if err := executor.replaceComposeFile(composeData); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("更新 compose 文件失败: %v", err)))
return
}
if err := executor.updateEnvVersion(executor.envPath(), version); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("更新 FLUX_VERSION 失败: %v", err)))
return
}
helperCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
imageID, err := executor.currentBackendImage(helperCtx)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
helperContainerID, err := executor.startHelper(helperCtx, imageID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(systemUpgradeRunData{
Version: version,
Message: systemUpgradeMessage,
HelperContainerID: helperContainerID,
Commands: []string{
"docker run -d --rm --volumes-from flux-panel-backend ...",
"docker compose pull backend frontend",
"docker compose up -d backend frontend",
},
}))
}
```
Leave `go-backend/internal/http/handler/upgrade.go` using the existing release-channel helpers and `buildGithubDownloadURL` as-is; `system_upgrade.go` should call those helpers rather than duplicating them.
- [ ] **Step 4: Run the focused backend tests again**
Run:
```bash
go test ./internal/http/handler -run 'Test(SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests|SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit only if the user explicitly requested a commit**
```bash
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/upgrade.go go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go
git commit -m "feat: add panel self-upgrade backend"
```
### Task 3: Add Docker CLI support and deployment mounts
**Files:**
- Modify: `go-backend/Dockerfile`
- Modify: `docker-compose-v4.yml`
- Modify: `docker-compose-v6.yml`
- [ ] **Step 1: Update the Docker Compose templates with runtime env and mounts**
Change both `docker-compose-v4.yml` and `docker-compose-v6.yml` backend services to include these exact lines:
```yaml
backend:
image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
container_name: flux-panel-backend
restart: unless-stopped
environment:
DB_TYPE: ${DB_TYPE:-sqlite}
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
PANEL_DEPLOY_DIR: /opt/flvx-panel
PANEL_BACKEND_CONTAINER: flux-panel-backend
volumes:
- sqlite_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
- ./:/opt/flvx-panel
```
- [ ] **Step 2: Validate the two Compose templates render cleanly**
Run:
```bash
JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v4.yml config >/tmp/flvx-v4.rendered.yml
JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v6.yml config >/tmp/flvx-v6.rendered.yml
```
Expected: both commands exit `0`, and each rendered backend service includes `PANEL_DEPLOY_DIR`, `PANEL_BACKEND_CONTAINER`, and `/var/run/docker.sock:/var/run/docker.sock`.
- [ ] **Step 3: Update the backend runtime image to include Docker CLI + compose plugin**
Replace `go-backend/Dockerfile` with this structure:
```dockerfile
FROM golang:1.25-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
FROM docker:27-cli AS dockercli
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/paneld /app/paneld
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
ENV SERVER_ADDR=:6365
EXPOSE 6365
ENTRYPOINT ["/app/paneld"]
```
- [ ] **Step 4: Build and smoke-test the backend image**
Run:
```bash
docker build -t flvx-backend-system-upgrade-test ./go-backend
docker run --rm flvx-backend-system-upgrade-test docker compose version
```
Expected: the image build exits `0`, and the container prints a compose version string and exits `0`.
- [ ] **Step 5: Commit only if the user explicitly requested a commit**
```bash
git add go-backend/Dockerfile docker-compose-v4.yml docker-compose-v6.yml
git commit -m "feat: wire docker runtime for panel upgrades"
```
### Task 4: Add typed frontend API wrappers for system upgrade
**Files:**
- Modify: `vite-frontend/src/api/types.ts`
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/pages/config.tsx`
**Note:** This repo has no frontend unit test runner. Use `pnpm run build` as the failing/passing contract check for the TypeScript surface.
- [ ] **Step 1: Make the config page import the not-yet-existing API members**
Update the imports at the top of `vite-frontend/src/pages/config.tsx` to include the missing members before you implement them:
```tsx
import {
updateConfigs,
activateLicense,
exportBackup,
importBackup,
getAnnouncement,
updateAnnouncement,
getStorageSummary,
getSystemUpgradeVersion,
checkSystemUpgrade,
runSystemUpgrade,
type AnnouncementData,
} from "@/api";
import type {
NodeReleaseApiItem,
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeVersionApiData,
} from "@/api/types";
```
- [ ] **Step 2: Run the frontend build to verify it fails**
Run:
```bash
pnpm run build
```
Expected: FAIL with TypeScript errors such as `Module '"@/api"' has no exported member 'getSystemUpgradeVersion'` and `Module '"@/api/types"' has no exported member 'SystemUpgradeVersionApiData'`.
- [ ] **Step 3: Add the missing frontend types and API functions**
Append these interfaces near the existing `StorageSummaryApiData` and monitor types in `vite-frontend/src/api/types.ts`:
```ts
export interface SystemUpgradeVersionApiData {
currentVersion: string;
channel: "stable" | "dev";
latestVersion?: string;
hasUpdate: boolean;
capable: boolean;
reason?: string;
deployDir?: string;
composeFile?: string;
backendContainer?: string;
}
export interface SystemUpgradeCheckApiData
extends SystemUpgradeVersionApiData {
releases: NodeReleaseApiItem[];
}
export interface SystemUpgradeRunApiData {
version: string;
message: string;
commands: string[];
helperContainerId?: string;
}
```
Then update the top import block and exports in `vite-frontend/src/api/index.ts`:
```ts
import type {
BatchOperationResult,
ForwardDiagnosisApiData,
ForwardApiItem,
GroupPermissionApiItem,
NodeReleaseApiItem,
NodeApiItem,
SpeedLimitApiItem,
StorageSummaryApiData,
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeVersionApiData,
TunnelApiItem,
TunnelBatchDeletePreviewApiData,
TunnelBatchDeleteWithForwardsApiData,
TunnelDeletePreviewApiData,
TunnelDeleteWithForwardsApiData,
TunnelDiagnosisApiData,
TunnelGroupApiItem,
TunnelMetricApiItem,
TunnelQualityApiItem,
UpdatePasswordPayload,
UserApiItem,
UserGroupApiItem,
UserListQuery,
UserMutationPayload,
UserPackageInfoApiData,
UserQuotaResetPayload,
UserTunnelApiItem,
UserTunnelAssignPayload,
UserTunnelListQuery,
UserTunnelPermissionApiItem,
UserTunnelRemovePayload,
} from "./types";
export const getSystemUpgradeVersion = () =>
Network.post<SystemUpgradeVersionApiData>("/system/version");
export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") =>
Network.post<SystemUpgradeCheckApiData>("/system/check-updates", {
channel,
});
export const runSystemUpgrade = (
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post<SystemUpgradeRunApiData>(
"/system/upgrade",
{ version: version || "", channel },
{ timeout: 60 * 1000 },
);
```
- [ ] **Step 4: Run the frontend build again**
Run:
```bash
pnpm run build
```
Expected: PASS, because the new imports now exist even though the config page does not render the upgrade UI yet.
- [ ] **Step 5: Commit only if the user explicitly requested a commit**
```bash
git add vite-frontend/src/api/index.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/config.tsx
git commit -m "feat: add frontend api surface for panel upgrades"
```
### Task 5: Add the settings-page upgrade card and confirmation modal
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
**Note:** This repo has no frontend unit test runner. Use `pnpm run build` as the failing/passing contract check for the UI state and JSX wiring.
- [ ] **Step 1: Add the upgrade card and modal markup before defining the state/handlers**
Insert this JSX block just after the database storage section in `vite-frontend/src/pages/config.tsx`, before the save button row:
```tsx
<Divider className="my-2" />
<div className="space-y-3">
<div className="flex flex-col gap-1 md:flex-row md:items-start md:justify-between">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
面板升级
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
升级 backend 与 frontend 容器。操作会短暂中断面板访问,仅管理员可用。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
isLoading={systemUpgradeChecking}
size="sm"
variant="flat"
onPress={handleCheckSystemUpgrade}
>
检查更新
</Button>
<Button
color="primary"
isDisabled={!systemUpgradeInfo?.capable || systemUpgradeLoading}
size="sm"
onPress={handleOpenSystemUpgradeModal}
>
立即升级
</Button>
</div>
</div>
<div className="rounded-lg border border-divider bg-default-50/60 px-4 py-3 dark:bg-default-100/10">
{systemUpgradeLoading ? (
<p className="text-sm text-default-500">加载升级状态中...</p>
) : (
<div className="space-y-2 text-sm text-default-700 dark:text-default-300">
<p>当前版本:{systemUpgradeInfo?.currentVersion || "未知"}</p>
<p>最新版本:{systemUpgradeInfo?.latestVersion || "未检查"}</p>
<p>当前通道:{updateChannel === "stable" ? "稳定版" : "开发版"}</p>
<p>
升级能力:
{systemUpgradeInfo?.capable ? "可用" : systemUpgradeInfo?.reason || "不可用"}
</p>
{systemUpgradeInfo?.deployDir ? (
<p>部署目录:{systemUpgradeInfo.deployDir}</p>
) : null}
</div>
)}
</div>
</div>
<Modal
isOpen={systemUpgradeModalOpen}
onOpenChange={setSystemUpgradeModalOpen}
>
<ModalContent>
<ModalHeader>确认升级面板</ModalHeader>
<ModalBody className="space-y-4">
<div className="rounded-lg border border-warning-300 bg-warning-50 px-4 py-3 text-sm text-warning-800 dark:border-warning-700 dark:bg-warning-900/20 dark:text-warning-200">
该操作会通过 Docker socket 控制宿主机 Docker,并短暂重启当前面板服务。
</div>
<Select
label="目标版本"
placeholder="留空时自动选择最新版本"
selectedKeys={systemUpgradeSelectedVersion ? [systemUpgradeSelectedVersion] : []}
variant="bordered"
onSelectionChange={(keys) => {
const selected = (Array.from(keys)[0] as string) || "";
setSystemUpgradeSelectedVersion(selected);
}}
>
{systemUpgradeReleases.map((release) => (
<SelectItem
key={release.version}
description={release.publishedAt}
>
{release.version}
</SelectItem>
))}
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={() => setSystemUpgradeModalOpen(false)}>
取消
</Button>
<Button
color="primary"
isLoading={systemUpgradeExecuting}
onPress={handleConfirmSystemUpgrade}
>
确认升级
</Button>
</ModalFooter>
</ModalContent>
</Modal>
```
- [ ] **Step 2: Run the frontend build to verify it fails**
Run:
```bash
pnpm run build
```
Expected: FAIL with TypeScript errors like `Cannot find name 'systemUpgradeInfo'`, `Cannot find name 'handleCheckSystemUpgrade'`, and `Cannot find name 'systemUpgradeReleases'`.
- [ ] **Step 3: Add the missing state, loaders, handlers, and modal control**
Add these hooks near the existing config page state in `vite-frontend/src/pages/config.tsx`:
```tsx
const [systemUpgradeInfo, setSystemUpgradeInfo] =
useState<SystemUpgradeVersionApiData | null>(null);
const [systemUpgradeChecking, setSystemUpgradeChecking] = useState(false);
const [systemUpgradeExecuting, setSystemUpgradeExecuting] = useState(false);
const [systemUpgradeLoading, setSystemUpgradeLoading] = useState(true);
const [systemUpgradeModalOpen, setSystemUpgradeModalOpen] = useState(false);
const [systemUpgradeReleases, setSystemUpgradeReleases] = useState<
NodeReleaseApiItem[]
>([]);
const [systemUpgradeSelectedVersion, setSystemUpgradeSelectedVersion] =
useState("");
```
Add these helpers near `loadStorageSummary` and `handleUpdateChannelChange`:
```tsx
const loadSystemUpgradeInfo = async () => {
setSystemUpgradeLoading(true);
try {
const response = await getSystemUpgradeVersion();
if (response.code === 0 && response.data) {
setSystemUpgradeInfo(response.data);
} else {
setSystemUpgradeInfo(null);
}
} catch {
setSystemUpgradeInfo(null);
} finally {
setSystemUpgradeLoading(false);
}
};
const handleCheckSystemUpgrade = async () => {
setSystemUpgradeChecking(true);
try {
const response = await checkSystemUpgrade(updateChannel);
if (response.code === 0 && response.data) {
const data = response.data as SystemUpgradeCheckApiData;
setSystemUpgradeInfo(data);
setSystemUpgradeReleases(data.releases || []);
toast.success(
data.latestVersion
? `已检查到最新版本 ${data.latestVersion}`
: "未获取到可用版本",
);
} else {
toast.error(response.msg || "检查更新失败");
}
} catch {
toast.error("检查更新失败,请重试");
} finally {
setSystemUpgradeChecking(false);
}
};
const handleOpenSystemUpgradeModal = async () => {
setSystemUpgradeModalOpen(true);
if (systemUpgradeReleases.length === 0) {
await handleCheckSystemUpgrade();
}
};
const handleConfirmSystemUpgrade = async () => {
setSystemUpgradeExecuting(true);
try {
const response = await runSystemUpgrade(
systemUpgradeSelectedVersion || undefined,
updateChannel,
);
if (response.code === 0 && response.data) {
const data = response.data as SystemUpgradeRunApiData;
toast.success(data.message || "升级已触发,请稍后刷新页面");
setSystemUpgradeModalOpen(false);
} else {
toast.error(response.msg || "面板升级失败");
}
} catch {
toast.error("面板升级失败,请重试");
} finally {
setSystemUpgradeExecuting(false);
}
};
```
Update the initial `useEffect` so it loads the new status alongside configs and storage:
```tsx
useEffect(() => {
const timer = setTimeout(() => {
loadConfigs(initialConfigs);
loadAnnouncement();
loadStorageSummary();
loadSystemUpgradeInfo();
}, 100);
return () => clearTimeout(timer);
}, []);
```
Finally, extend `handleUpdateChannelChange` so changing the update channel clears stale release picks and refreshes the displayed status:
```tsx
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
setSystemUpgradeSelectedVersion("");
setSystemUpgradeReleases([]);
void loadSystemUpgradeInfo();
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
```
- [ ] **Step 4: Run the frontend build again**
Run:
```bash
pnpm run build
```
Expected: PASS.
- [ ] **Step 5: Commit only if the user explicitly requested a commit**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat: add panel self-upgrade settings ui"
```
### Task 6: Run the full verification suite and admin QA smoke checks
**Files:**
- No new files.
- [ ] **Step 1: Re-run the targeted backend unit tests for the new helper and handlers**
Run:
```bash
go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs|SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests)' -count=1
```
Expected: PASS.
- [ ] **Step 2: Run the full backend test suite**
Run:
```bash
go test ./...
```
Expected: PASS.
- [ ] **Step 3: Run the frontend production build**
Run:
```bash
pnpm run build
```
Expected: PASS.
- [ ] **Step 4: Re-run the Compose template checks**
Run:
```bash
JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v4.yml config >/tmp/flvx-v4.rendered.yml
JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v6.yml config >/tmp/flvx-v6.rendered.yml
```
Expected: PASS for both templates.
- [ ] **Step 5: Re-run the backend image smoke test**
Run:
```bash
docker build -t flvx-backend-system-upgrade-test ./go-backend
docker run --rm flvx-backend-system-upgrade-test docker compose version
```
Expected: PASS, with the second command printing a compose version string.
- [ ] **Step 6: Manually smoke-test the admin flow in the browser**
Manual checklist:
```text
1. Log in as an admin user.
2. Open /config.
3. Confirm the “面板升级” card shows current version and capability state.
4. In an environment without /var/run/docker.sock, confirm the card shows the capability reason and disables the upgrade button.
5. In a Docker-enabled environment, click “检查更新” and confirm the latest version + release list populate.
6. Open the confirmation modal and confirm the warning about Docker socket / short restart is visible.
```
Expected: all six checks succeed.
- [ ] **Step 7: Commit only if the user explicitly requested a commit**
```bash
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go go-backend/Dockerfile docker-compose-v4.yml docker-compose-v6.yml vite-frontend/src/api/index.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/config.tsx
git commit -m "feat: add panel self-upgrade workflow"
```