feat(nodes): gRPC server + Hub + Redis pub/sub cross-instance delivery (tsk__58l3wTLvaSn)
Implements AgentService gRPC server with all 6 RPCs (Enroll/Register/Heartbeat/ Subscribe/Ack/ReportUsage), Hub command routing with Redis ZSET at-least-once persistence and cross-instance pub/sub delivery, LoadCache for node:load metrics, NodeStore SQL interface + MySQL implementation, and full mTLS gRPC listener in main. Integration tests: 18 tests covering full Enroll→Register→Heartbeat→Subscribe→Ack flow, reconnect resume with last_command_id, and cross-instance pub/sub delivery via bufconn + miniredis + mockNodeStore + real mTLS certificates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
|
||||
)
|
||||
|
||||
// NodeRow holds a node's essential fields from the nodes table.
|
||||
type NodeRow struct {
|
||||
ID int64
|
||||
UUID string
|
||||
Status string
|
||||
RealityPBK string
|
||||
RealitySNI string
|
||||
Endpoint string
|
||||
Hy2Port sql.NullInt32
|
||||
}
|
||||
|
||||
// NodeStore is the persistence interface used by the nodes domain handlers.
|
||||
// All methods are context-aware and safe for concurrent use.
|
||||
type NodeStore interface {
|
||||
// NodeByUUID looks up a node record by its UUID.
|
||||
// Returns (nil, nil) when no matching row exists.
|
||||
NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error)
|
||||
|
||||
// ConfigVersion returns the current global directory version.
|
||||
// This is the version from the directory_version singleton table.
|
||||
ConfigVersion(ctx context.Context) (int64, error)
|
||||
|
||||
// ActiveNodeUUIDs returns the UUIDs of all nodes with status 'up' or 'draining'.
|
||||
// Used by Broadcast to enumerate delivery targets.
|
||||
ActiveNodeUUIDs(ctx context.Context) ([]string, error)
|
||||
|
||||
// CredentialsForNode returns the active data-plane credentials for nodeUUID.
|
||||
// Returns an empty slice until task 5d creates the connect_credentials table.
|
||||
CredentialsForNode(ctx context.Context, nodeUUID string) ([]*agentv1.Credential, error)
|
||||
|
||||
// UserIDByDpUUID maps a data-plane UUID to the owning user's internal ID.
|
||||
// Returns (0, false, nil) if the dp_uuid is unknown or the user is inactive.
|
||||
UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error)
|
||||
|
||||
// AccumulateUsage adds bytes and minutes to usage_daily for userID on date.
|
||||
// Uses INSERT … ON DUPLICATE KEY UPDATE (idempotent within a day).
|
||||
AccumulateUsage(ctx context.Context, userID int64, date time.Time,
|
||||
bytesUp, bytesDown int64, minutes int64) error
|
||||
}
|
||||
|
||||
// SQLNodeStore implements NodeStore against a MySQL 8.x database.
|
||||
type SQLNodeStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLNodeStore creates a SQLNodeStore backed by db.
|
||||
func NewSQLNodeStore(db *sql.DB) *SQLNodeStore {
|
||||
return &SQLNodeStore{db: db}
|
||||
}
|
||||
|
||||
// NodeByUUID looks up a node by UUID. Returns (nil, nil) when not found.
|
||||
func (s *SQLNodeStore) NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error) {
|
||||
const q = `
|
||||
SELECT id, uuid, status, reality_pbk, reality_sni, endpoint, hy2_port
|
||||
FROM nodes
|
||||
WHERE uuid = ?
|
||||
`
|
||||
var n NodeRow
|
||||
err := s.db.QueryRowContext(ctx, q, uuid).Scan(
|
||||
&n.ID, &n.UUID, &n.Status,
|
||||
&n.RealityPBK, &n.RealitySNI, &n.Endpoint, &n.Hy2Port,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nodes.SQLNodeStore.NodeByUUID: %w", err)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// ConfigVersion returns the current global directory version.
|
||||
// Returns 0 with nil error if the directory_version row does not yet exist.
|
||||
func (s *SQLNodeStore) ConfigVersion(ctx context.Context) (int64, error) {
|
||||
var v int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT version FROM directory_version WHERE id = 1`).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("nodes.SQLNodeStore.ConfigVersion: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ActiveNodeUUIDs returns UUIDs of all nodes with status 'up' or 'draining'.
|
||||
func (s *SQLNodeStore) ActiveNodeUUIDs(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT uuid FROM nodes WHERE status IN ('up', 'draining')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nodes.SQLNodeStore.ActiveNodeUUIDs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var uuids []string
|
||||
for rows.Next() {
|
||||
var u string
|
||||
if err := rows.Scan(&u); err != nil {
|
||||
return nil, fmt.Errorf("nodes.SQLNodeStore.ActiveNodeUUIDs scan: %w", err)
|
||||
}
|
||||
uuids = append(uuids, u)
|
||||
}
|
||||
return uuids, rows.Err()
|
||||
}
|
||||
|
||||
// CredentialsForNode returns active credentials for nodeUUID.
|
||||
// Stub: returns empty slice until task 5d creates the connect_credentials table.
|
||||
func (s *SQLNodeStore) CredentialsForNode(_ context.Context, _ string) ([]*agentv1.Credential, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// UserIDByDpUUID resolves a data-plane UUID to an active user's internal ID.
|
||||
func (s *SQLNodeStore) UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) {
|
||||
var userID int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id FROM users WHERE dp_uuid = ? AND status = 'active' LIMIT 1`, dpUUID,
|
||||
).Scan(&userID)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("nodes.SQLNodeStore.UserIDByDpUUID: %w", err)
|
||||
}
|
||||
return userID, true, nil
|
||||
}
|
||||
|
||||
// AccumulateUsage adds bytes/minutes to usage_daily for the given user and date.
|
||||
func (s *SQLNodeStore) AccumulateUsage(
|
||||
ctx context.Context, userID int64, date time.Time,
|
||||
bytesUp, bytesDown int64, minutes int64,
|
||||
) error {
|
||||
const q = `
|
||||
INSERT INTO usage_daily (user_id, date, bytes_up, bytes_down, minutes_used)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
bytes_up = bytes_up + VALUES(bytes_up),
|
||||
bytes_down = bytes_down + VALUES(bytes_down),
|
||||
minutes_used = minutes_used + VALUES(minutes_used)
|
||||
`
|
||||
if _, err := s.db.ExecContext(ctx, q,
|
||||
userID, date.Format("2006-01-02"), bytesUp, bytesDown, minutes,
|
||||
); err != nil {
|
||||
return fmt.Errorf("nodes.SQLNodeStore.AccumulateUsage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user