From db784ad303953275a4beef0089e1ff4f71b46e5b Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 11:51:07 +0200 Subject: [PATCH] feat(discovery): orchestrate match, opt-in filter, dedup, path-check --- go.mod | 11 +- go.sum | 16 +++ internal/discovery/discovery.go | 89 +++++++++++++++ internal/discovery/discovery_test.go | 155 +++++++++++++++++++++++++++ internal/discovery/docker_client.go | 15 +++ 5 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 internal/discovery/discovery.go create mode 100644 internal/discovery/discovery_test.go create mode 100644 internal/discovery/docker_client.go diff --git a/go.mod b/go.mod index e6f82e2..16fdead 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,19 @@ module github.com/shcizo/package-updater go 1.26.3 -require github.com/stretchr/testify v1.11.1 +require ( + github.com/docker/docker v28.5.2+incompatible + github.com/stretchr/testify v1.11.1 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index c4c1710..e92b2e6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,19 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -8,3 +22,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..5f5e687 --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,89 @@ +package discovery + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/docker/docker/api/types/container" +) + +// Job describes a single (project, service, config_files) update to execute. +type Job struct { + Project string + Service string + WorkingDir string + ConfigFiles []string + // Refused is true when the WorkingDir falls outside STACKS_ROOT. + // The job is returned so the caller can surface a per-job "refused" + // result, but it MUST NOT be executed. + Refused bool + RefusedReason string +} + +// Discovery orchestrates "given an image, what jobs should we enqueue?". +type Discovery struct { + cli DockerClient + stacksRoot string + optInLabel string +} + +// New returns a Discovery bound to the given Docker client and config. +func New(cli DockerClient, stacksRoot, optInLabel string) *Discovery { + return &Discovery{cli: cli, stacksRoot: stacksRoot, optInLabel: optInLabel} +} + +// FindJobs lists running containers, filters by image match + opt-in label, +// extracts Compose info, applies the path safety check, and deduplicates. +func (d *Discovery) FindJobs(ctx context.Context, image string) ([]Job, error) { + all, err := d.cli.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return nil, fmt.Errorf("docker container list: %w", err) + } + + seen := make(map[string]struct{}) + var jobs []Job + + for _, c := range all { + if !ImagesMatch(image, c.Image) { + continue + } + if !HasOptIn(c.Labels, d.optInLabel) { + continue + } + cl, err := ParseComposeLabels(c.Labels) + if err != nil { + continue + } + + refused := !IsInsideRoot(d.stacksRoot, cl.WorkingDir) + reason := "" + if refused { + reason = fmt.Sprintf("working_dir %q outside STACKS_ROOT %q", cl.WorkingDir, d.stacksRoot) + } + + key := dedupKey(cl) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + + jobs = append(jobs, Job{ + Project: cl.Project, + Service: cl.Service, + WorkingDir: cl.WorkingDir, + ConfigFiles: cl.ConfigFiles, + Refused: refused, + RefusedReason: reason, + }) + } + + return jobs, nil +} + +func dedupKey(cl ComposeLabels) string { + files := append([]string(nil), cl.ConfigFiles...) + sort.Strings(files) + return cl.Project + "|" + cl.Service + "|" + strings.Join(files, ",") +} diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..285d83a --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -0,0 +1,155 @@ +package discovery_test + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/stretchr/testify/require" +) + +type fakeDockerClient struct { + containers []types.Container + err error +} + +func (f *fakeDockerClient) ContainerList(_ context.Context, _ container.ListOptions) ([]types.Container, error) { + return f.containers, f.err +} + +func (f *fakeDockerClient) Ping(_ context.Context) (types.Ping, error) { + return types.Ping{}, nil +} + +func mkContainer(image string, labels map[string]string) types.Container { + return types.Container{Image: image, Labels: labels} +} + +func mkComposeLabels(project, service, workingDir, configFile string, optIn bool) map[string]string { + m := map[string]string{ + "com.docker.compose.project": project, + "com.docker.compose.service": service, + "com.docker.compose.project.working_dir": workingDir, + "com.docker.compose.project.config_files": configFile, + } + if optIn { + m["se.shcizo.auto-update"] = "true" + } + return m +} + +func TestFindJobs_MatchAndOptIn(t *testing.T) { + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", mkComposeLabels( + "myapp-prod", "web", + "/home/shcizo/self-hosted/myapp-prod", + "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", + true, + )), + mkContainer("registry.example.com/other:v1", mkComposeLabels( + "other", "web", + "/home/shcizo/self-hosted/other", + "/home/shcizo/self-hosted/other/docker-compose.yml", + true, + )), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "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-prod", jobs[0].Project) + require.Equal(t, "web", jobs[0].Service) + require.Equal(t, "/home/shcizo/self-hosted/myapp-prod", jobs[0].WorkingDir) +} + +func TestFindJobs_SkipsWithoutOptIn(t *testing.T) { + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", mkComposeLabels( + "myapp-prod", "web", + "/home/shcizo/self-hosted/myapp-prod", + "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", + false, + )), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +} + +func TestFindJobs_DedupReplicas(t *testing.T) { + labels := mkComposeLabels( + "myapp", "web", + "/home/shcizo/self-hosted/myapp", + "/home/shcizo/self-hosted/myapp/docker-compose.yml", + true, + ) + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", labels), + mkContainer("registry.example.com/myapp:v1", labels), + mkContainer("registry.example.com/myapp:v1", labels), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 1) +} + +func TestFindJobs_OutsideRootProducesRefusedJob(t *testing.T) { + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", mkComposeLabels( + "myapp", "web", + "/opt/elsewhere/myapp", + "/opt/elsewhere/myapp/docker-compose.yml", + true, + )), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 1) + require.True(t, jobs[0].Refused) +} + +func TestFindJobs_MultipleStacksSameImage(t *testing.T) { + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", mkComposeLabels( + "myapp-prod", "web", + "/home/shcizo/self-hosted/myapp-prod", + "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", + true, + )), + mkContainer("registry.example.com/myapp:v1", mkComposeLabels( + "myapp-staging", "web", + "/home/shcizo/self-hosted/myapp-staging", + "/home/shcizo/self-hosted/myapp-staging/docker-compose.yml", + true, + )), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 2) +} + +func TestFindJobs_DockerError(t *testing.T) { + fake := &fakeDockerClient{err: errors.New("connection refused")} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + _, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.Error(t, err) +} + +func TestFindJobs_NonComposeContainerIsSkipped(t *testing.T) { + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", map[string]string{ + "se.shcizo.auto-update": "true", + }), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +} diff --git a/internal/discovery/docker_client.go b/internal/discovery/docker_client.go new file mode 100644 index 0000000..1c1aed5 --- /dev/null +++ b/internal/discovery/docker_client.go @@ -0,0 +1,15 @@ +package discovery + +import ( + "context" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" +) + +// DockerClient is the subset of the Docker SDK we depend on. +// Defined as an interface so tests can supply a fake. +type DockerClient interface { + ContainerList(ctx context.Context, opts container.ListOptions) ([]types.Container, error) + Ping(ctx context.Context) (types.Ping, error) +}