Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions internal/actions/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ func sendSplitNotifications(
"image_age": containerStatus.CooldownAge(),
"cooldown": containerStatus.CooldownDelay(),
"eligible_in": containerStatus.CooldownRemaining(),
"eligible_at": containerStatus.CooldownEligibleAt().Format(time.RFC3339),
},
Time: now,
}
Expand Down
3 changes: 2 additions & 1 deletion internal/actions/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,12 @@ func Update(
cooldownErr.Age,
cooldownErr.Delay,
cooldownErr.Remaining,
cooldownErr.EligibleAt,
cooldownErr.Passed,
)
} else if errors.Is(err, container.ErrImageCooldown) {
// Fallback for plain sentinel (keeps basic deferral visible)
progress.SetCooldownInfo(sourceContainer.ID(), "", "", "", false)
progress.SetCooldownInfo(sourceContainer.ID(), "", "", "", time.Time{}, false)
}

// Track if Watchtower self-update pull failed for safeguard.
Expand Down
27 changes: 16 additions & 11 deletions pkg/container/cooldown.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import (
// while still extracting the fields needed for progress reports and
// rich notifications.
type CooldownError struct {
Age string
Delay string
Remaining string
Passed bool
err error
Age string
Delay string
Remaining string
EligibleAt time.Time
Passed bool
err error
}

func (e *CooldownError) Error() string {
Expand Down Expand Up @@ -179,14 +180,16 @@ func evalImageAge(creationTime time.Time, delay time.Duration, clog *logrus.Entr
ageStr := util.FormatDuration(imageAge)
cooldownStr := util.FormatDuration(delay)
remainingStr := util.FormatDuration(remaining)
eligibleAt := time.Now().Add(remaining)

clog.WithFields(logrus.Fields{
"image_age": ageStr,
"cooldown": cooldownStr,
"eligible_in": remainingStr,
"eligible_at": eligibleAt.Format(time.RFC3339),
}).Info("Image is within cooldown period - deferring update")

return false, buildCooldownError(imageAge, delay)
return false, buildCooldownError(imageAge, delay, eligibleAt)
}

logCooldownExceeded(imageAge, delay, clog)
Expand All @@ -200,17 +203,19 @@ func evalImageAge(creationTime time.Time, delay time.Duration, clog *logrus.Entr
// Parameters:
// - imageAge: Elapsed time since the image was created.
// - delay: The configured cooldown delay.
// - eligibleAt: Precomputed time when the container becomes eligible.
//
// Returns:
// - *CooldownError: Populated with formatted age, delay, and remaining strings.
func buildCooldownError(imageAge, delay time.Duration) *CooldownError {
func buildCooldownError(imageAge, delay time.Duration, eligibleAt time.Time) *CooldownError {
remaining := delay - imageAge

return &CooldownError{
Age: util.FormatDuration(imageAge),
Delay: util.FormatDuration(delay),
Remaining: util.FormatDuration(remaining),
err: ErrImageCooldown,
Age: util.FormatDuration(imageAge),
Delay: util.FormatDuration(delay),
Remaining: util.FormatDuration(remaining),
EligibleAt: eligibleAt,
err: ErrImageCooldown,
}
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/notifications/common_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ var commonTemplates = map[string]string{
{{- else if eq $msg "Successfully removed all excess Watchtower instances" -}}
Successfully removed {{index $e.Data "removed_instances"}} excess Watchtower{{if eq (index $e.Data "removed_instances") 1}} instance{{else}} instances{{end}}
{{- else if eq $msg "Image is within cooldown period - deferring update" -}}
{{with (index $e.Data "image")}}{{.}}{{else}}unknown{{end}} created less than {{with (index $e.Data "cooldown")}}{{.}}{{else}}unknown{{end}} ago - eligible in {{with (index $e.Data "eligible_in")}}{{.}}{{else}}unknown{{end}}
{{with (index $e.Data "image")}}{{.}}{{else}}unknown{{end}} created less than {{with (index $e.Data "cooldown")}}{{.}}{{else}}unknown{{end}} ago - eligible in {{with (index $e.Data "eligible_in")}}{{.}}{{else}}unknown{{end}} ({{with (index $e.Data "eligible_at")}}{{RFC1123 .}}{{else}}unknown{{end}})
{{- else if eq $msg "Image age exceeds cooldown - proceeding with update" -}}
{{with (index $e.Data "image")}}{{.}}{{else}}unknown{{end}} created more than {{with (index $e.Data "cooldown")}}{{.}}{{else}}unknown{{end}} ago - proceeding with update
{{- else if eq $msg "Image creation time unavailable - deferring update" -}}
Expand Down
13 changes: 13 additions & 0 deletions pkg/notifications/templates/funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"fmt"
"strings"
"text/template"
"time"

"github.com/sirupsen/logrus"
"golang.org/x/text/cases"
Expand All @@ -25,6 +26,7 @@ var Funcs = template.FuncMap{
"ToLower": strings.ToLower,
"ToJSON": toJSON,
"Title": cases.Title(language.AmericanEnglish).String,
"RFC1123": formatRFC1123,
}

// toJSON marshals a value to a formatted JSON string for use in templates.
Expand All @@ -41,3 +43,14 @@ func toJSON(v any) string {

return string(bytes)
}

// formatRFC1123 parses an RFC3339 timestamp string and formats it as RFC1123.
// If parsing fails, it returns the original string unchanged.
func formatRFC1123(s string) string {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}

return t.Format(time.RFC1123)
}
47 changes: 33 additions & 14 deletions pkg/session/container_status.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package session

import (
"time"

"github.com/nicholas-fedor/watchtower/pkg/types"
)

Expand Down Expand Up @@ -35,19 +37,20 @@ const (
//
//nolint:errname // ContainerStatus is not an error type, it contains an error field.
type ContainerStatus struct {
containerID types.ContainerID // Container ID.
oldImage types.ImageID // Original image ID.
newImage types.ImageID // Latest image ID.
containerName string // Container name.
imageName string // Image name with tag.
containerError error // Error encountered, if any.
state State // Current state.
monitorOnly bool // Monitor-only flag.
newContainerID types.ContainerID // New container ID after update.
cooldownPassed bool // True if image passed cooldown check.
cooldownAge string // Human-readable image age (e.g., "47 days, 11 hours").
cooldownDelay string // Human-readable cooldown duration (e.g., "24 hours").
cooldownRemaining string // Human-readable remaining time (empty if passed).
containerID types.ContainerID // Container ID.
oldImage types.ImageID // Original image ID.
newImage types.ImageID // Latest image ID.
containerName string // Container name.
imageName string // Image name with tag.
containerError error // Error encountered, if any.
state State // Current state.
monitorOnly bool // Monitor-only flag.
newContainerID types.ContainerID // New container ID after update.
cooldownPassed bool // True if image passed cooldown check.
cooldownAge string // Human-readable image age (e.g., "47 days, 11 hours").
cooldownDelay string // Human-readable cooldown duration (e.g., "24 hours").
cooldownRemaining string // Human-readable remaining time (empty if passed).
cooldownEligibleAt time.Time // Time when the container becomes eligible for update.
}

// ID returns the container ID.
Expand Down Expand Up @@ -159,12 +162,28 @@ func (u *ContainerStatus) SetNewContainerID(newID types.ContainerID) {
// - age: Human-readable image age (e.g., "47 days, 11 hours").
// - delay: Human-readable cooldown duration (e.g., "24 hours").
// - remaining: Human-readable remaining time (empty if passed).
// - eligibleAt: Time when the container becomes eligible for update.
// - passed: True if the image passed the cooldown check.
func (u *ContainerStatus) SetCooldownInfo(age, delay, remaining string, passed bool) {
func (u *ContainerStatus) SetCooldownInfo(
age,
delay,
remaining string,
eligibleAt time.Time,
passed bool,
) {
u.cooldownPassed = passed
u.cooldownAge = age
u.cooldownDelay = delay
u.cooldownRemaining = remaining
u.cooldownEligibleAt = eligibleAt
}

// CooldownEligibleAt returns the time when the container becomes eligible for update.
//
// Returns:
// - time.Time: The eligible-at timestamp (zero if not set).
func (u *ContainerStatus) CooldownEligibleAt() time.Time {
return u.cooldownEligibleAt
}

// CooldownPassed returns whether the image passed the cooldown check.
Expand Down
92 changes: 56 additions & 36 deletions pkg/session/container_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"reflect"
"testing"
"time"

"github.com/nicholas-fedor/watchtower/pkg/types"
)
Expand Down Expand Up @@ -389,49 +390,54 @@ func TestContainerStatus_RestartedStateWithMissingData(t *testing.T) {

func TestContainerStatus_SetCooldownInfo(t *testing.T) {
tests := []struct {
name string
u *ContainerStatus
age string
delay string
remaining string
passed bool
name string
u *ContainerStatus
age string
delay string
remaining string
eligibleAt time.Time
passed bool
}{
{
name: "cooldown passed with age",
u: &ContainerStatus{containerID: "cont1"},
age: "47 days, 11 hours",
delay: "24 hours",
remaining: "",
passed: true,
name: "cooldown passed with age",
u: &ContainerStatus{containerID: "cont1"},
age: "47 days, 11 hours",
delay: "24 hours",
remaining: "",
eligibleAt: time.Time{},
passed: true,
},
{
name: "cooldown not passed with remaining",
u: &ContainerStatus{containerID: "cont2"},
age: "2 hours",
delay: "24 hours",
remaining: "22 hours",
passed: false,
name: "cooldown not passed with remaining",
u: &ContainerStatus{containerID: "cont2"},
age: "2 hours",
delay: "24 hours",
remaining: "22 hours",
eligibleAt: time.Date(2026, 5, 26, 0, 45, 0, 0, time.UTC),
passed: false,
},
{
name: "empty values",
u: &ContainerStatus{containerID: "cont3"},
age: "",
delay: "",
remaining: "",
passed: false,
name: "empty values",
u: &ContainerStatus{containerID: "cont3"},
age: "",
delay: "",
remaining: "",
eligibleAt: time.Time{},
passed: false,
},
{
name: "overwrites previous values",
u: &ContainerStatus{containerID: "cont4", cooldownAge: "old age", cooldownDelay: "old delay", cooldownRemaining: "old remaining", cooldownPassed: true},
age: "new age",
delay: "new delay",
remaining: "new remaining",
passed: false,
name: "overwrites previous values",
u: &ContainerStatus{containerID: "cont4", cooldownAge: "old age", cooldownDelay: "old delay", cooldownRemaining: "old remaining", cooldownPassed: true},
age: "new age",
delay: "new delay",
remaining: "new remaining",
eligibleAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC),
passed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.u.SetCooldownInfo(tt.age, tt.delay, tt.remaining, tt.passed)
tt.u.SetCooldownInfo(tt.age, tt.delay, tt.remaining, tt.eligibleAt, tt.passed)

if got := tt.u.CooldownAge(); got != tt.age {
t.Errorf("CooldownAge() = %v, want %v", got, tt.age)
Expand All @@ -448,6 +454,10 @@ func TestContainerStatus_SetCooldownInfo(t *testing.T) {
if got := tt.u.CooldownPassed(); got != tt.passed {
t.Errorf("CooldownPassed() = %v, want %v", got, tt.passed)
}

if got := tt.u.CooldownEligibleAt(); got != tt.eligibleAt {
t.Errorf("CooldownEligibleAt() = %v, want %v", got, tt.eligibleAt)
}
})
}
}
Expand All @@ -470,15 +480,21 @@ func TestContainerStatus_CooldownDefaults(t *testing.T) {
if got := u.CooldownRemaining(); got != "" {
t.Errorf("CooldownRemaining() default = %v, want empty", got)
}

if !u.CooldownEligibleAt().IsZero() {
t.Errorf("CooldownEligibleAt() default = %v, want zero", u.CooldownEligibleAt())
}
}

func TestContainerStatus_CooldownGettersReturnDirectValues(t *testing.T) {
eligibleAt := time.Date(2026, 5, 26, 0, 45, 0, 0, time.UTC)
u := &ContainerStatus{
containerID: "cont1",
cooldownPassed: true,
cooldownAge: "3 days",
cooldownDelay: "48 hours",
cooldownRemaining: "12 hours",
containerID: "cont1",
cooldownPassed: true,
cooldownAge: "3 days",
cooldownDelay: "48 hours",
cooldownRemaining: "12 hours",
cooldownEligibleAt: eligibleAt,
}

if got := u.CooldownPassed(); got != true {
Expand All @@ -496,6 +512,10 @@ func TestContainerStatus_CooldownGettersReturnDirectValues(t *testing.T) {
if got := u.CooldownRemaining(); got != "12 hours" {
t.Errorf("CooldownRemaining() = %v, want '12 hours'", got)
}

if got := u.CooldownEligibleAt(); got != eligibleAt {
t.Errorf("CooldownEligibleAt() = %v, want %v", got, eligibleAt)
}
}

func TestContainerStatus_RestartedStateIntegration(t *testing.T) {
Expand Down
14 changes: 12 additions & 2 deletions pkg/session/progress.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package session

import (
"time"

"github.com/sirupsen/logrus"

"github.com/nicholas-fedor/watchtower/pkg/types"
Expand Down Expand Up @@ -163,8 +165,16 @@ func (m Progress) MarkRestarted(containerID types.ContainerID) {
// - age: Human-readable image age.
// - delay: Human-readable cooldown duration.
// - remaining: Human-readable remaining time (empty if passed).
// - eligibleAt: Time when the container becomes eligible for update.
// - passed: True if the image passed the cooldown check.
func (m Progress) SetCooldownInfo(containerID types.ContainerID, age, delay, remaining string, passed bool) {
func (m Progress) SetCooldownInfo(
containerID types.ContainerID,
age,
delay,
remaining string,
eligibleAt time.Time,
passed bool,
) {
update, exists := m[containerID]
if !exists {
logrus.WithField("container_id", containerID.ShortID()).
Expand All @@ -173,7 +183,7 @@ func (m Progress) SetCooldownInfo(containerID types.ContainerID, age, delay, rem
return
}

update.SetCooldownInfo(age, delay, remaining, passed)
update.SetCooldownInfo(age, delay, remaining, eligibleAt, passed)
logrus.WithFields(logrus.Fields{
"container_id": containerID.ShortID(),
"name": update.Name(),
Expand Down
Loading
Loading