package discovery import ( "context" "fmt" "github.com/docker/docker/api/types/swarm" ) // SwarmDockerClient is the subset of the Docker SDK SwarmDiscovery depends // on. Defined as an interface so tests can supply a fake. type SwarmDockerClient interface { ServiceList(ctx context.Context, opts swarm.ServiceListOptions) ([]swarm.Service, error) } // SwarmDiscovery orchestrates "given an image, which Swarm services should // we update?". Unlike Discovery, it has no filesystem path to check — the // opt-in label on the service itself is the only gate, since Swarm services // have no local compose file to anchor a STACKS_ROOT check against. type SwarmDiscovery struct { cli SwarmDockerClient optInLabel string } // NewSwarm returns a SwarmDiscovery bound to the given Docker client and // opt-in label. func NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery { return &SwarmDiscovery{cli: cli, optInLabel: optInLabel} } // FindJobs lists Swarm services, filters by image match + opt-in label, and // returns one Job per matching service. Signature matches Discovery.FindJobs // so both satisfy api.Finder. func (d *SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error) { services, err := d.cli.ServiceList(ctx, swarm.ServiceListOptions{}) if err != nil { return nil, fmt.Errorf("docker service list: %w", err) } var jobs []Job for _, svc := range services { if svc.Spec.TaskTemplate.ContainerSpec == nil { continue } if !ImagesMatch(image, svc.Spec.TaskTemplate.ContainerSpec.Image) { continue } if !HasOptIn(svc.Spec.Labels, d.optInLabel) { continue } jobs = append(jobs, Job{ Service: svc.Spec.Name, ServiceID: svc.ID, Image: image, }) } return jobs, nil }