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>
487 lines
12 KiB
Go
487 lines
12 KiB
Go
package servicedef
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func boolPtr(b bool) *bool { return &b }
|
|
|
|
func sampleDef() *ServiceDef {
|
|
return &ServiceDef{
|
|
Name: "metacrypt",
|
|
Node: "rift",
|
|
Active: boolPtr(true),
|
|
Components: []ComponentDef{
|
|
{
|
|
Name: "api",
|
|
Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt:latest",
|
|
Network: "docker_default",
|
|
User: "0:0",
|
|
Restart: "unless-stopped",
|
|
Ports: []string{"127.0.0.1:18443:8443", "127.0.0.1:19443:9443"},
|
|
Volumes: []string{"/srv/metacrypt:/srv/metacrypt"},
|
|
},
|
|
{
|
|
Name: "web",
|
|
Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt-web:latest",
|
|
Network: "docker_default",
|
|
User: "0:0",
|
|
Restart: "unless-stopped",
|
|
Ports: []string{"127.0.0.1:18080:8080"},
|
|
Volumes: []string{"/srv/metacrypt:/srv/metacrypt"},
|
|
Cmd: []string{"server", "--config", "/srv/metacrypt/metacrypt.toml"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// compareComponents asserts that two component slices are equal field by field.
|
|
func compareComponents(t *testing.T, prefix string, got, want []ComponentDef) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("%s components: got %d, want %d", prefix, len(got), len(want))
|
|
}
|
|
for i, wantC := range want {
|
|
gotC := got[i]
|
|
if gotC.Name != wantC.Name {
|
|
t.Fatalf("%s component[%d] name: got %q, want %q", prefix, i, gotC.Name, wantC.Name)
|
|
}
|
|
if gotC.Image != wantC.Image {
|
|
t.Fatalf("%s component[%d] image: got %q, want %q", prefix, i, gotC.Image, wantC.Image)
|
|
}
|
|
if gotC.Network != wantC.Network {
|
|
t.Fatalf("%s component[%d] network: got %q, want %q", prefix, i, gotC.Network, wantC.Network)
|
|
}
|
|
if gotC.User != wantC.User {
|
|
t.Fatalf("%s component[%d] user: got %q, want %q", prefix, i, gotC.User, wantC.User)
|
|
}
|
|
if gotC.Restart != wantC.Restart {
|
|
t.Fatalf("%s component[%d] restart: got %q, want %q", prefix, i, gotC.Restart, wantC.Restart)
|
|
}
|
|
compareStrSlice(t, prefix, i, "ports", gotC.Ports, wantC.Ports)
|
|
compareStrSlice(t, prefix, i, "volumes", gotC.Volumes, wantC.Volumes)
|
|
compareStrSlice(t, prefix, i, "cmd", gotC.Cmd, wantC.Cmd)
|
|
}
|
|
}
|
|
|
|
func compareStrSlice(t *testing.T, prefix string, idx int, field string, got, want []string) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("%s component[%d] %s: got %d, want %d", prefix, idx, field, len(got), len(want))
|
|
}
|
|
for j := range want {
|
|
if got[j] != want[j] {
|
|
t.Fatalf("%s component[%d] %s[%d]: got %q, want %q", prefix, idx, field, j, got[j], want[j])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadWrite(t *testing.T) {
|
|
def := sampleDef()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "metacrypt.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 got.Name != def.Name {
|
|
t.Fatalf("name: got %q, want %q", got.Name, def.Name)
|
|
}
|
|
if got.Node != def.Node {
|
|
t.Fatalf("node: got %q, want %q", got.Node, def.Node)
|
|
}
|
|
if *got.Active != *def.Active {
|
|
t.Fatalf("active: got %v, want %v", *got.Active, *def.Active)
|
|
}
|
|
compareComponents(t, "load-write", got.Components, def.Components)
|
|
}
|
|
|
|
func TestValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
def *ServiceDef
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing name",
|
|
def: &ServiceDef{
|
|
Node: "rift",
|
|
Components: []ComponentDef{{Name: "api", Image: "img:v1"}},
|
|
},
|
|
wantErr: "service name is required",
|
|
},
|
|
{
|
|
name: "missing node",
|
|
def: &ServiceDef{
|
|
Name: "svc",
|
|
Components: []ComponentDef{{Name: "api", Image: "img:v1"}},
|
|
},
|
|
wantErr: "service node is required",
|
|
},
|
|
{
|
|
name: "empty components",
|
|
def: &ServiceDef{
|
|
Name: "svc",
|
|
Node: "rift",
|
|
},
|
|
wantErr: "must have at least one component",
|
|
},
|
|
{
|
|
name: "duplicate component names",
|
|
def: &ServiceDef{
|
|
Name: "svc",
|
|
Node: "rift",
|
|
Components: []ComponentDef{
|
|
{Name: "api", Image: "img:v1"},
|
|
{Name: "api", Image: "img:v2"},
|
|
},
|
|
},
|
|
wantErr: "duplicate component name",
|
|
},
|
|
{
|
|
name: "component missing name",
|
|
def: &ServiceDef{
|
|
Name: "svc",
|
|
Node: "rift",
|
|
Components: []ComponentDef{{Image: "img:v1"}},
|
|
},
|
|
wantErr: "component name is required",
|
|
},
|
|
{
|
|
name: "component missing image",
|
|
def: &ServiceDef{
|
|
Name: "svc",
|
|
Node: "rift",
|
|
Components: []ComponentDef{{Name: "api"}},
|
|
},
|
|
wantErr: "image is required",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validate(tt.def)
|
|
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 TestLoadAll(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Write services in non-alphabetical order.
|
|
defs := []*ServiceDef{
|
|
{
|
|
Name: "mcr",
|
|
Node: "rift",
|
|
Active: boolPtr(true),
|
|
Components: []ComponentDef{{Name: "api", Image: "mcr:latest"}},
|
|
},
|
|
{
|
|
Name: "metacrypt",
|
|
Node: "rift",
|
|
Active: boolPtr(true),
|
|
Components: []ComponentDef{{Name: "api", Image: "metacrypt:latest"}},
|
|
},
|
|
{
|
|
Name: "mcias",
|
|
Node: "rift",
|
|
Active: boolPtr(false),
|
|
Components: []ComponentDef{{Name: "api", Image: "mcias:latest"}},
|
|
},
|
|
}
|
|
|
|
for _, d := range defs {
|
|
if err := Write(filepath.Join(dir, d.Name+".toml"), d); err != nil {
|
|
t.Fatalf("write %s: %v", d.Name, err)
|
|
}
|
|
}
|
|
|
|
// Write a non-TOML file that should be ignored.
|
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# ignore"), 0o600); err != nil {
|
|
t.Fatalf("write readme: %v", err)
|
|
}
|
|
|
|
got, err := LoadAll(dir)
|
|
if err != nil {
|
|
t.Fatalf("load all: %v", err)
|
|
}
|
|
|
|
if len(got) != 3 {
|
|
t.Fatalf("count: got %d, want 3", len(got))
|
|
}
|
|
|
|
wantOrder := []string{"mcias", "mcr", "metacrypt"}
|
|
for i, name := range wantOrder {
|
|
if got[i].Name != name {
|
|
t.Fatalf("order[%d]: got %q, want %q", i, got[i].Name, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestActiveDefault(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "svc.toml")
|
|
|
|
content := `name = "svc"
|
|
node = "rift"
|
|
|
|
[[components]]
|
|
name = "api"
|
|
image = "img:latest"
|
|
`
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
def, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
if def.Active == nil {
|
|
t.Fatal("active should not be nil")
|
|
}
|
|
if !*def.Active {
|
|
t.Fatal("active should default to true")
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
spec := ToProto(def)
|
|
if spec.Name != def.Name {
|
|
t.Fatalf("proto name: got %q, want %q", spec.Name, def.Name)
|
|
}
|
|
if !spec.Active {
|
|
t.Fatal("proto active should be true")
|
|
}
|
|
if len(spec.Components) != len(def.Components) {
|
|
t.Fatalf("proto components: got %d, want %d", len(spec.Components), len(def.Components))
|
|
}
|
|
|
|
got := FromProto(spec, def.Node)
|
|
if got.Name != def.Name {
|
|
t.Fatalf("round-trip name: got %q, want %q", got.Name, def.Name)
|
|
}
|
|
if got.Node != def.Node {
|
|
t.Fatalf("round-trip node: got %q, want %q", got.Node, def.Node)
|
|
}
|
|
if *got.Active != *def.Active {
|
|
t.Fatalf("round-trip active: got %v, want %v", *got.Active, *def.Active)
|
|
}
|
|
compareComponents(t, "round-trip", got.Components, def.Components)
|
|
}
|