Persist agent/secrets settings with masked secrets and runner hot-reload; add maintenance windows, maintpage binary, and zero-downtime listener handoff.
183 lines
6.2 KiB
Go
183 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/handoff"
|
|
)
|
|
|
|
type maintState struct {
|
|
Message string
|
|
Start time.Time
|
|
End time.Time
|
|
Hours float64
|
|
Token string
|
|
ServerBin string
|
|
DataDir string
|
|
WindowID int64
|
|
}
|
|
|
|
func main() {
|
|
addr := envOr("MAINT_LISTEN_ADDR", "127.0.0.1:8080")
|
|
st := maintState{
|
|
Message: envOr("MAINT_MESSAGE", "系统正在维护升级,请稍后再试。"),
|
|
Token: strings.TrimSpace(os.Getenv("MAINT_ADMIN_TOKEN")),
|
|
ServerBin: envOr("MAINT_SERVER_BIN", "./bin/agent-server"),
|
|
DataDir: envOr("DATA_DIR", "./data/dev"),
|
|
}
|
|
if v := os.Getenv("MAINT_WINDOW_ID"); v != "" {
|
|
st.WindowID, _ = strconv.ParseInt(v, 10, 64)
|
|
}
|
|
if v := os.Getenv("MAINT_START"); v != "" {
|
|
st.Start, _ = time.Parse(time.RFC3339, v)
|
|
}
|
|
if v := os.Getenv("MAINT_END"); v != "" {
|
|
st.End, _ = time.Parse(time.RFC3339, v)
|
|
}
|
|
if !st.End.IsZero() && !st.Start.IsZero() {
|
|
st.Hours = st.End.Sub(st.Start).Hours()
|
|
}
|
|
|
|
ln, err := handoff.ListenOrAdopt(addr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "maintpage listen: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := handoff.SignalReady(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "maintpage ready: %v\n", err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"ok": true, "mode": "maintenance"})
|
|
})
|
|
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"ok": true, "mode": "maintenance"})
|
|
})
|
|
mux.HandleFunc("GET /api/v1/maintenance/status", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{
|
|
"active": true,
|
|
"scheduled": false,
|
|
"message": st.Message,
|
|
"scheduledStart": st.Start,
|
|
"scheduledEnd": st.End,
|
|
"expectedHours": st.Hours,
|
|
"windowId": st.WindowID,
|
|
"status": "active",
|
|
})
|
|
})
|
|
mux.HandleFunc("POST /api/v1/maintenance/admin/start", func(w http.ResponseWriter, r *http.Request) {
|
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if token == "" {
|
|
token = r.URL.Query().Get("token")
|
|
}
|
|
if st.Token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(st.Token)) != 1 {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
bin := st.ServerBin
|
|
if !filepath.IsAbs(bin) {
|
|
if abs, err := filepath.Abs(bin); err == nil {
|
|
bin = abs
|
|
}
|
|
}
|
|
extra := []string{
|
|
"MAINT_RESUME=1",
|
|
fmt.Sprintf("MAINT_WINDOW_ID=%d", st.WindowID),
|
|
fmt.Sprintf("DATA_DIR=%s", st.DataDir),
|
|
}
|
|
cmd, err := handoff.PassToChild(ln, bin, nil, extra, 30*time.Second)
|
|
if err != nil {
|
|
http.Error(w, "start failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true, "pid": cmd.Process.Pid})
|
|
go func() {
|
|
time.Sleep(500 * time.Millisecond)
|
|
os.Exit(0)
|
|
}()
|
|
})
|
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = pageTmpl.Execute(w, st)
|
|
})
|
|
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = adminTmpl.Execute(w, st)
|
|
})
|
|
|
|
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
|
fmt.Printf("maintpage listening (mode=maintenance) window=%d\n", st.WindowID)
|
|
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
fmt.Fprintf(os.Stderr, "maintpage serve: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
|
|
<html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<title>系统维护中</title>
|
|
<style>
|
|
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(160deg,#0f172a,#1e293b);color:#e2e8f0;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
.card{max-width:560px;padding:40px 36px;border-radius:16px;background:rgba(15,23,42,.72);border:1px solid rgba(148,163,184,.25);box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
|
h1{margin:0 0 12px;font-size:28px;letter-spacing:.02em}
|
|
p{margin:8px 0;line-height:1.6;color:#cbd5e1}
|
|
.meta{margin-top:20px;font-size:14px;color:#94a3b8}
|
|
a{color:#38bdf8}
|
|
</style></head><body>
|
|
<div class="card">
|
|
<h1>系统维护升级中</h1>
|
|
<p>{{.Message}}</p>
|
|
<div class="meta">
|
|
{{if not .Start.IsZero}}预计开始:{{.Start.Local.Format "2006年01月02日 15:04"}}<br/>{{end}}
|
|
{{if not .End.IsZero}}预计结束:{{.End.Local.Format "2006年01月02日 15:04"}}(约 {{printf "%.1f" .Hours}} 小时)<br/>{{end}}
|
|
管理员请前往 <a href="/admin">维护控制台</a> 完成上线。
|
|
</div>
|
|
</div></body></html>`))
|
|
|
|
var adminTmpl = template.Must(template.New("admin").Parse(`<!doctype html>
|
|
<html lang="zh-CN"><head><meta charset="utf-8"/><title>维护管理</title>
|
|
<style>
|
|
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;padding:40px}
|
|
input,button{padding:10px 14px;border-radius:8px;border:1px solid #334155;background:#1e293b;color:#e2e8f0}
|
|
button{cursor:pointer;background:#0369a1;border:none;margin-left:8px}
|
|
#msg{margin-top:16px;color:#94a3b8}
|
|
</style></head><body>
|
|
<h1>维护结束后上线</h1>
|
|
<p>替换服务端二进制后,输入激活时展示的管理员 token,启动新服务并下线本公告页。</p>
|
|
<input id="token" type="password" placeholder="维护管理员 Token" style="width:360px"/>
|
|
<button onclick="start()">启动服务并下线公告</button>
|
|
<pre id="msg"></pre>
|
|
<script>
|
|
async function start(){
|
|
const token=document.getElementById('token').value.trim();
|
|
const msg=document.getElementById('msg');
|
|
msg.textContent='请求中…';
|
|
try{
|
|
const r=await fetch('/api/v1/maintenance/admin/start',{method:'POST',headers:{'Authorization':'Bearer '+token}});
|
|
const t=await r.text();
|
|
msg.textContent=r.status+' '+t;
|
|
}catch(e){msg.textContent=String(e)}
|
|
}
|
|
</script></body></html>`))
|