feat: M6 stage B - cluster heartbeat, new-node bootstrap auto-pull, peer-first install (e2e verified)

This commit is contained in:
2026-08-17 18:05:01 +08:00
parent 79e0b3da79
commit fd7604cb9c
4 changed files with 237 additions and 13 deletions

View File

@ -11,6 +11,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
@ -311,3 +312,94 @@ func extractFrpcBytes(tarGz []byte) ([]byte, error) {
}
}
}
// Discover periodically polls registered peers for their cached versions and
// refreshes the node registry (M6 stage B heartbeats). Run in a goroutine.
func (r *Registry) Discover(ctx context.Context, interval time.Duration) {
tick := time.NewTicker(interval)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
peers := r.PeerList()
for _, p := range peers {
ni, err := r.queryNode(ctx, p)
if err != nil {
continue // peer unreachable; keep last known info
}
r.UpdateNodeInfo(ni)
}
}
}
}
// queryNode asks one peer for its node info (cached frpc versions).
func (r *Registry) queryNode(ctx context.Context, p *PeerTarget) (*NodeInfo, error) {
url := fmt.Sprintf("http://%s/api/manager/cluster/nodes", p.Addr)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if p.User != "" || p.Pass != "" {
req.SetBasicAuth(p.User, p.Pass)
}
cli := &http.Client{Timeout: 10 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("node query HTTP %d", resp.StatusCode)
}
var payload struct {
Nodes []struct {
Addr string `json:"addr"`
Cache []string `json:"cache"`
Version string `json:"version"`
} `json:"nodes"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
if len(payload.Nodes) == 0 {
return nil, fmt.Errorf("peer returned no nodes")
}
self := payload.Nodes[0]
return &NodeInfo{ID: self.Addr, Addr: self.Addr, Version: self.Version, Cache: self.Cache}, nil
}
// SyncFromPeers ensures the locally cached frpc set covers the latest version
// each known peer offers (new-member auto propagation). It leaves version
// moves to EnsureVersion on demand; here we just warm the cache.
func (r *Registry) SyncFromPeers(ctx context.Context) error {
// First refresh node info from registered peers so the cache set reflects
// what neighbors actually offer (a fresh join may have no info yet).
for _, p := range r.PeerList() {
if ni, err := r.queryNode(ctx, p); err == nil {
r.UpdateNodeInfo(ni)
}
}
for _, n := range r.NodeList() {
if n.Version == "" {
continue
}
if r.HasVersion(n.Version) {
continue
}
// EnsureVersion internally iterates registered peers (and falls back
// to the external URL), so no peer selection is needed here.
if _, err := r.EnsureVersion(ctx, n.Version); err != nil {
continue
}
}
return nil
}
// SelfInfo returns this node's own info for reporting.
func (r *Registry) SelfInfo() NodeInfo {
return NodeInfo{Addr: "self", Version: "", Cache: r.CachedVersions()}
}

View File

@ -0,0 +1,122 @@
package cluster
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// writeFakeFrpc writes an executable file pretending to be frpc (any bytes
// work for tests that don't run --version).
func writeFakeFrpc(t *testing.T, dir, version string) string {
t.Helper()
binDir := filepath.Join(dir, "frpc-"+version)
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(binDir, "frpc")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho frpc "+version+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return bin
}
func TestCachedVersionsAndProvide(t *testing.T) {
dir := t.TempDir()
writeFakeFrpc(t, dir, "0.71.0")
reg := NewRegistry(dir)
vs := reg.CachedVersions()
if len(vs) != 1 || vs[0] != "0.71.0" {
t.Fatalf("cached versions = %+v", vs)
}
if !reg.HasVersion("0.71.0") || reg.HasVersion("0.99.0") {
t.Fatalf("HasVersion wrong")
}
p, sum, err := reg.Provide("0.71.0")
if err != nil {
t.Fatal(err)
}
if p == "" || len(sum) != 64 {
t.Fatalf("provide p=%s sum=%s", p, sum)
}
}
func TestEnsureVersionPrefersLocalAndPeer(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(dir)
peerBin := []byte("#!/bin/sh\necho fake-frpc\n")
sum := sha256.Sum256(peerBin)
peerSum := hex.EncodeToString(sum[:])
peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/frpc/0.71.0" {
w.Header().Set("X-Frpc-SHA256", peerSum)
_, _ = w.Write(peerBin)
return
}
http.NotFound(w, r)
}))
defer peerSrv.Close()
host := peerSrv.Listener.Addr().String()
reg.RegisterPeer("peer1", host, "u", "p")
if reg.HasVersion("0.71.0") {
t.Fatal("should not be cached yet")
}
binPath, err := reg.EnsureVersion(context.Background(), "0.71.0")
if err != nil {
t.Fatalf("EnsureVersion: %v", err)
}
if !reg.HasVersion("0.71.0") {
t.Fatal("EnsureVersion should have stored binary")
}
got, err := reg.EnsureVersion(context.Background(), "0.71.0")
if err != nil || got != binPath {
t.Fatalf("local hit = %q err=%v", got, err)
}
}
func TestFetchFromPeerChecksumMismatch(t *testing.T) {
peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("corrupted"))
}))
defer peerSrv.Close()
_, err := fetchFromPeer(context.Background(), &PeerTarget{Addr: peerSrv.Listener.Addr().String()}, "1.2.3", "0000000000000000000000000000000000000000000000000000000000000000")
if err == nil {
t.Fatal("expected checksum mismatch error")
}
}
func TestRegisterAndDiscover(t *testing.T) {
dir := t.TempDir()
writeFakeFrpc(t, dir, "0.70.0")
reg := NewRegistry(dir)
peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/manager/cluster/nodes" {
_, _ = w.Write([]byte(`{"nodes":[{"addr":"10.0.0.9:7500","cache":["0.71.0"],"version":"0.71.0"}]}`))
return
}
http.NotFound(w, r)
}))
defer peerSrv.Close()
reg.RegisterPeer("peerX", peerSrv.Listener.Addr().String(), "u", "p")
ni, err := reg.queryNode(context.Background(), reg.PeerList()[0])
if err != nil {
t.Fatalf("queryNode: %v", err)
}
if ni.Version != "0.71.0" || len(ni.Cache) != 1 {
t.Fatalf("node info = %+v", ni)
}
reg.UpdateNodeInfo(ni)
if got := reg.NodeList(); len(got) != 1 || got[0].Addr != "10.0.0.9:7500" {
t.Fatalf("node list = %+v", got)
}
}

View File

@ -9,6 +9,7 @@ import (
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -38,6 +39,9 @@ type Handler struct {
SyncWorkers func()
// Cluster is the peer-to-peer binary registry (M6). Nil disables M6 routes.
Cluster *cluster.Registry
// SelfAddr is this node's reachable listen address (from -addr), used for
// cluster discovery so peers can reach back for binary exchange.
SelfAddr string
}
const (
@ -266,21 +270,21 @@ func (h *Handler) handleClusterNodes(w http.ResponseWriter, r *http.Request) {
http.Error(w, "cluster registry not enabled", http.StatusNotFound)
return
}
nodes := h.Cluster.NodeList()
// Self node goes first so a querier can identify us by Nodes[0].
type nodeResp struct {
Addr string `json:"addr"`
Cache []string `json:"cache,omitempty"`
Version string `json:"version,omitempty"`
}
out := make([]nodeResp, 0, len(nodes))
for _, n := range nodes {
out := make([]nodeResp, 0, 1+len(h.Cluster.NodeList()))
ver := ""
if h.BinaryPath != nil {
ver = frpcVersionOf(h.BinaryPath())
}
out = append(out, nodeResp{Addr: h.SelfAddr, Cache: h.Cluster.CachedVersions(), Version: ver})
for _, n := range h.Cluster.NodeList() {
out = append(out, nodeResp{Addr: n.Addr, Version: n.Version, Cache: n.Cache})
}
out = append(out, nodeResp{
Addr: "self",
Cache: h.Cluster.CachedVersions(),
Version: currentVersion(),
})
writeJSON(w, http.StatusOK, map[string]any{"nodes": out})
}
@ -312,9 +316,15 @@ func (h *Handler) handleFrpcBinary(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, f)
}
// currentVersion returns the app version for cluster node reporting.
func currentVersion() string {
return "0.1.0"
// frpcVersionOf extracts the frpc version from a binary path like
// .../bin/frpc-0.71.0/frpc. Empty when not a versioned cache path.
func frpcVersionOf(binPath string) string {
dir := filepath.Dir(binPath)
base := filepath.Base(dir)
if strings.HasPrefix(base, "frpc-") {
return strings.TrimPrefix(base, "frpc-")
}
return ""
}
// connStateOf derives the frps connection state from the local worker process