From 13ccb79ef59ecd3fb670005a2d45f80736fbcc1f Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 11:44:10 +0200 Subject: [PATCH] feat(discovery): parse Compose-managed labels --- internal/discovery/labels.go | 71 +++++++++++++++++++++++++ internal/discovery/labels_test.go | 88 +++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 internal/discovery/labels.go create mode 100644 internal/discovery/labels_test.go diff --git a/internal/discovery/labels.go b/internal/discovery/labels.go new file mode 100644 index 0000000..aecde76 --- /dev/null +++ b/internal/discovery/labels.go @@ -0,0 +1,71 @@ +package discovery + +import ( + "fmt" + "strings" +) + +// ComposeLabels captures the four Compose-managed labels we need +// to drive a `docker compose pull`/`up -d` against the right stack. +type ComposeLabels struct { + Project string + Service string + WorkingDir string + ConfigFiles []string +} + +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" +) + +// ParseComposeLabels extracts the Compose labels we need. Returns an +// error naming the missing label if any required field is absent — +// in practice this should only happen if the container was not +// started by Compose. +func ParseComposeLabels(labels map[string]string) (ComposeLabels, error) { + get := func(key string) (string, error) { + v, ok := labels[key] + if !ok || v == "" { + return "", fmt.Errorf("missing required label: %s", key) + } + return v, nil + } + + project, err := get(labelProject) + if err != nil { + return ComposeLabels{}, err + } + service, err := get(labelService) + if err != nil { + return ComposeLabels{}, err + } + workingDir, err := get(labelWorkingDir) + if err != nil { + return ComposeLabels{}, err + } + configFilesRaw, err := get(labelConfigFiles) + if err != nil { + return ComposeLabels{}, err + } + + files := strings.Split(configFilesRaw, ",") + for i, f := range files { + files[i] = strings.TrimSpace(f) + } + + return ComposeLabels{ + Project: project, + Service: service, + WorkingDir: workingDir, + ConfigFiles: files, + }, nil +} + +// HasOptIn reports whether the labels include the opt-in marker with +// value "true" (exact, case-sensitive — anything else is excluded). +func HasOptIn(labels map[string]string, key string) bool { + return labels[key] == "true" +} diff --git a/internal/discovery/labels_test.go b/internal/discovery/labels_test.go new file mode 100644 index 0000000..179f5dd --- /dev/null +++ b/internal/discovery/labels_test.go @@ -0,0 +1,88 @@ +package discovery_test + +import ( + "testing" + + "github.com/shcizo/package-updater/internal/discovery" + "github.com/stretchr/testify/require" +) + +func TestParseComposeLabels_Success(t *testing.T) { + labels := map[string]string{ + "com.docker.compose.project": "myapp-prod", + "com.docker.compose.service": "web", + "com.docker.compose.project.working_dir": "/home/shcizo/self-hosted/myapp-prod", + "com.docker.compose.project.config_files": "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", + "se.shcizo.auto-update": "true", + } + got, err := discovery.ParseComposeLabels(labels) + require.NoError(t, err) + require.Equal(t, "myapp-prod", got.Project) + require.Equal(t, "web", got.Service) + require.Equal(t, "/home/shcizo/self-hosted/myapp-prod", got.WorkingDir) + require.Equal(t, []string{"/home/shcizo/self-hosted/myapp-prod/docker-compose.yml"}, got.ConfigFiles) +} + +func TestParseComposeLabels_MultipleConfigFiles(t *testing.T) { + labels := map[string]string{ + "com.docker.compose.project": "myapp", + "com.docker.compose.service": "web", + "com.docker.compose.project.working_dir": "/srv/myapp", + "com.docker.compose.project.config_files": "/srv/myapp/docker-compose.yml,/srv/myapp/docker-compose.prod.yml", + } + got, err := discovery.ParseComposeLabels(labels) + require.NoError(t, err) + require.Equal(t, []string{ + "/srv/myapp/docker-compose.yml", + "/srv/myapp/docker-compose.prod.yml", + }, got.ConfigFiles) +} + +func TestParseComposeLabels_MissingFieldsError(t *testing.T) { + cases := []struct { + name string + missing string + labels map[string]string + }{ + {"project", "com.docker.compose.project", map[string]string{ + "com.docker.compose.service": "web", + "com.docker.compose.project.working_dir": "/x", + "com.docker.compose.project.config_files": "/x/y.yml", + }}, + {"service", "com.docker.compose.service", map[string]string{ + "com.docker.compose.project": "p", + "com.docker.compose.project.working_dir": "/x", + "com.docker.compose.project.config_files": "/x/y.yml", + }}, + {"working_dir", "com.docker.compose.project.working_dir", map[string]string{ + "com.docker.compose.project": "p", + "com.docker.compose.service": "web", + "com.docker.compose.project.config_files": "/x/y.yml", + }}, + {"config_files", "com.docker.compose.project.config_files", map[string]string{ + "com.docker.compose.project": "p", + "com.docker.compose.service": "web", + "com.docker.compose.project.working_dir": "/x", + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := discovery.ParseComposeLabels(c.labels) + require.Error(t, err) + require.Contains(t, err.Error(), c.missing) + }) + } +} + +func TestHasOptIn(t *testing.T) { + require.True(t, discovery.HasOptIn(map[string]string{ + "se.shcizo.auto-update": "true", + }, "se.shcizo.auto-update")) + require.False(t, discovery.HasOptIn(map[string]string{ + "se.shcizo.auto-update": "false", + }, "se.shcizo.auto-update")) + require.False(t, discovery.HasOptIn(map[string]string{ + "se.shcizo.auto-update": "TRUE", + }, "se.shcizo.auto-update")) + require.False(t, discovery.HasOptIn(map[string]string{}, "se.shcizo.auto-update")) +}