// Package inventory turns what the local Docker daemon runs into the // services an agent reports to the master. package inventory import ( "context" "fmt" "sort" "strings" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/system" "github.com/shcizo/package-updater/internal/imageref" "github.com/shcizo/package-updater/internal/protocol" ) const ( labelProject = "com.docker.compose.project" labelService = "com.docker.compose.service" labelWorkingDir = "com.docker.compose.project.working_dir" labelConfigFiles = "com.docker.compose.project.config_files" labelSwarmTask = "com.docker.swarm.service.id" ) // DockerClient is the subset of the Docker SDK the collector needs. // *client.Client satisfies it. type DockerClient interface { ContainerList(ctx context.Context, opts container.ListOptions) ([]container.Summary, error) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) ImageList(ctx context.Context, opts image.ListOptions) ([]image.Summary, error) ServiceList(ctx context.Context, opts swarm.ServiceListOptions) ([]swarm.Service, error) Info(ctx context.Context) (system.Info, error) } // Collector reads the local daemon's inventory. type Collector struct { cli DockerClient selfID string } // New returns a Collector. selfID is a prefix of the agent's own container // ID (inside a container, the hostname); that container is marked Self. func New(cli DockerClient, selfID string) *Collector { return &Collector{cli: cli, selfID: selfID} } // Hello returns the machine facts sent on connect. AgentVersion is left for // the caller to fill in. func (c *Collector) Hello(ctx context.Context) (protocol.Hello, error) { info, err := c.cli.Info(ctx) if err != nil { return protocol.Hello{}, fmt.Errorf("docker info: %w", err) } h := protocol.Hello{Hostname: info.Name, SwarmManager: info.Swarm.ControlAvailable} if info.Swarm.Cluster != nil { h.SwarmClusterID = info.Swarm.Cluster.ID } return h, nil } // Snapshot returns every service on this machine, sorted by Key. func (c *Collector) Snapshot(ctx context.Context) ([]protocol.Service, error) { info, err := c.cli.Info(ctx) if err != nil { return nil, fmt.Errorf("docker info: %w", err) } containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true}) if err != nil { return nil, fmt.Errorf("docker container list: %w", err) } images, err := c.cli.ImageList(ctx, image.ListOptions{}) if err != nil { return nil, fmt.Errorf("docker image list: %w", err) } repoDigests := make(map[string][]string, len(images)) for _, img := range images { repoDigests[img.ID] = img.RepoDigests } byKey := make(map[string]protocol.Service) for _, ct := range containers { if _, isTask := ct.Labels[labelSwarmTask]; isTask { continue } ref, err := c.imageRef(ctx, ct) if err != nil { return nil, err } svc := c.fromContainer(ct, ref, repoDigests[ct.ImageID]) if prev, ok := byKey[svc.Key()]; ok { svc = mergeReplicas(prev, svc) } byKey[svc.Key()] = svc } if info.Swarm.ControlAvailable { services, err := c.cli.ServiceList(ctx, swarm.ServiceListOptions{Status: true}) if err != nil { return nil, fmt.Errorf("docker service list: %w", err) } for _, s := range services { if s.Spec.TaskTemplate.ContainerSpec == nil { continue } svc := fromSwarmService(s) byKey[svc.Key()] = svc } } out := make([]protocol.Service, 0, len(byKey)) for _, s := range byKey { out = append(out, s) } sort.Slice(out, func(i, j int) bool { return out[i].Key() < out[j].Key() }) return out, nil } // imageRef returns the image reference the container was created from. // When the tag has since moved to another image, ContainerList reports the // image ID ("sha256:...") instead, so the reference is read from the // container's config. func (c *Collector) imageRef(ctx context.Context, ct container.Summary) (string, error) { if !strings.HasPrefix(ct.Image, "sha256:") { return ct.Image, nil } insp, err := c.cli.ContainerInspect(ctx, ct.ID) if err != nil { return "", fmt.Errorf("docker container inspect %s: %w", ct.ID, err) } if insp.Config == nil { return ct.Image, nil } return insp.Config.Image, nil } func (c *Collector) fromContainer(ct container.Summary, ref string, repoDigests []string) protocol.Service { svc := protocol.Service{ Image: ref, Digest: digestFor(ref, repoDigests), Status: containerStatus(ct.State), Self: c.selfID != "" && strings.HasPrefix(ct.ID, c.selfID), } project, service := ct.Labels[labelProject], ct.Labels[labelService] if project != "" && service != "" { svc.Kind = protocol.KindCompose svc.ComposeProject = project svc.ComposeService = service svc.WorkingDir = ct.Labels[labelWorkingDir] svc.ConfigFiles = ct.Labels[labelConfigFiles] return svc } svc.Kind = protocol.KindContainer svc.ContainerName = ct.ID if len(ct.Names) > 0 { svc.ContainerName = strings.TrimPrefix(ct.Names[0], "/") } return svc } func fromSwarmService(s swarm.Service) protocol.Service { ref := s.Spec.TaskTemplate.ContainerSpec.Image return protocol.Service{ Kind: protocol.KindSwarm, SwarmService: s.Spec.Name, Image: ref, Digest: imageref.Parse(ref).Digest, Status: swarmStatus(s.ServiceStatus), } } // mergeReplicas folds a second container of the same Compose service into // the first: the service counts as running if any replica runs. func mergeReplicas(a, b protocol.Service) protocol.Service { if b.Status == "running" { a.Status = "running" } a.Self = a.Self || b.Self return a } // digestFor returns the registry digest the container's image was pulled // as. A reference that already pins a digest wins; otherwise the image's // RepoDigests entry for the same repository is used. func digestFor(ref string, repoDigests []string) string { r := imageref.Parse(ref) if r.Digest != "" { return r.Digest } for _, rd := range repoDigests { if p := imageref.Parse(rd); p.Repo == r.Repo { return p.Digest } } return "" } func containerStatus(state string) string { if state == "running" { return "running" } return "stopped" } func swarmStatus(st *swarm.ServiceStatus) string { switch { case st == nil: return "unknown" case st.DesiredTasks == 0: return "stopped" case st.RunningTasks >= st.DesiredTasks: return "running" default: return "degraded" } }