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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user