server add records it after restarting the new control plane, which then never named it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
124 lines
3.7 KiB
Go
124 lines
3.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// floatingPath is where `server add --vip` records the floating address.
|
|
const floatingPath = "/etc/llmgw/floating"
|
|
|
|
// controlPlaneProbe answers the summary's question of which control plane
|
|
// leads: it asks each one's /leader, on the client port every one of them
|
|
// listens on, since they share a server.yaml.
|
|
//
|
|
// Asked, rather than read from the lease, because the lease names whoever
|
|
// held it last and says nothing about a standby that has stopped — and a
|
|
// standby that is not there is the thing worth seeing before it is needed.
|
|
type controlPlaneProbe struct {
|
|
st *store.DB
|
|
self string
|
|
// floating is the file the floating address is recorded in.
|
|
floating string
|
|
client *http.Client
|
|
// leaderURL is where a host is asked; tests point it elsewhere.
|
|
leaderURL func(host string) string
|
|
|
|
mu sync.Mutex
|
|
at time.Time
|
|
last adminapi.ControlPlanes
|
|
}
|
|
|
|
func newControlPlaneProbe(st *store.DB, listen string) *controlPlaneProbe {
|
|
_, port, _ := net.SplitHostPort(listen)
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
self, _ := os.Hostname()
|
|
return &controlPlaneProbe{st: st, self: self,
|
|
leaderURL: func(host string) string { return "http://" + net.JoinHostPort(host, port) + "/leader" },
|
|
floating: floatingAddress(floatingPath),
|
|
client: &http.Client{Timeout: time.Second}}
|
|
}
|
|
|
|
// Status is the answer, kept for a few seconds: the console asks every three,
|
|
// and each asking is a request to every control plane.
|
|
func (p *controlPlaneProbe) Status() adminapi.ControlPlanes {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if time.Since(p.at) < 2*time.Second {
|
|
return p.last
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
cps, err := p.st.ControlPlanes(ctx)
|
|
if err != nil || len(cps) < 2 {
|
|
// One control plane is not a set of them; nothing to say.
|
|
p.at, p.last = time.Now(), adminapi.ControlPlanes{}
|
|
return p.last
|
|
}
|
|
// Read each time: server add writes it after this process may have
|
|
// started.
|
|
out := adminapi.ControlPlanes{Members: make([]adminapi.ControlPlane, len(cps)), Floating: floatingAddress(p.floating)}
|
|
var wg sync.WaitGroup
|
|
for i, cp := range cps {
|
|
host := cp.URL
|
|
if u, err := url.Parse(cp.URL); err == nil && u.Hostname() != "" {
|
|
host = u.Hostname()
|
|
}
|
|
out.Members[i] = adminapi.ControlPlane{Name: cp.Name, Address: host, This: cp.Name == p.self}
|
|
wg.Go(func() { out.Members[i].State = p.ask(ctx, host) })
|
|
}
|
|
if out.Floating != "" {
|
|
wg.Go(func() { out.FloatingUp = p.ask(ctx, out.Floating) == "leading" })
|
|
}
|
|
wg.Wait()
|
|
p.at, p.last = time.Now(), out
|
|
return out
|
|
}
|
|
|
|
// ask is what one address's /leader says.
|
|
func (p *controlPlaneProbe) ask(ctx context.Context, host string) string {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.leaderURL(host), nil)
|
|
if err != nil {
|
|
return "not answering"
|
|
}
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return "not answering"
|
|
}
|
|
_ = resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
return "leading"
|
|
case http.StatusServiceUnavailable:
|
|
return "standing by"
|
|
}
|
|
return "not answering"
|
|
}
|
|
|
|
// floatingAddress is the address in the file server add keeps beside the
|
|
// keepalived configuration, without its prefix; "" when nothing floats.
|
|
// keepalived's own file holds its password and is root's alone, and this
|
|
// process is not root.
|
|
func floatingAddress(path string) string {
|
|
b, err := os.ReadFile(path) //nolint:gosec // a fixed path of our own
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
addr, _, _ := strings.Cut(strings.TrimSpace(string(b)), "/")
|
|
if net.ParseIP(addr) == nil {
|
|
return ""
|
|
}
|
|
return addr
|
|
}
|