New server-streaming Logs RPC streams container output to the CLI. Supports --tail/-n, --follow/-f, --timestamps/-t, --since. Detects journald log driver and falls back to journalctl (podman logs can't read journald outside the originating user session). New containers default to k8s-file via mcp user's containers.conf. Also adds stream auth interceptor for the agent gRPC server (required for streaming RPCs). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
313 lines
9.9 KiB
Go
313 lines
9.9 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
|
|
}
|
|
|
|
// Logs returns an exec.Cmd that streams container logs. For containers
|
|
// using the journald log driver, it uses journalctl (podman logs can't
|
|
// read journald outside the originating user session). For k8s-file or
|
|
// other drivers, it uses podman logs directly.
|
|
func (p *Podman) Logs(ctx context.Context, containerName string, tail int, follow, timestamps bool, since string) *exec.Cmd {
|
|
// Check if this container uses the journald log driver.
|
|
inspectCmd := exec.CommandContext(ctx, p.command(), "inspect", "--format", "{{.HostConfig.LogConfig.Type}}", containerName) //nolint:gosec
|
|
if out, err := inspectCmd.Output(); err == nil && strings.TrimSpace(string(out)) == "journald" {
|
|
return p.journalLogs(ctx, containerName, tail, follow, since)
|
|
}
|
|
|
|
args := []string{"logs"}
|
|
if tail > 0 {
|
|
args = append(args, "--tail", fmt.Sprintf("%d", tail))
|
|
}
|
|
if follow {
|
|
args = append(args, "--follow")
|
|
}
|
|
if timestamps {
|
|
args = append(args, "--timestamps")
|
|
}
|
|
if since != "" {
|
|
args = append(args, "--since", since)
|
|
}
|
|
args = append(args, containerName)
|
|
return exec.CommandContext(ctx, p.command(), args...) //nolint:gosec // args built programmatically
|
|
}
|
|
|
|
// journalLogs returns a journalctl command filtered by container name.
|
|
func (p *Podman) journalLogs(ctx context.Context, containerName string, tail int, follow bool, since string) *exec.Cmd {
|
|
args := []string{"--no-pager", "--output", "cat", "CONTAINER_NAME=" + containerName}
|
|
if tail > 0 {
|
|
args = append(args, "--lines", fmt.Sprintf("%d", tail))
|
|
}
|
|
if follow {
|
|
args = append(args, "--follow")
|
|
}
|
|
if since != "" {
|
|
args = append(args, "--since", since)
|
|
}
|
|
return exec.CommandContext(ctx, "journalctl", args...) //nolint:gosec // args built programmatically
|
|
}
|
|
|
|
// 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
|
|
}
|