Files
firewall_containers/network-go/docker/docker.go
gyurix 6c19e22deb
continuous-integration/drone/push Build is passing
refactor(docker): switch container lookup to use filtered ContainerList
Replace ContainerInspect with ContainerList and name filters for exact and prefix matching. This improves efficiency and correctness by leveraging Docker's filtering capabilities, matching the old shell script's grep behavior more accurately. Add regexp import to properly escape container names in filters.
2026-06-15 15:25:38 +02:00

269 lines
8.2 KiB
Go

package docker
import (
"context"
"fmt"
"net"
"os/exec"
"regexp"
"strings"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"firewall_containers/network-go/config"
)
// DockerAPI defines the interface for Docker operations, enabling mock implementations for testing
type DockerAPI interface {
Close() error
EnsureNetwork(ctx context.Context, netCfg config.NetworkConfig) error
RemoveNetwork(ctx context.Context, networkName string) error
ConnectContainer(ctx context.Context, containerName, networkName, ip string) error
DisconnectContainer(ctx context.Context, containerName, networkName string) error
InspectContainer(ctx context.Context, containerName string) (*types.ContainerJSON, error)
WaitForContainerRunning(ctx context.Context, containerName string, timeout time.Duration) error
GetContainerPID(ctx context.Context, containerName string) (int, error)
AddRouteInContainer(ctx context.Context, containerName, network, gateway string) error
FindContainerName(ctx context.Context, name, selector string) (string, error)
}
// Client wraps the Docker SDK client
type Client struct {
cli *client.Client
}
// Ensure Client implements DockerAPI
var _ DockerAPI = (*Client)(nil)
// 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)
}
return &Client{cli: cli}, nil
}
// Close closes the Docker client
func (c *Client) Close() error {
return c.cli.Close()
}
// EnsureNetwork creates a Docker network if it does not already exist
func (c *Client) EnsureNetwork(ctx context.Context, netCfg config.NetworkConfig) error {
existingNetworks, err := c.cli.NetworkList(ctx, network.ListOptions{
Filters: filters.NewArgs(filters.Arg("name", netCfg.NetworkName)),
})
if err != nil {
return fmt.Errorf("failed to list networks: %w", err)
}
for _, n := range existingNetworks {
if n.Name == netCfg.NetworkName {
return nil
}
}
_, ipNet, err := net.ParseCIDR(netCfg.Subnet)
if err != nil {
return fmt.Errorf("failed to parse subnet %s: %w", netCfg.Subnet, err)
}
gatewayIP := net.ParseIP(netCfg.Gateway)
if gatewayIP == nil {
return fmt.Errorf("failed to parse gateway IP %s", netCfg.Gateway)
}
createOpts := network.CreateOptions{
Driver: "bridge",
IPAM: &network.IPAM{
Config: []network.IPAMConfig{
{
Subnet: ipNet.String(),
Gateway: gatewayIP.String(),
},
},
},
Labels: map[string]string{
"managed-by": "firewall-network-go",
},
Attachable: true,
}
resp, err := c.cli.NetworkCreate(ctx, netCfg.NetworkName, createOpts)
if err != nil {
return fmt.Errorf("failed to create network %s: %w", netCfg.NetworkName, err)
}
_ = resp
return nil
}
// RemoveNetwork removes a Docker network
func (c *Client) RemoveNetwork(ctx context.Context, networkName string) error {
err := c.cli.NetworkRemove(ctx, networkName)
if err != nil {
return fmt.Errorf("failed to remove network %s: %w", networkName, err)
}
return nil
}
// ConnectContainer connects a container to a network with a specific IP
func (c *Client) ConnectContainer(ctx context.Context, containerName, networkName, ip string) error {
endpointSettings := &network.EndpointSettings{
IPAMConfig: &network.EndpointIPAMConfig{
IPv4Address: ip,
},
}
err := c.cli.NetworkConnect(ctx, networkName, containerName, endpointSettings)
if err != nil {
return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
}
return nil
}
// DisconnectContainer disconnects a container from a network
func (c *Client) DisconnectContainer(ctx context.Context, containerName, networkName string) error {
err := c.cli.NetworkDisconnect(ctx, networkName, containerName, true)
if err != nil {
return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
}
return nil
}
// InspectContainer returns the container's details
func (c *Client) InspectContainer(ctx context.Context, containerName string) (*types.ContainerJSON, error) {
container, err := c.cli.ContainerInspect(ctx, containerName)
if err != nil {
return nil, fmt.Errorf("failed to inspect container %s: %w", containerName, err)
}
return &container, nil
}
// WaitForContainerRunning waits until a container is in running state, with a timeout
func (c *Client) WaitForContainerRunning(ctx context.Context, containerName string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for container %s to be running", containerName)
case <-ticker.C:
container, err := c.cli.ContainerInspect(ctx, containerName)
if err != nil {
continue
}
if container.State != nil && container.State.Running {
return nil
}
}
}
}
// GetContainerPID returns the PID of a container for nsenter operations
func (c *Client) GetContainerPID(ctx context.Context, containerName string) (int, error) {
cont, err := c.cli.ContainerInspect(ctx, containerName)
if err != nil {
return 0, fmt.Errorf("failed to inspect container %s: %w", containerName, err)
}
if cont.State == nil || !cont.State.Running {
return 0, fmt.Errorf("container %s is not running", containerName)
}
return cont.State.Pid, nil
}
// AddRouteInContainer adds a route inside a container's network namespace using nsenter
func (c *Client) AddRouteInContainer(ctx context.Context, containerName, network, gateway string) error {
pid, err := c.GetContainerPID(ctx, containerName)
if err != nil {
return fmt.Errorf("failed to get PID for container %s: %w", containerName, err)
}
args := []string{
"-t", fmt.Sprintf("%d", pid),
"-n", "--",
"ip", "route", "add", network, "via", gateway,
}
cmd := exec.Command("nsenter", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to add route in container %s: %w\noutput: %s", containerName, err, string(output))
}
return nil
}
// FindContainerName attempts to find a running container by name or selector.
// First tries exact name match, then exact selector, then prefix matching
// (matching the old shell script's grep $D"-" behavior).
func (c *Client) FindContainerName(ctx context.Context, name, selector string) (string, error) {
// Try exact name match using ContainerList with a name filter
// Docker API name filter does an exact match by default
containers, err := c.cli.ContainerList(ctx, container.ListOptions{
Filters: filters.NewArgs(
filters.Arg("name", "^/?"+regexp.QuoteMeta(name)+"$"),
filters.Arg("status", "running"),
),
})
if err == nil && len(containers) > 0 {
cName := strings.TrimPrefix(containers[0].Names[0], "/")
return cName, nil
}
// Try exact selector match
if selector != "" && selector != name {
containers, err = c.cli.ContainerList(ctx, container.ListOptions{
Filters: filters.NewArgs(
filters.Arg("name", "^/?"+regexp.QuoteMeta(selector)+"$"),
filters.Arg("status", "running"),
),
})
if err == nil && len(containers) > 0 {
cName := strings.TrimPrefix(containers[0].Names[0], "/")
return cName, nil
}
}
// Try prefix matching on both name and selector (shell script behavior: grep $D"-")
candidates := []string{name, selector}
for _, candidate := range candidates {
if candidate == "" {
continue
}
// Extract prefix before first dash if present
prefix := candidate
if strings.Contains(candidate, "-") {
prefix = candidate[:strings.Index(candidate, "-")]
}
containers, err = c.cli.ContainerList(ctx, container.ListOptions{
Filters: filters.NewArgs(
filters.Arg("name", prefix+"-"),
filters.Arg("status", "running"),
),
})
if err != nil {
continue
}
for _, c := range containers {
for _, cName := range c.Names {
cName = strings.TrimPrefix(cName, "/")
return cName, nil
}
}
}
return "", fmt.Errorf("no running container found matching name=%q selector=%q", name, selector)
}