Files
mcp/internal/runtime/podman.go
Kyle Isom efa32a7712 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>
2026-03-26 15:13:20 -07:00

214 lines
5.9 KiB
Go

package runtime
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// Podman implements the Runtime interface using the podman CLI.
type Podman struct {
Command string // path to podman binary, default "podman"
}
func (p *Podman) command() string {
if p.Command != "" {
return p.Command
}
return "podman"
}
// Pull pulls a container image.
func (p *Podman) Pull(ctx context.Context, image string) error {
cmd := exec.CommandContext(ctx, p.command(), "pull", image) //nolint:gosec // args built programmatically
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman pull %q: %w: %s", image, err, out)
}
return nil
}
// BuildRunArgs constructs the argument list for podman run. Exported for testing.
func (p *Podman) BuildRunArgs(spec ContainerSpec) []string {
args := []string{"run", "-d", "--name", spec.Name}
if spec.Network != "" {
args = append(args, "--network", spec.Network)
}
if spec.User != "" {
args = append(args, "--user", spec.User)
}
if spec.Restart != "" {
args = append(args, "--restart", spec.Restart)
}
for _, port := range spec.Ports {
args = append(args, "-p", port)
}
for _, vol := range spec.Volumes {
args = append(args, "-v", vol)
}
args = append(args, spec.Image)
args = append(args, spec.Cmd...)
return args
}
// Run creates and starts a container from the given spec.
func (p *Podman) Run(ctx context.Context, spec ContainerSpec) error {
args := p.BuildRunArgs(spec)
cmd := exec.CommandContext(ctx, p.command(), args...) //nolint:gosec // args built programmatically
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman run %q: %w: %s", spec.Name, err, out)
}
return nil
}
// Stop stops a running container.
func (p *Podman) Stop(ctx context.Context, name string) error {
cmd := exec.CommandContext(ctx, p.command(), "stop", name) //nolint:gosec // args built programmatically
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman stop %q: %w: %s", name, err, out)
}
return nil
}
// Remove removes a container.
func (p *Podman) Remove(ctx context.Context, name string) error {
cmd := exec.CommandContext(ctx, p.command(), "rm", name) //nolint:gosec // args built programmatically
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman rm %q: %w: %s", name, err, out)
}
return nil
}
// podmanPortBinding is a single port binding from the inspect output.
type podmanPortBinding struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
}
// podmanInspectResult is the subset of podman inspect JSON we parse.
type podmanInspectResult struct {
Name string `json:"Name"`
Config struct {
Image string `json:"Image"`
Cmd []string `json:"Cmd"`
User string `json:"User"`
} `json:"Config"`
State struct {
Status string `json:"Status"`
StartedAt string `json:"StartedAt"`
} `json:"State"`
HostConfig struct {
RestartPolicy struct {
Name string `json:"Name"`
} `json:"RestartPolicy"`
NetworkMode string `json:"NetworkMode"`
} `json:"HostConfig"`
NetworkSettings struct {
Networks map[string]struct{} `json:"Networks"`
Ports map[string][]podmanPortBinding `json:"Ports"`
} `json:"NetworkSettings"`
Mounts []struct {
Source string `json:"Source"`
Destination string `json:"Destination"`
} `json:"Mounts"`
}
// Inspect retrieves information about a container.
func (p *Podman) Inspect(ctx context.Context, name string) (ContainerInfo, error) {
cmd := exec.CommandContext(ctx, p.command(), "inspect", name) //nolint:gosec // args built programmatically
out, err := cmd.Output()
if err != nil {
return ContainerInfo{}, fmt.Errorf("podman inspect %q: %w", name, err)
}
var results []podmanInspectResult
if err := json.Unmarshal(out, &results); err != nil {
return ContainerInfo{}, fmt.Errorf("parse inspect output: %w", err)
}
if len(results) == 0 {
return ContainerInfo{}, fmt.Errorf("podman inspect %q: no results", name)
}
r := results[0]
info := ContainerInfo{
Name: strings.TrimPrefix(r.Name, "/"),
Image: r.Config.Image,
State: r.State.Status,
User: r.Config.User,
Restart: r.HostConfig.RestartPolicy.Name,
Cmd: r.Config.Cmd,
Version: ExtractVersion(r.Config.Image),
}
if t, err := time.Parse(time.RFC3339Nano, r.State.StartedAt); err == nil {
info.Started = t
}
info.Network = r.HostConfig.NetworkMode
if len(r.NetworkSettings.Networks) > 0 {
for netName := range r.NetworkSettings.Networks {
info.Network = netName
break
}
}
for containerPort, bindings := range r.NetworkSettings.Ports {
for _, b := range bindings {
port := strings.SplitN(containerPort, "/", 2)[0] // strip "/tcp" suffix
mapping := b.HostPort + ":" + port
if b.HostIP != "" && b.HostIP != "0.0.0.0" {
mapping = b.HostIP + ":" + mapping
}
info.Ports = append(info.Ports, mapping)
}
}
for _, m := range r.Mounts {
info.Volumes = append(info.Volumes, m.Source+":"+m.Destination)
}
return info, nil
}
// podmanPSEntry is a single entry from podman ps --format json.
type podmanPSEntry struct {
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Command []string `json:"Command"`
}
// List returns information about all containers.
func (p *Podman) List(ctx context.Context) ([]ContainerInfo, error) {
cmd := exec.CommandContext(ctx, p.command(), "ps", "-a", "--format", "json") //nolint:gosec // args built programmatically
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("podman ps: %w", err)
}
var entries []podmanPSEntry
if err := json.Unmarshal(out, &entries); err != nil {
return nil, fmt.Errorf("parse ps output: %w", err)
}
infos := make([]ContainerInfo, 0, len(entries))
for _, e := range entries {
name := ""
if len(e.Names) > 0 {
name = e.Names[0]
}
infos = append(infos, ContainerInfo{
Name: name,
Image: e.Image,
State: e.State,
Version: ExtractVersion(e.Image),
})
}
return infos, nil
}