// Package inventory turns what the local Docker daemon runs into the // services an agent reports to the master. package inventory import ( "context" "fmt" "log/slog" "os" "sort" "strings" "sync" "time" "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" labelEnvFile = "com.docker.compose.project.environment_file" 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) } // BuildCheck finds the services of a Compose project that are built from // source. The arguments are labels of one of the project's containers. // *updater.BuildChecker satisfies it. type BuildCheck interface { BuiltServices(ctx context.Context, project, workingDir, configFilesLabel, envFileLabel string) (map[string]bool, error) } // buildRetry is how long a failed build check is not repeated while the // project's files stay the same. const buildRetry = 5 * time.Minute // Collector reads the local daemon's inventory. type Collector struct { cli DockerClient selfID string check BuildCheck log *slog.Logger now func() time.Time mu sync.Mutex cache map[string]buildEntry // by Compose project warned map[string]bool // logged warnings: "" (check failed) and "/" (service not in config) } // buildEntry is the cached outcome of a build check. It is valid as long as // key (the project's labels plus its files' sizes and mtimes) is unchanged. type buildEntry struct { key string built map[string]bool err error at time.Time } // stackLabels are the Compose labels a build check needs. type stackLabels struct{ workingDir, configFiles, envFile 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, now: time.Now, cache: map[string]buildEntry{}, warned: map[string]bool{}} } // WithBuildCheck makes the collector mark Compose services that have a // build: section as Built. The check re-runs only when a project's files // change. When it fails, the project's services are reported as not built and // the failure is logged once. It returns c. func (c *Collector) WithBuildCheck(check BuildCheck, log *slog.Logger) *Collector { c.check, c.log = check, log return c } // 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) stacks := make(map[string]stackLabels) 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 svc.Kind == protocol.KindCompose { if _, ok := stacks[svc.ComposeProject]; !ok { stacks[svc.ComposeProject] = stackLabels{svc.WorkingDir, svc.ConfigFiles, ct.Labels[labelEnvFile]} } } if prev, ok := byKey[svc.Key()]; ok { svc = mergeReplicas(prev, svc) } byKey[svc.Key()] = svc } c.markBuilt(ctx, byKey, stacks) 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" } } // markBuilt sets Built on Compose services that have a build: section. func (c *Collector) markBuilt(ctx context.Context, byKey map[string]protocol.Service, stacks map[string]stackLabels) { if c.check == nil { return } for key, svc := range byKey { if svc.Kind != protocol.KindCompose { continue } built := c.builtServices(ctx, svc.ComposeProject, stacks[svc.ComposeProject]) if built == nil { continue // check failed; already logged } isBuilt, known := built[svc.ComposeService] if !known { // Fail closed: a service compose does not list cannot be shown to // be free of build:, so it is not offered for update. c.warnOnce(svc.ComposeProject+"/"+svc.ComposeService, "compose_service_not_in_config", "project", svc.ComposeProject, "service", svc.ComposeService) } if isBuilt || !known { svc.Built = true byKey[key] = svc } } } // builtServices returns the project's built services, from the cache while // the project's files are unchanged. func (c *Collector) builtServices(ctx context.Context, project string, l stackLabels) map[string]bool { // The key covers the files named in the labels. Changes in files they // pull in (extends:, include:, the default .env) do not invalidate the // cache, so Built can be stale until another change or an agent restart. // The job-time check is always fresh, so jobs stay safe. key := l.workingDir + "|" + l.configFiles + "|" + l.envFile + "|" + fileStamps(l.configFiles+","+l.envFile) c.mu.Lock() defer c.mu.Unlock() if e, ok := c.cache[project]; ok && e.key == key && (e.err == nil || c.now().Sub(e.at) < buildRetry) { return e.built } built, err := c.check.BuiltServices(ctx, project, l.workingDir, l.configFiles, l.envFile) c.cache[project] = buildEntry{key: key, built: built, err: err, at: c.now()} if err == nil { // Forget the project's warnings so they are logged again if the // problem returns. Project names cannot contain "/". for k := range c.warned { if k == project || strings.HasPrefix(k, project+"/") { delete(c.warned, k) } } } else { c.warnOnceLocked(project, "compose_build_check_failed", "project", project, "error", err) } return built } // warnOnce logs msg the first time it is called for key. key is a project // name (its build check failed) or "/" (the service is not // in the project's config); a successful check of the project forgets both. func (c *Collector) warnOnce(key, msg string, args ...any) { c.mu.Lock() defer c.mu.Unlock() c.warnOnceLocked(key, msg, args...) } func (c *Collector) warnOnceLocked(key, msg string, args ...any) { if c.warned[key] || c.log == nil { return } c.warned[key] = true c.log.Warn(msg, args...) } // fileStamps summarises size and mtime of the comma-separated files, so a // changed file changes the string. func fileStamps(list string) string { var b strings.Builder for _, f := range strings.Split(list, ",") { if f = strings.TrimSpace(f); f == "" { continue } if fi, err := os.Stat(f); err == nil { fmt.Fprintf(&b, "%s:%d:%d;", f, fi.Size(), fi.ModTime().UnixNano()) } else { fmt.Fprintf(&b, "%s:missing;", f) } } return b.String() }