feat(server): 自动更新 /version 清单端点(镜像 jiu)
GET /version(公开)读 /etc/pangolin/version.yaml(每请求重载,免重启);缺省回退。 download_urls 接 /downloads。deploy.sh 装清单(存在则不覆盖, 保住线上 bump)。含 4 测试。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -178,6 +178,7 @@ GRPC_KEY_PATH=$ETC/grpc.key
|
||||
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
|
||||
PANGOLIN_RULES_DIR=$DATA_DIR/rules
|
||||
DOWNLOADS_DIR=$DATA_DIR/downloads
|
||||
VERSION_MANIFEST=$ETC/version.yaml
|
||||
EOF
|
||||
if [ -n "${SMTP_HOST:-}" ]; then
|
||||
cat >> "$ETC/server.env" <<EOF
|
||||
@@ -211,6 +212,15 @@ curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
|
||||
DOWNLOADS_DIR="$DATA_DIR/downloads"
|
||||
install -d -m 755 "$DOWNLOADS_DIR"
|
||||
|
||||
# ── 6d. 客户端自动更新版本清单 ─────────────────────────────────────────────────
|
||||
# GET /version(公开、免鉴权)按 VERSION_MANIFEST(见上 server.env)读取该文件;
|
||||
# scripts/ci/release-client.sh 每次 client-v* 发版都会以本仓库这份文件为模板,
|
||||
# 改写 version/build_number 后 SSH 推到这个路径覆盖 —— 幂等重跑本脚本不应该把
|
||||
# 已发布的最新版本号退回仓库里的默认值,所以文件已存在时不覆盖。
|
||||
if [ ! -f "$ETC/version.yaml" ]; then
|
||||
install -m 644 "$HERE/version.yaml" "$ETC/version.yaml"
|
||||
fi
|
||||
|
||||
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
|
||||
log "执行迁移(sqlite)..."
|
||||
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# version.yaml — 客户端自动更新版本清单(committed default)。
|
||||
#
|
||||
# deploy/single-node/deploy.sh 把这份文件安装到 /etc/pangolin/version.yaml
|
||||
# (VERSION_MANIFEST 默认路径,见 server/internal/httpapi/version.go)。
|
||||
# scripts/ci/release-client.sh 在每次 client-v* 发版时,以这份文件为模板改写
|
||||
# version / build_number 字段,再原样(SSH)推到 pangolin1 的
|
||||
# /etc/pangolin/version.yaml —— 控制面每次请求都重新读该文件,发版立即生效,
|
||||
# 不需要重启/重新部署控制面。
|
||||
#
|
||||
# download_urls 目前是固定「仅保留最新一份」的稳定 URL(deploy-client.sh 每次
|
||||
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写;
|
||||
# macos / ios 产物尚未产出,先留空字符串。
|
||||
version: "1.0.48"
|
||||
build_number: 10048
|
||||
force_update: false
|
||||
release_notes: ""
|
||||
download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: ""
|
||||
ios: ""
|
||||
changelog: []
|
||||
@@ -138,6 +138,13 @@ func main() {
|
||||
downloadsHandler := httpapi.NewDownloadsHandler(os.Getenv("DOWNLOADS_DIR"))
|
||||
r.Get("/downloads/*", downloadsHandler.Serve)
|
||||
|
||||
// Public (no auth): 客户端自动更新版本清单。VERSION_MANIFEST 可配置清单路径
|
||||
// (默认 /etc/pangolin/version.yaml);deploy/single-node/deploy.sh 安装仓库内
|
||||
// 默认清单,scripts/ci/release-client.sh 在每次 client-v* 发版时改写其
|
||||
// version/build_number。每次请求都重新读文件,发版脚本改完立即生效,无需重启。
|
||||
versionHandler := httpapi.NewVersionHandler(os.Getenv("VERSION_MANIFEST"))
|
||||
r.Get("/version", versionHandler.Serve)
|
||||
|
||||
// Optional probe ingest route. sharedProbeStore is reused by the scheduler
|
||||
// (below) when both are enabled, so they share one Redis-backed store.
|
||||
var sharedProbeStore *probe.Store
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// defaultVersionManifestPath is VERSION_MANIFEST's default when unset — mirrors
|
||||
// how server.env wires DOWNLOADS_DIR alongside DownloadsHandler's own built-in
|
||||
// default (see downloads.go / deploy/single-node/deploy.sh).
|
||||
const defaultVersionManifestPath = "/etc/pangolin/version.yaml"
|
||||
|
||||
// Fallback values used ONLY when the manifest file itself is missing (fresh
|
||||
// box that hasn't run deploy/single-node/deploy.sh's manifest-install step
|
||||
// yet, or a local dev server). These are hand-set, not auto-derived — once a
|
||||
// box is deployed, the real source of truth is the on-disk manifest that
|
||||
// scripts/ci/release-client.sh overwrites on every client-v* release.
|
||||
const (
|
||||
defaultManifestVersion = "1.0.48"
|
||||
defaultManifestBuildNumber = 10048
|
||||
)
|
||||
|
||||
// changelogSection / changelogEntry mirror jiu's backend/config/version.yaml
|
||||
// shape (~/code/jiu/backend/internal/handler/version.go) so any future shared
|
||||
// tooling (e.g. a website changelog widget) can treat both services'
|
||||
// /version responses identically. Pangolin's release pipeline doesn't
|
||||
// populate Changelog yet (no CHANGELOG-client.md parsing wired in
|
||||
// scripts/ci/release-client.sh) — the field exists for shape-compatibility
|
||||
// and always serializes as [] rather than null.
|
||||
type changelogSection struct {
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Items []string `yaml:"items" json:"items"`
|
||||
}
|
||||
|
||||
type changelogEntry struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Date string `yaml:"date" json:"date"`
|
||||
Intro string `yaml:"intro" json:"intro"`
|
||||
Sections []changelogSection `yaml:"sections" json:"sections"`
|
||||
}
|
||||
|
||||
// versionManifest is the on-disk (and wire) shape of the auto-update
|
||||
// manifest. download_urls keys in practice: macos, windows, ios, android
|
||||
// (web is intentionally not used — pangolin's client is native-only).
|
||||
type versionManifest struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
BuildNumber int `yaml:"build_number" json:"build_number"`
|
||||
ForceUpdate bool `yaml:"force_update" json:"force_update"`
|
||||
ReleaseNotes string `yaml:"release_notes" json:"release_notes"`
|
||||
DownloadURLs map[string]string `yaml:"download_urls" json:"download_urls"`
|
||||
Changelog []changelogEntry `yaml:"changelog" json:"changelog"`
|
||||
}
|
||||
|
||||
// VersionHandler serves GET /version (public, no auth — mounted directly in
|
||||
// main.go next to /healthz and /downloads/*). Unlike most handlers in this
|
||||
// package it re-reads its manifest file from disk on EVERY request rather
|
||||
// than caching it in memory: scripts/ci/release-client.sh rewrites
|
||||
// /etc/pangolin/version.yaml's version/build_number on each client-v*
|
||||
// release, and that must take effect immediately without a control-plane
|
||||
// restart or redeploy (mirrors jiu's loadVersionConfig()-per-request).
|
||||
type VersionHandler struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// NewVersionHandler builds a VersionHandler reading the manifest at path.
|
||||
// path=="" falls back to defaultVersionManifestPath (in production this is
|
||||
// overridden via the VERSION_MANIFEST env var — see main.go).
|
||||
func NewVersionHandler(path string) *VersionHandler {
|
||||
if path == "" {
|
||||
path = defaultVersionManifestPath
|
||||
}
|
||||
return &VersionHandler{path: path}
|
||||
}
|
||||
|
||||
// defaultManifest is returned when the manifest file doesn't exist yet, so
|
||||
// GET /version still answers usefully (client update-checks shouldn't hard
|
||||
// fail just because deploy/single-node/deploy.sh hasn't run on this box).
|
||||
func defaultManifest() versionManifest {
|
||||
return versionManifest{
|
||||
Version: defaultManifestVersion,
|
||||
BuildNumber: defaultManifestBuildNumber,
|
||||
ForceUpdate: false,
|
||||
ReleaseNotes: "",
|
||||
DownloadURLs: map[string]string{
|
||||
"android": "https://api.yanmeiai.com/downloads/pangolin-android.apk",
|
||||
"windows": "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe",
|
||||
"macos": "",
|
||||
"ios": "",
|
||||
},
|
||||
Changelog: []changelogEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadManifest reads+parses h.path. A missing file is NOT an error (falls
|
||||
// back to defaultManifest()); any other read/parse failure IS, so ServeHTTP
|
||||
// can 500 instead of silently masking a corrupt manifest written by a bad
|
||||
// release-client.sh run.
|
||||
func (h *VersionHandler) loadManifest() (versionManifest, error) {
|
||||
data, err := os.ReadFile(h.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return defaultManifest(), nil
|
||||
}
|
||||
return versionManifest{}, err
|
||||
}
|
||||
var m versionManifest
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
return versionManifest{}, err
|
||||
}
|
||||
// Keep the JSON response shape stable (empty object/array, never null)
|
||||
// regardless of what the manifest on disk happens to omit.
|
||||
if m.DownloadURLs == nil {
|
||||
m.DownloadURLs = map[string]string{}
|
||||
}
|
||||
if m.Changelog == nil {
|
||||
m.Changelog = []changelogEntry{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Serve handles GET /version. Named Serve (not ServeHTTP) to match
|
||||
// DownloadsHandler's convention in this package (see downloads.go).
|
||||
func (h *VersionHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := h.loadManifest()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "version manifest unavailable"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(m)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// buildVersionRouter mounts VersionHandler on a real chi router (mirrors how
|
||||
// main.go mounts GET /version) so routing behaves exactly as in production.
|
||||
func buildVersionRouter(path string) chi.Router {
|
||||
h := NewVersionHandler(path)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/version", h.Serve)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestVersionHandler_ServesManifestFromDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "version.yaml")
|
||||
yamlContent := `version: "1.2.3"
|
||||
build_number: 10203
|
||||
force_update: true
|
||||
release_notes: "测试发布说明"
|
||||
download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: ""
|
||||
ios: ""
|
||||
changelog:
|
||||
- version: "1.2.3"
|
||||
date: "2026-07-06"
|
||||
intro: "小版本更新"
|
||||
sections:
|
||||
- type: "新增"
|
||||
items:
|
||||
- "示例条目"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yamlContent), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var got versionManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
if got.Version != "1.2.3" {
|
||||
t.Errorf("version = %q, want %q", got.Version, "1.2.3")
|
||||
}
|
||||
if got.BuildNumber != 10203 {
|
||||
t.Errorf("build_number = %d, want %d", got.BuildNumber, 10203)
|
||||
}
|
||||
if !got.ForceUpdate {
|
||||
t.Errorf("force_update = false, want true")
|
||||
}
|
||||
if got.ReleaseNotes != "测试发布说明" {
|
||||
t.Errorf("release_notes = %q, want %q", got.ReleaseNotes, "测试发布说明")
|
||||
}
|
||||
if got.DownloadURLs["android"] != "https://api.yanmeiai.com/downloads/pangolin-android.apk" {
|
||||
t.Errorf("download_urls.android = %q", got.DownloadURLs["android"])
|
||||
}
|
||||
if got.DownloadURLs["windows"] != "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe" {
|
||||
t.Errorf("download_urls.windows = %q", got.DownloadURLs["windows"])
|
||||
}
|
||||
if got.DownloadURLs["macos"] != "" || got.DownloadURLs["ios"] != "" {
|
||||
t.Errorf("expected empty macos/ios download URLs, got macos=%q ios=%q", got.DownloadURLs["macos"], got.DownloadURLs["ios"])
|
||||
}
|
||||
if len(got.Changelog) != 1 || got.Changelog[0].Version != "1.2.3" {
|
||||
t.Errorf("changelog = %+v, want 1 entry for version 1.2.3", got.Changelog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_MissingFileReturnsDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "does-not-exist.yaml")
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200 (missing file falls back to default manifest); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var got versionManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if got.Version == "" {
|
||||
t.Errorf("default manifest: version should not be empty")
|
||||
}
|
||||
if got.DownloadURLs["android"] == "" {
|
||||
t.Errorf("default manifest: download_urls.android should not be empty")
|
||||
}
|
||||
if got.DownloadURLs["windows"] == "" {
|
||||
t.Errorf("default manifest: download_urls.windows should not be empty")
|
||||
}
|
||||
if got.Changelog == nil {
|
||||
t.Errorf("default manifest: changelog should be [] not null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_JSONShape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "version.yaml")
|
||||
if err := os.WriteFile(path, []byte(`version: "1.0.0"
|
||||
build_number: 10000
|
||||
force_update: false
|
||||
release_notes: ""
|
||||
download_urls:
|
||||
android: "https://example.com/a.apk"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("unmarshal raw response: %v", err)
|
||||
}
|
||||
for _, key := range []string{"version", "build_number", "force_update", "release_notes", "download_urls", "changelog"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("response missing expected top-level key %q; body=%s", key, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// changelog must serialize as an array even when the manifest omits it —
|
||||
// front-ends should be able to blindly .map()/range over it.
|
||||
if string(raw["changelog"]) != "[]" {
|
||||
t.Errorf("changelog = %s, want []", raw["changelog"])
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_DefaultPathFallback(t *testing.T) {
|
||||
h := NewVersionHandler("")
|
||||
if h.path != defaultVersionManifestPath {
|
||||
t.Errorf("NewVersionHandler(\"\").path = %q, want %q", h.path, defaultVersionManifestPath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user