Initial implementation: package-updater v1 #1
@@ -2,10 +2,19 @@ module github.com/shcizo/package-updater
|
|||||||
|
|
||||||
go 1.26.3
|
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 (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
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
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
||||||
|
|||||||
@@ -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, ",")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user