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) }