diff --git a/internal/discovery/swarm.go b/internal/discovery/swarm.go new file mode 100644 index 0000000..108fad5 --- /dev/null +++ b/internal/discovery/swarm.go @@ -0,0 +1,60 @@ +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 +} diff --git a/internal/discovery/swarm_test.go b/internal/discovery/swarm_test.go new file mode 100644 index 0000000..5c3f048 --- /dev/null +++ b/internal/discovery/swarm_test.go @@ -0,0 +1,86 @@ +package discovery_test + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types/swarm" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/stretchr/testify/require" +) + +type fakeSwarmDockerClient struct { + services []swarm.Service + err error +} + +func (f *fakeSwarmDockerClient) ServiceList(_ context.Context, _ swarm.ServiceListOptions) ([]swarm.Service, error) { + return f.services, f.err +} + +func mkService(id, name, image string, labels map[string]string) swarm.Service { + return swarm.Service{ + ID: id, + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: name, Labels: labels}, + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{Image: image}, + }, + }, + } +} + +func TestSwarmFindJobs_MatchAndOptIn(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", map[string]string{ + "se.shcizo.auto-update": "true", + }), + mkService("svc-other", "other_web", "registry.example.com/other:v1", map[string]string{ + "se.shcizo.auto-update": "true", + }), + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, "myapp_web", jobs[0].Service) + require.Equal(t, "svc-myapp", jobs[0].ServiceID) + require.Equal(t, "registry.example.com/myapp", jobs[0].Image) + require.False(t, jobs[0].Refused) + require.Empty(t, jobs[0].WorkingDir) + require.Empty(t, jobs[0].ConfigFiles) +} + +func TestSwarmFindJobs_SkipsWithoutOptIn(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", nil), + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +} + +func TestSwarmFindJobs_DockerError(t *testing.T) { + fake := &fakeSwarmDockerClient{err: errors.New("connection refused")} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + _, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.Error(t, err) +} + +func TestSwarmFindJobs_NoContainerSpecIsSkipped(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + { + ID: "svc-weird", + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "weird", Labels: map[string]string{"se.shcizo.auto-update": "true"}}, + TaskTemplate: swarm.TaskSpec{ContainerSpec: nil}, + }, + }, + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +}