P1.2-P1.5: Complete Phase 1 core libraries
Four packages built in parallel: - P1.2 runtime: Container runtime abstraction with podman implementation. Interface (Pull/Run/Stop/Remove/Inspect/List), ContainerSpec/ContainerInfo types, CLI arg building, version extraction from image tags. 2 tests. - P1.3 servicedef: TOML service definition file parsing. Load/Write/LoadAll, validation (required fields, unique component names), proto conversion. 5 tests. - P1.4 config: CLI and agent config loading from TOML. Duration type for time fields, env var overrides (MCP_*/MCP_AGENT_*), required field validation, sensible defaults. 7 tests. - P1.5 auth: MCIAS integration. Token validator with 30s SHA-256 cache, gRPC unary interceptor (admin role enforcement, audit logging), Login/LoadToken/SaveToken for CLI. 9 tests. All packages pass build, vet, lint, and test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
208
internal/runtime/podman.go
Normal file
208
internal/runtime/podman.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
} `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),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
62
internal/runtime/runtime.go
Normal file
62
internal/runtime/runtime.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContainerSpec describes a container to create and run.
|
||||
type ContainerSpec struct {
|
||||
Name string // container name, format: <service>-<component>
|
||||
Image string // full image reference
|
||||
Network string // docker network name
|
||||
User string // container user (e.g., "0:0")
|
||||
Restart string // restart policy (e.g., "unless-stopped")
|
||||
Ports []string // "host:container" port mappings
|
||||
Volumes []string // "host:container" volume mounts
|
||||
Cmd []string // command and arguments
|
||||
}
|
||||
|
||||
// ContainerInfo describes the observed state of a running or stopped container.
|
||||
type ContainerInfo struct {
|
||||
Name string
|
||||
Image string
|
||||
State string // "running", "stopped", "exited", etc.
|
||||
Network string
|
||||
User string
|
||||
Restart string
|
||||
Ports []string
|
||||
Volumes []string
|
||||
Cmd []string
|
||||
Version string // extracted from image tag
|
||||
}
|
||||
|
||||
// Runtime is the container runtime abstraction.
|
||||
type Runtime interface {
|
||||
Pull(ctx context.Context, image string) error
|
||||
Run(ctx context.Context, spec ContainerSpec) error
|
||||
Stop(ctx context.Context, name string) error
|
||||
Remove(ctx context.Context, name string) error
|
||||
Inspect(ctx context.Context, name string) (ContainerInfo, error)
|
||||
List(ctx context.Context) ([]ContainerInfo, error)
|
||||
}
|
||||
|
||||
// ExtractVersion parses the tag from an image reference.
|
||||
// Examples:
|
||||
//
|
||||
// "registry/img:v1.2.0" -> "v1.2.0"
|
||||
// "registry/img:latest" -> "latest"
|
||||
// "registry/img" -> ""
|
||||
// "registry:5000/img:v1" -> "v1"
|
||||
func ExtractVersion(image string) string {
|
||||
// Strip registry/path prefix so that a port like "registry:5000" isn't
|
||||
// mistaken for a tag separator.
|
||||
name := image
|
||||
if i := strings.LastIndex(image, "/"); i >= 0 {
|
||||
name = image[i+1:]
|
||||
}
|
||||
if i := strings.LastIndex(name, ":"); i >= 0 {
|
||||
return name[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
113
internal/runtime/runtime_test.go
Normal file
113
internal/runtime/runtime_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requireEqualArgs(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("args mismatch\ngot: %v\nwant: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRunArgs(t *testing.T) {
|
||||
p := &Podman{}
|
||||
|
||||
t.Run("full spec", func(t *testing.T) {
|
||||
spec := ContainerSpec{
|
||||
Name: "metacrypt-api",
|
||||
Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt:v1.0.0",
|
||||
Network: "docker_default",
|
||||
User: "0:0",
|
||||
Restart: "unless-stopped",
|
||||
Ports: []string{"127.0.0.1:18443:8443", "127.0.0.1:19443:9443"},
|
||||
Volumes: []string{"/srv/metacrypt:/srv/metacrypt", "/etc/ssl:/etc/ssl:ro"},
|
||||
Cmd: []string{"server", "--config", "/srv/metacrypt/metacrypt.toml"},
|
||||
}
|
||||
requireEqualArgs(t, p.BuildRunArgs(spec), []string{
|
||||
"run", "-d", "--name", "metacrypt-api",
|
||||
"--network", "docker_default",
|
||||
"--user", "0:0",
|
||||
"--restart", "unless-stopped",
|
||||
"-p", "127.0.0.1:18443:8443",
|
||||
"-p", "127.0.0.1:19443:9443",
|
||||
"-v", "/srv/metacrypt:/srv/metacrypt",
|
||||
"-v", "/etc/ssl:/etc/ssl:ro",
|
||||
"mcr.svc.mcp.metacircular.net:8443/metacrypt:v1.0.0",
|
||||
"server", "--config", "/srv/metacrypt/metacrypt.toml",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("minimal spec", func(t *testing.T) {
|
||||
spec := ContainerSpec{
|
||||
Name: "test-app",
|
||||
Image: "img:latest",
|
||||
}
|
||||
requireEqualArgs(t, p.BuildRunArgs(spec), []string{
|
||||
"run", "-d", "--name", "test-app", "img:latest",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ports only", func(t *testing.T) {
|
||||
spec := ContainerSpec{
|
||||
Name: "test-app",
|
||||
Image: "img:latest",
|
||||
Ports: []string{"8080:80", "8443:443"},
|
||||
}
|
||||
requireEqualArgs(t, p.BuildRunArgs(spec), []string{
|
||||
"run", "-d", "--name", "test-app",
|
||||
"-p", "8080:80", "-p", "8443:443",
|
||||
"img:latest",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("volumes only", func(t *testing.T) {
|
||||
spec := ContainerSpec{
|
||||
Name: "test-app",
|
||||
Image: "img:latest",
|
||||
Volumes: []string{"/data:/data", "/config:/config:ro"},
|
||||
}
|
||||
requireEqualArgs(t, p.BuildRunArgs(spec), []string{
|
||||
"run", "-d", "--name", "test-app",
|
||||
"-v", "/data:/data", "-v", "/config:/config:ro",
|
||||
"img:latest",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("cmd after image", func(t *testing.T) {
|
||||
spec := ContainerSpec{
|
||||
Name: "test-app",
|
||||
Image: "img:latest",
|
||||
Cmd: []string{"serve", "--port", "8080"},
|
||||
}
|
||||
requireEqualArgs(t, p.BuildRunArgs(spec), []string{
|
||||
"run", "-d", "--name", "test-app",
|
||||
"img:latest",
|
||||
"serve", "--port", "8080",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
image string
|
||||
want string
|
||||
}{
|
||||
{"registry.example.com:5000/img:v1.2.0", "v1.2.0"},
|
||||
{"img:latest", "latest"},
|
||||
{"img", ""},
|
||||
{"registry.example.com/path/img:v1", "v1"},
|
||||
{"registry.example.com:5000/path/img", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.image, func(t *testing.T) {
|
||||
got := ExtractVersion(tt.image)
|
||||
if got != tt.want {
|
||||
t.Fatalf("ExtractVersion(%q) = %q, want %q", tt.image, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user