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
12 changes: 12 additions & 0 deletions docs/http-api/endpoints/check/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,15 @@ The `/v1/check` endpoint returns a JSON array of container check results:
| 200 | Check completed successfully |
| 401 | Invalid or missing authentication token |
| 500 | Internal server error during request processing|

## SSE Events

When the [`/v1/events`](../events/index.md) SSE endpoint is also enabled, execution of the `v1/check` endpoint broadcasts the following events:

- `scan_started`: Broadcasted before the check begins
- `scan_completed`: Broadcasted after the check finishes successfully
- `scan_failed`: Broadcasted if the check encounters an error

!!! Note
The `scan_completed` payload always reports `updated: 0` because no updates are applied.
The `failed` field counts containers whose per-container check returned an error.
7 changes: 7 additions & 0 deletions docs/http-api/endpoints/events/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,20 @@ The events endpoint uses a separate token from the main API token to limit blast

- Started
- Completed
- Failed

!!! Note
Scan events are broadcasted only for updates (HTTP API or scheduled) or checks (HTTP API).

### Update Events

- Started
- Completed
- Failed

!!! Note
Update events are broadcasted only for HTTP API or scheduled updates.

## Event Format

Each event is a Server-Sent Event with an event type and JSON data payload:
Expand Down
9 changes: 9 additions & 0 deletions docs/http-api/endpoints/update/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,12 @@ services:

!!! Warning
Enabling the HTTP API with port mappings will automatically disable Watchtower's self-update functionality to prevent port conflicts during container recreation. See [Updating Watchtower](../../../getting-started/updating-watchtower/index.md#port_configuration_limitation) for more details.

## SSE Events

When the [`/v1/events`](../events/index.md) SSE endpoint is also enabled, the update process broadcasts the following events:

- `scan_started`: Broadcasted before the update scan begins
- `scan_completed`: Broadcasted after the update scan finishes
- `scan_failed`: Broadcasted if the update scan encounters an error
- `image_cleanup`: Broadcasted after image cleanup if enabled and images were removed
15 changes: 1 addition & 14 deletions internal/actions/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,20 +80,7 @@ func RunUpdatesWithNotifications(
params.EventBroadcaster.Publish(events.Event{
Type: "scan_started",
Timestamp: time.Now().UTC(),
Data: events.ScanStartedData{
Cleanup: updateConfig.Cleanup,
NoRestart: updateConfig.NoRestart,
MonitorOnly: updateConfig.MonitorOnly,
LifecycleHooks: updateConfig.LifecycleHooks,
RollingRestart: updateConfig.RollingRestart,
LabelPrecedence: updateConfig.LabelPrecedence,
NoPull: updateConfig.NoPull,
RunOnce: updateConfig.RunOnce,
UseComposeDependsOn: updateConfig.UseComposeDependsOn,
SkipSelfUpdate: updateConfig.SkipSelfUpdate,
EphemeralSelfUpdate: updateConfig.EphemeralSelfUpdate,
ReviveStopped: updateConfig.ReviveStopped,
},
Data: events.NewScanStartedData(updateConfig),
})
}

Expand Down
74 changes: 69 additions & 5 deletions internal/api/handlers/check/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,26 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/sirupsen/logrus"

"github.com/nicholas-fedor/watchtower/internal/api/handlers/events"
"github.com/nicholas-fedor/watchtower/pkg/types"
)

// Handler serves the /v1/check endpoint.
type Handler struct {
check CheckFunc
Path string
maxTimeout time.Duration
notifier types.Notifier
// check is the function that checks container update availability.
check CheckFunc
// Path is the HTTP route path for the check endpoint.
Path string
// maxTimeout bounds the optional per-request ?timeout= query parameter.
maxTimeout time.Duration
// notifier batches log entries during the check to suppress immediate notifications.
notifier types.Notifier
// splitByContainer sends per-container notifications when true.
splitByContainer bool
// eventBroadcaster publishes SSE events for check runs.
eventBroadcaster *events.Broadcaster
// scanStartedData carries redacted policy flags reused for every scan_started event.
scanStartedData events.ScanStartedData
}

// New creates a new check handler backed by the given check function.
Expand All @@ -28,13 +38,25 @@ type Handler struct {
// - notifier: Optional notification system instance. When provided, notification batching is enabled
// during the check to prevent log entries from triggering immediate notifications.
// - splitByContainer: When true, notifications are split by container instead of being grouped.
func New(check CheckFunc, maxTimeout time.Duration, notifier types.Notifier, splitByContainer bool) *Handler {
// - eventBroadcaster: Optional SSE broadcaster. When provided, scan_started, scan_completed,
// and scan_failed events are emitted during check runs.
// - scanStartedData: Pre-built redacted policy flags for the scan_started event.
func New(
check CheckFunc,
maxTimeout time.Duration,
notifier types.Notifier,
splitByContainer bool,
eventBroadcaster *events.Broadcaster,
scanStartedData events.ScanStartedData,
) *Handler {
return &Handler{
check: check,
Path: "/v1/check",
maxTimeout: maxTimeout,
notifier: notifier,
splitByContainer: splitByContainer,
eventBroadcaster: eventBroadcaster,
scanStartedData: scanStartedData,
}
}

Expand Down Expand Up @@ -112,11 +134,31 @@ func (h *Handler) Handle(c fiber.Ctx) error {
}()
}

// Notify SSE subscribers that the check scan is starting.
if h.eventBroadcaster != nil {
h.eventBroadcaster.Publish(events.Event{
Type: "scan_started",
Timestamp: time.Now().UTC(),
Data: h.scanStartedData,
})
}

results, err := h.check(ctx, images, containers)
if err != nil {
logrus.WithError(err).WithField("notify", "no").
Error("Failed to check for updates")

// Notify SSE subscribers that the check scan failed.
if h.eventBroadcaster != nil {
h.eventBroadcaster.Publish(events.Event{
Type: "scan_failed",
Timestamp: time.Now().UTC(),
Data: events.ScanFailedData{
Error: "failed to check for updates",
},
})
}

sendErr := c.Status(fiber.StatusInternalServerError).
SendString("failed to check for updates")
if sendErr != nil {
Expand All @@ -126,6 +168,28 @@ func (h *Handler) Handle(c fiber.Ctx) error {
return nil
}

// Count containers whose per-container check returned an error.
failedCount := 0

for _, r := range results {
if r.Error != "" {
failedCount++
}
}

// Notify SSE subscribers that the check scan completed successfully.
if h.eventBroadcaster != nil {
h.eventBroadcaster.Publish(events.Event{
Type: "scan_completed",
Timestamp: time.Now().UTC(),
Data: events.ScanCompletedData{
Scanned: len(results),
Updated: 0,
Failed: failedCount,
},
})
}

err = c.Status(fiber.StatusOK).JSON(fiber.Map{
"containers": results,
"count": len(results),
Expand Down
146 changes: 140 additions & 6 deletions internal/api/handlers/check/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/nicholas-fedor/watchtower/internal/api/handlers/events"
)

func TestNew(t *testing.T) {
Expand All @@ -30,7 +32,7 @@ func TestNew(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := New(tt.check, 5*time.Minute, nil, false)
h := New(tt.check, 5*time.Minute, nil, false, nil, events.ScanStartedData{})
require.NotNil(t, h)
assert.Equal(t, "/v1/check", h.Path)
})
Expand Down Expand Up @@ -70,7 +72,7 @@ func TestHandler_Handle(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := New(tt.checkFunc, 5*time.Minute, nil, false)
h := New(tt.checkFunc, 5*time.Minute, nil, false, nil, events.ScanStartedData{})
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

Expand Down Expand Up @@ -135,7 +137,7 @@ func TestHandler_Handle_WithFilters(t *testing.T) {
capturedNames = names

return []ContainerCheck{}, nil
}, 5*time.Minute, nil, false)
}, 5*time.Minute, nil, false, nil, events.ScanStartedData{})
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

Expand All @@ -162,7 +164,7 @@ func TestHandler_Handle_TimeoutOverride(t *testing.T) {
t.Run("valid timeout is applied", func(t *testing.T) {
h := New(func(ctx context.Context, _, _ []string) ([]ContainerCheck, error) {
return []ContainerCheck{}, nil
}, 5*time.Minute, nil, false)
}, 5*time.Minute, nil, false, nil, events.ScanStartedData{})
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

Expand All @@ -178,7 +180,7 @@ func TestHandler_Handle_TimeoutOverride(t *testing.T) {
t.Run("timeout exceeding max is clamped", func(t *testing.T) {
h := New(func(ctx context.Context, _, _ []string) ([]ContainerCheck, error) {
return []ContainerCheck{}, nil
}, 2*time.Minute, nil, false)
}, 2*time.Minute, nil, false, nil, events.ScanStartedData{})
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

Expand All @@ -194,7 +196,7 @@ func TestHandler_Handle_TimeoutOverride(t *testing.T) {
t.Run("invalid timeout is ignored", func(t *testing.T) {
h := New(func(ctx context.Context, _, _ []string) ([]ContainerCheck, error) {
return []ContainerCheck{}, nil
}, 5*time.Minute, nil, false)
}, 5*time.Minute, nil, false, nil, events.ScanStartedData{})
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

Expand All @@ -207,3 +209,135 @@ func TestHandler_Handle_TimeoutOverride(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}

func TestHandler_Handle_EmitsEvents(t *testing.T) {
scanStartedData := events.ScanStartedData{
Cleanup: true,
NoRestart: false,
MonitorOnly: false,
LifecycleHooks: true,
RollingRestart: false,
LabelPrecedence: false,
NoPull: false,
RunOnce: false,
UseComposeDependsOn: false,
SkipSelfUpdate: false,
EphemeralSelfUpdate: false,
ReviveStopped: false,
}

t.Run("success emits scan_started and scan_completed", func(t *testing.T) {
b := events.NewBroadcaster()
ch := b.Subscribe()
require.NotNil(t, ch)

h := New(
func(_ context.Context, _, _ []string) ([]ContainerCheck, error) {
return []ContainerCheck{
{Name: "c1", Image: "nginx:latest", ImageID: "sha256:abc", UpdateAvailable: true},
{Name: "c2", Image: "redis:latest", ImageID: "sha256:def", UpdateAvailable: false, Error: "registry error"},
}, nil
},
5*time.Minute, nil, false, b, scanStartedData,
)
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/check", nil)
resp, err := app.Test(req)
require.NoError(t, err)

defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)

var started, completed bool

for started == false || completed == false {
select {
case evt := <-ch:
switch evt.Type {
case "scan_started":
started = true
data, ok := evt.Data.(events.ScanStartedData)
require.True(t, ok)
assert.True(t, data.Cleanup)
case "scan_completed":
completed = true
data, ok := evt.Data.(events.ScanCompletedData)
require.True(t, ok)
assert.Equal(t, 2, data.Scanned)
assert.Equal(t, 0, data.Updated)
assert.Equal(t, 1, data.Failed)
case "scan_failed":
t.Fatal("unexpected scan_failed event on success")
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for events: started=%v completed=%v", started, completed)
}
}
})

t.Run("check error emits scan_started and scan_failed", func(t *testing.T) {
b := events.NewBroadcaster()
ch := b.Subscribe()
require.NotNil(t, ch)

h := New(
func(_ context.Context, _, _ []string) ([]ContainerCheck, error) {
return nil, errors.New("docker api error")
},
5*time.Minute, nil, false, b, scanStartedData,
)
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/check", nil)
resp, err := app.Test(req)
require.NoError(t, err)

defer resp.Body.Close()

assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)

var started, failed bool

for started == false || failed == false {
select {
case evt := <-ch:
switch evt.Type {
case "scan_started":
started = true
case "scan_failed":
failed = true
data, ok := evt.Data.(events.ScanFailedData)
require.True(t, ok)
assert.Equal(t, "failed to check for updates", data.Error)
case "scan_completed":
t.Fatal("unexpected scan_completed event on error")
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for events: started=%v failed=%v", started, failed)
}
}
})

t.Run("nil broadcaster emits no events", func(t *testing.T) {
h := New(
func(_ context.Context, _, _ []string) ([]ContainerCheck, error) {
return []ContainerCheck{{Name: "c1"}}, nil
},
5*time.Minute, nil, false, nil, events.ScanStartedData{},
)
app := fiber.New(fiber.Config{})
app.Post("/v1/check", h.Handle)

req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/check", nil)
resp, err := app.Test(req)
require.NoError(t, err)

defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
Loading