package docker import ( "context" "fmt" "regexp" "strconv" "strings" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/network" "github.com/docker/go-connections/nat" ) // ContainerConfig holds all parameters needed to create a managed container. type ContainerConfig struct { // Name is the container name (deterministic: dw-{project}-{stage}-{tag}). Name string // Image is the full image reference including tag (e.g. "myapp:v1.2.3"). Image string // Env is a list of environment variables in "KEY=VALUE" format. Env []string // ExposedPorts lists the container ports to publish (e.g. ["8080/tcp"]). // Each port is mapped to a random host port via Docker auto-assignment. ExposedPorts []string // NetworkName is the Docker network to attach the container to at creation. NetworkName string // NetworkID is the ID of the Docker network (used for endpoint config). NetworkID string // Labels are additional labels to apply to the container. // docker-watcher management labels are added automatically via Project, Stage, and InstanceID. Labels map[string]string // Project is the docker-watcher project name (used for labelling). Project string // Stage is the docker-watcher stage name (used for labelling). Stage string // InstanceID is the docker-watcher instance ID (used for labelling). InstanceID string } // sanitizeTag replaces characters that are invalid in Docker container names // with hyphens and lowercases the result. var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_.-]`) // ContainerName builds a deterministic container name from project, stage, and tag. func ContainerName(project, stage, tag string) string { sanitizeComponent := func(s string) string { s = invalidNameChars.ReplaceAllString(s, "-") return strings.Trim(s, "-") } return fmt.Sprintf("dw-%s-%s-%s", sanitizeComponent(project), sanitizeComponent(stage), sanitizeComponent(tag)) } // CreateContainer creates a new container with the given configuration. // It returns the container ID on success. func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (string, error) { // Build port bindings: each exposed port maps to a random host port. exposedPorts := nat.PortSet{} portBindings := nat.PortMap{} for _, p := range cfg.ExposedPorts { port := nat.Port(p) exposedPorts[port] = struct{}{} portBindings[port] = []nat.PortBinding{ {HostIP: "0.0.0.0", HostPort: ""}, // empty HostPort = auto-assign } } // Merge docker-watcher labels with any additional labels. labels := make(map[string]string) for k, v := range cfg.Labels { labels[k] = v } labels[LabelProject] = cfg.Project labels[LabelStage] = cfg.Stage labels[LabelInstanceID] = cfg.InstanceID containerCfg := &container.Config{ Image: cfg.Image, Env: cfg.Env, ExposedPorts: exposedPorts, Labels: labels, } hostCfg := &container.HostConfig{ PortBindings: portBindings, RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyDisabled}, } // Attach to network at creation time if specified. var networkCfg *network.NetworkingConfig if cfg.NetworkName != "" { networkCfg = &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ cfg.NetworkName: { NetworkID: cfg.NetworkID, }, }, } } resp, err := c.api.ContainerCreate(ctx, containerCfg, hostCfg, networkCfg, nil, cfg.Name) if err != nil { return "", fmt.Errorf("create container %s: %w", cfg.Name, err) } return resp.ID, nil } // StartContainer starts a stopped container. func (c *Client) StartContainer(ctx context.Context, containerID string) error { if err := c.api.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { return fmt.Errorf("start container %s: %w", containerID, err) } return nil } // StopContainer gracefully stops a running container with the given timeout in seconds. // A nil timeout uses the Docker default (10 seconds). func (c *Client) StopContainer(ctx context.Context, containerID string, timeoutSeconds int) error { opts := container.StopOptions{} if timeoutSeconds > 0 { opts.Timeout = &timeoutSeconds } if err := c.api.ContainerStop(ctx, containerID, opts); err != nil { return fmt.Errorf("stop container %s: %w", containerID, err) } return nil } // RemoveContainer removes a container. If force is true, a running container // will be killed before removal. func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error { opts := container.RemoveOptions{ Force: force, RemoveVolumes: true, } if err := c.api.ContainerRemove(ctx, containerID, opts); err != nil { return fmt.Errorf("remove container %s: %w", containerID, err) } return nil } // RestartContainer restarts a container with the given timeout in seconds. func (c *Client) RestartContainer(ctx context.Context, containerID string, timeoutSeconds int) error { opts := container.StopOptions{} if timeoutSeconds > 0 { opts.Timeout = &timeoutSeconds } if err := c.api.ContainerRestart(ctx, containerID, opts); err != nil { return fmt.Errorf("restart container %s: %w", containerID, err) } return nil } // ManagedContainer holds summary information about a container managed by docker-watcher. type ManagedContainer struct { ID string Name string Image string Status string State string Project string Stage string InstanceID string Ports []uint16 } // ListContainers returns all containers matching the given label filters. // Pass nil or an empty map to list all docker-watcher managed containers. // Label filters are key=value pairs applied as Docker label filters. func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]string) ([]ManagedContainer, error) { filterArgs := filters.NewArgs() // Always filter by the docker-watcher project label to only return managed containers. filterArgs.Add("label", LabelProject) for k, v := range labelFilters { if v != "" { filterArgs.Add("label", k+"="+v) } else { filterArgs.Add("label", k) } } containers, err := c.api.ContainerList(ctx, container.ListOptions{ All: true, Filters: filterArgs, }) if err != nil { return nil, fmt.Errorf("list containers: %w", err) } result := make([]ManagedContainer, 0, len(containers)) for _, ctr := range containers { name := "" if len(ctr.Names) > 0 { // Docker prefixes names with "/". name = strings.TrimPrefix(ctr.Names[0], "/") } var ports []uint16 for _, p := range ctr.Ports { if p.PublicPort > 0 { ports = append(ports, p.PublicPort) } } result = append(result, ManagedContainer{ ID: ctr.ID, Name: name, Image: ctr.Image, Status: ctr.Status, State: ctr.State, Project: ctr.Labels[LabelProject], Stage: ctr.Labels[LabelStage], InstanceID: ctr.Labels[LabelInstanceID], Ports: ports, }) } return result, nil } // InspectContainerPort returns the host port mapped to a given container port. // This is useful after starting a container with auto-assigned ports. func (c *Client) InspectContainerPort(ctx context.Context, containerID string, containerPort string) (uint16, error) { inspect, err := c.api.ContainerInspect(ctx, containerID) if err != nil { return 0, fmt.Errorf("inspect container %s: %w", containerID, err) } port := nat.Port(containerPort) bindings, ok := inspect.NetworkSettings.Ports[port] if !ok || len(bindings) == 0 { return 0, fmt.Errorf("container %s: no binding for port %s", containerID, containerPort) } var hostPort uint16 for _, b := range bindings { if b.HostPort != "" { parsed := parsePort(b.HostPort) if parsed > 0 { hostPort = parsed break } } } if hostPort == 0 { return 0, fmt.Errorf("container %s: no host port for %s", containerID, containerPort) } return hostPort, nil } // parsePort converts a port string to uint16. Returns 0 on failure. func parsePort(s string) uint16 { n, err := strconv.ParseUint(s, 10, 16) if err != nil { return 0 } return uint16(n) }