fix: address review findings for self-update (#15)

This commit is contained in:
2026-10-13 12:06:49 +02:00
parent fe4aca2883
commit aec21d23d9
25 changed files with 365 additions and 126 deletions
+56 -12
View File
@@ -22,12 +22,14 @@ import (
)
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"
labelEnvFile = "com.docker.compose.project.environment_file"
labelSwarmTask = "com.docker.swarm.service.id"
labelProject = "com.docker.compose.project"
labelService = "com.docker.compose.service"
labelWorkingDir = "com.docker.compose.project.working_dir"
labelConfigFiles = "com.docker.compose.project.config_files"
labelEnvFile = "com.docker.compose.project.environment_file"
labelSwarmTask = "com.docker.swarm.service.id"
labelSwarmName = "com.docker.swarm.service.name"
labelSelfUpdateJob = "se.shcizo.package-updater.self-update.job"
)
// DockerClient is the subset of the Docker SDK the collector needs.
@@ -61,6 +63,7 @@ type Collector struct {
now func() time.Time
mu sync.Mutex
self *selfInfo // the agent's own container, once inspected
cache map[string]buildEntry // by Compose project
warned map[string]bool // logged warnings: "<project>" (check failed) and "<project>/<service>" (service not in config)
}
@@ -77,8 +80,15 @@ type buildEntry struct {
// stackLabels are the Compose labels a build check needs.
type stackLabels struct{ workingDir, configFiles, envFile string }
// New returns a Collector. selfID is a prefix of the agent's own container
// ID (inside a container, the hostname); that container is marked Self.
// selfInfo is what the inspect of the agent's own container tells.
type selfInfo struct {
id string // full container ID
swarmService string // name of the Swarm service the container is a task of, if any
}
// New returns a Collector. selfID is the agent's own container: a prefix of
// its ID (inside a container, the hostname) or its name. That container is
// marked Self, and so is the Swarm service it is a task of.
func New(cli DockerClient, selfID string) *Collector {
return &Collector{cli: cli, selfID: selfID, now: time.Now, cache: map[string]buildEntry{}, warned: map[string]bool{}}
}
@@ -92,6 +102,29 @@ func (c *Collector) WithBuildCheck(check BuildCheck, log *slog.Logger) *Collecto
return c
}
// resolveSelf inspects the agent's own container once and remembers the
// answer. It returns nil while that fails (or selfID is empty), and the
// caller then compares container IDs with the selfID prefix.
func (c *Collector) resolveSelf(ctx context.Context) *selfInfo {
if c.selfID == "" {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.self != nil {
return c.self
}
insp, err := c.cli.ContainerInspect(ctx, c.selfID)
if err != nil || insp.ContainerJSONBase == nil || insp.ID == "" {
return nil
}
c.self = &selfInfo{id: insp.ID}
if insp.Config != nil {
c.self.swarmService = insp.Config.Labels[labelSwarmName]
}
return c.self
}
// Hello returns the machine facts sent on connect. AgentVersion is left for
// the caller to fill in.
func (c *Collector) Hello(ctx context.Context) (protocol.Hello, error) {
@@ -125,20 +158,21 @@ func (c *Collector) Snapshot(ctx context.Context) ([]protocol.Service, error) {
repoDigests[img.ID] = img.RepoDigests
}
self := c.resolveSelf(ctx)
byKey := make(map[string]protocol.Service)
stacks := make(map[string]stackLabels)
for _, ct := range containers {
if _, isTask := ct.Labels[labelSwarmTask]; isTask {
continue
}
if _, isHelper := ct.Labels[protocol.LabelSelfUpdateJob]; isHelper {
if _, isHelper := ct.Labels[labelSelfUpdateJob]; isHelper {
continue
}
ref, err := c.imageRef(ctx, ct)
if err != nil {
return nil, err
}
svc := c.fromContainer(ct, ref, repoDigests[ct.ImageID])
svc := c.fromContainer(ct, ref, repoDigests[ct.ImageID], self)
if svc.Kind == protocol.KindCompose {
if _, ok := stacks[svc.ComposeProject]; !ok {
stacks[svc.ComposeProject] = stackLabels{svc.WorkingDir, svc.ConfigFiles, ct.Labels[labelEnvFile]}
@@ -162,6 +196,7 @@ func (c *Collector) Snapshot(ctx context.Context) ([]protocol.Service, error) {
continue
}
svc := fromSwarmService(s)
svc.Self = self != nil && self.swarmService != "" && svc.SwarmService == self.swarmService
byKey[svc.Key()] = svc
}
}
@@ -192,12 +227,12 @@ func (c *Collector) imageRef(ctx context.Context, ct container.Summary) (string,
return insp.Config.Image, nil
}
func (c *Collector) fromContainer(ct container.Summary, ref string, repoDigests []string) protocol.Service {
func (c *Collector) fromContainer(ct container.Summary, ref string, repoDigests []string, self *selfInfo) protocol.Service {
svc := protocol.Service{
Image: ref,
Digest: digestFor(ref, repoDigests),
Status: containerStatus(ct.State),
Self: c.selfID != "" && strings.HasPrefix(ct.ID, c.selfID),
Self: c.isSelf(ct.ID, self),
}
project, service := ct.Labels[labelProject], ct.Labels[labelService]
if project != "" && service != "" {
@@ -216,6 +251,15 @@ func (c *Collector) fromContainer(ct container.Summary, ref string, repoDigests
return svc
}
// isSelf reports whether id is the agent's own container: by the inspected
// ID when there is one, else by the selfID prefix.
func (c *Collector) isSelf(id string, self *selfInfo) bool {
if self != nil {
return id == self.id
}
return c.selfID != "" && strings.HasPrefix(id, c.selfID)
}
func fromSwarmService(s swarm.Service) protocol.Service {
ref := s.Spec.TaskTemplate.ContainerSpec.Image
return protocol.Service{
+58 -1
View File
@@ -138,7 +138,7 @@ func TestSnapshot_PlainContainerAndSwarmTaskSkipped(t *testing.T) {
func TestSnapshot_SelfUpdateHelperSkipped(t *testing.T) {
cli := &fakeDocker{containers: []container.Summary{
{ID: "h1", Names: []string{"/quirky_helper"}, Image: "sha256:agentimage", State: "running",
Labels: map[string]string{protocol.LabelSelfUpdateJob: "7"}},
Labels: map[string]string{labelSelfUpdateJob: "7"}},
{ID: "p1", Names: []string{"/adhoc"}, Image: "redis:alpine", State: "exited"},
}}
got, err := New(cli, "").Snapshot(context.Background())
@@ -158,6 +158,63 @@ func TestSnapshot_SelfFlag(t *testing.T) {
assert.True(t, got[0].Self)
}
func TestSnapshot_SelfFlagByContainerName(t *testing.T) {
cli := &fakeDocker{
containers: []container.Summary{
composeContainer("0123456789abcdef", "updater-agent", "agent", "pu:1", "sha256:i", "running"),
composeContainer("fedcba9876543210", "slusk", "web", "slusk:1", "sha256:j", "running"),
},
inspect: map[string]container.InspectResponse{
"updater-agent-agent-1": {ContainerJSONBase: &container.ContainerJSONBase{ID: "0123456789abcdef"}},
},
}
got, err := New(cli, "updater-agent-agent-1").Snapshot(context.Background())
require.NoError(t, err)
require.Len(t, got, 2)
assert.False(t, got[0].Self, "slusk")
assert.True(t, got[1].Self, "updater-agent")
}
func TestSnapshot_SelfFlagFallsBackToIDPrefixWhenInspectFails(t *testing.T) {
cli := &fakeDocker{
containers: []container.Summary{composeContainer("0123456789abcdef", "updater-agent", "agent", "pu:1", "sha256:i", "running")},
inspectErr: errors.New("boom"),
}
got, err := New(cli, "0123456789ab").Snapshot(context.Background())
require.NoError(t, err)
require.Len(t, got, 1)
assert.True(t, got[0].Self)
}
func TestSnapshot_AgentsOwnSwarmServiceIsSelf(t *testing.T) {
swarmSvc := func(name string) swarm.Service {
return swarm.Service{
Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: name},
TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: "pu:1"}},
},
ServiceStatus: &swarm.ServiceStatus{RunningTasks: 1, DesiredTasks: 1},
}
}
cli := &fakeDocker{
info: managerInfo(),
services: []swarm.Service{swarmSvc("pu_agent"), swarmSvc("pu_master")},
inspect: map[string]container.InspectResponse{
"abc123": {
ContainerJSONBase: &container.ContainerJSONBase{ID: "abc123full"},
Config: &container.Config{Labels: map[string]string{labelSwarmName: "pu_agent"}},
},
},
}
got, err := New(cli, "abc123").Snapshot(context.Background())
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, "pu_agent", got[0].SwarmService)
assert.True(t, got[0].Self)
assert.Equal(t, "pu_master", got[1].SwarmService)
assert.False(t, got[1].Self)
}
func TestSnapshot_SwarmServicesOnlyOnManager(t *testing.T) {
svc := swarm.Service{
Spec: swarm.ServiceSpec{