Add route declarations and automatic port allocation to MCP agent

Service definitions can now declare routes per component instead of
manual port mappings:

  [[components.routes]]
  name = "rest"
  port = 8443
  mode = "l4"

The agent allocates free host ports at deploy time and injects
$PORT/$PORT_<NAME> env vars into containers. Backward compatible:
components with old-style ports= work unchanged.

Changes:
- Proto: RouteSpec message, routes + env fields on ComponentSpec
- Servicedef: RouteDef parsing and validation from TOML
- Registry: component_routes table with host_port tracking
- Runtime: Env field on ContainerSpec, -e flag in BuildRunArgs
- Agent: PortAllocator (random 10000-60000, availability check),
  deploy wiring for route→port mapping and env injection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 01:04:47 -07:00
parent 503c52dc26
commit 777ba8a0e1
14 changed files with 1101 additions and 222 deletions

View File

@@ -21,16 +21,27 @@ type ServiceDef struct {
Components []ComponentDef `toml:"components"`
}
// RouteDef describes a route for a component, used for automatic port
// allocation and mc-proxy integration.
type RouteDef struct {
Name string `toml:"name,omitempty"`
Port int `toml:"port"`
Mode string `toml:"mode,omitempty"`
Hostname string `toml:"hostname,omitempty"`
}
// ComponentDef describes a single container component within a service.
type ComponentDef struct {
Name string `toml:"name"`
Image string `toml:"image"`
Network string `toml:"network,omitempty"`
User string `toml:"user,omitempty"`
Restart string `toml:"restart,omitempty"`
Ports []string `toml:"ports,omitempty"`
Volumes []string `toml:"volumes,omitempty"`
Cmd []string `toml:"cmd,omitempty"`
Name string `toml:"name"`
Image string `toml:"image"`
Network string `toml:"network,omitempty"`
User string `toml:"user,omitempty"`
Restart string `toml:"restart,omitempty"`
Ports []string `toml:"ports,omitempty"`
Volumes []string `toml:"volumes,omitempty"`
Cmd []string `toml:"cmd,omitempty"`
Routes []RouteDef `toml:"routes,omitempty"`
Env []string `toml:"env,omitempty"`
}
// Load reads and parses a TOML service definition file. If the active field
@@ -129,11 +140,46 @@ func validate(def *ServiceDef) error {
return fmt.Errorf("duplicate component name %q in service %q", c.Name, def.Name)
}
seen[c.Name] = true
if err := validateRoutes(c.Name, def.Name, c.Routes); err != nil {
return err
}
}
return nil
}
// validateRoutes checks that routes within a component are valid.
func validateRoutes(compName, svcName string, routes []RouteDef) error {
if len(routes) == 0 {
return nil
}
routeNames := make(map[string]bool)
for i, r := range routes {
if r.Port <= 0 {
return fmt.Errorf("route port must be > 0 in component %q of service %q", compName, svcName)
}
if r.Mode != "" && r.Mode != "l4" && r.Mode != "l7" {
return fmt.Errorf("route mode must be \"l4\" or \"l7\" in component %q of service %q", compName, svcName)
}
if len(routes) > 1 && r.Name == "" {
return fmt.Errorf("route name is required when component has multiple routes in component %q of service %q", compName, svcName)
}
// Use index-based key for unnamed single routes.
key := r.Name
if key == "" {
key = fmt.Sprintf("_route_%d", i)
}
if routeNames[key] {
return fmt.Errorf("duplicate route name %q in component %q of service %q", r.Name, compName, svcName)
}
routeNames[key] = true
}
return nil
}
// ToProto converts a ServiceDef to a proto ServiceSpec.
func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
spec := &mcpv1.ServiceSpec{
@@ -142,7 +188,7 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
}
for _, c := range def.Components {
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{
cs := &mcpv1.ComponentSpec{
Name: c.Name,
Image: c.Image,
Network: c.Network,
@@ -151,7 +197,17 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
Ports: c.Ports,
Volumes: c.Volumes,
Cmd: c.Cmd,
})
Env: c.Env,
}
for _, r := range c.Routes {
cs.Routes = append(cs.Routes, &mcpv1.RouteSpec{
Name: r.Name,
Port: int32(r.Port),
Mode: r.Mode,
Hostname: r.Hostname,
})
}
spec.Components = append(spec.Components, cs)
}
return spec
@@ -169,7 +225,7 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
}
for _, c := range spec.GetComponents() {
def.Components = append(def.Components, ComponentDef{
cd := ComponentDef{
Name: c.GetName(),
Image: c.GetImage(),
Network: c.GetNetwork(),
@@ -178,7 +234,17 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
Ports: c.GetPorts(),
Volumes: c.GetVolumes(),
Cmd: c.GetCmd(),
})
Env: c.GetEnv(),
}
for _, r := range c.GetRoutes() {
cd.Routes = append(cd.Routes, RouteDef{
Name: r.GetName(),
Port: int(r.GetPort()),
Mode: r.GetMode(),
Hostname: r.GetHostname(),
})
}
def.Components = append(def.Components, cd)
}
return def

View File

@@ -261,6 +261,203 @@ image = "img:latest"
}
}
func TestLoadWriteWithRoutes(t *testing.T) {
def := &ServiceDef{
Name: "myservice",
Node: "rift",
Active: boolPtr(true),
Components: []ComponentDef{
{
Name: "api",
Image: "img:latest",
Network: "docker_default",
Routes: []RouteDef{
{Name: "rest", Port: 8443, Mode: "l7", Hostname: "api.example.com"},
{Name: "grpc", Port: 9443, Mode: "l4"},
},
Env: []string{"FOO=bar"},
},
},
}
dir := t.TempDir()
path := filepath.Join(dir, "myservice.toml")
if err := Write(path, def); err != nil {
t.Fatalf("write: %v", err)
}
got, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(got.Components[0].Routes) != 2 {
t.Fatalf("routes: got %d, want 2", len(got.Components[0].Routes))
}
r := got.Components[0].Routes[0]
if r.Name != "rest" || r.Port != 8443 || r.Mode != "l7" || r.Hostname != "api.example.com" {
t.Fatalf("route[0] mismatch: %+v", r)
}
r2 := got.Components[0].Routes[1]
if r2.Name != "grpc" || r2.Port != 9443 || r2.Mode != "l4" {
t.Fatalf("route[1] mismatch: %+v", r2)
}
if len(got.Components[0].Env) != 1 || got.Components[0].Env[0] != "FOO=bar" {
t.Fatalf("env mismatch: %v", got.Components[0].Env)
}
}
func TestRouteValidation(t *testing.T) {
tests := []struct {
name string
def *ServiceDef
wantErr string
}{
{
name: "route missing port",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Name: "rest", Port: 0}},
}},
},
wantErr: "route port must be > 0",
},
{
name: "route invalid mode",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Port: 8443, Mode: "tcp"}},
}},
},
wantErr: "route mode must be",
},
{
name: "multi-route missing name",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{
{Name: "rest", Port: 8443},
{Port: 9443},
},
}},
},
wantErr: "route name is required when component has multiple routes",
},
{
name: "duplicate route name",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{
{Name: "rest", Port: 8443},
{Name: "rest", Port: 9443},
},
}},
},
wantErr: "duplicate route name",
},
{
name: "single unnamed route is valid",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Port: 8443}},
}},
},
wantErr: "",
},
{
name: "valid l4 mode",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Port: 8443, Mode: "l4"}},
}},
},
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate(tt.def)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("expected validation error")
}
if got := err.Error(); !strings.Contains(got, tt.wantErr) {
t.Fatalf("error %q does not contain %q", got, tt.wantErr)
}
})
}
}
func TestProtoConversionWithRoutes(t *testing.T) {
def := &ServiceDef{
Name: "svc",
Node: "rift",
Active: boolPtr(true),
Components: []ComponentDef{
{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{
{Name: "rest", Port: 8443, Mode: "l7", Hostname: "api.example.com"},
{Name: "grpc", Port: 9443, Mode: "l4"},
},
Env: []string{"PORT_REST=12345", "PORT_GRPC=12346"},
},
},
}
spec := ToProto(def)
if len(spec.Components[0].Routes) != 2 {
t.Fatalf("proto routes: got %d, want 2", len(spec.Components[0].Routes))
}
r := spec.Components[0].Routes[0]
if r.GetName() != "rest" || r.GetPort() != 8443 || r.GetMode() != "l7" || r.GetHostname() != "api.example.com" {
t.Fatalf("proto route[0] mismatch: %+v", r)
}
if len(spec.Components[0].Env) != 2 {
t.Fatalf("proto env: got %d, want 2", len(spec.Components[0].Env))
}
got := FromProto(spec, "rift")
if len(got.Components[0].Routes) != 2 {
t.Fatalf("round-trip routes: got %d, want 2", len(got.Components[0].Routes))
}
gotR := got.Components[0].Routes[0]
if gotR.Name != "rest" || gotR.Port != 8443 || gotR.Mode != "l7" || gotR.Hostname != "api.example.com" {
t.Fatalf("round-trip route[0] mismatch: %+v", gotR)
}
if len(got.Components[0].Env) != 2 {
t.Fatalf("round-trip env: got %d, want 2", len(got.Components[0].Env))
}
}
func TestProtoConversion(t *testing.T) {
def := sampleDef()