Files
mcp/internal/runtime/podman.go
Kyle Isom dd167b8e0b Auto-login to MCR before image push using CLI token
mcp build and mcp deploy (auto-build path) now authenticate to the
container registry using the CLI's stored MCIAS token before pushing.
MCR accepts JWTs as passwords, so this works with both human and
service account tokens. Falls back silently to existing podman auth.

Eliminates the need for a separate interactive `podman login` step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:13:35 -07:00

270 lines
8.2 KiB
Go

package runtime
import (
"context"
"encoding/json"
"errors"
"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)
}
for _, env := range spec.Env {
args = append(args, "-e", env)
}
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
}
// Login authenticates to a container registry using the given token as
// the password. This enables non-interactive push with service account
// tokens (MCR accepts MCIAS JWTs as passwords).
func (p *Podman) Login(ctx context.Context, registry, username, token string) error {
cmd := exec.CommandContext(ctx, p.command(), "login", "--username", username, "--password-stdin", registry) //nolint:gosec // args built programmatically
cmd.Stdin = strings.NewReader(token)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman login %q: %w: %s", registry, err, out)
}
return nil
}
// Build builds a container image from a Dockerfile.
func (p *Podman) Build(ctx context.Context, image, contextDir, dockerfile string) error {
args := []string{"build", "-t", image, "-f", dockerfile, contextDir}
cmd := exec.CommandContext(ctx, p.command(), args...) //nolint:gosec // args built programmatically
cmd.Dir = contextDir
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman build %q: %w: %s", image, err, out)
}
return nil
}
// Push pushes a container image to a remote registry.
func (p *Podman) Push(ctx context.Context, image string) error {
cmd := exec.CommandContext(ctx, p.command(), "push", image) //nolint:gosec // args built programmatically
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman push %q: %w: %s", image, err, out)
}
return nil
}
// ImageExists checks whether an image tag exists in a remote registry.
// Uses skopeo inspect which works for both regular images and multi-arch
// manifests, unlike podman manifest inspect which only handles manifests.
func (p *Podman) ImageExists(ctx context.Context, image string) (bool, error) {
cmd := exec.CommandContext(ctx, "skopeo", "inspect", "--tls-verify=false", "docker://"+image) //nolint:gosec // args built programmatically
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if ok := errors.As(err, &exitErr); ok && exitErr.ExitCode() != 0 {
return false, nil
}
return false, fmt.Errorf("skopeo inspect %q: %w", image, err)
}
return true, 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"`
StartedAt int64 `json:"StartedAt"`
}
// 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]
}
info := ContainerInfo{
Name: name,
Image: e.Image,
State: e.State,
Version: ExtractVersion(e.Image),
}
if e.StartedAt > 0 {
info.Started = time.Unix(e.StartedAt, 0)
}
infos = append(infos, info)
}
return infos, nil
}