Add mcp dns and mcp node routes commands
mcp dns queries MCNS via an agent to list all zones and DNS records. mcp node routes queries mc-proxy on each node for listener/route status, matching the mcproxyctl status output format. New agent RPCs: ListDNSRecords, ListProxyRoutes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,8 @@ type DNSRegistrar struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// dnsRecord is the JSON representation of an MCNS record.
|
||||
type dnsRecord struct {
|
||||
// DNSRecord is the JSON representation of an MCNS record.
|
||||
type DNSRecord struct {
|
||||
ID int `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
@@ -136,8 +136,87 @@ func (d *DNSRegistrar) RemoveRecord(ctx context.Context, serviceName string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// DNSZone is the JSON representation of an MCNS zone.
|
||||
type DNSZone struct {
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
// ListZones returns all zones from MCNS.
|
||||
func (d *DNSRegistrar) ListZones(ctx context.Context) ([]DNSZone, error) {
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("DNS registrar not configured")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/zones", d.serverURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create list zones request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+d.token)
|
||||
|
||||
resp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list zones: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read list zones response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("list zones: mcns returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Zones []DNSZone `json:"zones"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("parse list zones response: %w", err)
|
||||
}
|
||||
return envelope.Zones, nil
|
||||
}
|
||||
|
||||
// ListZoneRecords returns all records in the given zone (no filters).
|
||||
func (d *DNSRegistrar) ListZoneRecords(ctx context.Context, zone string) ([]DNSRecord, error) {
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("DNS registrar not configured")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/zones/%s/records", d.serverURL, zone)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create list zone records request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+d.token)
|
||||
|
||||
resp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list zone records: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read list zone records response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("list zone records: mcns returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Records []DNSRecord `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("parse list zone records response: %w", err)
|
||||
}
|
||||
return envelope.Records, nil
|
||||
}
|
||||
|
||||
// listRecords returns A records matching the service name in the zone.
|
||||
func (d *DNSRegistrar) listRecords(ctx context.Context, serviceName string) ([]dnsRecord, error) {
|
||||
func (d *DNSRegistrar) listRecords(ctx context.Context, serviceName string) ([]DNSRecord, error) {
|
||||
url := fmt.Sprintf("%s/v1/zones/%s/records?name=%s&type=A", d.serverURL, d.zone, serviceName)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -161,7 +240,7 @@ func (d *DNSRegistrar) listRecords(ctx context.Context, serviceName string) ([]d
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Records []dnsRecord `json:"records"`
|
||||
Records []DNSRecord `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("parse list response: %w", err)
|
||||
|
||||
40
internal/agent/dns_rpc.go
Normal file
40
internal/agent/dns_rpc.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||
)
|
||||
|
||||
// ListDNSRecords queries MCNS for all zones and their records.
|
||||
func (a *Agent) ListDNSRecords(ctx context.Context, _ *mcpv1.ListDNSRecordsRequest) (*mcpv1.ListDNSRecordsResponse, error) {
|
||||
a.Logger.Debug("ListDNSRecords called")
|
||||
|
||||
zones, err := a.DNS.ListZones(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list zones: %w", err)
|
||||
}
|
||||
|
||||
resp := &mcpv1.ListDNSRecordsResponse{}
|
||||
for _, z := range zones {
|
||||
records, err := a.DNS.ListZoneRecords(ctx, z.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list records for zone %q: %w", z.Name, err)
|
||||
}
|
||||
|
||||
zone := &mcpv1.DNSZone{Name: z.Name}
|
||||
for _, r := range records {
|
||||
zone.Records = append(zone.Records, &mcpv1.DNSRecord{
|
||||
Id: int64(r.ID),
|
||||
Name: r.Name,
|
||||
Type: r.Type,
|
||||
Value: r.Value,
|
||||
Ttl: int32(r.TTL), //nolint:gosec // TTL is bounded
|
||||
})
|
||||
}
|
||||
resp.Zones = append(resp.Zones, zone)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestEnsureRecordSkipsWhenExists(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
// Return an existing record with the correct value.
|
||||
resp := map[string][]dnsRecord{"records": {{ID: 1, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
||||
resp := map[string][]DNSRecord{"records": {{ID: 1, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
@@ -124,7 +124,7 @@ func TestEnsureRecordUpdatesWrongValue(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
// Return a record with a stale value.
|
||||
resp := map[string][]dnsRecord{"records": {{ID: 42, Name: "myservice", Type: "A", Value: "10.0.0.1", TTL: 300}}}
|
||||
resp := map[string][]DNSRecord{"records": {{ID: 42, Name: "myservice", Type: "A", Value: "10.0.0.1", TTL: 300}}}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
@@ -160,7 +160,7 @@ func TestRemoveRecordDeletes(t *testing.T) {
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
resp := map[string][]dnsRecord{"records": {{ID: 7, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
||||
resp := map[string][]DNSRecord{"records": {{ID: 7, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
|
||||
@@ -48,6 +48,14 @@ func (p *ProxyRouter) Close() error {
|
||||
return p.client.Close()
|
||||
}
|
||||
|
||||
// GetStatus returns the mc-proxy server status.
|
||||
func (p *ProxyRouter) GetStatus(ctx context.Context) (*mcproxy.Status, error) {
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("mc-proxy not configured")
|
||||
}
|
||||
return p.client.GetStatus(ctx)
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all routes for a service component with mc-proxy.
|
||||
// It uses the assigned host ports from the registry.
|
||||
func (p *ProxyRouter) RegisterRoutes(ctx context.Context, serviceName string, routes []registry.Route, hostPorts map[string]int) error {
|
||||
|
||||
46
internal/agent/proxy_rpc.go
Normal file
46
internal/agent/proxy_rpc.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// ListProxyRoutes queries mc-proxy for its current status and routes.
|
||||
func (a *Agent) ListProxyRoutes(ctx context.Context, _ *mcpv1.ListProxyRoutesRequest) (*mcpv1.ListProxyRoutesResponse, error) {
|
||||
a.Logger.Debug("ListProxyRoutes called")
|
||||
|
||||
status, err := a.Proxy.GetStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get mc-proxy status: %w", err)
|
||||
}
|
||||
|
||||
resp := &mcpv1.ListProxyRoutesResponse{
|
||||
Version: status.Version,
|
||||
TotalConnections: status.TotalConnections,
|
||||
}
|
||||
if !status.StartedAt.IsZero() {
|
||||
resp.StartedAt = timestamppb.New(status.StartedAt)
|
||||
}
|
||||
|
||||
for _, ls := range status.Listeners {
|
||||
listener := &mcpv1.ProxyListenerInfo{
|
||||
Addr: ls.Addr,
|
||||
RouteCount: int32(ls.RouteCount), //nolint:gosec // bounded
|
||||
ActiveConnections: ls.ActiveConnections,
|
||||
}
|
||||
for _, r := range ls.Routes {
|
||||
listener.Routes = append(listener.Routes, &mcpv1.ProxyRouteInfo{
|
||||
Hostname: r.Hostname,
|
||||
Backend: r.Backend,
|
||||
Mode: r.Mode,
|
||||
BackendTls: r.BackendTLS,
|
||||
})
|
||||
}
|
||||
resp.Listeners = append(resp.Listeners, listener)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
Reference in New Issue
Block a user