Fix container name handling for hyphenated service names

Extract ContainerNameFor and SplitContainerName into names.go.
ContainerNameFor handles single-component services where service
name equals component name (e.g., mc-proxy → "mc-proxy" not
"mc-proxy-mc-proxy"). SplitContainerName checks known services
from the registry before falling back to naive split on "-", fixing
mc-proxy being misidentified as service "mc" component "proxy".

Also fixes podman ps JSON parsing (Command field is []string not
string) found during deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 15:13:20 -07:00
parent 941dd7003a
commit efa32a7712
7 changed files with 75 additions and 22 deletions

View File

@@ -11,6 +11,8 @@ RestartSec=5
User=mcp
Group=mcp
Environment=HOME=/srv/mcp
Environment=XDG_RUNTIME_DIR=/run/user/%U
NoNewPrivileges=true
ProtectSystem=strict

View File

@@ -49,7 +49,7 @@ func (a *Agent) Deploy(ctx context.Context, req *mcpv1.DeployRequest) (*mcpv1.De
// deployComponent handles the full deploy lifecycle for a single component.
func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcpv1.ComponentSpec, active bool) *mcpv1.ComponentResult {
compName := cs.GetName()
containerName := serviceName + "-" + compName
containerName := ContainerNameFor(serviceName, compName)
desiredState := "running"
if !active {

View File

@@ -27,7 +27,7 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest)
var results []*mcpv1.ComponentResult
for _, c := range components {
containerName := req.GetName() + "-" + c.Name
containerName := ContainerNameFor(req.GetName(), c.Name)
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
if err := a.Runtime.Stop(ctx, containerName); err != nil {
@@ -94,7 +94,7 @@ func (a *Agent) RestartService(ctx context.Context, req *mcpv1.RestartServiceReq
// startComponent removes any existing container and runs a fresh one from
// the registry spec, then updates state to running.
func startComponent(ctx context.Context, a *Agent, service string, c *registry.Component) *mcpv1.ComponentResult {
containerName := service + "-" + c.Name
containerName := ContainerNameFor(service, c.Name)
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
// Remove any pre-existing container; ignore errors for non-existent ones.
@@ -118,7 +118,7 @@ func startComponent(ctx context.Context, a *Agent, service string, c *registry.C
// restartComponent stops, removes, and re-creates a container without
// changing the desired_state in the registry.
func restartComponent(ctx context.Context, a *Agent, service string, c *registry.Component) *mcpv1.ComponentResult {
containerName := service + "-" + c.Name
containerName := ContainerNameFor(service, c.Name)
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
_ = a.Runtime.Stop(ctx, containerName)
@@ -142,7 +142,7 @@ func restartComponent(ctx context.Context, a *Agent, service string, c *registry
// componentToSpec builds a runtime.ContainerSpec from a registry Component.
func componentToSpec(service string, c *registry.Component) runtime.ContainerSpec {
return runtime.ContainerSpec{
Name: service + "-" + c.Name,
Name: ContainerNameFor(service, c.Name),
Image: c.Image,
Network: c.Network,
User: c.UserSpec,

34
internal/agent/names.go Normal file
View File

@@ -0,0 +1,34 @@
package agent
import "strings"
// ContainerNameFor returns the expected container name for a service and
// component. For single-component services where the component name equals
// the service name, the container name is just the service name (e.g.,
// "mc-proxy" not "mc-proxy-mc-proxy").
func ContainerNameFor(service, component string) string {
if service == component {
return service
}
return service + "-" + component
}
// SplitContainerName splits a container name into service and component parts.
// It checks known service names first to handle names like "mc-proxy" where a
// naive split on "-" would produce the wrong result. If no known service
// matches, it falls back to splitting on the first "-".
func SplitContainerName(name string, knownServices map[string]bool) (service, component string) {
if knownServices[name] {
return name, name
}
for svc := range knownServices {
prefix := svc + "-"
if strings.HasPrefix(name, prefix) && len(name) > len(prefix) {
return svc, name[len(prefix):]
}
}
if i := strings.Index(name, "-"); i >= 0 {
return name[:i], name[i+1:]
}
return name, name
}

View File

@@ -3,7 +3,6 @@ package agent
import (
"context"
"fmt"
"strings"
"time"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
@@ -75,7 +74,10 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
}
var result []*mcpv1.ServiceInfo
knownServices := make(map[string]bool, len(services))
for _, svc := range services {
knownServices[svc.Name] = true
components, err := registry.ListComponents(a.DB, svc.Name)
if err != nil {
return nil, fmt.Errorf("list components for %q: %w", svc.Name, err)
@@ -87,7 +89,7 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
}
for _, comp := range components {
containerName := svc.Name + "-" + comp.Name
containerName := ContainerNameFor(svc.Name, comp.Name)
ci := &mcpv1.ComponentInfo{
Name: comp.Name,
Image: comp.Image,
@@ -116,7 +118,7 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
continue
}
svcName, compName := splitContainerName(c.Name)
svcName, compName := SplitContainerName(c.Name, knownServices)
result = append(result, &mcpv1.ServiceInfo{
Name: svcName,
@@ -210,13 +212,3 @@ func (a *Agent) GetServiceStatus(ctx context.Context, req *mcpv1.GetServiceStatu
RecentEvents: protoEvents,
}, nil
}
// splitContainerName splits a container name like "metacrypt-api" into service
// and component parts. If there is no hyphen, the whole name is used as both
// the service and component name.
func splitContainerName(name string) (service, component string) {
if i := strings.Index(name, "-"); i >= 0 {
return name[:i], name[i+1:]
}
return name, name
}

View File

@@ -253,22 +253,47 @@ func TestGetServiceStatus_IgnoreSkipsDrift(t *testing.T) {
}
func TestSplitContainerName(t *testing.T) {
known := map[string]bool{
"metacrypt": true,
"mc-proxy": true,
"mcr": true,
}
tests := []struct {
name string
service string
comp string
}{
{"metacrypt-api", "metacrypt", "api"},
{"metacrypt-web-ui", "metacrypt", "web-ui"},
{"metacrypt-web", "metacrypt", "web"},
{"mc-proxy", "mc-proxy", "mc-proxy"},
{"mcr-api", "mcr", "api"},
{"standalone", "standalone", "standalone"},
{"unknown-thing", "unknown", "thing"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, comp := splitContainerName(tt.name)
svc, comp := SplitContainerName(tt.name, known)
if svc != tt.service || comp != tt.comp {
t.Fatalf("splitContainerName(%q) = (%q, %q), want (%q, %q)",
t.Fatalf("SplitContainerName(%q) = (%q, %q), want (%q, %q)",
tt.name, svc, comp, tt.service, tt.comp)
}
})
}
}
func TestContainerNameFor(t *testing.T) {
tests := []struct {
service, component, want string
}{
{"metacrypt", "api", "metacrypt-api"},
{"mc-proxy", "mc-proxy", "mc-proxy"},
{"mcr", "web", "mcr-web"},
}
for _, tt := range tests {
got := ContainerNameFor(tt.service, tt.component)
if got != tt.want {
t.Fatalf("ContainerNameFor(%q, %q) = %q, want %q",
tt.service, tt.component, got, tt.want)
}
}
}

View File

@@ -179,7 +179,7 @@ type podmanPSEntry struct {
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Command string `json:"Command"`
Command []string `json:"Command"`
}
// List returns information about all containers.