Files
uncloud/pkg/api/container.go
T

339 lines
10 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/netip"
"os"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
)
const (
// DockerNetworkName is the name of the Docker network used by uncloud. Keep the value in sync with NetworkName
// in internal/machine/docker/manager.go.
DockerNetworkName = "uncloud"
LabelManaged = "uncloud.managed"
LabelServiceID = "uncloud.service.id"
LabelServiceName = "uncloud.service.name"
LabelServiceMode = "uncloud.service.mode"
LabelServicePorts = "uncloud.service.ports"
// LabelHook marks a container as a deployment hook. The value indicates the hook type (e.g. LabelHookPreDeploy).
LabelHook = "uncloud.service.hook"
// LabelHookPreDeploy indicates that the container is a pre-deploy hook that runs before deploying the service.
LabelHookPreDeploy = "pre-deploy"
)
type Container struct {
container.InspectResponse
// created caches the parsed creation time by CreatedTime.
created time.Time
}
// CreatedTime returns the time when the container was created parsed from the Created field.
func (c *Container) CreatedTime() time.Time {
if c.created.IsZero() && c.Created != "" {
created, err := time.Parse(time.RFC3339Nano, c.Created)
if err != nil {
return time.Time{}
}
c.created = created
}
return c.created
}
// HasHealthcheck returns true if the container has a health check configured.
func (c *Container) HasHealthcheck() bool {
hc := c.Config.Healthcheck
if hc == nil {
return false
}
if len(hc.Test) > 0 && hc.Test[0] == "NONE" {
return false
}
return true
}
// Healthy determines if the container is running and healthy.
// A running container with no health check configured is considered healthy.
func (c *Container) Healthy() bool {
if !c.State.Running || c.State.Paused || c.State.Restarting {
return false
}
// If there's no health status (no health check configured), container is considered healthy.
if c.State.Health == nil {
return true
}
return c.State.Health.Status == container.Healthy
}
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
// https://github.com/moby/moby/blob/b343d235a0a1f30c8f05b1d651238e72158dc25d/container/state.go#L79-L113
func (c *Container) HumanState() (string, error) {
startedAt, err := time.Parse(time.RFC3339Nano, c.State.StartedAt)
if err != nil {
return "", fmt.Errorf("parse started time: %w", err)
}
finishedAt, err := time.Parse(time.RFC3339Nano, c.State.FinishedAt)
if err != nil {
return "", fmt.Errorf("parse finished time: %w", err)
}
if c.State.Running {
if c.State.Paused {
return fmt.Sprintf("Up %s (Paused)", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil
}
if c.State.Restarting {
return fmt.Sprintf("Restarting (%d) %s ago",
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
}
if c.State.Health != nil {
status := c.State.Health.Status
if status == container.Starting {
status = "health: " + status
}
return fmt.Sprintf("Up %s (%s)", units.HumanDuration(time.Now().UTC().Sub(startedAt)), status), nil
}
return fmt.Sprintf("Up %s", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil
}
if c.State.Status == "removing" {
return "Removal In Progress", nil
}
if c.State.Dead {
return "Dead", nil
}
if startedAt.IsZero() {
return "Created", nil
}
if finishedAt.IsZero() {
return "", nil
}
return fmt.Sprintf("Exited (%d) %s ago",
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
}
// UncloudNetworkIP returns the IP address of the container in the uncloud Docker network.
func (c *Container) UncloudNetworkIP() netip.Addr {
network, ok := c.NetworkSettings.Networks[DockerNetworkName]
if !ok {
// Container is not connected to the uncloud Docker network (could be host network).
return netip.Addr{}
}
ip, err := netip.ParseAddr(network.IPAddress)
if err != nil {
return netip.Addr{}
}
return ip
}
func (c *Container) UnmarshalJSON(data []byte) error {
// A temporary type that's identical to Container but doesn't have the UnmarshalJSON method.
type ContainerAlias Container
var temp ContainerAlias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
*c = Container(temp)
if c.ContainerJSONBase == nil {
return fmt.Errorf("container data is missing mandatory base fields: %s", data)
}
c.Name = strings.TrimPrefix(c.Name, "/")
return nil
}
// CreateContainerResponse wraps a container creation response with the container name assigned during creation.
type CreateContainerResponse struct {
container.CreateResponse
// Name is the container name assigned during creation.
Name string
}
type ServiceContainer struct {
Container
ServiceSpec ServiceSpec
}
// ShortID returns the truncated ID of the container (12 characters).
func (c *ServiceContainer) ShortID() string {
return stringid.TruncateID(c.ID)
}
// ServiceID returns the ID of the service this container belongs to.
func (c *ServiceContainer) ServiceID() string {
return c.Config.Labels[LabelServiceID]
}
// ServiceName returns the name of the service this container belongs to.
func (c *ServiceContainer) ServiceName() string {
return c.Config.Labels[LabelServiceName]
}
// ServiceMode returns the replication mode of the service this container belongs to.
func (c *ServiceContainer) ServiceMode() string {
return c.ServiceSpec.Mode
}
// IsHook returns true if the container is a deployment hook (e.g. pre-deploy).
func (c *ServiceContainer) IsHook() bool {
_, ok := c.Config.Labels[LabelHook]
return ok
}
// ServicePorts returns the ports this container publishes as part of its service.
// TODO: return ports from ServiceSpec to allow updating ingress ports without recreating containers.
func (c *ServiceContainer) ServicePorts() ([]PortSpec, error) {
encoded, ok := c.Config.Labels[LabelServicePorts]
if !ok {
return nil, nil
}
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
publishPorts := strings.Split(encoded, ",")
ports := make([]PortSpec, len(publishPorts))
for i, p := range publishPorts {
port, err := ParsePortSpec(strings.TrimSpace(p))
if err != nil {
return nil, err
}
ports[i] = port
}
return ports, nil
}
// ConflictingServicePorts returns a list of service ports that conflict with the given ports.
func (c *ServiceContainer) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
svcPorts, err := c.ServicePorts()
if err != nil {
return nil, fmt.Errorf("get service ports: %w", err)
}
var conflicting []PortSpec
for _, p := range ports {
if p.Mode != PortModeHost {
continue
}
// Two host ports conflict if they have the same published port number and protocol, and either:
// * At least one host IP is not set (meaning it uses all interfaces)
// * Both host IPs are identical
for _, svcPort := range svcPorts {
if svcPort.Mode != PortModeHost ||
svcPort.PublishedPort != p.PublishedPort ||
svcPort.Protocol != p.Protocol {
continue
}
if !svcPort.HostIP.IsValid() || !p.HostIP.IsValid() || svcPort.HostIP.Compare(p.HostIP) == 0 {
conflicting = append(conflicting, p)
}
}
}
return conflicting, nil
}
// UnmarshalJSON implements custom unmarshalling for ServiceContainer to override the custom unmarshaler
// of the embedded Container field.
func (c *ServiceContainer) UnmarshalJSON(data []byte) error {
// Unmarshal everything except Container into a temporary struct. Keep this in sync with ServiceContainer.
var temp struct {
ServiceSpec ServiceSpec
}
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
// Let Container's UnmarshalJSON handle its part.
if err := json.Unmarshal(data, &c.Container); err != nil {
return err
}
c.ServiceSpec = temp.ServiceSpec
return nil
}
// DefaultHealthMonitorPeriod is the default duration (5 seconds) to wait before checking that the container is still
// running and not restarting. Can be overridden with the UNCLOUD_HEALTH_MONITOR_PERIOD environment variable
// (e.g. "10s" or "0").
var DefaultHealthMonitorPeriod = defaultHealthMonitorPeriod()
func defaultHealthMonitorPeriod() time.Duration {
if v, ok := os.LookupEnv("UNCLOUD_HEALTH_MONITOR_PERIOD"); ok {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return 5 * time.Second
}
// WaitContainerHealthyOptions configures the behaviour of WaitContainerHealthy.
type WaitContainerHealthyOptions struct {
// MonitorPeriod is how long to wait before checking that the container is still running and not restarting.
// Containers with a health check that become healthy before the period ends succeed early.
// nil means use the default DefaultHealthMonitorPeriod.
// Zero skips the monitoring and checks the container's health immediately after starting.
MonitorPeriod *time.Duration
}
// ExecOptions contains configuration for executing a command in a container.
type ExecOptions struct {
// Command is the command to run in the container.
Command []string
// AttachStdin attaches the stdin stream to the exec session.
AttachStdin bool
// AttachStdout attaches the stdout stream to the exec session.
AttachStdout bool
// AttachStderr attaches the stderr stream to the exec session.
AttachStderr bool
// Tty allocates a pseudo-TTY for the exec session.
Tty bool
// Detach runs the command in the background without attaching to streams.
Detach bool
//// Not yet implemented fields
// User specifies the user to run the command as.
User string
// Privileged runs the command in privileged mode.
Privileged bool
// WorkingDir sets the working directory for the command.
WorkingDir string
// Env sets environment variables for the command.
Env []string
// Client-side only fields (not serialized, not sent to server)
// Stdin is the input stream. Defaults to os.Stdin if nil.
Stdin io.Reader `json:"-"`
// Stdout is the output stream. Defaults to os.Stdout if nil.
Stdout io.Writer `json:"-"`
// Stderr is the error stream. Defaults to os.Stderr if nil.
Stderr io.Writer `json:"-"`
}