10 Commits

Author SHA1 Message Date
da59d60c2d Add master integration to CLI deploy and undeploy
- CLIConfig gains optional [master] section with address field
- dialMaster() creates McpMasterServiceClient (same TLS/token pattern)
- deploy: routes through master when [master] configured, --direct
  flag bypasses master for v1-style agent deployment
- undeploy: same master/direct routing pattern
- Master responses show per-step results (deploy, dns, edge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:43:51 -07:00
598ea44e0b Add mcp-master binary and build target
New cmd/mcp-master/ entry point following the agent pattern:
cobra CLI with --config, version, and server commands.

Makefile: add mcp-master target, update all and clean targets.
Example config: deploy/examples/mcp-master.toml with all sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:41:43 -07:00
6fd81cacf2 Add master core: deploy, undeploy, status, placement, DNS
Master struct with Run() lifecycle following the agent pattern exactly:
open DB → bootstrap nodes → create agent pool → DNS client → TLS →
auth interceptor → gRPC server → signal handler.

RPC handlers:
- Deploy: place service (tier-aware), forward to agent, register DNS
  with Tailnet IP, detect public routes, validate against allowed
  domains, coordinate edge routing via SetupEdgeRoute, record placement
  and edge routes in master DB, return structured per-step results.
- Undeploy: undeploy on worker first, then remove edge routes, DNS,
  and DB records. Best-effort cleanup on failure.
- Status: query agents for service status, aggregate with placements
  and edge route info from master DB.
- ListNodes: return all nodes with placement counts.

Placement algorithm: fewest services, ties broken alphabetically.
DNS client: extracted from agent's DNSRegistrar with explicit nodeAddr
parameter (master registers for different nodes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:39:46 -07:00
20735e4b41 Add agent client and connection pool for master
AgentClient wraps a gRPC connection to a single agent with typed
forwarding methods (Deploy, UndeployService, SetupEdgeRoute, etc.).
AgentPool manages connections to multiple agents keyed by node name.

Follows the same TLS 1.3 + token interceptor pattern as cmd/mcp/dial.go
but runs server-side with the master's own MCIAS service token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:35:16 -07:00
3c0b55f9f8 Add master database with nodes, placements, and edge_routes
New internal/masterdb/ package for mcp-master cluster state. Separate
from the agent's registry because the schemas are fundamentally
different (cluster-wide placement vs node-local containers).

Tables: nodes, placements, edge_routes. Full CRUD with tests.
Follows the same Open/migrate pattern as internal/registry/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:26:04 -07:00
78890ed76a Add master config loader
MasterConfig with TOML loading, env overrides (MCP_MASTER_*), defaults,
and validation. Follows the exact pattern of AgentConfig. Includes:
server, database, MCIAS, edge (allowed_domains), registration
(allowed_agents, max_nodes), timeouts, MCNS, bootstrap [[nodes]], and
master service token path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:23:19 -07:00
c5ff5bb63c Add McpMasterService proto and v2 ServiceSpec fields
- New proto/mcp/v1/master.proto: McpMasterService with Deploy, Undeploy,
  Status, ListNodes RPCs and all message types per architecture v2 spec.
- ServiceSpec gains tier (field 5), node (field 6), snapshot (field 7).
- RouteSpec gains public (field 5) for edge routing.
- New SnapshotConfig message (method + excludes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:22:04 -07:00
ddd6f123ab Merge pull request 'Document v2 multi-node architecture in CLAUDE.md' (#3) from claude/update-claude-md-v2-multinode into master 2026-04-02 22:20:19 +00:00
90445507a3 Document v2 multi-node architecture in CLAUDE.md
Add v2 Development section covering multi-node fleet design (master,
agent self-registration, tier-based placement, edge routing). Update
project structure to reflect new agent subsystems (edge_rpc, dns,
proxy, certs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:14:20 -07:00
68d670b3ed Add edge CLI scaffolding for Phase 2 testing
Temporary CLI commands for testing edge routing RPCs directly
(before the master exists):

  mcp edge list -n svc
  mcp edge setup <hostname> -n svc --backend-hostname ... --backend-port ...
  mcp edge remove <hostname> -n svc

Verified end-to-end on svc: setup provisions route in mc-proxy and
persists in agent registry, remove cleans up both, list shows routes
with cert metadata.

Finding: MCNS registers LAN IPs for .svc.mcp. hostnames, not Tailnet
IPs. The v2 master needs to register Tailnet IPs in deploy flow step 3.

These commands will be removed or replaced when the master is built
(Phase 3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:04:12 -07:00
28 changed files with 3949 additions and 334 deletions

View File

@@ -12,6 +12,21 @@ MCP has two components:
Services have one or more components (containers). Container naming: `<service>-<component>`.
## v2 Development (Multi-Node)
MCP v2 extends the single-node agent model to a multi-node fleet with a central master process. See the root repo's `docs/phase-e-plan.md` and `docs/architecture-v2.md` for the full design.
**Current state:**
- **svc** is operational as an edge node (manages mc-proxy routing only, no containers)
- **rift** runs the agent with full container management
- **orion** is provisioned but offline for maintenance
**Key v2 concepts (in development):**
- **mcp-master** — central orchestrator on rift. Accepts CLI commands, dispatches to agents, maintains node registry, coordinates edge routing.
- **Agent self-registration** — agents register with the master on startup (name, role, address, arch). No static node config required after bootstrap.
- **Tier-based placement** — `tier = "core"` runs on the master node, `tier = "worker"` (default) is auto-placed on a worker with capacity, `node = "<name>"` overrides for pinned services.
- **Edge routing** — `public = true` on routes declares intent; the master assigns the route to an edge node (currently svc).
## Build Commands
```bash
@@ -33,7 +48,7 @@ Run a single test: `go test ./internal/registry/ -run TestComponentCRUD`
- `cmd/mcp/` — CLI entry point
- `cmd/mcp-agent/` — Agent entry point
- `internal/agent/` — Agent core (deploy, lifecycle, sync, adopt, status, files)
- `internal/agent/` — Agent core (deploy, lifecycle, sync, adopt, status, files, edge_rpc, dns, proxy, certs)
- `internal/runtime/` — Container runtime abstraction (podman)
- `internal/registry/` — SQLite registry (services, components, events)
- `internal/monitor/` — Monitoring subsystem (watch loop, alerting)

View File

@@ -8,6 +8,9 @@ mcp:
mcp-agent:
CGO_ENABLED=0 go build $(LDFLAGS) -o mcp-agent ./cmd/mcp-agent
mcp-master:
CGO_ENABLED=0 go build $(LDFLAGS) -o mcp-master ./cmd/mcp-master
build:
go build ./...
@@ -30,6 +33,6 @@ proto-lint:
buf breaking --against '.git#branch=master,subdir=proto'
clean:
rm -f mcp mcp-agent
rm -f mcp mcp-agent mcp-master
all: vet lint test mcp mcp-agent
all: vet lint test mcp mcp-agent mcp-master

49
cmd/mcp-master/main.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
"log"
"os"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/master"
"github.com/spf13/cobra"
)
var (
version = "dev"
cfgPath string
)
func main() {
root := &cobra.Command{
Use: "mcp-master",
Short: "Metacircular Control Plane master",
}
root.PersistentFlags().StringVarP(&cfgPath, "config", "c", "", "config file path")
root.AddCommand(&cobra.Command{
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(version)
},
})
root.AddCommand(&cobra.Command{
Use: "server",
Short: "Start the master server",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadMasterConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
return master.Run(cfg, version)
},
})
if err := root.Execute(); err != nil {
log.Fatal(err)
os.Exit(1)
}
}

View File

@@ -15,6 +15,8 @@ import (
)
func deployCmd() *cobra.Command {
var direct bool
cmd := &cobra.Command{
Use: "deploy <service>[/<component>]",
Short: "Deploy service from service definition",
@@ -40,6 +42,12 @@ func deployCmd() *cobra.Command {
spec := servicedef.ToProto(def)
// Route through master if configured and not in direct mode.
if cfg.Master != nil && cfg.Master.Address != "" && !direct {
return deployViaMaster(cfg, spec)
}
// Direct mode: deploy to agent.
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
@@ -64,9 +72,48 @@ func deployCmd() *cobra.Command {
},
}
cmd.Flags().StringP("file", "f", "", "service definition file")
cmd.Flags().BoolVar(&direct, "direct", false, "bypass master, deploy directly to agent (v1 mode)")
return cmd
}
func deployViaMaster(cfg *config.CLIConfig, spec *mcpv1.ServiceSpec) error {
client, conn, err := dialMaster(cfg.Master.Address, cfg)
if err != nil {
return fmt.Errorf("dial master: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.Deploy(context.Background(), &mcpv1.MasterDeployRequest{
Service: spec,
})
if err != nil {
return fmt.Errorf("master deploy: %w", err)
}
fmt.Printf(" %s: placed on %s\n", spec.GetName(), resp.GetNode())
if r := resp.GetDeployResult(); r != nil {
printStepResult("deploy", r)
}
if r := resp.GetDnsResult(); r != nil {
printStepResult("dns", r)
}
if r := resp.GetEdgeRouteResult(); r != nil {
printStepResult("edge", r)
}
if !resp.GetSuccess() {
return fmt.Errorf("deploy failed: %s", resp.GetError())
}
return nil
}
func printStepResult(name string, r *mcpv1.StepResult) {
if r.GetSuccess() {
fmt.Printf(" %s: ok\n", name)
} else {
fmt.Printf(" %s: FAILED — %s\n", name, r.GetError())
}
}
// parseServiceArg splits a "service/component" argument into its parts.
func parseServiceArg(arg string) (service, component string) {
parts := strings.SplitN(arg, "/", 2)
@@ -125,8 +172,9 @@ func loadServiceDef(cmd *cobra.Command, cfg *config.CLIConfig, serviceName strin
// ServiceInfo, not ServiceSpec.
func serviceSpecFromInfo(info *mcpv1.ServiceInfo) *mcpv1.ServiceSpec {
spec := &mcpv1.ServiceSpec{
Name: info.GetName(),
Active: info.GetActive(),
Name: info.GetName(),
Active: info.GetActive(),
Comment: info.GetComment(),
}
for _, c := range info.GetComponents() {
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{

View File

@@ -52,6 +52,43 @@ func dialAgent(address string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClie
return mcpv1.NewMcpAgentServiceClient(conn), conn, nil
}
// dialMaster connects to the master at the given address and returns a gRPC
// client for the McpMasterService.
func dialMaster(address string, cfg *config.CLIConfig) (mcpv1.McpMasterServiceClient, *grpc.ClientConn, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if cfg.MCIAS.CACert != "" {
caCert, err := os.ReadFile(cfg.MCIAS.CACert) //nolint:gosec // trusted config path
if err != nil {
return nil, nil, fmt.Errorf("read CA cert %q: %w", cfg.MCIAS.CACert, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, nil, fmt.Errorf("invalid CA cert %q", cfg.MCIAS.CACert)
}
tlsConfig.RootCAs = pool
}
token, err := loadBearerToken(cfg)
if err != nil {
return nil, nil, fmt.Errorf("load token: %w", err)
}
conn, err := grpc.NewClient(
address,
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
grpc.WithUnaryInterceptor(tokenInterceptor(token)),
grpc.WithStreamInterceptor(streamTokenInterceptor(token)),
)
if err != nil {
return nil, nil, fmt.Errorf("dial master %q: %w", address, err)
}
return mcpv1.NewMcpMasterServiceClient(conn), conn, nil
}
// tokenInterceptor returns a gRPC client interceptor that attaches the
// bearer token to outgoing RPC metadata.
func tokenInterceptor(token string) grpc.UnaryClientInterceptor {

180
cmd/mcp/edge.go Normal file
View File

@@ -0,0 +1,180 @@
package main
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
)
func edgeCmd() *cobra.Command {
var nodeName string
cmd := &cobra.Command{
Use: "edge",
Short: "Manage edge routes (scaffolding — will be replaced by master)",
}
list := &cobra.Command{
Use: "list",
Short: "List edge routes on a node",
RunE: func(_ *cobra.Command, _ []string) error {
if nodeName == "" {
return fmt.Errorf("--node is required")
}
return runEdgeList(nodeName)
},
}
var (
backendHostname string
backendPort int
)
setup := &cobra.Command{
Use: "setup <hostname>",
Short: "Set up an edge route (provisions cert, registers mc-proxy route)",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
if nodeName == "" {
return fmt.Errorf("--node is required")
}
if backendHostname == "" {
return fmt.Errorf("--backend-hostname is required")
}
if backendPort == 0 {
return fmt.Errorf("--backend-port is required")
}
return runEdgeSetup(nodeName, args[0], backendHostname, backendPort)
},
}
setup.Flags().StringVar(&backendHostname, "backend-hostname", "", "internal .svc.mcp hostname")
setup.Flags().IntVar(&backendPort, "backend-port", 0, "port on worker's mc-proxy")
remove := &cobra.Command{
Use: "remove <hostname>",
Short: "Remove an edge route",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
if nodeName == "" {
return fmt.Errorf("--node is required")
}
return runEdgeRemove(nodeName, args[0])
},
}
cmd.PersistentFlags().StringVarP(&nodeName, "node", "n", "", "target node (required)")
cmd.AddCommand(list, setup, remove)
return cmd
}
func runEdgeList(nodeName string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
address, err := findNodeAddress(cfg, nodeName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.ListEdgeRoutes(ctx, &mcpv1.ListEdgeRoutesRequest{})
if err != nil {
return fmt.Errorf("list edge routes: %w", err)
}
if len(resp.GetRoutes()) == 0 {
fmt.Printf("No edge routes on %s\n", nodeName)
return nil
}
fmt.Printf("Edge routes on %s:\n", nodeName)
for _, r := range resp.GetRoutes() {
expires := r.GetCertExpires()
if expires == "" {
expires = "unknown"
}
fmt.Printf(" %s → %s:%d cert_expires=%s\n",
r.GetHostname(), r.GetBackendHostname(), r.GetBackendPort(), expires)
}
return nil
}
func runEdgeSetup(nodeName, hostname, backendHostname string, backendPort int) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
address, err := findNodeAddress(cfg, nodeName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err = client.SetupEdgeRoute(ctx, &mcpv1.SetupEdgeRouteRequest{
Hostname: hostname,
BackendHostname: backendHostname,
BackendPort: int32(backendPort), //nolint:gosec // port is a small positive integer
BackendTls: true,
})
if err != nil {
return fmt.Errorf("setup edge route: %w", err)
}
fmt.Printf("edge route established: %s → %s:%d on %s\n", hostname, backendHostname, backendPort, nodeName)
return nil
}
func runEdgeRemove(nodeName, hostname string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
address, err := findNodeAddress(cfg, nodeName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = client.RemoveEdgeRoute(ctx, &mcpv1.RemoveEdgeRouteRequest{
Hostname: hostname,
})
if err != nil {
return fmt.Errorf("remove edge route: %w", err)
}
fmt.Printf("edge route removed: %s on %s\n", hostname, nodeName)
return nil
}

View File

@@ -54,6 +54,7 @@ func main() {
root.AddCommand(editCmd())
root.AddCommand(dnsCmd())
root.AddCommand(routeCmd())
root.AddCommand(edgeCmd())
if err := root.Execute(); err != nil {
log.Fatal(err)

View File

@@ -13,7 +13,9 @@ import (
)
func undeployCmd() *cobra.Command {
return &cobra.Command{
var direct bool
cmd := &cobra.Command{
Use: "undeploy <service>",
Short: "Fully undeploy a service: remove routes, DNS, certs, and containers",
Args: cobra.ExactArgs(1),
@@ -38,6 +40,11 @@ func undeployCmd() *cobra.Command {
return fmt.Errorf("write service def: %w", err)
}
// Route through master if configured and not in direct mode.
if cfg.Master != nil && cfg.Master.Address != "" && !direct {
return undeployViaMaster(cfg, serviceName)
}
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
@@ -60,4 +67,28 @@ func undeployCmd() *cobra.Command {
return nil
},
}
cmd.Flags().BoolVar(&direct, "direct", false, "bypass master, undeploy directly via agent")
return cmd
}
func undeployViaMaster(cfg *config.CLIConfig, serviceName string) error {
client, conn, err := dialMaster(cfg.Master.Address, cfg)
if err != nil {
return fmt.Errorf("dial master: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.Undeploy(context.Background(), &mcpv1.MasterUndeployRequest{
ServiceName: serviceName,
})
if err != nil {
return fmt.Errorf("master undeploy: %w", err)
}
if resp.GetSuccess() {
fmt.Printf(" %s: undeployed\n", serviceName)
} else {
return fmt.Errorf("undeploy failed: %s", resp.GetError())
}
return nil
}

View File

@@ -0,0 +1,94 @@
# MCP Master configuration
#
# Default location: /srv/mcp-master/mcp-master.toml
# Override with: mcp-master server --config /path/to/mcp-master.toml
# ------------------------------------------------------------------
# gRPC server
# ------------------------------------------------------------------
[server]
# Listen address for the gRPC server. Bind to the Tailnet interface.
grpc_addr = "100.95.252.120:9555"
tls_cert = "/srv/mcp-master/certs/cert.pem"
tls_key = "/srv/mcp-master/certs/key.pem"
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
[database]
path = "/srv/mcp-master/master.db"
# ------------------------------------------------------------------
# MCIAS (for validating inbound CLI/agent tokens)
# ------------------------------------------------------------------
[mcias]
server_url = "https://mcias.metacircular.net:8443"
ca_cert = "/srv/mcp-master/certs/ca.pem"
service_name = "mcp-master"
# ------------------------------------------------------------------
# Master identity (for dialing agents)
# ------------------------------------------------------------------
[master]
# Path to the MCIAS service token file used by the master to
# authenticate to agents when forwarding deploys and edge routes.
service_token_path = "/srv/mcp-master/mcias-token"
# CA cert for verifying agent TLS certificates.
ca_cert = "/srv/mcp-master/certs/ca.pem"
# ------------------------------------------------------------------
# Edge routing
# ------------------------------------------------------------------
[edge]
# Public hostnames in service definitions must fall under one of these
# domains. Validation uses proper domain label matching.
allowed_domains = ["metacircular.net", "wntrmute.net"]
# ------------------------------------------------------------------
# Agent registration
# ------------------------------------------------------------------
[registration]
# MCIAS service identities permitted to register.
allowed_agents = ["agent-rift", "agent-svc", "agent-orion"]
# Maximum registered nodes.
max_nodes = 16
# ------------------------------------------------------------------
# Timeouts
# ------------------------------------------------------------------
[timeouts]
deploy = "5m"
edge_route = "30s"
health_check = "5s"
undeploy = "2m"
snapshot = "10m"
# ------------------------------------------------------------------
# DNS (MCNS)
# ------------------------------------------------------------------
[mcns]
server_url = "https://mcns.svc.mcp.metacircular.net:8443"
ca_cert = "/srv/mcp-master/certs/ca.pem"
token_path = "/srv/mcp-master/mcns-token"
zone = "svc.mcp.metacircular.net"
# ------------------------------------------------------------------
# Logging
# ------------------------------------------------------------------
[log]
level = "info"
# ------------------------------------------------------------------
# Bootstrap nodes
# ------------------------------------------------------------------
[[nodes]]
name = "rift"
address = "100.95.252.120:9444"
role = "master"
[[nodes]]
name = "svc"
address = "100.106.232.4:9555"
role = "edge"

849
gen/mcp/v1/master.pb.go Normal file
View File

@@ -0,0 +1,849 @@
// McpMasterService: Multi-node orchestration for the Metacircular platform.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: proto/mcp/v1/master.proto
package mcpv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type MasterDeployRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Service *ServiceSpec `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterDeployRequest) Reset() {
*x = MasterDeployRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterDeployRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterDeployRequest) ProtoMessage() {}
func (x *MasterDeployRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterDeployRequest.ProtoReflect.Descriptor instead.
func (*MasterDeployRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{0}
}
func (x *MasterDeployRequest) GetService() *ServiceSpec {
if x != nil {
return x.Service
}
return nil
}
type MasterDeployResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` // node the service was placed on
Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` // true only if ALL steps succeeded
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
// Per-step results for operator visibility.
DeployResult *StepResult `protobuf:"bytes,4,opt,name=deploy_result,json=deployResult,proto3" json:"deploy_result,omitempty"`
EdgeRouteResult *StepResult `protobuf:"bytes,5,opt,name=edge_route_result,json=edgeRouteResult,proto3" json:"edge_route_result,omitempty"`
DnsResult *StepResult `protobuf:"bytes,6,opt,name=dns_result,json=dnsResult,proto3" json:"dns_result,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterDeployResponse) Reset() {
*x = MasterDeployResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterDeployResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterDeployResponse) ProtoMessage() {}
func (x *MasterDeployResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterDeployResponse.ProtoReflect.Descriptor instead.
func (*MasterDeployResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{1}
}
func (x *MasterDeployResponse) GetNode() string {
if x != nil {
return x.Node
}
return ""
}
func (x *MasterDeployResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *MasterDeployResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
func (x *MasterDeployResponse) GetDeployResult() *StepResult {
if x != nil {
return x.DeployResult
}
return nil
}
func (x *MasterDeployResponse) GetEdgeRouteResult() *StepResult {
if x != nil {
return x.EdgeRouteResult
}
return nil
}
func (x *MasterDeployResponse) GetDnsResult() *StepResult {
if x != nil {
return x.DnsResult
}
return nil
}
type StepResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
Step string `protobuf:"bytes,1,opt,name=step,proto3" json:"step,omitempty"`
Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"`
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StepResult) Reset() {
*x = StepResult{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StepResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StepResult) ProtoMessage() {}
func (x *StepResult) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StepResult.ProtoReflect.Descriptor instead.
func (*StepResult) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{2}
}
func (x *StepResult) GetStep() string {
if x != nil {
return x.Step
}
return ""
}
func (x *StepResult) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *StepResult) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type MasterUndeployRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterUndeployRequest) Reset() {
*x = MasterUndeployRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterUndeployRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterUndeployRequest) ProtoMessage() {}
func (x *MasterUndeployRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterUndeployRequest.ProtoReflect.Descriptor instead.
func (*MasterUndeployRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{3}
}
func (x *MasterUndeployRequest) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
type MasterUndeployResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterUndeployResponse) Reset() {
*x = MasterUndeployResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterUndeployResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterUndeployResponse) ProtoMessage() {}
func (x *MasterUndeployResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterUndeployResponse.ProtoReflect.Descriptor instead.
func (*MasterUndeployResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{4}
}
func (x *MasterUndeployResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *MasterUndeployResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type MasterStatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` // empty = all services
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterStatusRequest) Reset() {
*x = MasterStatusRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterStatusRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterStatusRequest) ProtoMessage() {}
func (x *MasterStatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterStatusRequest.ProtoReflect.Descriptor instead.
func (*MasterStatusRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{5}
}
func (x *MasterStatusRequest) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
type MasterStatusResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Services []*ServiceStatus `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterStatusResponse) Reset() {
*x = MasterStatusResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterStatusResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterStatusResponse) ProtoMessage() {}
func (x *MasterStatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterStatusResponse.ProtoReflect.Descriptor instead.
func (*MasterStatusResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{6}
}
func (x *MasterStatusResponse) GetServices() []*ServiceStatus {
if x != nil {
return x.Services
}
return nil
}
type ServiceStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Node string `protobuf:"bytes,2,opt,name=node,proto3" json:"node,omitempty"`
Tier string `protobuf:"bytes,3,opt,name=tier,proto3" json:"tier,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` // "running", "stopped", "unhealthy", "unknown"
EdgeRoutes []*EdgeRouteStatus `protobuf:"bytes,5,rep,name=edge_routes,json=edgeRoutes,proto3" json:"edge_routes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ServiceStatus) Reset() {
*x = ServiceStatus{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ServiceStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ServiceStatus) ProtoMessage() {}
func (x *ServiceStatus) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ServiceStatus.ProtoReflect.Descriptor instead.
func (*ServiceStatus) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{7}
}
func (x *ServiceStatus) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ServiceStatus) GetNode() string {
if x != nil {
return x.Node
}
return ""
}
func (x *ServiceStatus) GetTier() string {
if x != nil {
return x.Tier
}
return ""
}
func (x *ServiceStatus) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *ServiceStatus) GetEdgeRoutes() []*EdgeRouteStatus {
if x != nil {
return x.EdgeRoutes
}
return nil
}
type EdgeRouteStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"`
EdgeNode string `protobuf:"bytes,2,opt,name=edge_node,json=edgeNode,proto3" json:"edge_node,omitempty"`
CertExpires string `protobuf:"bytes,3,opt,name=cert_expires,json=certExpires,proto3" json:"cert_expires,omitempty"` // RFC3339
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EdgeRouteStatus) Reset() {
*x = EdgeRouteStatus{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EdgeRouteStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EdgeRouteStatus) ProtoMessage() {}
func (x *EdgeRouteStatus) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EdgeRouteStatus.ProtoReflect.Descriptor instead.
func (*EdgeRouteStatus) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{8}
}
func (x *EdgeRouteStatus) GetHostname() string {
if x != nil {
return x.Hostname
}
return ""
}
func (x *EdgeRouteStatus) GetEdgeNode() string {
if x != nil {
return x.EdgeNode
}
return ""
}
func (x *EdgeRouteStatus) GetCertExpires() string {
if x != nil {
return x.CertExpires
}
return ""
}
type ListNodesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListNodesRequest) Reset() {
*x = ListNodesRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListNodesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListNodesRequest) ProtoMessage() {}
func (x *ListNodesRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListNodesRequest.ProtoReflect.Descriptor instead.
func (*ListNodesRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{9}
}
type ListNodesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Nodes []*NodeInfo `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListNodesResponse) Reset() {
*x = ListNodesResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListNodesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListNodesResponse) ProtoMessage() {}
func (x *ListNodesResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListNodesResponse.ProtoReflect.Descriptor instead.
func (*ListNodesResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{10}
}
func (x *ListNodesResponse) GetNodes() []*NodeInfo {
if x != nil {
return x.Nodes
}
return nil
}
type NodeInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"`
Arch string `protobuf:"bytes,4,opt,name=arch,proto3" json:"arch,omitempty"`
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` // "healthy", "unhealthy", "unknown"
Containers int32 `protobuf:"varint,6,opt,name=containers,proto3" json:"containers,omitempty"`
LastHeartbeat string `protobuf:"bytes,7,opt,name=last_heartbeat,json=lastHeartbeat,proto3" json:"last_heartbeat,omitempty"` // RFC3339
Services int32 `protobuf:"varint,8,opt,name=services,proto3" json:"services,omitempty"` // placement count
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NodeInfo) Reset() {
*x = NodeInfo{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NodeInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NodeInfo) ProtoMessage() {}
func (x *NodeInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NodeInfo.ProtoReflect.Descriptor instead.
func (*NodeInfo) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{11}
}
func (x *NodeInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *NodeInfo) GetRole() string {
if x != nil {
return x.Role
}
return ""
}
func (x *NodeInfo) GetAddress() string {
if x != nil {
return x.Address
}
return ""
}
func (x *NodeInfo) GetArch() string {
if x != nil {
return x.Arch
}
return ""
}
func (x *NodeInfo) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *NodeInfo) GetContainers() int32 {
if x != nil {
return x.Containers
}
return 0
}
func (x *NodeInfo) GetLastHeartbeat() string {
if x != nil {
return x.LastHeartbeat
}
return ""
}
func (x *NodeInfo) GetServices() int32 {
if x != nil {
return x.Services
}
return 0
}
var File_proto_mcp_v1_master_proto protoreflect.FileDescriptor
const file_proto_mcp_v1_master_proto_rawDesc = "" +
"\n" +
"\x19proto/mcp/v1/master.proto\x12\x06mcp.v1\x1a\x16proto/mcp/v1/mcp.proto\"D\n" +
"\x13MasterDeployRequest\x12-\n" +
"\aservice\x18\x01 \x01(\v2\x13.mcp.v1.ServiceSpecR\aservice\"\x86\x02\n" +
"\x14MasterDeployResponse\x12\x12\n" +
"\x04node\x18\x01 \x01(\tR\x04node\x12\x18\n" +
"\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\x127\n" +
"\rdeploy_result\x18\x04 \x01(\v2\x12.mcp.v1.StepResultR\fdeployResult\x12>\n" +
"\x11edge_route_result\x18\x05 \x01(\v2\x12.mcp.v1.StepResultR\x0fedgeRouteResult\x121\n" +
"\n" +
"dns_result\x18\x06 \x01(\v2\x12.mcp.v1.StepResultR\tdnsResult\"P\n" +
"\n" +
"StepResult\x12\x12\n" +
"\x04step\x18\x01 \x01(\tR\x04step\x12\x18\n" +
"\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\":\n" +
"\x15MasterUndeployRequest\x12!\n" +
"\fservice_name\x18\x01 \x01(\tR\vserviceName\"H\n" +
"\x16MasterUndeployResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"8\n" +
"\x13MasterStatusRequest\x12!\n" +
"\fservice_name\x18\x01 \x01(\tR\vserviceName\"I\n" +
"\x14MasterStatusResponse\x121\n" +
"\bservices\x18\x01 \x03(\v2\x15.mcp.v1.ServiceStatusR\bservices\"\x9d\x01\n" +
"\rServiceStatus\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
"\x04node\x18\x02 \x01(\tR\x04node\x12\x12\n" +
"\x04tier\x18\x03 \x01(\tR\x04tier\x12\x16\n" +
"\x06status\x18\x04 \x01(\tR\x06status\x128\n" +
"\vedge_routes\x18\x05 \x03(\v2\x17.mcp.v1.EdgeRouteStatusR\n" +
"edgeRoutes\"m\n" +
"\x0fEdgeRouteStatus\x12\x1a\n" +
"\bhostname\x18\x01 \x01(\tR\bhostname\x12\x1b\n" +
"\tedge_node\x18\x02 \x01(\tR\bedgeNode\x12!\n" +
"\fcert_expires\x18\x03 \x01(\tR\vcertExpires\"\x12\n" +
"\x10ListNodesRequest\";\n" +
"\x11ListNodesResponse\x12&\n" +
"\x05nodes\x18\x01 \x03(\v2\x10.mcp.v1.NodeInfoR\x05nodes\"\xdb\x01\n" +
"\bNodeInfo\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
"\x04role\x18\x02 \x01(\tR\x04role\x12\x18\n" +
"\aaddress\x18\x03 \x01(\tR\aaddress\x12\x12\n" +
"\x04arch\x18\x04 \x01(\tR\x04arch\x12\x16\n" +
"\x06status\x18\x05 \x01(\tR\x06status\x12\x1e\n" +
"\n" +
"containers\x18\x06 \x01(\x05R\n" +
"containers\x12%\n" +
"\x0elast_heartbeat\x18\a \x01(\tR\rlastHeartbeat\x12\x1a\n" +
"\bservices\x18\b \x01(\x05R\bservices2\xa9\x02\n" +
"\x10McpMasterService\x12C\n" +
"\x06Deploy\x12\x1b.mcp.v1.MasterDeployRequest\x1a\x1c.mcp.v1.MasterDeployResponse\x12I\n" +
"\bUndeploy\x12\x1d.mcp.v1.MasterUndeployRequest\x1a\x1e.mcp.v1.MasterUndeployResponse\x12C\n" +
"\x06Status\x12\x1b.mcp.v1.MasterStatusRequest\x1a\x1c.mcp.v1.MasterStatusResponse\x12@\n" +
"\tListNodes\x12\x18.mcp.v1.ListNodesRequest\x1a\x19.mcp.v1.ListNodesResponseB*Z(git.wntrmute.dev/mc/mcp/gen/mcp/v1;mcpv1b\x06proto3"
var (
file_proto_mcp_v1_master_proto_rawDescOnce sync.Once
file_proto_mcp_v1_master_proto_rawDescData []byte
)
func file_proto_mcp_v1_master_proto_rawDescGZIP() []byte {
file_proto_mcp_v1_master_proto_rawDescOnce.Do(func() {
file_proto_mcp_v1_master_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_mcp_v1_master_proto_rawDesc), len(file_proto_mcp_v1_master_proto_rawDesc)))
})
return file_proto_mcp_v1_master_proto_rawDescData
}
var file_proto_mcp_v1_master_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_proto_mcp_v1_master_proto_goTypes = []any{
(*MasterDeployRequest)(nil), // 0: mcp.v1.MasterDeployRequest
(*MasterDeployResponse)(nil), // 1: mcp.v1.MasterDeployResponse
(*StepResult)(nil), // 2: mcp.v1.StepResult
(*MasterUndeployRequest)(nil), // 3: mcp.v1.MasterUndeployRequest
(*MasterUndeployResponse)(nil), // 4: mcp.v1.MasterUndeployResponse
(*MasterStatusRequest)(nil), // 5: mcp.v1.MasterStatusRequest
(*MasterStatusResponse)(nil), // 6: mcp.v1.MasterStatusResponse
(*ServiceStatus)(nil), // 7: mcp.v1.ServiceStatus
(*EdgeRouteStatus)(nil), // 8: mcp.v1.EdgeRouteStatus
(*ListNodesRequest)(nil), // 9: mcp.v1.ListNodesRequest
(*ListNodesResponse)(nil), // 10: mcp.v1.ListNodesResponse
(*NodeInfo)(nil), // 11: mcp.v1.NodeInfo
(*ServiceSpec)(nil), // 12: mcp.v1.ServiceSpec
}
var file_proto_mcp_v1_master_proto_depIdxs = []int32{
12, // 0: mcp.v1.MasterDeployRequest.service:type_name -> mcp.v1.ServiceSpec
2, // 1: mcp.v1.MasterDeployResponse.deploy_result:type_name -> mcp.v1.StepResult
2, // 2: mcp.v1.MasterDeployResponse.edge_route_result:type_name -> mcp.v1.StepResult
2, // 3: mcp.v1.MasterDeployResponse.dns_result:type_name -> mcp.v1.StepResult
7, // 4: mcp.v1.MasterStatusResponse.services:type_name -> mcp.v1.ServiceStatus
8, // 5: mcp.v1.ServiceStatus.edge_routes:type_name -> mcp.v1.EdgeRouteStatus
11, // 6: mcp.v1.ListNodesResponse.nodes:type_name -> mcp.v1.NodeInfo
0, // 7: mcp.v1.McpMasterService.Deploy:input_type -> mcp.v1.MasterDeployRequest
3, // 8: mcp.v1.McpMasterService.Undeploy:input_type -> mcp.v1.MasterUndeployRequest
5, // 9: mcp.v1.McpMasterService.Status:input_type -> mcp.v1.MasterStatusRequest
9, // 10: mcp.v1.McpMasterService.ListNodes:input_type -> mcp.v1.ListNodesRequest
1, // 11: mcp.v1.McpMasterService.Deploy:output_type -> mcp.v1.MasterDeployResponse
4, // 12: mcp.v1.McpMasterService.Undeploy:output_type -> mcp.v1.MasterUndeployResponse
6, // 13: mcp.v1.McpMasterService.Status:output_type -> mcp.v1.MasterStatusResponse
10, // 14: mcp.v1.McpMasterService.ListNodes:output_type -> mcp.v1.ListNodesResponse
11, // [11:15] is the sub-list for method output_type
7, // [7:11] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_proto_mcp_v1_master_proto_init() }
func file_proto_mcp_v1_master_proto_init() {
if File_proto_mcp_v1_master_proto != nil {
return
}
file_proto_mcp_v1_mcp_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_mcp_v1_master_proto_rawDesc), len(file_proto_mcp_v1_master_proto_rawDesc)),
NumEnums: 0,
NumMessages: 12,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_mcp_v1_master_proto_goTypes,
DependencyIndexes: file_proto_mcp_v1_master_proto_depIdxs,
MessageInfos: file_proto_mcp_v1_master_proto_msgTypes,
}.Build()
File_proto_mcp_v1_master_proto = out.File
file_proto_mcp_v1_master_proto_goTypes = nil
file_proto_mcp_v1_master_proto_depIdxs = nil
}

View File

@@ -0,0 +1,247 @@
// McpMasterService: Multi-node orchestration for the Metacircular platform.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mcp/v1/master.proto
package mcpv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
McpMasterService_Deploy_FullMethodName = "/mcp.v1.McpMasterService/Deploy"
McpMasterService_Undeploy_FullMethodName = "/mcp.v1.McpMasterService/Undeploy"
McpMasterService_Status_FullMethodName = "/mcp.v1.McpMasterService/Status"
McpMasterService_ListNodes_FullMethodName = "/mcp.v1.McpMasterService/ListNodes"
)
// McpMasterServiceClient is the client API for McpMasterService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// McpMasterService coordinates multi-node deployments. The CLI sends
// deploy/undeploy/status requests to the master, which places services on
// nodes, forwards to agents, and coordinates edge routing.
type McpMasterServiceClient interface {
// CLI operations.
Deploy(ctx context.Context, in *MasterDeployRequest, opts ...grpc.CallOption) (*MasterDeployResponse, error)
Undeploy(ctx context.Context, in *MasterUndeployRequest, opts ...grpc.CallOption) (*MasterUndeployResponse, error)
Status(ctx context.Context, in *MasterStatusRequest, opts ...grpc.CallOption) (*MasterStatusResponse, error)
ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error)
}
type mcpMasterServiceClient struct {
cc grpc.ClientConnInterface
}
func NewMcpMasterServiceClient(cc grpc.ClientConnInterface) McpMasterServiceClient {
return &mcpMasterServiceClient{cc}
}
func (c *mcpMasterServiceClient) Deploy(ctx context.Context, in *MasterDeployRequest, opts ...grpc.CallOption) (*MasterDeployResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MasterDeployResponse)
err := c.cc.Invoke(ctx, McpMasterService_Deploy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpMasterServiceClient) Undeploy(ctx context.Context, in *MasterUndeployRequest, opts ...grpc.CallOption) (*MasterUndeployResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MasterUndeployResponse)
err := c.cc.Invoke(ctx, McpMasterService_Undeploy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpMasterServiceClient) Status(ctx context.Context, in *MasterStatusRequest, opts ...grpc.CallOption) (*MasterStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MasterStatusResponse)
err := c.cc.Invoke(ctx, McpMasterService_Status_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpMasterServiceClient) ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListNodesResponse)
err := c.cc.Invoke(ctx, McpMasterService_ListNodes_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// McpMasterServiceServer is the server API for McpMasterService service.
// All implementations must embed UnimplementedMcpMasterServiceServer
// for forward compatibility.
//
// McpMasterService coordinates multi-node deployments. The CLI sends
// deploy/undeploy/status requests to the master, which places services on
// nodes, forwards to agents, and coordinates edge routing.
type McpMasterServiceServer interface {
// CLI operations.
Deploy(context.Context, *MasterDeployRequest) (*MasterDeployResponse, error)
Undeploy(context.Context, *MasterUndeployRequest) (*MasterUndeployResponse, error)
Status(context.Context, *MasterStatusRequest) (*MasterStatusResponse, error)
ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error)
mustEmbedUnimplementedMcpMasterServiceServer()
}
// UnimplementedMcpMasterServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedMcpMasterServiceServer struct{}
func (UnimplementedMcpMasterServiceServer) Deploy(context.Context, *MasterDeployRequest) (*MasterDeployResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Deploy not implemented")
}
func (UnimplementedMcpMasterServiceServer) Undeploy(context.Context, *MasterUndeployRequest) (*MasterUndeployResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Undeploy not implemented")
}
func (UnimplementedMcpMasterServiceServer) Status(context.Context, *MasterStatusRequest) (*MasterStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Status not implemented")
}
func (UnimplementedMcpMasterServiceServer) ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListNodes not implemented")
}
func (UnimplementedMcpMasterServiceServer) mustEmbedUnimplementedMcpMasterServiceServer() {}
func (UnimplementedMcpMasterServiceServer) testEmbeddedByValue() {}
// UnsafeMcpMasterServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to McpMasterServiceServer will
// result in compilation errors.
type UnsafeMcpMasterServiceServer interface {
mustEmbedUnimplementedMcpMasterServiceServer()
}
func RegisterMcpMasterServiceServer(s grpc.ServiceRegistrar, srv McpMasterServiceServer) {
// If the following call panics, it indicates UnimplementedMcpMasterServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&McpMasterService_ServiceDesc, srv)
}
func _McpMasterService_Deploy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MasterDeployRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).Deploy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_Deploy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).Deploy(ctx, req.(*MasterDeployRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpMasterService_Undeploy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MasterUndeployRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).Undeploy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_Undeploy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).Undeploy(ctx, req.(*MasterUndeployRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpMasterService_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MasterStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).Status(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_Status_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).Status(ctx, req.(*MasterStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpMasterService_ListNodes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListNodesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).ListNodes(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_ListNodes_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).ListNodes(ctx, req.(*ListNodesRequest))
}
return interceptor(ctx, in, info, handler)
}
// McpMasterService_ServiceDesc is the grpc.ServiceDesc for McpMasterService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var McpMasterService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcp.v1.McpMasterService",
HandlerType: (*McpMasterServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Deploy",
Handler: _McpMasterService_Deploy_Handler,
},
{
MethodName: "Undeploy",
Handler: _McpMasterService_Undeploy_Handler,
},
{
MethodName: "Status",
Handler: _McpMasterService_Status_Handler,
},
{
MethodName: "ListNodes",
Handler: _McpMasterService_ListNodes_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcp/v1/master.proto",
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,19 @@ import (
// CLIConfig is the configuration for the mcp CLI binary.
type CLIConfig struct {
Services ServicesConfig `toml:"services"`
Build BuildConfig `toml:"build"`
MCIAS MCIASConfig `toml:"mcias"`
Auth AuthConfig `toml:"auth"`
Nodes []NodeConfig `toml:"nodes"`
Services ServicesConfig `toml:"services"`
Build BuildConfig `toml:"build"`
MCIAS MCIASConfig `toml:"mcias"`
Auth AuthConfig `toml:"auth"`
Nodes []NodeConfig `toml:"nodes"`
Master *CLIMasterConfig `toml:"master,omitempty"`
}
// CLIMasterConfig holds the optional master connection settings.
// When configured, deploy/undeploy/status go through the master
// instead of directly to agents.
type CLIMasterConfig struct {
Address string `toml:"address"` // master gRPC address (e.g. "100.95.252.120:9555")
}
// BuildConfig holds settings for building container images.

168
internal/config/master.go Normal file
View File

@@ -0,0 +1,168 @@
package config
import (
"fmt"
"os"
"time"
toml "github.com/pelletier/go-toml/v2"
)
// MasterConfig is the configuration for the mcp-master daemon.
type MasterConfig struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
MCIAS MCIASConfig `toml:"mcias"`
Edge EdgeConfig `toml:"edge"`
Registration RegistrationConfig `toml:"registration"`
Timeouts TimeoutsConfig `toml:"timeouts"`
MCNS MCNSConfig `toml:"mcns"`
Log LogConfig `toml:"log"`
Nodes []MasterNodeConfig `toml:"nodes"`
// Master holds the master's own MCIAS service token for dialing agents.
Master MasterSettings `toml:"master"`
}
// MasterSettings holds settings specific to the master's own identity.
type MasterSettings struct {
// ServiceTokenPath is the path to the MCIAS service token file
// used by the master to authenticate to agents.
ServiceTokenPath string `toml:"service_token_path"`
// CACert is the path to the CA certificate for verifying agent TLS.
CACert string `toml:"ca_cert"`
}
// EdgeConfig holds settings for edge route management.
type EdgeConfig struct {
// AllowedDomains is the list of domains that public hostnames
// must fall under. Validation uses proper domain label matching.
AllowedDomains []string `toml:"allowed_domains"`
}
// RegistrationConfig holds agent registration settings.
type RegistrationConfig struct {
// AllowedAgents is the list of MCIAS service identities permitted
// to register with the master (e.g., "agent-rift", "agent-svc").
AllowedAgents []string `toml:"allowed_agents"`
// MaxNodes is the maximum number of registered nodes.
MaxNodes int `toml:"max_nodes"`
}
// TimeoutsConfig holds timeout durations for master operations.
type TimeoutsConfig struct {
Deploy Duration `toml:"deploy"`
EdgeRoute Duration `toml:"edge_route"`
HealthCheck Duration `toml:"health_check"`
Undeploy Duration `toml:"undeploy"`
Snapshot Duration `toml:"snapshot"`
}
// MasterNodeConfig is a bootstrap node entry in the master config.
type MasterNodeConfig struct {
Name string `toml:"name"`
Address string `toml:"address"`
Role string `toml:"role"` // "worker", "edge", or "master"
}
// LoadMasterConfig reads and validates a master configuration file.
func LoadMasterConfig(path string) (*MasterConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // config path from trusted CLI flag
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
var cfg MasterConfig
if err := toml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
applyMasterDefaults(&cfg)
applyMasterEnvOverrides(&cfg)
if err := validateMasterConfig(&cfg); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}
func applyMasterDefaults(cfg *MasterConfig) {
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
if cfg.Registration.MaxNodes == 0 {
cfg.Registration.MaxNodes = 16
}
if cfg.Timeouts.Deploy.Duration == 0 {
cfg.Timeouts.Deploy.Duration = 5 * time.Minute
}
if cfg.Timeouts.EdgeRoute.Duration == 0 {
cfg.Timeouts.EdgeRoute.Duration = 30 * time.Second
}
if cfg.Timeouts.HealthCheck.Duration == 0 {
cfg.Timeouts.HealthCheck.Duration = 5 * time.Second
}
if cfg.Timeouts.Undeploy.Duration == 0 {
cfg.Timeouts.Undeploy.Duration = 2 * time.Minute
}
if cfg.Timeouts.Snapshot.Duration == 0 {
cfg.Timeouts.Snapshot.Duration = 10 * time.Minute
}
if cfg.MCNS.Zone == "" {
cfg.MCNS.Zone = "svc.mcp.metacircular.net"
}
for i := range cfg.Nodes {
if cfg.Nodes[i].Role == "" {
cfg.Nodes[i].Role = "worker"
}
}
}
func applyMasterEnvOverrides(cfg *MasterConfig) {
if v := os.Getenv("MCP_MASTER_SERVER_GRPC_ADDR"); v != "" {
cfg.Server.GRPCAddr = v
}
if v := os.Getenv("MCP_MASTER_SERVER_TLS_CERT"); v != "" {
cfg.Server.TLSCert = v
}
if v := os.Getenv("MCP_MASTER_SERVER_TLS_KEY"); v != "" {
cfg.Server.TLSKey = v
}
if v := os.Getenv("MCP_MASTER_DATABASE_PATH"); v != "" {
cfg.Database.Path = v
}
if v := os.Getenv("MCP_MASTER_LOG_LEVEL"); v != "" {
cfg.Log.Level = v
}
}
func validateMasterConfig(cfg *MasterConfig) error {
if cfg.Server.GRPCAddr == "" {
return fmt.Errorf("server.grpc_addr is required")
}
if cfg.Server.TLSCert == "" {
return fmt.Errorf("server.tls_cert is required")
}
if cfg.Server.TLSKey == "" {
return fmt.Errorf("server.tls_key is required")
}
if cfg.Database.Path == "" {
return fmt.Errorf("database.path is required")
}
if cfg.MCIAS.ServerURL == "" {
return fmt.Errorf("mcias.server_url is required")
}
if cfg.MCIAS.ServiceName == "" {
return fmt.Errorf("mcias.service_name is required")
}
if len(cfg.Nodes) == 0 {
return fmt.Errorf("at least one [[nodes]] entry is required")
}
if cfg.Master.ServiceTokenPath == "" {
return fmt.Errorf("master.service_token_path is required")
}
return nil
}

View File

@@ -0,0 +1,190 @@
// Package master implements the mcp-master orchestrator.
package master
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"sync"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
// AgentClient wraps a gRPC connection to a single mcp-agent.
type AgentClient struct {
conn *grpc.ClientConn
client mcpv1.McpAgentServiceClient
Node string
}
// DialAgent connects to an agent at the given address using TLS 1.3.
// The token is attached to every outgoing RPC via metadata.
func DialAgent(address, caCertPath, token string) (*AgentClient, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if caCertPath != "" {
caCert, err := os.ReadFile(caCertPath) //nolint:gosec // trusted config path
if err != nil {
return nil, fmt.Errorf("read CA cert %q: %w", caCertPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("invalid CA cert %q", caCertPath)
}
tlsConfig.RootCAs = pool
}
conn, err := grpc.NewClient(
address,
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
grpc.WithUnaryInterceptor(agentTokenInterceptor(token)),
grpc.WithStreamInterceptor(agentStreamTokenInterceptor(token)),
)
if err != nil {
return nil, fmt.Errorf("dial agent %q: %w", address, err)
}
return &AgentClient{
conn: conn,
client: mcpv1.NewMcpAgentServiceClient(conn),
}, nil
}
// Close closes the underlying gRPC connection.
func (c *AgentClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
// Deploy forwards a deploy request to the agent.
func (c *AgentClient) Deploy(ctx context.Context, req *mcpv1.DeployRequest) (*mcpv1.DeployResponse, error) {
return c.client.Deploy(ctx, req)
}
// UndeployService forwards an undeploy request to the agent.
func (c *AgentClient) UndeployService(ctx context.Context, req *mcpv1.UndeployServiceRequest) (*mcpv1.UndeployServiceResponse, error) {
return c.client.UndeployService(ctx, req)
}
// GetServiceStatus queries a service's status on the agent.
func (c *AgentClient) GetServiceStatus(ctx context.Context, req *mcpv1.GetServiceStatusRequest) (*mcpv1.GetServiceStatusResponse, error) {
return c.client.GetServiceStatus(ctx, req)
}
// ListServices lists all services on the agent.
func (c *AgentClient) ListServices(ctx context.Context, req *mcpv1.ListServicesRequest) (*mcpv1.ListServicesResponse, error) {
return c.client.ListServices(ctx, req)
}
// SetupEdgeRoute sets up an edge route on the agent.
func (c *AgentClient) SetupEdgeRoute(ctx context.Context, req *mcpv1.SetupEdgeRouteRequest) (*mcpv1.SetupEdgeRouteResponse, error) {
return c.client.SetupEdgeRoute(ctx, req)
}
// RemoveEdgeRoute removes an edge route from the agent.
func (c *AgentClient) RemoveEdgeRoute(ctx context.Context, req *mcpv1.RemoveEdgeRouteRequest) (*mcpv1.RemoveEdgeRouteResponse, error) {
return c.client.RemoveEdgeRoute(ctx, req)
}
// ListEdgeRoutes lists edge routes on the agent.
func (c *AgentClient) ListEdgeRoutes(ctx context.Context, req *mcpv1.ListEdgeRoutesRequest) (*mcpv1.ListEdgeRoutesResponse, error) {
return c.client.ListEdgeRoutes(ctx, req)
}
// HealthCheck checks the agent's health.
func (c *AgentClient) HealthCheck(ctx context.Context, req *mcpv1.HealthCheckRequest) (*mcpv1.HealthCheckResponse, error) {
return c.client.HealthCheck(ctx, req)
}
// agentTokenInterceptor attaches the bearer token to outgoing RPCs.
func agentTokenInterceptor(token string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func agentStreamTokenInterceptor(token string) grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
return streamer(ctx, desc, cc, method, opts...)
}
}
// AgentPool manages connections to multiple agents, keyed by node name.
type AgentPool struct {
mu sync.RWMutex
clients map[string]*AgentClient
caCert string
token string
}
// NewAgentPool creates a pool with the given CA cert and service token.
func NewAgentPool(caCertPath, token string) *AgentPool {
return &AgentPool{
clients: make(map[string]*AgentClient),
caCert: caCertPath,
token: token,
}
}
// AddNode dials an agent and adds it to the pool.
func (p *AgentPool) AddNode(name, address string) error {
client, err := DialAgent(address, p.caCert, p.token)
if err != nil {
return fmt.Errorf("add node %s: %w", name, err)
}
client.Node = name
p.mu.Lock()
defer p.mu.Unlock()
// Close existing connection if re-adding.
if old, ok := p.clients[name]; ok {
_ = old.Close()
}
p.clients[name] = client
return nil
}
// Get returns the agent client for a node.
func (p *AgentPool) Get(name string) (*AgentClient, error) {
p.mu.RLock()
defer p.mu.RUnlock()
client, ok := p.clients[name]
if !ok {
return nil, fmt.Errorf("node %q not found in pool", name)
}
return client, nil
}
// Close closes all agent connections.
func (p *AgentPool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for _, c := range p.clients {
_ = c.Close()
}
p.clients = make(map[string]*AgentClient)
}
// LoadServiceToken reads a token from a file path.
func LoadServiceToken(path string) (string, error) {
data, err := os.ReadFile(path) //nolint:gosec // trusted config path
if err != nil {
return "", fmt.Errorf("read service token %q: %w", path, err)
}
return strings.TrimSpace(string(data)), nil
}

211
internal/master/deploy.go Normal file
View File

@@ -0,0 +1,211 @@
package master
import (
"context"
"fmt"
"net"
"strings"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// Deploy handles the MasterDeployRequest: places the service, forwards to
// the agent, registers DNS, and coordinates edge routing.
func (m *Master) Deploy(ctx context.Context, req *mcpv1.MasterDeployRequest) (*mcpv1.MasterDeployResponse, error) {
spec := req.GetService()
if spec == nil || spec.GetName() == "" {
return nil, fmt.Errorf("service spec with name is required")
}
serviceName := spec.GetName()
tier := spec.GetTier()
if tier == "" {
tier = "worker"
}
m.Logger.Info("Deploy", "service", serviceName, "tier", tier, "node_override", spec.GetNode())
resp := &mcpv1.MasterDeployResponse{}
// Step 1: Place service.
nodeName := spec.GetNode()
if nodeName == "" {
var err error
switch tier {
case "core":
nodeName, err = FindMasterNode(m.DB)
default:
nodeName, err = PickNode(m.DB)
}
if err != nil {
resp.Error = fmt.Sprintf("placement failed: %v", err)
return resp, nil
}
}
resp.Node = nodeName
node, err := masterdb.GetNode(m.DB, nodeName)
if err != nil || node == nil {
resp.Error = fmt.Sprintf("node %q not found", nodeName)
return resp, nil
}
// Parse the node's Tailnet IP from its address (host:port).
nodeHost, _, err := net.SplitHostPort(node.Address)
if err != nil {
resp.Error = fmt.Sprintf("invalid node address %q: %v", node.Address, err)
return resp, nil
}
// Step 2: Forward deploy to the agent.
client, err := m.Pool.Get(nodeName)
if err != nil {
resp.Error = fmt.Sprintf("agent connection: %v", err)
return resp, nil
}
deployCtx, deployCancel := context.WithTimeout(ctx, m.Config.Timeouts.Deploy.Duration)
defer deployCancel()
deployResp, err := client.Deploy(deployCtx, &mcpv1.DeployRequest{
Service: spec,
})
if err != nil {
resp.DeployResult = &mcpv1.StepResult{Step: "deploy", Error: err.Error()}
resp.Error = fmt.Sprintf("agent deploy failed: %v", err)
return resp, nil
}
resp.DeployResult = &mcpv1.StepResult{Step: "deploy", Success: true}
// Check agent-side results for failures.
for _, cr := range deployResp.GetResults() {
if !cr.GetSuccess() {
resp.DeployResult.Success = false
resp.DeployResult.Error = fmt.Sprintf("component %s: %s", cr.GetName(), cr.GetError())
resp.Error = resp.DeployResult.Error
return resp, nil
}
}
// Step 3: Register DNS — Tailnet IP from the node address.
if m.DNS != nil {
if err := m.DNS.EnsureRecord(ctx, serviceName, nodeHost); err != nil {
m.Logger.Warn("DNS registration failed", "service", serviceName, "err", err)
resp.DnsResult = &mcpv1.StepResult{Step: "dns", Error: err.Error()}
} else {
resp.DnsResult = &mcpv1.StepResult{Step: "dns", Success: true}
}
}
// Record placement.
if err := masterdb.CreatePlacement(m.DB, serviceName, nodeName, tier); err != nil {
m.Logger.Error("record placement", "service", serviceName, "err", err)
}
// Steps 4-9: Detect public routes and coordinate edge routing.
edgeResult := m.setupEdgeRoutes(ctx, spec, serviceName, nodeHost)
if edgeResult != nil {
resp.EdgeRouteResult = edgeResult
}
// Compute overall success.
resp.Success = true
if resp.DeployResult != nil && !resp.DeployResult.Success {
resp.Success = false
}
if resp.EdgeRouteResult != nil && !resp.EdgeRouteResult.Success {
resp.Success = false
}
m.Logger.Info("deploy complete", "service", serviceName, "node", nodeName, "success", resp.Success)
return resp, nil
}
// setupEdgeRoutes detects public routes and coordinates edge routing.
func (m *Master) setupEdgeRoutes(ctx context.Context, spec *mcpv1.ServiceSpec, serviceName, nodeHost string) *mcpv1.StepResult {
var publicRoutes []*mcpv1.RouteSpec
for _, comp := range spec.GetComponents() {
for _, route := range comp.GetRoutes() {
if route.GetPublic() && route.GetHostname() != "" {
publicRoutes = append(publicRoutes, route)
}
}
}
if len(publicRoutes) == 0 {
return nil
}
// Find the edge node.
edgeNodeName, err := FindEdgeNode(m.DB)
if err != nil {
return &mcpv1.StepResult{Step: "edge_route", Error: fmt.Sprintf("no edge node: %v", err)}
}
edgeClient, err := m.Pool.Get(edgeNodeName)
if err != nil {
return &mcpv1.StepResult{Step: "edge_route", Error: fmt.Sprintf("edge agent connection: %v", err)}
}
var lastErr string
for _, route := range publicRoutes {
hostname := route.GetHostname()
// Validate hostname against allowed domains.
if !m.isAllowedDomain(hostname) {
lastErr = fmt.Sprintf("hostname %q not under an allowed domain", hostname)
m.Logger.Warn("edge route rejected", "hostname", hostname, "reason", lastErr)
continue
}
// Construct the backend hostname: <component>.svc.mcp.<zone>
// For simplicity, use the service name as the component name.
zone := "metacircular.net"
if m.DNS != nil && m.DNS.Zone() != "" {
zone = m.DNS.Zone()
}
backendHostname := serviceName + "." + zone
edgeCtx, edgeCancel := context.WithTimeout(ctx, m.Config.Timeouts.EdgeRoute.Duration)
_, setupErr := edgeClient.SetupEdgeRoute(edgeCtx, &mcpv1.SetupEdgeRouteRequest{
Hostname: hostname,
BackendHostname: backendHostname,
BackendPort: route.GetPort(),
BackendTls: true,
})
edgeCancel()
if setupErr != nil {
lastErr = fmt.Sprintf("setup edge route %s: %v", hostname, setupErr)
m.Logger.Warn("edge route setup failed", "hostname", hostname, "err", setupErr)
continue
}
// Record edge route in master DB.
if dbErr := masterdb.CreateEdgeRoute(m.DB, hostname, serviceName, edgeNodeName, backendHostname, int(route.GetPort())); dbErr != nil {
m.Logger.Warn("record edge route", "hostname", hostname, "err", dbErr)
}
m.Logger.Info("edge route established", "hostname", hostname, "edge_node", edgeNodeName)
}
if lastErr != "" {
return &mcpv1.StepResult{Step: "edge_route", Error: lastErr}
}
return &mcpv1.StepResult{Step: "edge_route", Success: true}
}
// isAllowedDomain checks if hostname falls under one of the configured
// allowed domains using proper domain label matching.
func (m *Master) isAllowedDomain(hostname string) bool {
if len(m.Config.Edge.AllowedDomains) == 0 {
return true // no restrictions configured
}
for _, domain := range m.Config.Edge.AllowedDomains {
if hostname == domain || strings.HasSuffix(hostname, "."+domain) {
return true
}
}
return false
}

252
internal/master/dns.go Normal file
View File

@@ -0,0 +1,252 @@
package master
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
)
// DNSClient creates and removes A records in MCNS. Unlike the agent's
// DNSRegistrar, the master registers records for different node IPs
// (the nodeAddr is a per-call parameter, not a fixed config value).
type DNSClient struct {
serverURL string
token string
zone string
httpClient *http.Client
logger *slog.Logger
}
type dnsRecord struct {
ID int `json:"ID"`
Name string `json:"Name"`
Type string `json:"Type"`
Value string `json:"Value"`
TTL int `json:"TTL"`
}
// NewDNSClient creates a DNS client. Returns (nil, nil) if serverURL is empty.
func NewDNSClient(cfg config.MCNSConfig, logger *slog.Logger) (*DNSClient, error) {
if cfg.ServerURL == "" {
logger.Info("mcns not configured, DNS registration disabled")
return nil, nil
}
token, err := auth.LoadToken(cfg.TokenPath)
if err != nil {
return nil, fmt.Errorf("load mcns token: %w", err)
}
httpClient, err := newHTTPClient(cfg.CACert)
if err != nil {
return nil, fmt.Errorf("create mcns HTTP client: %w", err)
}
logger.Info("master DNS client enabled", "server", cfg.ServerURL, "zone", cfg.Zone)
return &DNSClient{
serverURL: strings.TrimRight(cfg.ServerURL, "/"),
token: token,
zone: cfg.Zone,
httpClient: httpClient,
logger: logger,
}, nil
}
// Zone returns the configured DNS zone.
func (d *DNSClient) Zone() string {
if d == nil {
return ""
}
return d.zone
}
// EnsureRecord ensures an A record exists for serviceName pointing to nodeAddr.
func (d *DNSClient) EnsureRecord(ctx context.Context, serviceName, nodeAddr string) error {
if d == nil {
return nil
}
existing, err := d.listRecords(ctx, serviceName)
if err != nil {
return fmt.Errorf("list DNS records: %w", err)
}
for _, r := range existing {
if r.Value == nodeAddr {
d.logger.Debug("DNS record exists", "service", serviceName, "value", r.Value)
return nil
}
}
if len(existing) > 0 {
d.logger.Info("updating DNS record", "service", serviceName,
"old_value", existing[0].Value, "new_value", nodeAddr)
return d.updateRecord(ctx, existing[0].ID, serviceName, nodeAddr)
}
d.logger.Info("creating DNS record", "service", serviceName,
"record", serviceName+"."+d.zone, "value", nodeAddr)
return d.createRecord(ctx, serviceName, nodeAddr)
}
// RemoveRecord removes A records for serviceName.
func (d *DNSClient) RemoveRecord(ctx context.Context, serviceName string) error {
if d == nil {
return nil
}
existing, err := d.listRecords(ctx, serviceName)
if err != nil {
return fmt.Errorf("list DNS records: %w", err)
}
for _, r := range existing {
d.logger.Info("removing DNS record", "service", serviceName, "id", r.ID)
if err := d.deleteRecord(ctx, r.ID); err != nil {
return err
}
}
return nil
}
func (d *DNSClient) 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 {
return nil, fmt.Errorf("create list request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read list response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list 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 response: %w", err)
}
return envelope.Records, nil
}
func (d *DNSClient) createRecord(ctx context.Context, serviceName, nodeAddr string) error {
reqBody, _ := json.Marshal(map[string]interface{}{
"name": serviceName, "type": "A", "value": nodeAddr, "ttl": 300,
})
url := fmt.Sprintf("%s/v1/zones/%s/records", d.serverURL, d.zone)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("create record request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("create record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("create record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (d *DNSClient) updateRecord(ctx context.Context, recordID int, serviceName, nodeAddr string) error {
reqBody, _ := json.Marshal(map[string]interface{}{
"name": serviceName, "type": "A", "value": nodeAddr, "ttl": 300,
})
url := fmt.Sprintf("%s/v1/zones/%s/records/%d", d.serverURL, d.zone, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("create update request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("update record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("update record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (d *DNSClient) deleteRecord(ctx context.Context, recordID int) error {
url := fmt.Sprintf("%s/v1/zones/%s/records/%d", d.serverURL, d.zone, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("create delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func newHTTPClient(caCertPath string) (*http.Client, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if caCertPath != "" {
caCert, err := os.ReadFile(caCertPath) //nolint:gosec // path from trusted config
if err != nil {
return nil, fmt.Errorf("read CA cert %q: %w", caCertPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("parse CA cert %q: no valid certificates found", caCertPath)
}
tlsConfig.RootCAs = pool
}
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}, nil
}

159
internal/master/master.go Normal file
View File

@@ -0,0 +1,159 @@
package master
import (
"context"
"crypto/tls"
"database/sql"
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Master is the MCP cluster master. It coordinates multi-node deployments,
// manages edge routes, and stores cluster state.
type Master struct {
mcpv1.UnimplementedMcpMasterServiceServer
Config *config.MasterConfig
DB *sql.DB
Pool *AgentPool
DNS *DNSClient
Logger *slog.Logger
Version string
}
// Run starts the master: opens the database, bootstraps nodes, sets up the
// gRPC server with TLS and auth, and blocks until SIGINT/SIGTERM.
func Run(cfg *config.MasterConfig, version string) error {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
}))
// Open master database.
db, err := masterdb.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("open master database: %w", err)
}
defer func() { _ = db.Close() }()
// Bootstrap nodes from config.
for _, n := range cfg.Nodes {
if err := masterdb.UpsertNode(db, n.Name, n.Address, n.Role, "amd64"); err != nil {
return fmt.Errorf("bootstrap node %s: %w", n.Name, err)
}
logger.Info("bootstrapped node", "name", n.Name, "address", n.Address, "role", n.Role)
}
// Load service token for dialing agents.
token, err := LoadServiceToken(cfg.Master.ServiceTokenPath)
if err != nil {
return fmt.Errorf("load service token: %w", err)
}
// Create agent connection pool.
pool := NewAgentPool(cfg.Master.CACert, token)
for _, n := range cfg.Nodes {
if addErr := pool.AddNode(n.Name, n.Address); addErr != nil {
logger.Warn("failed to connect to agent", "node", n.Name, "err", addErr)
// Non-fatal: the node may come up later.
}
}
// Create DNS client.
dns, err := NewDNSClient(cfg.MCNS, logger)
if err != nil {
return fmt.Errorf("create DNS client: %w", err)
}
m := &Master{
Config: cfg,
DB: db,
Pool: pool,
DNS: dns,
Logger: logger,
Version: version,
}
// TLS.
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("load TLS cert: %w", err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{tlsCert},
MinVersion: tls.VersionTLS13,
}
// Auth interceptor (same as agent — validates MCIAS tokens).
validator, err := auth.NewMCIASValidator(cfg.MCIAS.ServerURL, cfg.MCIAS.CACert)
if err != nil {
return fmt.Errorf("create MCIAS validator: %w", err)
}
// gRPC server.
server := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsConfig)),
grpc.ChainUnaryInterceptor(
auth.AuthInterceptor(validator),
),
grpc.ChainStreamInterceptor(
auth.StreamAuthInterceptor(validator),
),
)
mcpv1.RegisterMcpMasterServiceServer(server, m)
// Listen.
lis, err := net.Listen("tcp", cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("listen %q: %w", cfg.Server.GRPCAddr, err)
}
logger.Info("master starting",
"addr", cfg.Server.GRPCAddr,
"version", version,
"nodes", len(cfg.Nodes),
)
// Signal handling.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(lis)
}()
select {
case <-ctx.Done():
logger.Info("shutting down")
server.GracefulStop()
pool.Close()
return nil
case err := <-errCh:
pool.Close()
return fmt.Errorf("serve: %w", err)
}
}
func parseLogLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,64 @@
package master
import (
"database/sql"
"fmt"
"sort"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// PickNode selects the best worker node for a new service deployment.
// Algorithm: fewest placed services, ties broken alphabetically.
func PickNode(db *sql.DB) (string, error) {
workers, err := masterdb.ListWorkerNodes(db)
if err != nil {
return "", fmt.Errorf("list workers: %w", err)
}
if len(workers) == 0 {
return "", fmt.Errorf("no worker nodes available")
}
counts, err := masterdb.CountPlacementsPerNode(db)
if err != nil {
return "", fmt.Errorf("count placements: %w", err)
}
// Sort: fewest placements first, then alphabetically.
sort.Slice(workers, func(i, j int) bool {
ci := counts[workers[i].Name]
cj := counts[workers[j].Name]
if ci != cj {
return ci < cj
}
return workers[i].Name < workers[j].Name
})
return workers[0].Name, nil
}
// FindMasterNode returns the name of the node with role "master".
func FindMasterNode(db *sql.DB) (string, error) {
nodes, err := masterdb.ListNodes(db)
if err != nil {
return "", fmt.Errorf("list nodes: %w", err)
}
for _, n := range nodes {
if n.Role == "master" {
return n.Name, nil
}
}
return "", fmt.Errorf("no master node found")
}
// FindEdgeNode returns the name of the first edge node.
func FindEdgeNode(db *sql.DB) (string, error) {
edges, err := masterdb.ListEdgeNodes(db)
if err != nil {
return "", fmt.Errorf("list edge nodes: %w", err)
}
if len(edges) == 0 {
return "", fmt.Errorf("no edge nodes available")
}
return edges[0].Name, nil
}

130
internal/master/status.go Normal file
View File

@@ -0,0 +1,130 @@
package master
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// Status returns the status of services across the fleet.
func (m *Master) Status(ctx context.Context, req *mcpv1.MasterStatusRequest) (*mcpv1.MasterStatusResponse, error) {
m.Logger.Debug("Status", "service", req.GetServiceName())
resp := &mcpv1.MasterStatusResponse{}
// If a specific service is requested, look up its placement.
if name := req.GetServiceName(); name != "" {
placement, err := masterdb.GetPlacement(m.DB, name)
if err != nil {
return nil, fmt.Errorf("lookup placement: %w", err)
}
if placement == nil {
return resp, nil // empty — service not found
}
ss := m.getServiceStatus(ctx, placement)
resp.Services = append(resp.Services, ss)
return resp, nil
}
// All services.
placements, err := masterdb.ListPlacements(m.DB)
if err != nil {
return nil, fmt.Errorf("list placements: %w", err)
}
for _, p := range placements {
ss := m.getServiceStatus(ctx, p)
resp.Services = append(resp.Services, ss)
}
return resp, nil
}
func (m *Master) getServiceStatus(ctx context.Context, p *masterdb.Placement) *mcpv1.ServiceStatus {
ss := &mcpv1.ServiceStatus{
Name: p.ServiceName,
Node: p.Node,
Tier: p.Tier,
Status: "unknown",
}
// Query the agent for live status.
client, err := m.Pool.Get(p.Node)
if err != nil {
ss.Status = "unreachable"
return ss
}
statusCtx, cancel := context.WithTimeout(ctx, m.Config.Timeouts.HealthCheck.Duration)
defer cancel()
agentResp, err := client.GetServiceStatus(statusCtx, &mcpv1.GetServiceStatusRequest{
Name: p.ServiceName,
})
if err != nil {
ss.Status = "unreachable"
return ss
}
// Map agent status to master status.
for _, info := range agentResp.GetServices() {
if info.GetName() == p.ServiceName {
if info.GetActive() {
ss.Status = "running"
} else {
ss.Status = "stopped"
}
break
}
}
// Attach edge route info.
edgeRoutes, err := masterdb.ListEdgeRoutesForService(m.DB, p.ServiceName)
if err == nil {
for _, er := range edgeRoutes {
ss.EdgeRoutes = append(ss.EdgeRoutes, &mcpv1.EdgeRouteStatus{
Hostname: er.Hostname,
EdgeNode: er.EdgeNode,
})
}
}
return ss
}
// ListNodes returns all nodes in the registry with placement counts.
func (m *Master) ListNodes(_ context.Context, _ *mcpv1.ListNodesRequest) (*mcpv1.ListNodesResponse, error) {
m.Logger.Debug("ListNodes")
nodes, err := masterdb.ListNodes(m.DB)
if err != nil {
return nil, fmt.Errorf("list nodes: %w", err)
}
counts, err := masterdb.CountPlacementsPerNode(m.DB)
if err != nil {
return nil, fmt.Errorf("count placements: %w", err)
}
resp := &mcpv1.ListNodesResponse{}
for _, n := range nodes {
ni := &mcpv1.NodeInfo{
Name: n.Name,
Role: n.Role,
Address: n.Address,
Arch: n.Arch,
Status: n.Status,
Containers: int32(n.Containers), //nolint:gosec // small number
Services: int32(counts[n.Name]), //nolint:gosec // small number
}
if n.LastHeartbeat != nil {
ni.LastHeartbeat = n.LastHeartbeat.Format("2006-01-02T15:04:05Z")
}
resp.Nodes = append(resp.Nodes, ni)
}
return resp, nil
}

View File

@@ -0,0 +1,94 @@
package master
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// Undeploy handles MasterUndeployRequest: removes edge routes, DNS, then
// forwards the undeploy to the worker agent.
func (m *Master) Undeploy(ctx context.Context, req *mcpv1.MasterUndeployRequest) (*mcpv1.MasterUndeployResponse, error) {
serviceName := req.GetServiceName()
if serviceName == "" {
return nil, fmt.Errorf("service_name is required")
}
m.Logger.Info("Undeploy", "service", serviceName)
// Look up placement.
placement, err := masterdb.GetPlacement(m.DB, serviceName)
if err != nil {
return &mcpv1.MasterUndeployResponse{Error: fmt.Sprintf("lookup placement: %v", err)}, nil
}
if placement == nil {
return &mcpv1.MasterUndeployResponse{Error: fmt.Sprintf("service %q not found in placements", serviceName)}, nil
}
// Step 1: Undeploy on worker first (stops the backend).
client, err := m.Pool.Get(placement.Node)
if err != nil {
return &mcpv1.MasterUndeployResponse{Error: fmt.Sprintf("agent connection: %v", err)}, nil
}
undeployCtx, undeployCancel := context.WithTimeout(ctx, m.Config.Timeouts.Undeploy.Duration)
defer undeployCancel()
_, undeployErr := client.UndeployService(undeployCtx, &mcpv1.UndeployServiceRequest{
Name: serviceName,
})
if undeployErr != nil {
m.Logger.Warn("agent undeploy failed", "service", serviceName, "node", placement.Node, "err", undeployErr)
// Continue — still clean up edge routes and records.
}
// Step 2: Remove edge routes.
edgeRoutes, err := masterdb.ListEdgeRoutesForService(m.DB, serviceName)
if err != nil {
m.Logger.Warn("list edge routes for undeploy", "service", serviceName, "err", err)
}
for _, er := range edgeRoutes {
edgeClient, getErr := m.Pool.Get(er.EdgeNode)
if getErr != nil {
m.Logger.Warn("edge agent connection", "edge_node", er.EdgeNode, "err", getErr)
continue
}
edgeCtx, edgeCancel := context.WithTimeout(ctx, m.Config.Timeouts.EdgeRoute.Duration)
_, removeErr := edgeClient.RemoveEdgeRoute(edgeCtx, &mcpv1.RemoveEdgeRouteRequest{
Hostname: er.Hostname,
})
edgeCancel()
if removeErr != nil {
m.Logger.Warn("remove edge route", "hostname", er.Hostname, "err", removeErr)
} else {
m.Logger.Info("edge route removed", "hostname", er.Hostname, "edge_node", er.EdgeNode)
}
}
// Step 3: Remove DNS.
if m.DNS != nil {
if dnsErr := m.DNS.RemoveRecord(ctx, serviceName); dnsErr != nil {
m.Logger.Warn("DNS removal failed", "service", serviceName, "err", dnsErr)
}
}
// Step 4: Clean up records.
_ = masterdb.DeleteEdgeRoutesForService(m.DB, serviceName)
_ = masterdb.DeletePlacement(m.DB, serviceName)
success := undeployErr == nil
var errMsg string
if !success {
errMsg = fmt.Sprintf("agent undeploy: %v", undeployErr)
}
m.Logger.Info("undeploy complete", "service", serviceName, "success", success)
return &mcpv1.MasterUndeployResponse{
Success: success,
Error: errMsg,
}, nil
}

106
internal/masterdb/db.go Normal file
View File

@@ -0,0 +1,106 @@
// Package masterdb provides the SQLite database for the mcp-master daemon.
// It stores the cluster-wide node registry, service placements, and edge routes.
// This is separate from the agent's registry (internal/registry/) because the
// master and agent have fundamentally different schemas.
package masterdb
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens the master database at the given path and runs migrations.
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
for _, pragma := range []string{
"PRAGMA journal_mode = WAL",
"PRAGMA foreign_keys = ON",
"PRAGMA busy_timeout = 5000",
} {
if _, err := db.Exec(pragma); err != nil {
_ = db.Close()
return nil, fmt.Errorf("exec %q: %w", pragma, err)
}
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
if err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
for i, m := range migrations {
version := i + 1
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&count); err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if count > 0 {
continue
}
if _, err := db.Exec(m); err != nil {
return fmt.Errorf("run migration %d: %w", version, err)
}
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
}
return nil
}
var migrations = []string{
// Migration 1: cluster state
`
CREATE TABLE IF NOT EXISTS nodes (
name TEXT PRIMARY KEY,
address TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'worker',
arch TEXT NOT NULL DEFAULT 'amd64',
status TEXT NOT NULL DEFAULT 'unknown',
containers INTEGER NOT NULL DEFAULT 0,
last_heartbeat TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS placements (
service_name TEXT PRIMARY KEY,
node TEXT NOT NULL REFERENCES nodes(name),
tier TEXT NOT NULL DEFAULT 'worker',
deployed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS edge_routes (
hostname TEXT PRIMARY KEY,
service_name TEXT NOT NULL,
edge_node TEXT NOT NULL REFERENCES nodes(name),
backend_hostname TEXT NOT NULL,
backend_port INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_edge_routes_service
ON edge_routes(service_name);
`,
}

View File

@@ -0,0 +1,185 @@
package masterdb
import (
"database/sql"
"path/filepath"
"testing"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestOpenAndMigrate(t *testing.T) {
openTestDB(t)
}
func TestNodeCRUD(t *testing.T) {
db := openTestDB(t)
if err := UpsertNode(db, "rift", "100.95.252.120:9444", "master", "amd64"); err != nil {
t.Fatalf("UpsertNode: %v", err)
}
if err := UpsertNode(db, "svc", "100.106.232.4:9555", "edge", "amd64"); err != nil {
t.Fatalf("UpsertNode: %v", err)
}
if err := UpsertNode(db, "orion", "100.1.2.3:9444", "worker", "amd64"); err != nil {
t.Fatalf("UpsertNode: %v", err)
}
// Get.
n, err := GetNode(db, "rift")
if err != nil {
t.Fatalf("GetNode: %v", err)
}
if n == nil || n.Address != "100.95.252.120:9444" {
t.Errorf("GetNode(rift) = %+v", n)
}
// Get nonexistent.
n, err = GetNode(db, "nonexistent")
if err != nil {
t.Fatalf("GetNode: %v", err)
}
if n != nil {
t.Errorf("expected nil for nonexistent node")
}
// List all.
nodes, err := ListNodes(db)
if err != nil {
t.Fatalf("ListNodes: %v", err)
}
if len(nodes) != 3 {
t.Errorf("ListNodes: got %d, want 3", len(nodes))
}
// List workers (includes master role).
workers, err := ListWorkerNodes(db)
if err != nil {
t.Fatalf("ListWorkerNodes: %v", err)
}
if len(workers) != 2 {
t.Errorf("ListWorkerNodes: got %d, want 2 (rift+orion)", len(workers))
}
// List edge.
edges, err := ListEdgeNodes(db)
if err != nil {
t.Fatalf("ListEdgeNodes: %v", err)
}
if len(edges) != 1 || edges[0].Name != "svc" {
t.Errorf("ListEdgeNodes: got %v", edges)
}
// Update status.
if err := UpdateNodeStatus(db, "rift", "healthy"); err != nil {
t.Fatalf("UpdateNodeStatus: %v", err)
}
n, _ = GetNode(db, "rift")
if n.Status != "healthy" {
t.Errorf("status = %q, want healthy", n.Status)
}
}
func TestPlacementCRUD(t *testing.T) {
db := openTestDB(t)
_ = UpsertNode(db, "rift", "100.95.252.120:9444", "master", "amd64")
_ = UpsertNode(db, "orion", "100.1.2.3:9444", "worker", "amd64")
if err := CreatePlacement(db, "mcq", "rift", "worker"); err != nil {
t.Fatalf("CreatePlacement: %v", err)
}
if err := CreatePlacement(db, "mcdoc", "orion", "worker"); err != nil {
t.Fatalf("CreatePlacement: %v", err)
}
p, err := GetPlacement(db, "mcq")
if err != nil {
t.Fatalf("GetPlacement: %v", err)
}
if p == nil || p.Node != "rift" {
t.Errorf("GetPlacement(mcq) = %+v", p)
}
p, _ = GetPlacement(db, "nonexistent")
if p != nil {
t.Errorf("expected nil for nonexistent placement")
}
counts, err := CountPlacementsPerNode(db)
if err != nil {
t.Fatalf("CountPlacementsPerNode: %v", err)
}
if counts["rift"] != 1 || counts["orion"] != 1 {
t.Errorf("counts = %v", counts)
}
placements, err := ListPlacements(db)
if err != nil {
t.Fatalf("ListPlacements: %v", err)
}
if len(placements) != 2 {
t.Errorf("ListPlacements: got %d", len(placements))
}
if err := DeletePlacement(db, "mcq"); err != nil {
t.Fatalf("DeletePlacement: %v", err)
}
p, _ = GetPlacement(db, "mcq")
if p != nil {
t.Errorf("expected nil after delete")
}
}
func TestEdgeRouteCRUD(t *testing.T) {
db := openTestDB(t)
_ = UpsertNode(db, "svc", "100.106.232.4:9555", "edge", "amd64")
if err := CreateEdgeRoute(db, "mcq.metacircular.net", "mcq", "svc", "mcq.svc.mcp.metacircular.net", 8443); err != nil {
t.Fatalf("CreateEdgeRoute: %v", err)
}
if err := CreateEdgeRoute(db, "docs.metacircular.net", "mcdoc", "svc", "mcdoc.svc.mcp.metacircular.net", 443); err != nil {
t.Fatalf("CreateEdgeRoute: %v", err)
}
routes, err := ListEdgeRoutes(db)
if err != nil {
t.Fatalf("ListEdgeRoutes: %v", err)
}
if len(routes) != 2 {
t.Errorf("ListEdgeRoutes: got %d", len(routes))
}
routes, err = ListEdgeRoutesForService(db, "mcq")
if err != nil {
t.Fatalf("ListEdgeRoutesForService: %v", err)
}
if len(routes) != 1 || routes[0].Hostname != "mcq.metacircular.net" {
t.Errorf("ListEdgeRoutesForService(mcq) = %v", routes)
}
if err := DeleteEdgeRoute(db, "mcq.metacircular.net"); err != nil {
t.Fatalf("DeleteEdgeRoute: %v", err)
}
routes, _ = ListEdgeRoutes(db)
if len(routes) != 1 {
t.Errorf("expected 1 route after delete, got %d", len(routes))
}
_ = CreateEdgeRoute(db, "docs2.metacircular.net", "mcdoc", "svc", "mcdoc.svc.mcp.metacircular.net", 443)
if err := DeleteEdgeRoutesForService(db, "mcdoc"); err != nil {
t.Fatalf("DeleteEdgeRoutesForService: %v", err)
}
routes, _ = ListEdgeRoutes(db)
if len(routes) != 0 {
t.Errorf("expected 0 routes after service delete, got %d", len(routes))
}
}

View File

@@ -0,0 +1,95 @@
package masterdb
import (
"database/sql"
"fmt"
"time"
)
// EdgeRoute records a public route managed by the master.
type EdgeRoute struct {
Hostname string
ServiceName string
EdgeNode string
BackendHostname string
BackendPort int
CreatedAt time.Time
}
// CreateEdgeRoute inserts or replaces an edge route record.
func CreateEdgeRoute(db *sql.DB, hostname, serviceName, edgeNode, backendHostname string, backendPort int) error {
_, err := db.Exec(`
INSERT INTO edge_routes (hostname, service_name, edge_node, backend_hostname, backend_port, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(hostname) DO UPDATE SET
service_name = excluded.service_name,
edge_node = excluded.edge_node,
backend_hostname = excluded.backend_hostname,
backend_port = excluded.backend_port
`, hostname, serviceName, edgeNode, backendHostname, backendPort)
if err != nil {
return fmt.Errorf("create edge route %s: %w", hostname, err)
}
return nil
}
// ListEdgeRoutes returns all edge routes.
func ListEdgeRoutes(db *sql.DB) ([]*EdgeRoute, error) {
return queryEdgeRoutes(db, `SELECT hostname, service_name, edge_node, backend_hostname, backend_port, created_at FROM edge_routes ORDER BY hostname`)
}
// ListEdgeRoutesForService returns edge routes for a specific service.
func ListEdgeRoutesForService(db *sql.DB, serviceName string) ([]*EdgeRoute, error) {
rows, err := db.Query(`
SELECT hostname, service_name, edge_node, backend_hostname, backend_port, created_at
FROM edge_routes WHERE service_name = ? ORDER BY hostname
`, serviceName)
if err != nil {
return nil, fmt.Errorf("list edge routes for %s: %w", serviceName, err)
}
defer func() { _ = rows.Close() }()
return scanEdgeRoutes(rows)
}
// DeleteEdgeRoute removes a single edge route by hostname.
func DeleteEdgeRoute(db *sql.DB, hostname string) error {
_, err := db.Exec(`DELETE FROM edge_routes WHERE hostname = ?`, hostname)
if err != nil {
return fmt.Errorf("delete edge route %s: %w", hostname, err)
}
return nil
}
// DeleteEdgeRoutesForService removes all edge routes for a service.
func DeleteEdgeRoutesForService(db *sql.DB, serviceName string) error {
_, err := db.Exec(`DELETE FROM edge_routes WHERE service_name = ?`, serviceName)
if err != nil {
return fmt.Errorf("delete edge routes for %s: %w", serviceName, err)
}
return nil
}
func queryEdgeRoutes(db *sql.DB, query string) ([]*EdgeRoute, error) {
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("query edge routes: %w", err)
}
defer func() { _ = rows.Close() }()
return scanEdgeRoutes(rows)
}
func scanEdgeRoutes(rows *sql.Rows) ([]*EdgeRoute, error) {
var routes []*EdgeRoute
for rows.Next() {
var r EdgeRoute
var createdAt string
if err := rows.Scan(&r.Hostname, &r.ServiceName, &r.EdgeNode, &r.BackendHostname, &r.BackendPort, &createdAt); err != nil {
return nil, fmt.Errorf("scan edge route: %w", err)
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
routes = append(routes, &r)
}
return routes, rows.Err()
}

103
internal/masterdb/nodes.go Normal file
View File

@@ -0,0 +1,103 @@
package masterdb
import (
"database/sql"
"fmt"
"time"
)
// Node represents a registered node in the cluster.
type Node struct {
Name string
Address string
Role string
Arch string
Status string
Containers int
LastHeartbeat *time.Time
}
// UpsertNode inserts or updates a node in the registry.
func UpsertNode(db *sql.DB, name, address, role, arch string) error {
_, err := db.Exec(`
INSERT INTO nodes (name, address, role, arch, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(name) DO UPDATE SET
address = excluded.address,
role = excluded.role,
arch = excluded.arch,
updated_at = datetime('now')
`, name, address, role, arch)
if err != nil {
return fmt.Errorf("upsert node %s: %w", name, err)
}
return nil
}
// GetNode returns a single node by name.
func GetNode(db *sql.DB, name string) (*Node, error) {
var n Node
var lastHB sql.NullString
err := db.QueryRow(`
SELECT name, address, role, arch, status, containers, last_heartbeat
FROM nodes WHERE name = ?
`, name).Scan(&n.Name, &n.Address, &n.Role, &n.Arch, &n.Status, &n.Containers, &lastHB)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get node %s: %w", name, err)
}
if lastHB.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", lastHB.String)
n.LastHeartbeat = &t
}
return &n, nil
}
// ListNodes returns all nodes.
func ListNodes(db *sql.DB) ([]*Node, error) {
return queryNodes(db, `SELECT name, address, role, arch, status, containers, last_heartbeat FROM nodes ORDER BY name`)
}
// ListWorkerNodes returns nodes with role "worker" or "master" (master is also a worker).
func ListWorkerNodes(db *sql.DB) ([]*Node, error) {
return queryNodes(db, `SELECT name, address, role, arch, status, containers, last_heartbeat FROM nodes WHERE role IN ('worker', 'master') ORDER BY name`)
}
// ListEdgeNodes returns nodes with role "edge".
func ListEdgeNodes(db *sql.DB) ([]*Node, error) {
return queryNodes(db, `SELECT name, address, role, arch, status, containers, last_heartbeat FROM nodes WHERE role = 'edge' ORDER BY name`)
}
func queryNodes(db *sql.DB, query string) ([]*Node, error) {
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("query nodes: %w", err)
}
defer func() { _ = rows.Close() }()
var nodes []*Node
for rows.Next() {
var n Node
var lastHB sql.NullString
if err := rows.Scan(&n.Name, &n.Address, &n.Role, &n.Arch, &n.Status, &n.Containers, &lastHB); err != nil {
return nil, fmt.Errorf("scan node: %w", err)
}
if lastHB.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", lastHB.String)
n.LastHeartbeat = &t
}
nodes = append(nodes, &n)
}
return nodes, rows.Err()
}
// UpdateNodeStatus updates a node's status field.
func UpdateNodeStatus(db *sql.DB, name, status string) error {
_, err := db.Exec(`UPDATE nodes SET status = ?, updated_at = datetime('now') WHERE name = ?`, status, name)
if err != nil {
return fmt.Errorf("update node status %s: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,99 @@
package masterdb
import (
"database/sql"
"fmt"
"time"
)
// Placement records which node hosts which service.
type Placement struct {
ServiceName string
Node string
Tier string
DeployedAt time.Time
}
// CreatePlacement inserts or replaces a placement record.
func CreatePlacement(db *sql.DB, serviceName, node, tier string) error {
_, err := db.Exec(`
INSERT INTO placements (service_name, node, tier, deployed_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(service_name) DO UPDATE SET
node = excluded.node,
tier = excluded.tier,
deployed_at = datetime('now')
`, serviceName, node, tier)
if err != nil {
return fmt.Errorf("create placement %s: %w", serviceName, err)
}
return nil
}
// GetPlacement returns the placement for a service.
func GetPlacement(db *sql.DB, serviceName string) (*Placement, error) {
var p Placement
var deployedAt string
err := db.QueryRow(`
SELECT service_name, node, tier, deployed_at
FROM placements WHERE service_name = ?
`, serviceName).Scan(&p.ServiceName, &p.Node, &p.Tier, &deployedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get placement %s: %w", serviceName, err)
}
p.DeployedAt, _ = time.Parse("2006-01-02 15:04:05", deployedAt)
return &p, nil
}
// ListPlacements returns all placements.
func ListPlacements(db *sql.DB) ([]*Placement, error) {
rows, err := db.Query(`SELECT service_name, node, tier, deployed_at FROM placements ORDER BY service_name`)
if err != nil {
return nil, fmt.Errorf("list placements: %w", err)
}
defer func() { _ = rows.Close() }()
var placements []*Placement
for rows.Next() {
var p Placement
var deployedAt string
if err := rows.Scan(&p.ServiceName, &p.Node, &p.Tier, &deployedAt); err != nil {
return nil, fmt.Errorf("scan placement: %w", err)
}
p.DeployedAt, _ = time.Parse("2006-01-02 15:04:05", deployedAt)
placements = append(placements, &p)
}
return placements, rows.Err()
}
// DeletePlacement removes a placement record.
func DeletePlacement(db *sql.DB, serviceName string) error {
_, err := db.Exec(`DELETE FROM placements WHERE service_name = ?`, serviceName)
if err != nil {
return fmt.Errorf("delete placement %s: %w", serviceName, err)
}
return nil
}
// CountPlacementsPerNode returns a map of node name → number of placed services.
func CountPlacementsPerNode(db *sql.DB) (map[string]int, error) {
rows, err := db.Query(`SELECT node, COUNT(*) FROM placements GROUP BY node`)
if err != nil {
return nil, fmt.Errorf("count placements: %w", err)
}
defer func() { _ = rows.Close() }()
counts := make(map[string]int)
for rows.Next() {
var node string
var count int
if err := rows.Scan(&node, &count); err != nil {
return nil, fmt.Errorf("scan count: %w", err)
}
counts[node] = count
}
return counts, rows.Err()
}

95
proto/mcp/v1/master.proto Normal file
View File

@@ -0,0 +1,95 @@
// McpMasterService: Multi-node orchestration for the Metacircular platform.
syntax = "proto3";
package mcp.v1;
option go_package = "git.wntrmute.dev/mc/mcp/gen/mcp/v1;mcpv1";
import "proto/mcp/v1/mcp.proto";
// McpMasterService coordinates multi-node deployments. The CLI sends
// deploy/undeploy/status requests to the master, which places services on
// nodes, forwards to agents, and coordinates edge routing.
service McpMasterService {
// CLI operations.
rpc Deploy(MasterDeployRequest) returns (MasterDeployResponse);
rpc Undeploy(MasterUndeployRequest) returns (MasterUndeployResponse);
rpc Status(MasterStatusRequest) returns (MasterStatusResponse);
rpc ListNodes(ListNodesRequest) returns (ListNodesResponse);
}
// --- Deploy ---
message MasterDeployRequest {
ServiceSpec service = 1;
}
message MasterDeployResponse {
string node = 1; // node the service was placed on
bool success = 2; // true only if ALL steps succeeded
string error = 3;
// Per-step results for operator visibility.
StepResult deploy_result = 4;
StepResult edge_route_result = 5;
StepResult dns_result = 6;
}
message StepResult {
string step = 1;
bool success = 2;
string error = 3;
}
// --- Undeploy ---
message MasterUndeployRequest {
string service_name = 1;
}
message MasterUndeployResponse {
bool success = 1;
string error = 2;
}
// --- Status ---
message MasterStatusRequest {
string service_name = 1; // empty = all services
}
message MasterStatusResponse {
repeated ServiceStatus services = 1;
}
message ServiceStatus {
string name = 1;
string node = 2;
string tier = 3;
string status = 4; // "running", "stopped", "unhealthy", "unknown"
repeated EdgeRouteStatus edge_routes = 5;
}
message EdgeRouteStatus {
string hostname = 1;
string edge_node = 2;
string cert_expires = 3; // RFC3339
}
// --- Nodes ---
message ListNodesRequest {}
message ListNodesResponse {
repeated NodeInfo nodes = 1;
}
message NodeInfo {
string name = 1;
string role = 2;
string address = 3;
string arch = 4;
string status = 5; // "healthy", "unhealthy", "unknown"
int32 containers = 6;
string last_heartbeat = 7; // RFC3339
int32 services = 8; // placement count
}

View File

@@ -61,6 +61,7 @@ message RouteSpec {
int32 port = 2; // mc-proxy listener port (e.g. 443, 8443, 9443); NOT the container internal port
string mode = 3; // "l4" or "l7"
string hostname = 4; // optional public hostname override
bool public = 5; // triggers edge routing when true
}
message ComponentSpec {
@@ -76,11 +77,19 @@ message ComponentSpec {
repeated string env = 10;
}
message SnapshotConfig {
string method = 1; // "grpc", "cli", "exec: <cmd>", "full", or "" (default)
repeated string excludes = 2; // paths relative to /srv/<service>/ to skip
}
message ServiceSpec {
string name = 1;
bool active = 2;
repeated ComponentSpec components = 3;
string comment = 4;
string tier = 5; // "core" or "worker" (default: "worker")
string node = 6; // explicit node pin (overrides tier)
SnapshotConfig snapshot = 7; // snapshot method and excludes
}
message DeployRequest {