Phase2
This commit is contained in:
357
apps/node-agent/internal/ws/client.go
Normal file
357
apps/node-agent/internal/ws/client.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||
runtimepkg "github.com/hexahost/gamecloud/node-agent/internal/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxReconnectDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
// Client maintains an outbound WebSocket connection to the control plane.
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
runtime *runtimepkg.Manager
|
||||
agentVersion string
|
||||
|
||||
writeMu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
// NewClient creates a WebSocket client for the control plane.
|
||||
func NewClient(cfg *config.Config, log *slog.Logger, mgr *runtimepkg.Manager, agentVersion string) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
runtime: mgr,
|
||||
agentVersion: agentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// SetRuntime attaches the runtime manager after construction to break init cycles.
|
||||
func (c *Client) SetRuntime(mgr *runtimepkg.Manager) {
|
||||
c.runtime = mgr
|
||||
}
|
||||
|
||||
// Run connects to the control plane and processes messages until ctx is cancelled.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
backoff := c.cfg.ReconnectBackoff
|
||||
if backoff <= 0 {
|
||||
backoff = 5 * time.Second
|
||||
}
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
err := c.session(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
c.log.Warn("websocket disconnected", "error", err, "retry_in", backoff.String())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
backoff *= 2
|
||||
if backoff > maxReconnectDelay {
|
||||
backoff = maxReconnectDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) session(ctx context.Context) error {
|
||||
conn, err := c.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
c.conn = conn
|
||||
c.writeMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.writeMu.Lock()
|
||||
c.conn = nil
|
||||
c.writeMu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
if err := c.sendHello(ctx); err != nil {
|
||||
return fmt.Errorf("send hello: %w", err)
|
||||
}
|
||||
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
errCh <- c.heartbeatLoop(sessionCtx)
|
||||
}()
|
||||
go func() {
|
||||
errCh <- c.readLoop(sessionCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) dial(ctx context.Context) (*websocket.Conn, error) {
|
||||
endpoint, err := c.buildURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialer := websocket.DefaultDialer
|
||||
conn, _, err := dialer.DialContext(ctx, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial websocket: %w", err)
|
||||
}
|
||||
c.log.Info("connected to control plane", "url", redactToken(endpoint))
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *Client) buildURL() (string, error) {
|
||||
u, err := url.Parse(c.cfg.ControlPlaneURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse control plane url: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("nodeId", c.cfg.NodeID)
|
||||
if c.cfg.NodeToken != "" {
|
||||
q.Set("token", c.cfg.NodeToken)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) sendHello(ctx context.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
payload := protocol.AgentHelloPayload{
|
||||
NodeID: c.cfg.NodeID,
|
||||
AgentVersion: c.agentVersion,
|
||||
ProtocolVersion: protocol.CurrentProtocolVersion,
|
||||
Hostname: hostname,
|
||||
Capabilities: []string{"minecraft", "docker"},
|
||||
}
|
||||
return c.send(ctx, protocol.TypeAgentHello, "", payload)
|
||||
}
|
||||
|
||||
func (c *Client) heartbeatLoop(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
pingTicker := time.NewTicker(pingPeriod)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := c.sendHeartbeat(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
if err := c.writePing(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendHeartbeat(ctx context.Context) error {
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
payload := protocol.AgentHeartbeatPayload{
|
||||
NodeID: c.cfg.NodeID,
|
||||
CPUUsagePercent: 0,
|
||||
MemoryUsedBytes: int64(memStats.Alloc),
|
||||
MemoryTotalBytes: int64(memStats.Sys),
|
||||
RunningServers: c.runtime.RunningCount(),
|
||||
}
|
||||
return c.send(ctx, protocol.TypeAgentHeartbeat, "", payload)
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(ctx context.Context) error {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
conn := c.conn
|
||||
c.writeMu.Unlock()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("connection closed")
|
||||
}
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
|
||||
env, err := protocol.DecodeEnvelope(data)
|
||||
if err != nil {
|
||||
c.log.Warn("invalid inbound envelope", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
c.dispatch(ctx, env)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||
correlationID := env.CorrelationID
|
||||
if correlationID == "" {
|
||||
correlationID = env.MessageID
|
||||
}
|
||||
|
||||
switch env.Type {
|
||||
case protocol.TypeServerProvision:
|
||||
var payload protocol.ServerProvisionPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.provision", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleProvision(ctx, correlationID, payload)
|
||||
|
||||
case protocol.TypeServerStart:
|
||||
var payload protocol.ServerStartPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.start", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleStart(ctx, correlationID, payload)
|
||||
|
||||
case protocol.TypeServerStop:
|
||||
var payload protocol.ServerStopPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.stop", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleStop(ctx, correlationID, payload)
|
||||
|
||||
case protocol.TypeServerDelete:
|
||||
var meta protocol.OperationMeta
|
||||
if err := json.Unmarshal(env.Payload, &meta); err != nil {
|
||||
c.log.Warn("decode server.delete", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleDelete(ctx, correlationID, meta)
|
||||
|
||||
default:
|
||||
c.log.Debug("ignored control message", "type", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) send(ctx context.Context, msgType protocol.MessageType, correlationID string, payload any) error {
|
||||
env, err := protocol.NewEnvelope(msgType, uuid.NewString(), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if correlationID != "" {
|
||||
env.CorrelationID = correlationID
|
||||
}
|
||||
return c.writeEnvelope(ctx, env)
|
||||
}
|
||||
|
||||
func (c *Client) writeEnvelope(ctx context.Context, env *protocol.Envelope) error {
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
conn := c.conn
|
||||
c.writeMu.Unlock()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(writeWait)
|
||||
}
|
||||
if err := conn.SetWriteDeadline(deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
return c.conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
func (c *Client) writePing() error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
return c.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
}
|
||||
|
||||
// SendState implements runtime.Responder.
|
||||
func (c *Client) SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error {
|
||||
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendOperationComplete implements runtime.Responder.
|
||||
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendOperationFailed implements runtime.Responder.
|
||||
func (c *Client) SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerOperationFailed, correlationID, payload)
|
||||
}
|
||||
|
||||
func redactToken(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
if u.Query().Get("token") != "" {
|
||||
q := u.Query()
|
||||
q.Set("token", "REDACTED")
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
Reference in New Issue
Block a user