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>
270 lines
8.2 KiB
Go
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
|
|
}
|