diff --git a/internal/container/apple.go b/internal/container/apple.go index a3ffb18..6789c9f 100644 --- a/internal/container/apple.go +++ b/internal/container/apple.go @@ -1,10 +1,8 @@ package container import ( - "context" "encoding/json" "fmt" - "strings" "time" "github.com/jmgilman/headjack/internal/exec" @@ -16,154 +14,103 @@ type AppleConfig struct { // at the manager level. Kept for future runtime-specific settings. } +// appleRuntime implements Runtime using Apple Containerization CLI. +// All common functionality is provided by the embedded baseRuntime. type appleRuntime struct { baseRuntime config AppleConfig } +// appleParser implements containerParser for Apple Containerization JSON output. +type appleParser struct{} + // NewAppleRuntime creates a Runtime using Apple Containerization CLI. func NewAppleRuntime(e exec.Executor, cfg AppleConfig) Runtime { + parser := &appleParser{} return &appleRuntime{ baseRuntime: baseRuntime{ exec: e, binaryName: "container", execCommand: []string{"container", "exec"}, + listArgs: []string{"list"}, + parser: parser, }, config: cfg, } } -func (r *appleRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) { - args := buildRunArgs(cfg) +// appleInspect represents the JSON output of `container inspect`. +type appleInspect struct { + Status string `json:"status"` + Created string `json:"created"` // ISO 8601 format if available + Configuration struct { + ID string `json:"id"` + Image struct { + Reference string `json:"reference"` + } `json:"image"` + } `json:"configuration"` +} - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - stderr := string(result.Stderr) - if isAlreadyExistsError(stderr) { - return nil, ErrAlreadyExists +func (c *appleInspect) toContainer() *Container { + // Parse created timestamp if available + // Apple Containerization uses ISO 8601 format + var createdAt time.Time + if c.Created != "" { + // Try RFC3339Nano first (most precise), then RFC3339 + if parsed, err := time.Parse(time.RFC3339Nano, c.Created); err == nil { + createdAt = parsed + } else if parsed, err := time.Parse(time.RFC3339, c.Created); err == nil { + createdAt = parsed } - return nil, cliError("run container", result, err) + // If parsing fails, createdAt remains zero value } - // Container ID is returned on stdout - containerID := strings.TrimSpace(string(result.Stdout)) - return &Container{ - ID: containerID, - Name: cfg.Name, - Image: cfg.Image, - Status: StatusRunning, - CreatedAt: time.Now(), - }, nil -} - -func (r *appleRuntime) Exec(ctx context.Context, id string, cfg ExecConfig) error { - // Verify container exists and is running - container, err := r.Get(ctx, id) - if err != nil { - return err - } - if container.Status != StatusRunning { - return ErrNotRunning - } - - args := buildExecArgs(id, cfg) - - if cfg.Interactive { - return r.execInteractive(ctx, args) - } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - return cliError("exec in container", result, err) - } - - return nil -} - -func (r *appleRuntime) Stop(ctx context.Context, id string) error { - // Verify container exists - c, err := r.Get(ctx, id) - if err != nil { - return err - } - - // No-op if already stopped - if c.Status == StatusStopped { - return nil + ID: c.Configuration.ID, + Name: c.Configuration.ID, + Image: c.Configuration.Image.Reference, + Status: parseContainerStatus(c.Status), + CreatedAt: createdAt, } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"stop", id}, - }) - if err != nil { - return cliError("stop container", result, err) - } - - return nil } -func (r *appleRuntime) Start(ctx context.Context, id string) error { - // Verify container exists - c, err := r.Get(ctx, id) - if err != nil { - return err - } - - // No-op if already running - if c.Status == StatusRunning { - return nil - } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"start", id}, - }) - if err != nil { - return cliError("start container", result, err) - } - - return nil +// appleListItem represents a single item in `container list` JSON output. +// Note: Apple container list has similar format to inspect. +type appleListItem struct { + Status string `json:"status"` + Created string `json:"created"` // ISO 8601 format if available + Configuration struct { + ID string `json:"id"` + Image struct { + Reference string `json:"reference"` + } `json:"image"` + } `json:"configuration"` } -func (r *appleRuntime) Remove(ctx context.Context, id string) error { - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"rm", id}, - }) - if err != nil { - stderr := string(result.Stderr) - if isNotFoundError(stderr) { - return ErrNotFound +func (c *appleListItem) toContainer() Container { + // Parse created timestamp if available + var createdAt time.Time + if c.Created != "" { + if parsed, err := time.Parse(time.RFC3339Nano, c.Created); err == nil { + createdAt = parsed + } else if parsed, err := time.Parse(time.RFC3339, c.Created); err == nil { + createdAt = parsed } - return cliError("remove container", result, err) } - return nil -} - -func (r *appleRuntime) Get(ctx context.Context, id string) (*Container, error) { - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"inspect", id}, - }) - if err != nil { - stderr := string(result.Stderr) - if isNotFoundError(stderr) { - return nil, ErrNotFound - } - return nil, cliError("inspect container", result, err) + return Container{ + ID: c.Configuration.ID, + Name: c.Configuration.ID, + Image: c.Configuration.Image.Reference, + Status: parseContainerStatus(c.Status), + CreatedAt: createdAt, } +} +// parseInspect parses the JSON output of `container inspect`. +func (p *appleParser) parseInspect(data []byte) (*Container, error) { var infos []appleInspect - if err := json.Unmarshal(result.Stdout, &infos); err != nil { + if err := json.Unmarshal(data, &infos); err != nil { return nil, fmt.Errorf("parse container info: %w", err) } @@ -174,29 +121,10 @@ func (r *appleRuntime) Get(ctx context.Context, id string) (*Container, error) { return infos[0].toContainer(), nil } -func (r *appleRuntime) List(ctx context.Context, filter ListFilter) ([]Container, error) { - args := []string{"list", "--format", "json"} - - if filter.Name != "" { - args = append(args, "--filter", "name="+filter.Name) - } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - return nil, cliError("list containers", result, err) - } - - // Handle empty list - stdout := strings.TrimSpace(string(result.Stdout)) - if stdout == "" || stdout == "[]" { - return []Container{}, nil - } - +// parseList parses the JSON output of `container list`. +func (p *appleParser) parseList(data []byte) ([]Container, error) { var items []appleListItem - if err := json.Unmarshal(result.Stdout, &items); err != nil { + if err := json.Unmarshal(data, &items); err != nil { return nil, fmt.Errorf("parse container list: %w", err) } @@ -207,58 +135,3 @@ func (r *appleRuntime) List(ctx context.Context, filter ListFilter) ([]Container return containers, nil } - -func (r *appleRuntime) Build(ctx context.Context, cfg *BuildConfig) error { - args := buildBuildArgs(cfg) - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - return fmt.Errorf("%w: %s", ErrBuildFailed, strings.TrimSpace(string(result.Stderr))) - } - - return nil -} - -// appleInspect represents the JSON output of `container inspect`. -type appleInspect struct { - Status string `json:"status"` - Configuration struct { - ID string `json:"id"` - Image struct { - Reference string `json:"reference"` - } `json:"image"` - } `json:"configuration"` -} - -func (c *appleInspect) toContainer() *Container { - return &Container{ - ID: c.Configuration.ID, - Name: c.Configuration.ID, - Image: c.Configuration.Image.Reference, - Status: parseContainerStatus(c.Status), - } -} - -// appleListItem represents a single item in `container list` JSON output. -// Note: Apple container list has same format as inspect. -type appleListItem struct { - Status string `json:"status"` - Configuration struct { - ID string `json:"id"` - Image struct { - Reference string `json:"reference"` - } `json:"image"` - } `json:"configuration"` -} - -func (c *appleListItem) toContainer() Container { - return Container{ - ID: c.Configuration.ID, - Name: c.Configuration.ID, - Image: c.Configuration.Image.Reference, - Status: parseContainerStatus(c.Status), - } -} diff --git a/internal/container/common.go b/internal/container/common.go index 38cb6fd..0eaa137 100644 --- a/internal/container/common.go +++ b/internal/container/common.go @@ -7,18 +7,32 @@ import ( "os/signal" "strings" "syscall" + "time" "golang.org/x/term" "github.com/jmgilman/headjack/internal/exec" ) +// containerParser handles runtime-specific JSON parsing for container inspect and list operations. +// Each runtime implementation provides its own parser to handle the different JSON formats +// returned by each container CLI. +type containerParser interface { + // parseInspect parses the JSON output of the inspect command. + parseInspect(data []byte) (*Container, error) + // parseList parses the JSON output of the list command. + parseList(data []byte) ([]Container, error) +} + // baseRuntime provides shared functionality for container runtimes. -// Concrete implementations (Podman, Apple) embed this and provide runtime-specific behavior. +// Concrete implementations (Podman, Apple) configure this with runtime-specific settings +// and provide a containerParser for JSON parsing. type baseRuntime struct { exec exec.Executor binaryName string execCommand []string + listArgs []string // e.g., ["ps", "-a"] for Podman, ["list"] for Apple + parser containerParser // Runtime-specific JSON parser } // cliError formats an error from a container CLI, including stderr if available. @@ -32,8 +46,194 @@ func cliError(operation string, result *exec.Result, err error) error { return fmt.Errorf("%s: %w", operation, err) } +// Run creates and starts a new container. +func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) { + args := buildRunArgs(cfg) + + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: args, + }) + if err != nil { + stderr := string(result.Stderr) + if isAlreadyExistsError(stderr) { + return nil, ErrAlreadyExists + } + return nil, cliError("run container", result, err) + } + + // Container ID is returned on stdout + containerID := strings.TrimSpace(string(result.Stdout)) + + return &Container{ + ID: containerID, + Name: cfg.Name, + Image: cfg.Image, + Status: StatusRunning, + CreatedAt: time.Now(), + }, nil +} + +// Exec executes a command in a running container. +func (r *baseRuntime) Exec(ctx context.Context, id string, cfg ExecConfig) error { + // Verify container exists and is running + container, err := r.Get(ctx, id) + if err != nil { + return err + } + if container.Status != StatusRunning { + return ErrNotRunning + } + + args := buildExecArgs(id, cfg) + + if cfg.Interactive { + return r.execInteractive(ctx, args) + } + + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: args, + }) + if err != nil { + return cliError("exec in container", result, err) + } + + return nil +} + +// Stop stops a running container gracefully. +func (r *baseRuntime) Stop(ctx context.Context, id string) error { + // Verify container exists + c, err := r.Get(ctx, id) + if err != nil { + return err + } + + // No-op if already stopped + if c.Status == StatusStopped { + return nil + } + + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: []string{"stop", id}, + }) + if err != nil { + return cliError("stop container", result, err) + } + + return nil +} + +// Start starts a stopped container. +func (r *baseRuntime) Start(ctx context.Context, id string) error { + // Verify container exists + c, err := r.Get(ctx, id) + if err != nil { + return err + } + + // No-op if already running + if c.Status == StatusRunning { + return nil + } + + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: []string{"start", id}, + }) + if err != nil { + return cliError("start container", result, err) + } + + return nil +} + +// Remove deletes a container. +func (r *baseRuntime) Remove(ctx context.Context, id string) error { + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: []string{"rm", id}, + }) + if err != nil { + stderr := string(result.Stderr) + if isNotFoundError(stderr) { + return ErrNotFound + } + return cliError("remove container", result, err) + } + + return nil +} + +// Get retrieves container information by ID or name. +func (r *baseRuntime) Get(ctx context.Context, id string) (*Container, error) { + if r.parser == nil { + return nil, ErrNoParser + } + + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: []string{"inspect", id}, + }) + if err != nil { + stderr := string(result.Stderr) + if isNotFoundError(stderr) { + return nil, ErrNotFound + } + return nil, cliError("inspect container", result, err) + } + + return r.parser.parseInspect(result.Stdout) +} + +// List returns all containers matching the filter. +func (r *baseRuntime) List(ctx context.Context, filter ListFilter) ([]Container, error) { + if r.parser == nil { + return nil, ErrNoParser + } + + args := append([]string{}, r.listArgs...) + args = append(args, "--format", "json") + + if filter.Name != "" { + args = append(args, "--filter", "name="+filter.Name) + } + + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: args, + }) + if err != nil { + return nil, cliError("list containers", result, err) + } + + // Handle empty list + stdout := strings.TrimSpace(string(result.Stdout)) + if stdout == "" || stdout == "[]" { + return []Container{}, nil + } + + return r.parser.parseList(result.Stdout) +} + +// Build builds an OCI image from a Dockerfile. +func (r *baseRuntime) Build(ctx context.Context, cfg *BuildConfig) error { + args := buildBuildArgs(cfg) + + result, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: args, + }) + if err != nil { + return fmt.Errorf("%w: %s", ErrBuildFailed, strings.TrimSpace(string(result.Stderr))) + } + + return nil +} + // execInteractive runs a container exec command with TTY support. -// This is shared between runtime implementations. func (r *baseRuntime) execInteractive(ctx context.Context, args []string) error { stdinFd := int(os.Stdin.Fd()) diff --git a/internal/container/container.go b/internal/container/container.go index c924062..475b34c 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -13,6 +13,7 @@ var ( ErrNotRunning = errors.New("container not running") ErrAlreadyExists = errors.New("container already exists") ErrBuildFailed = errors.New("image build failed") + ErrNoParser = errors.New("runtime has no parser configured") ) // Status represents the container state. diff --git a/internal/container/podman.go b/internal/container/podman.go index 83b45dc..1154287 100644 --- a/internal/container/podman.go +++ b/internal/container/podman.go @@ -1,7 +1,6 @@ package container import ( - "context" "encoding/json" "fmt" "strings" @@ -16,212 +15,31 @@ type PodmanConfig struct { // at the manager level. Kept for future runtime-specific settings. } +// podmanRuntime implements Runtime using Podman CLI. +// All common functionality is provided by the embedded baseRuntime. type podmanRuntime struct { baseRuntime config PodmanConfig } +// podmanParser implements containerParser for Podman JSON output. +type podmanParser struct{} + // NewPodmanRuntime creates a Runtime using Podman CLI. func NewPodmanRuntime(e exec.Executor, cfg PodmanConfig) Runtime { + parser := &podmanParser{} return &podmanRuntime{ baseRuntime: baseRuntime{ exec: e, binaryName: "podman", execCommand: []string{"podman", "exec"}, + listArgs: []string{"ps", "-a"}, + parser: parser, }, config: cfg, } } -func (r *podmanRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) { - args := buildRunArgs(cfg) - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - stderr := string(result.Stderr) - if isAlreadyExistsError(stderr) { - return nil, ErrAlreadyExists - } - return nil, cliError("run container", result, err) - } - - // Container ID is returned on stdout - containerID := strings.TrimSpace(string(result.Stdout)) - - return &Container{ - ID: containerID, - Name: cfg.Name, - Image: cfg.Image, - Status: StatusRunning, - CreatedAt: time.Now(), - }, nil -} - -func (r *podmanRuntime) Exec(ctx context.Context, id string, cfg ExecConfig) error { - // Verify container exists and is running - container, err := r.Get(ctx, id) - if err != nil { - return err - } - if container.Status != StatusRunning { - return ErrNotRunning - } - - args := buildExecArgs(id, cfg) - - if cfg.Interactive { - return r.execInteractive(ctx, args) - } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - return cliError("exec in container", result, err) - } - - return nil -} - -func (r *podmanRuntime) Stop(ctx context.Context, id string) error { - // Verify container exists - c, err := r.Get(ctx, id) - if err != nil { - return err - } - - // No-op if already stopped - if c.Status == StatusStopped { - return nil - } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"stop", id}, - }) - if err != nil { - return cliError("stop container", result, err) - } - - return nil -} - -func (r *podmanRuntime) Start(ctx context.Context, id string) error { - // Verify container exists - c, err := r.Get(ctx, id) - if err != nil { - return err - } - - // No-op if already running - if c.Status == StatusRunning { - return nil - } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"start", id}, - }) - if err != nil { - return cliError("start container", result, err) - } - - return nil -} - -func (r *podmanRuntime) Remove(ctx context.Context, id string) error { - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"rm", id}, - }) - if err != nil { - stderr := string(result.Stderr) - if isNotFoundError(stderr) { - return ErrNotFound - } - return cliError("remove container", result, err) - } - - return nil -} - -func (r *podmanRuntime) Get(ctx context.Context, id string) (*Container, error) { - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: []string{"inspect", id}, - }) - if err != nil { - stderr := string(result.Stderr) - if isNotFoundError(stderr) { - return nil, ErrNotFound - } - return nil, cliError("inspect container", result, err) - } - - var infos []podmanInspect - if err := json.Unmarshal(result.Stdout, &infos); err != nil { - return nil, fmt.Errorf("parse container info: %w", err) - } - - if len(infos) == 0 { - return nil, ErrNotFound - } - - return infos[0].toContainer(), nil -} - -func (r *podmanRuntime) List(ctx context.Context, filter ListFilter) ([]Container, error) { - args := []string{"ps", "-a", "--format", "json"} - - if filter.Name != "" { - args = append(args, "--filter", "name="+filter.Name) - } - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - return nil, cliError("list containers", result, err) - } - - // Handle empty list - stdout := strings.TrimSpace(string(result.Stdout)) - if stdout == "" || stdout == "[]" { - return []Container{}, nil - } - - var items []podmanListItem - if err := json.Unmarshal(result.Stdout, &items); err != nil { - return nil, fmt.Errorf("parse container list: %w", err) - } - - containers := make([]Container, len(items)) - for i, item := range items { - containers[i] = item.toContainer() - } - - return containers, nil -} - -func (r *podmanRuntime) Build(ctx context.Context, cfg *BuildConfig) error { - args := buildBuildArgs(cfg) - - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, - }) - if err != nil { - return fmt.Errorf("%w: %s", ErrBuildFailed, strings.TrimSpace(string(result.Stderr))) - } - - return nil -} - // podmanInspect represents the JSON output of `podman inspect`. type podmanInspect struct { ID string `json:"Id"` @@ -291,3 +109,32 @@ func (p *podmanListItem) toContainer() Container { CreatedAt: time.Unix(p.Created, 0), } } + +// parseInspect parses the JSON output of `podman inspect`. +func (p *podmanParser) parseInspect(data []byte) (*Container, error) { + var infos []podmanInspect + if err := json.Unmarshal(data, &infos); err != nil { + return nil, fmt.Errorf("parse container info: %w", err) + } + + if len(infos) == 0 { + return nil, ErrNotFound + } + + return infos[0].toContainer(), nil +} + +// parseList parses the JSON output of `podman ps`. +func (p *podmanParser) parseList(data []byte) ([]Container, error) { + var items []podmanListItem + if err := json.Unmarshal(data, &items); err != nil { + return nil, fmt.Errorf("parse container list: %w", err) + } + + containers := make([]Container, len(items)) + for i, item := range items { + containers[i] = item.toContainer() + } + + return containers, nil +}