mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
GET /services/:id/terminal upgrades to a WebSocket attached to a `docker exec` PTY in the service's running replica. JSON frames both ways: stdin/resize out, stdout/exit/error in. Shell probe tries each candidate (bash→sh) since `command -v` needs a shell to run; distroless images get a clean "no shell" error instead of an OCI failure dump. - docker.Client gains ExecAttach + ExecResize wrappers - Frontend: xterm.js replaces the one-off console section — live PTY, fit-addon resizes forwarded over the socket - CLI: `containr shell <id>` bridges the local TTY (raw mode, SIGWINCH resize forwarding); `containr exec` keeps the one-off command path - Access mirrors service logs: project manage scope required Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
583 lines
17 KiB
Go
583 lines
17 KiB
Go
package docker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/docker/docker/api/types"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/events"
|
|
"github.com/docker/docker/api/types/filters"
|
|
"github.com/docker/docker/api/types/image"
|
|
"github.com/docker/docker/api/types/network"
|
|
"github.com/docker/docker/api/types/registry"
|
|
"github.com/docker/docker/api/types/system"
|
|
"github.com/docker/docker/api/types/volume"
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
)
|
|
|
|
// Client wraps the Docker client with additional functionality
|
|
type Client struct {
|
|
cli *client.Client
|
|
probeHost string
|
|
}
|
|
|
|
// NewClient creates a new Docker client
|
|
func NewClient() (*Client, error) {
|
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Docker client: %w", err)
|
|
}
|
|
|
|
// Test connection
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
_, err = cli.Ping(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to Docker daemon: %w", err)
|
|
}
|
|
|
|
return &Client{cli: cli}, nil
|
|
}
|
|
|
|
// HostProbeHost returns an address through which host-published container
|
|
// ports are reachable. When this process runs inside a container, host
|
|
// loopback is unreachable; the bridge gateway IP routes to ports published
|
|
// on 0.0.0.0. Result is cached for the process lifetime.
|
|
func (c *Client) HostProbeHost(ctx context.Context) string {
|
|
if c.probeHost != "" {
|
|
return c.probeHost
|
|
}
|
|
host := "127.0.0.1"
|
|
if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil {
|
|
for _, n := range self.NetworkSettings.Networks {
|
|
if n != nil && n.Gateway != "" {
|
|
host = n.Gateway
|
|
break
|
|
}
|
|
}
|
|
}
|
|
c.probeHost = host
|
|
return host
|
|
}
|
|
|
|
// OwnNetworkName returns the name of the platform network this process's
|
|
// container is attached to, or "" when not running containerized. Per-project
|
|
// and default Docker networks are never selected; among remaining networks
|
|
// the lexicographically first wins so the choice is deterministic.
|
|
func (c *Client) OwnNetworkName(ctx context.Context) string {
|
|
self, err := c.self(ctx)
|
|
if err != nil || self.NetworkSettings == nil {
|
|
return ""
|
|
}
|
|
names := make([]string, 0, len(self.NetworkSettings.Networks))
|
|
for name := range self.NetworkSettings.Networks {
|
|
switch {
|
|
case name == "bridge" || name == "host" || name == "none":
|
|
case strings.HasPrefix(name, "containr-proj-"):
|
|
default:
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
if len(names) == 0 {
|
|
return ""
|
|
}
|
|
return names[0]
|
|
}
|
|
|
|
// self inspects the container this process runs in (hostname == container ID).
|
|
func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) {
|
|
hostname, err := os.Hostname()
|
|
if err != nil || hostname == "" {
|
|
return types.ContainerJSON{}, fmt.Errorf("hostname unavailable")
|
|
}
|
|
return c.cli.ContainerInspect(ctx, hostname)
|
|
}
|
|
|
|
// ConnectSelfToNetwork attaches this process's container to the given network.
|
|
// No-op when already attached or when not running containerized.
|
|
func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error {
|
|
hostname, err := os.Hostname()
|
|
if err != nil || hostname == "" {
|
|
return fmt.Errorf("hostname unavailable")
|
|
}
|
|
err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})
|
|
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ListContainers returns all containers
|
|
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
|
|
return c.cli.ContainerList(ctx, container.ListOptions{
|
|
All: all,
|
|
})
|
|
}
|
|
|
|
// ListContainersFiltered returns containers matching the given filters.
|
|
func (c *Client) ListContainersFiltered(ctx context.Context, f filters.Args, all bool) ([]types.Container, error) {
|
|
return c.cli.ContainerList(ctx, container.ListOptions{
|
|
All: all,
|
|
Filters: f,
|
|
})
|
|
}
|
|
|
|
// GetContainer returns detailed information about a specific container
|
|
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
|
return c.cli.ContainerInspect(ctx, containerID)
|
|
}
|
|
|
|
// CreateContainer creates a new container
|
|
func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (string, error) {
|
|
containerConfig := &container.Config{
|
|
Image: config.Image,
|
|
Cmd: config.Cmd,
|
|
Env: config.Env,
|
|
Labels: config.Labels,
|
|
ExposedPorts: config.ExposedPorts,
|
|
Healthcheck: config.Healthcheck,
|
|
}
|
|
|
|
hostConfig := &container.HostConfig{
|
|
RestartPolicy: container.RestartPolicy{
|
|
Name: container.RestartPolicyMode(config.RestartPolicy),
|
|
},
|
|
PortBindings: config.PortBindings,
|
|
Mounts: config.Mounts,
|
|
Resources: container.Resources{
|
|
Memory: config.Memory,
|
|
MemoryReservation: config.MemoryReservation,
|
|
NanoCPUs: config.NanoCPUs,
|
|
CPUShares: config.CPUShares,
|
|
},
|
|
NetworkMode: container.NetworkMode(config.NetworkMode),
|
|
}
|
|
|
|
networkingConfig := &network.NetworkingConfig{
|
|
EndpointsConfig: config.Networks,
|
|
}
|
|
|
|
resp, err := c.cli.ContainerCreate(
|
|
ctx,
|
|
containerConfig,
|
|
hostConfig,
|
|
networkingConfig,
|
|
nil,
|
|
config.Name,
|
|
)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create container: %w", err)
|
|
}
|
|
|
|
return resp.ID, nil
|
|
}
|
|
|
|
// StartContainer starts a container
|
|
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
|
return c.cli.ContainerStart(ctx, containerID, container.StartOptions{})
|
|
}
|
|
|
|
// StopContainer stops a container
|
|
func (c *Client) StopContainer(ctx context.Context, containerID string, timeout *time.Duration) error {
|
|
var timeoutInt *int
|
|
if timeout != nil {
|
|
t := int(timeout.Seconds())
|
|
timeoutInt = &t
|
|
}
|
|
return c.cli.ContainerStop(ctx, containerID, container.StopOptions{
|
|
Timeout: timeoutInt,
|
|
})
|
|
}
|
|
|
|
// RestartContainer restarts a container
|
|
func (c *Client) RestartContainer(ctx context.Context, containerID string, timeout *time.Duration) error {
|
|
var timeoutInt *int
|
|
if timeout != nil {
|
|
t := int(timeout.Seconds())
|
|
timeoutInt = &t
|
|
}
|
|
return c.cli.ContainerRestart(ctx, containerID, container.StopOptions{
|
|
Timeout: timeoutInt,
|
|
})
|
|
}
|
|
|
|
// RemoveContainer removes a container
|
|
func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error {
|
|
return c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
|
|
Force: force,
|
|
})
|
|
}
|
|
|
|
// GetContainerLogs returns logs for a container
|
|
func (c *Client) GetContainerLogs(ctx context.Context, containerID string, options LogOptions) (io.ReadCloser, error) {
|
|
return c.cli.ContainerLogs(ctx, containerID, container.LogsOptions{
|
|
ShowStdout: options.Stdout,
|
|
ShowStderr: options.Stderr,
|
|
Follow: options.Follow,
|
|
Tail: options.Tail,
|
|
Timestamps: options.Timestamps,
|
|
})
|
|
}
|
|
|
|
// CopyFromContainer streams a tar archive of srcPath out of a container.
|
|
// The container only needs to exist — it does not have to be running.
|
|
func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
|
|
reader, _, err := c.cli.CopyFromContainer(ctx, containerID, srcPath)
|
|
return reader, err
|
|
}
|
|
|
|
// CopyToContainer copies a tar archive stream into a container path.
|
|
func (c *Client) CopyToContainer(ctx context.Context, containerID, dstPath string, content io.Reader) error {
|
|
return c.cli.CopyToContainer(ctx, containerID, dstPath, content, container.CopyToContainerOptions{})
|
|
}
|
|
|
|
// GetContainerStats returns real-time resource usage statistics for a container
|
|
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
|
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// ListImages returns all images
|
|
func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, error) {
|
|
return c.cli.ImageList(ctx, image.ListOptions{
|
|
All: all,
|
|
})
|
|
}
|
|
|
|
// PullImage pulls an image from a registry. The pull runs as the returned
|
|
// stream is consumed — callers must drain and close it (or use
|
|
// PullImageWait) or the pull may be aborted.
|
|
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
|
|
authStr, _ := registry.EncodeAuthConfig(auth)
|
|
return c.cli.ImagePull(ctx, ref, image.PullOptions{
|
|
RegistryAuth: authStr,
|
|
})
|
|
}
|
|
|
|
// PullImageWait pulls an image and blocks until the pull stream completes.
|
|
func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.AuthConfig) error {
|
|
reader, err := c.PullImage(ctx, ref, auth)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer reader.Close()
|
|
// The daemon performs the pull while streaming progress; drain it fully
|
|
// and surface any errorDetail the daemon reports in the stream.
|
|
dec := json.NewDecoder(reader)
|
|
var msg struct {
|
|
ErrorDetail *struct {
|
|
Message string `json:"message"`
|
|
} `json:"errorDetail"`
|
|
Error string `json:"error"`
|
|
}
|
|
for dec.Decode(&msg) == nil {
|
|
if msg.ErrorDetail != nil {
|
|
return fmt.Errorf("%s", msg.ErrorDetail.Message)
|
|
}
|
|
if msg.Error != "" {
|
|
return fmt.Errorf("%s", msg.Error)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuildImage builds an image from a Dockerfile
|
|
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
|
resp, err := c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
|
Dockerfile: options.Dockerfile,
|
|
Tags: options.Tags,
|
|
BuildArgs: options.BuildArgs,
|
|
Labels: options.Labels,
|
|
Remove: options.Remove,
|
|
NoCache: options.NoCache,
|
|
})
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
// The daemon aborts the build if the response stream isn't consumed,
|
|
// and reports failures only inside that stream.
|
|
defer resp.Body.Close()
|
|
var buildErr string
|
|
dec := json.NewDecoder(resp.Body)
|
|
for {
|
|
var line struct {
|
|
ErrorDetail struct {
|
|
Message string `json:"message"`
|
|
} `json:"errorDetail"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := dec.Decode(&line); err != nil {
|
|
break
|
|
}
|
|
if line.ErrorDetail.Message != "" {
|
|
buildErr = line.ErrorDetail.Message
|
|
} else if line.Error != "" {
|
|
buildErr = line.Error
|
|
}
|
|
}
|
|
if buildErr != "" {
|
|
return resp, fmt.Errorf("docker build failed: %s", buildErr)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// RemoveImage removes an image
|
|
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
|
|
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
|
|
Force: force,
|
|
})
|
|
}
|
|
|
|
// TagImage tags an image
|
|
func (c *Client) TagImage(ctx context.Context, imageID, ref string) error {
|
|
return c.cli.ImageTag(ctx, imageID, ref)
|
|
}
|
|
|
|
// ListNetworks returns all networks
|
|
func (c *Client) ListNetworks(ctx context.Context) ([]network.Summary, error) {
|
|
return c.cli.NetworkList(ctx, network.ListOptions{})
|
|
}
|
|
|
|
// CreateNetwork creates a new network
|
|
func (c *Client) CreateNetwork(ctx context.Context, config NetworkConfig) (string, error) {
|
|
resp, err := c.cli.NetworkCreate(ctx, config.Name, network.CreateOptions{
|
|
Driver: config.Driver,
|
|
Internal: config.Internal,
|
|
Labels: config.Labels,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return resp.ID, nil
|
|
}
|
|
|
|
// RemoveNetwork removes a network
|
|
func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error {
|
|
return c.cli.NetworkRemove(ctx, networkID)
|
|
}
|
|
|
|
// NetworkGateway returns the IPAM gateway of a network — the address
|
|
// containers on it can use to reach host-published ports.
|
|
func (c *Client) NetworkGateway(ctx context.Context, networkID string) string {
|
|
resp, err := c.cli.NetworkInspect(ctx, networkID, network.InspectOptions{})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, cfg := range resp.IPAM.Config {
|
|
if cfg.Gateway != "" {
|
|
return cfg.Gateway
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ConnectNetwork connects a container to a network
|
|
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
|
|
return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
|
|
}
|
|
|
|
// DisconnectNetwork disconnects a container from a network
|
|
func (c *Client) DisconnectNetwork(ctx context.Context, networkID, containerID string, force bool) error {
|
|
return c.cli.NetworkDisconnect(ctx, networkID, containerID, force)
|
|
}
|
|
|
|
// ListVolumes returns all volumes
|
|
func (c *Client) ListVolumes(ctx context.Context) (volume.ListResponse, error) {
|
|
return c.cli.VolumeList(ctx, volume.ListOptions{})
|
|
}
|
|
|
|
// CreateVolume creates a new volume
|
|
func (c *Client) CreateVolume(ctx context.Context, config VolumeConfig) (volume.Volume, error) {
|
|
return c.cli.VolumeCreate(ctx, volume.CreateOptions{
|
|
Name: config.Name,
|
|
Driver: config.Driver,
|
|
Labels: config.Labels,
|
|
DriverOpts: config.DriverOpts,
|
|
})
|
|
}
|
|
|
|
// RemoveVolume removes a volume
|
|
func (c *Client) RemoveVolume(ctx context.Context, volumeID string, force bool) error {
|
|
return c.cli.VolumeRemove(ctx, volumeID, force)
|
|
}
|
|
|
|
// GetSystemInfo returns system-wide information
|
|
func (c *Client) GetSystemInfo(ctx context.Context) (system.Info, error) {
|
|
return c.cli.Info(ctx)
|
|
}
|
|
|
|
// GetDiskUsage returns Docker disk usage information
|
|
func (c *Client) GetDiskUsage(ctx context.Context) (types.DiskUsage, error) {
|
|
return c.cli.DiskUsage(ctx, types.DiskUsageOptions{})
|
|
}
|
|
|
|
// GetEvents returns Docker events
|
|
func (c *Client) GetEvents(ctx context.Context, options EventOptions) (io.ReadCloser, error) {
|
|
resp, errChan := c.cli.Events(ctx, events.ListOptions{
|
|
Since: options.Since,
|
|
Until: options.Until,
|
|
Filters: options.Filters,
|
|
})
|
|
|
|
// Convert the channel to a reader
|
|
r, w := io.Pipe()
|
|
go func() {
|
|
defer w.Close()
|
|
for {
|
|
select {
|
|
case event, ok := <-resp:
|
|
if !ok {
|
|
return
|
|
}
|
|
// Write event data to pipe
|
|
w.Write([]byte(fmt.Sprintf("%v\n", event)))
|
|
case err := <-errChan:
|
|
if err != nil {
|
|
w.CloseWithError(err)
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return r, nil
|
|
}
|
|
|
|
// ExecCreate creates an exec instance in a container
|
|
func (c *Client) ExecCreate(ctx context.Context, containerID string, config ExecConfig) (types.IDResponse, error) {
|
|
return c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
|
|
Cmd: config.Cmd,
|
|
Env: config.Env,
|
|
WorkingDir: config.WorkingDir,
|
|
User: config.User,
|
|
AttachStdin: config.AttachStdin,
|
|
AttachStdout: config.AttachStdout,
|
|
AttachStderr: config.AttachStderr,
|
|
Tty: config.Tty,
|
|
})
|
|
}
|
|
|
|
// ExecStart starts an exec instance
|
|
func (c *Client) ExecStart(ctx context.Context, execID string, config ExecStartConfig) error {
|
|
return c.cli.ContainerExecStart(ctx, execID, container.ExecStartOptions{
|
|
Detach: config.Detach,
|
|
Tty: config.Tty,
|
|
})
|
|
}
|
|
|
|
// ExecInspect returns information about an exec instance
|
|
func (c *Client) ExecInspect(ctx context.Context, execID string) (container.ExecInspect, error) {
|
|
return c.cli.ContainerExecInspect(ctx, execID)
|
|
}
|
|
|
|
// ExecRun runs a command in a container and returns its combined
|
|
// stdout/stderr output and exit code.
|
|
func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string) (string, int, error) {
|
|
exec, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
|
|
Cmd: cmd,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
})
|
|
if err != nil {
|
|
return "", -1, err
|
|
}
|
|
|
|
resp, err := c.cli.ContainerExecAttach(ctx, exec.ID, container.ExecAttachOptions{})
|
|
if err != nil {
|
|
return "", -1, err
|
|
}
|
|
defer resp.Close()
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := stdcopy.StdCopy(&buf, &buf, resp.Reader); err != nil {
|
|
return "", -1, err
|
|
}
|
|
|
|
inspect, err := c.cli.ContainerExecInspect(ctx, exec.ID)
|
|
if err != nil {
|
|
return buf.String(), -1, err
|
|
}
|
|
return buf.String(), inspect.ExitCode, nil
|
|
}
|
|
|
|
// ExecAttach attaches to a running exec instance, returning the hijacked
|
|
// bidirectional stream. With Tty enabled the stream carries raw pty data
|
|
// (no stdcopy multiplexing).
|
|
func (c *Client) ExecAttach(ctx context.Context, execID string) (types.HijackedResponse, error) {
|
|
return c.cli.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
|
|
}
|
|
|
|
// ExecResize resizes the pty of a running exec instance.
|
|
func (c *Client) ExecResize(ctx context.Context, execID string, cols, rows uint) error {
|
|
return c.cli.ContainerExecResize(ctx, execID, container.ResizeOptions{
|
|
Height: rows,
|
|
Width: cols,
|
|
})
|
|
}
|
|
|
|
// GetImageInfo returns information about a Docker image
|
|
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
|
|
images, err := c.cli.ImageList(ctx, image.ListOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, img := range images {
|
|
for _, tag := range img.RepoTags {
|
|
if tag == imageName || tag == imageName+":latest" {
|
|
return &ImageInfo{
|
|
ID: img.ID,
|
|
RepoTags: img.RepoTags,
|
|
Size: img.Size,
|
|
Created: img.Created,
|
|
Labels: img.Labels,
|
|
RepoDigests: img.RepoDigests,
|
|
Digest: getDigestFromRepoTags(img.RepoDigests),
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("image not found: %s", imageName)
|
|
}
|
|
|
|
// PushImage pushes an image to a registry
|
|
func (c *Client) PushImage(ctx context.Context, imageName, registryURL string) error {
|
|
auth := registry.AuthConfig{}
|
|
authStr, _ := registry.EncodeAuthConfig(auth)
|
|
|
|
_, err := c.cli.ImagePush(ctx, imageName, image.PushOptions{
|
|
RegistryAuth: authStr,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Close closes the Docker client connection
|
|
func (c *Client) Close() error {
|
|
return c.cli.Close()
|
|
}
|
|
|
|
// Helper function to extract digest from repo digests
|
|
func getDigestFromRepoTags(digests []string) string {
|
|
if len(digests) > 0 {
|
|
return digests[0]
|
|
}
|
|
return ""
|
|
}
|