Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 109 additions & 227 deletions updater/apply.go

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions updater/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func TestApplyPendingSkipsUnchangedPulledImage(t *testing.T) {
if err != nil {
t.Fatalf("ApplyPending() error = %v", err)
}
if got.Checked != 1 || got.Updated != 0 || got.Skipped != 0 || got.Failed != 0 {
if got.Checked != 1 || got.Updated != 0 || got.Skipped != 1 || got.Failed != 0 {
t.Fatalf("ApplyPending() counts = checked:%d updated:%d skipped:%d failed:%d", got.Checked, got.Updated, got.Skipped, got.Failed)
}
if len(got.Items) != 1 || got.Items[0].Status != StatusUpToDate {
Expand Down Expand Up @@ -264,7 +264,7 @@ func TestApplyPendingReportsUpToDateWhenKnownDigestMatchesAnyLocalRepoDigest(t *
if err != nil {
t.Fatalf("ApplyPending() error = %v", err)
}
if got.Checked != 1 || got.Updated != 0 || got.Skipped != 0 || got.Failed != 0 {
if got.Checked != 1 || got.Updated != 0 || got.Skipped != 1 || got.Failed != 0 {
t.Fatalf("ApplyPending() counts = checked:%d updated:%d skipped:%d failed:%d", got.Checked, got.Updated, got.Skipped, got.Failed)
}
if len(got.Items) != 1 || got.Items[0].Status != StatusUpToDate {
Expand Down Expand Up @@ -302,7 +302,7 @@ func TestApplyPendingRestartsStaleContainersWhenImageAlreadyPulled(t *testing.T)
{ID: "app-id", Names: []string{"/app"}, Image: "app:1", ImageID: "sha256:old-app", State: "running"},
})
case r.Method == http.MethodGet && path == "/containers/app-id/json":
writeDockerJSON(t, w, container.InspectResponse{ID: "app-id", Name: "/app", Image: "sha256:old-app", Config: &container.Config{Image: "app:1"}})
writeDockerJSON(t, w, container.InspectResponse{State: &container.State{Running: true}, ID: "app-id", Name: "/app", Image: "sha256:old-app", Config: &container.Config{Image: "app:1"}})
case r.Method == http.MethodPost && strings.HasSuffix(path, "/stop"):
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/containers/"):
Expand Down Expand Up @@ -376,8 +376,8 @@ func TestApplyPendingReusesDockerClientWhileBuildingPlans(t *testing.T) {

_, _ = service.ApplyPending(t.Context(), Options{})

if provider.calls != 2 {
t.Fatalf("DockerClient calls = %d, want 2 total calls independent of record count", provider.calls)
if provider.calls != 1 {
t.Fatalf("DockerClient calls = %d, want 1 call independent of record count", provider.calls)
}
}

Expand Down Expand Up @@ -407,7 +407,7 @@ func TestApplyPendingKeepsPulledRecordWhenRestartFails(t *testing.T) {
{ID: "app-id", Names: []string{"/app"}, Image: "app:1", ImageID: "sha256:old-app", State: "running"},
})
case r.Method == http.MethodGet && path == "/containers/app-id/json":
writeDockerJSON(t, w, container.InspectResponse{ID: "app-id", Name: "/app", Image: "sha256:old-app", Config: &container.Config{Image: "app:1"}})
writeDockerJSON(t, w, container.InspectResponse{State: &container.State{Running: true}, ID: "app-id", Name: "/app", Image: "sha256:old-app", Config: &container.Config{Image: "app:1"}})
case r.Method == http.MethodPost && strings.HasSuffix(path, "/stop"):
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/containers/"):
Expand Down
41 changes: 18 additions & 23 deletions updater/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"slices"

"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
Expand All @@ -20,7 +20,7 @@ import (
// CheckImageUpdate discovers an image update without pulling or changing state.
// CurrentDigest should describe the image in use, not a separately cached tag.
func (s *Service) CheckImageUpdate(ctx context.Context, request types.CheckRequest) (types.CheckResult, error) {
result, err := s.selectImageTagInternal(ctx, request.ImageRef, request.Policy)
result, err := s.selectImageTag(ctx, request.ImageRef, request.Policy)
if err != nil || result.Reason != "" || result.UpdateAvailable {
return result, err
}
Expand Down Expand Up @@ -57,7 +57,7 @@ func (s *Service) CheckImageUpdate(ctx context.Context, request types.CheckReque
return result, nil
}

func (s *Service) selectImageTagInternal(ctx context.Context, imageRef string, policy types.Policy) (types.CheckResult, error) {
func (s *Service) selectImageTag(ctx context.Context, imageRef string, policy types.Policy) (types.CheckResult, error) {
result := types.CheckResult{CurrentRef: imageRef, TargetRef: imageRef}
if err := ctx.Err(); err != nil {
return result, err
Expand Down Expand Up @@ -121,22 +121,20 @@ func (s *Service) CheckContainerUpdate(ctx context.Context, containerID string)
if result.ContainerID == "" {
result.ContainerID = containerID
}
reason, err := s.containerEligibilityInternal(ctx, inspect, enforceSettingsExclusions)
reason, err := s.containerEligibility(ctx, inspect, enforceSettingsExclusions)
if err != nil {
return result, err
}
if reason != "" {
result.Reason = reason
return result, nil
}
imageRef := inspect.Config.Image
result, err = s.selectImageTagInternal(ctx, imageRef, s.config.LabelPolicy.TagPolicy(inspect.Config.Labels))
result, err = s.selectImageTag(ctx, inspect.Config.Image, s.config.LabelPolicy.TagPolicy(inspect.Config.Labels))
result.ContainerID = inspect.ID
if err != nil || result.Reason != "" || result.UpdateAvailable {
return result, err
}
// Compare the running image, even if the configured tag is already cached at
// a newer digest in the daemon.
// Compare the running image, even if the configured tag is cached at a newer digest.
image, err := dockerClient.ImageInspect(ctx, inspect.Image)
if err != nil {
return result, err
Expand All @@ -152,25 +150,22 @@ func (s *Service) CheckContainerUpdate(ctx context.Context, containerID string)
if err != nil {
return result, err
}
result.CurrentDigest = image.ID
localDigests := digestcheck.RepoDigests(image.RepoDigests, result.CurrentRef)
result.UpdateType = string(UpdateTypeDigest)
result.UpdateAvailable = true
for _, repoDigest := range image.RepoDigests {
if local, ok := digest.FromReferenceSuffix(repoDigest); ok {
result.CurrentDigest = local
if local == result.TargetDigest {
result.UpdateAvailable = false
break
}
}
result.UpdateAvailable = !slices.Contains(localDigests, result.TargetDigest)
result.CurrentDigest = image.ID
switch {
case !result.UpdateAvailable:
result.CurrentDigest = result.TargetDigest
case len(localDigests) > 0:
result.CurrentDigest = localDigests[0]
}
return result, nil
}

// containerEligibilityInternal reports why a container cannot be updated, or
// "" when it can. The label, Swarm, and configuration checks always apply;
// policy decides whether the settings exclusion list is consulted too.
func (s *Service) containerEligibilityInternal(ctx context.Context, inspect container.InspectResponse, policy exclusionPolicy) (string, error) {
// containerEligibility reports why a container cannot be updated, or "" when it can; policy decides
// whether the settings exclusions apply on top of the label, Swarm, and config checks.
func (s *Service) containerEligibility(ctx context.Context, inspect container.InspectResponse, policy exclusionPolicy) (string, error) {
if inspect.Config == nil {
return "container config unavailable", nil
}
Expand All @@ -188,7 +183,7 @@ func (s *Service) containerEligibilityInternal(ctx context.Context, inspect cont
if err != nil {
return "", err
}
if excluded[inspect.ID] || excluded[strings.TrimPrefix(inspect.Name, "/")] {
if isExcluded(excluded, inspect.ID, inspect.Name) {
return "container excluded by settings", nil
}
return "", nil
Expand Down
36 changes: 8 additions & 28 deletions updater/compose_groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ type composeGroup struct {
err error
projectName string
services []string
seen map[string]struct{}
}

// buildComposeGroups groups planned Compose containers by resolved project, flagging conflicting service targets.
func (s *Service) buildComposeGroups(ctx context.Context, sorted []deps.ContainerWithDeps, plansByName map[string]*restartPlan) map[string]composeGroup {
groups := map[string]composeGroup{}
if s.config.ProjectUpdater == nil {
Expand All @@ -26,15 +26,10 @@ func (s *Service) buildComposeGroups(ctx context.Context, sorted []deps.Containe

for _, candidate := range sorted {
plan := plansByName[candidate.Name]
if plan == nil || plan.newRef == "" || plan.inspect == nil || plan.inspect.Config == nil {
if plan == nil || plan.newRef == "" || plan.inspect == nil || plan.inspect.Config == nil || s.config.LabelPolicy.IsSelfUpdateTarget(plan.inspect.Config.Labels) {
continue
}
labels := plan.inspect.Config.Labels
if s.config.LabelPolicy.IsSelfUpdateTarget(labels) {
continue
}
projectName := compose.ProjectLabel(labels)
serviceName := compose.ServiceLabel(labels)
projectName, serviceName := compose.ProjectLabel(plan.inspect.Config.Labels), compose.ServiceLabel(plan.inspect.Config.Labels)
if projectName == "" || serviceName == "" {
continue
}
Expand All @@ -44,35 +39,20 @@ func (s *Service) buildComposeGroups(ctx context.Context, sorted []deps.Containe
}
group := groups[project.ID]
group.projectName = projectName
if group.seen == nil {
group.seen = map[string]struct{}{}
}
if group.images == nil {
group.images = map[string]types.ServiceImageChange{}
}
change := types.ServiceImageChange{ExpectedRef: plan.inspect.Config.Image, TargetRef: plan.newRef}
if previous, ok := group.images[serviceName]; ok && previous != change {
previous, seen := group.images[serviceName]
if seen && previous != change {
group.err = fmt.Errorf("conflicting targets for compose service %s/%s", projectName, serviceName)
}
group.images[serviceName] = change
group.tagChanges = group.tagChanges || isComposeTagChangeInternal(plan)
if _, seen := group.seen[serviceName]; !seen {
if !seen {
group.services = append(group.services, serviceName)
group.seen[serviceName] = struct{}{}
}
group.images[serviceName] = change
group.tagChanges = group.tagChanges || isComposeTagChange(plan.inspect, plan.newRef)
groups[project.ID] = group
}
return groups
}

func composeProjectID(projectName string, groups map[string]composeGroup) string {
if projectName == "" {
return ""
}
for projectID, group := range groups {
if group.projectName == projectName {
return projectID
}
}
return ""
}
119 changes: 48 additions & 71 deletions updater/compose_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,102 +15,79 @@ import (
updatetypes "go.getarcane.app/updater/types"
)

func (s *Service) preflightComposeImageInternal(ctx context.Context, cnt container.Summary, inspect container.InspectResponse, newRef string) error {
// preflightComposeImage resolves the Compose project a tag change to newRef must be persisted
// through, and returns "" when the change needs none.
func (s *Service) preflightComposeImage(ctx context.Context, containerID string, inspect container.InspectResponse, newRef string) (string, error) {
labels := labelsFromInspect(inspect)
if s.isSelfUpdateCandidate(cnt.ID, labels) {
return nil
}
if inspect.Config != nil && refs.NormalizeImageUpdateRef(inspect.Config.Image) == refs.NormalizeImageUpdateRef(newRef) {
return nil
if s.isSelfUpdateCandidate(containerID, labels) || !isComposeTagChange(&inspect, newRef) {
return "", nil
}
projectName, serviceName := compose.ProjectLabel(labels), compose.ServiceLabel(labels)
if projectName == "" && serviceName == "" {
projectName, serviceName = compose.ProjectLabel(cnt.Labels), compose.ServiceLabel(cnt.Labels)
}
if projectName == "" && serviceName == "" {
return nil
}
if projectName == "" || serviceName == "" {
return errors.New("compose tag update requires project and service labels")
return "", errors.New("compose tag update requires project and service labels")
}
if _, ok := s.config.ProjectUpdater.(updatetypes.ProjectImageUpdater); !ok {
return fmt.Errorf("compose project %s requires a ProjectImageUpdater adapter for tag updates", projectName)
return "", fmt.Errorf("compose project %s requires a ProjectImageUpdater adapter for tag updates", projectName)
}
project, err := s.config.ProjectUpdater.ProjectByComposeName(ctx, projectName)
if err != nil {
return fmt.Errorf("resolve compose project %s for tag update: %w", projectName, err)
return "", fmt.Errorf("resolve compose project %s for tag update: %w", projectName, err)
}
if strings.TrimSpace(project.ID) == "" {
return fmt.Errorf("compose project %s has no project ID for tag update", projectName)
return "", fmt.Errorf("compose project %s has no project ID for tag update", projectName)
}
return nil
return project.ID, nil
}

func (s *Service) updateComposeImageInternal(ctx context.Context, target container.Summary, inspect container.InspectResponse, newRef string) error {
if err := s.preflightComposeImageInternal(ctx, target, inspect, newRef); err != nil {
return err
}
labels := labelsFromInspect(inspect)
projectName, serviceName := compose.ProjectLabel(labels), compose.ServiceLabel(labels)
if projectName == "" && serviceName == "" {
projectName, serviceName = compose.ProjectLabel(target.Labels), compose.ServiceLabel(target.Labels)
}
adapter, ok := s.config.ProjectUpdater.(updatetypes.ProjectImageUpdater)
if !ok || projectName == "" || serviceName == "" || inspect.Config == nil {
return errors.New("compose tag update requires an image updater and complete container configuration")
}
project, err := s.config.ProjectUpdater.ProjectByComposeName(ctx, projectName)
if err != nil {
return fmt.Errorf("resolve compose project %s for tag update: %w", projectName, err)
}
if strings.TrimSpace(project.ID) == "" {
return fmt.Errorf("compose project %s has no project ID for tag update", projectName)
}
opCtx, cancel := s.opCtx(ctx)
defer cancel()
err = adapter.UpdateServiceImages(opCtx, project.ID, map[string]updatetypes.ServiceImageChange{
serviceName: {ExpectedRef: inspect.Config.Image, TargetRef: newRef},
})
if err != nil {
return fmt.Errorf("update compose service %s/%s image: %w", projectName, serviceName, err)
}
dockerClient, err := s.dockerClient(ctx)
if err != nil {
return err
}
return verifyComposeTargetInternal(ctx, dockerClient, projectName, serviceName, newRef)
// isComposeTagChange reports whether recreating a Compose container as newRef changes its configured image,
// which only a ProjectImageUpdater can persist.
func isComposeTagChange(inspect *container.InspectResponse, newRef string) bool {
return inspect != nil && inspect.Config != nil &&
refs.NormalizeImageUpdateRef(inspect.Config.Image) != refs.NormalizeImageUpdateRef(newRef) &&
(compose.ProjectLabel(inspect.Config.Labels) != "" || compose.ServiceLabel(inspect.Config.Labels) != "")
}

func verifyComposeTargetInternal(ctx context.Context, dockerClient *client.Client, projectName, serviceName, newRef string) error {
normalizedRef := refs.NormalizeImageUpdateRef(newRef)
if dockerClient == nil || strings.TrimSpace(projectName) == "" || strings.TrimSpace(serviceName) == "" || normalizedRef == "" {
return errors.New("verify compose target requires a Docker client, project, service, and valid target reference")
}
image, err := dockerClient.ImageInspect(ctx, newRef)
if err != nil {
return fmt.Errorf("verify compose target: inspect target image: %w", err)
// verifyComposeService checks that a service's running containers run targetRef or, without a target,
// no longer run oldImageID.
func verifyComposeService(ctx context.Context, dockerClient *client.Client, projectName, serviceName, oldImageID, targetRef string) error {
if targetRef == "" && oldImageID == "" {
return nil
}
if strings.TrimSpace(image.ID) == "" {
return errors.New("verify compose target: target image has no image ID")
targetID := ""
if targetRef != "" {
image, err := dockerClient.ImageInspect(ctx, targetRef)
if err != nil {
return fmt.Errorf("verify compose service: inspect target image: %w", err)
}
if targetID = strings.TrimSpace(image.ID); targetID == "" {
return errors.New("verify compose service: target image has no image ID")
}
}
filters := make(client.Filters)
filters = filters.Add("label", compose.ProjectLabelKey+"="+projectName)
filters = filters.Add("label", compose.ServiceLabelKey+"="+serviceName)
containers, err := dockerClient.ContainerList(ctx, client.ContainerListOptions{All: false, Filters: filters})
filters := make(client.Filters).Add("label", compose.ProjectLabelKey+"="+projectName, compose.ServiceLabelKey+"="+serviceName, compose.ServiceContainerFilter)
containers, err := dockerClient.ContainerList(ctx, client.ContainerListOptions{Filters: filters})
if err != nil {
return fmt.Errorf("verify compose target: list service containers: %w", err)
return fmt.Errorf("verify compose service: list service containers: %w", err)
}
if len(containers.Items) == 0 {
return fmt.Errorf("compose service %s/%s has no running container after update", projectName, serviceName)
}
for _, cnt := range containers.Items {
inspected, inspectErr := compat.ContainerInspectWithCompatibility(ctx, dockerClient, cnt.ID, client.ContainerInspectOptions{})
if inspectErr != nil {
return fmt.Errorf("verify compose target: inspect container %s: %w", cnt.ID, inspectErr)
currentImageID := strings.TrimSpace(cnt.ImageID)
var current container.InspectResponse
if targetID != "" || currentImageID == "" {
inspected, inspectErr := compat.ContainerInspectWithCompatibility(ctx, dockerClient, cnt.ID, client.ContainerInspectOptions{})
if inspectErr != nil {
return fmt.Errorf("verify compose service: inspect container %s: %w", cnt.ID, inspectErr)
}
current = inspected.Container
currentImageID = strings.TrimSpace(current.Image)
}
current := inspected.Container
if current.Config == nil || refs.NormalizeImageUpdateRef(current.Config.Image) != normalizedRef || current.Image != image.ID || current.State == nil || !current.State.Running {
return fmt.Errorf("compose service %s/%s container %s does not run target %s (%s)", projectName, serviceName, cnt.ID, newRef, image.ID)
switch {
case targetID == "" && currentImageID == oldImageID:
return fmt.Errorf("compose service %s/%s still running old image %s after update", projectName, serviceName, oldImageID)
case targetID != "" && (current.Config == nil || refs.NormalizeImageUpdateRef(current.Config.Image) != refs.NormalizeImageUpdateRef(targetRef) ||
currentImageID != targetID || current.State == nil || !current.State.Running):
return fmt.Errorf("compose service %s/%s container %s does not run target %s (%s)", projectName, serviceName, cnt.ID, targetRef, targetID)
}
}
return nil
Expand Down
Loading
Loading