Skip to content

Commit bb267c8

Browse files
feat(api): add read-only /v1/containers endpoint (#1700)
- Add `--http-api-containers` flag and `WATCHTOWER_HTTP_API_CONTAINERS` env var - Expose watched containers with name, image, image ID, and registry digest - Return JSON response with container list, count, timestamp, and API version - Cover success, auth failure, list errors, and empty results in tests - Document endpoint usage, response format, and configuration options --------- Co-authored-by: Nick Fedor <nick@nickfedor.com>
1 parent 09da353 commit bb267c8

9 files changed

Lines changed: 665 additions & 18 deletions

File tree

cmd/root.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ func run(command *cobra.Command, args []string) {
497497
updateOnStart, _ := command.PersistentFlags().GetBool("update-on-start")
498498
enableUpdateAPI, _ := command.PersistentFlags().GetBool("http-api-update")
499499
enableMetricsAPI, _ := command.PersistentFlags().GetBool("http-api-metrics")
500+
enableContainersAPI, _ := command.PersistentFlags().GetBool("http-api-containers")
500501
unblockHTTPAPI, _ := command.PersistentFlags().GetBool("http-api-periodic-polls")
501502
noStartupMessage, _ := command.PersistentFlags().GetBool("no-startup-message")
502503
apiToken, _ := command.PersistentFlags().GetString("http-api-token")
@@ -551,20 +552,21 @@ func run(command *cobra.Command, args []string) {
551552

552553
// Set configuration for core execution, encapsulating all operational parameters.
553554
cfg := types.RunConfig{
554-
Command: command,
555-
Names: normalizedContainerNames,
556-
Filter: filter,
557-
FilterDesc: filterDesc,
558-
RunOnce: runOnce,
559-
UpdateOnStart: updateOnStart,
560-
EnableUpdateAPI: enableUpdateAPI,
561-
EnableMetricsAPI: enableMetricsAPI,
562-
UnblockHTTPAPI: unblockHTTPAPI,
563-
NoStartupMessage: noStartupMessage,
564-
APIToken: apiToken,
565-
APIHost: apiHost,
566-
APIPort: apiPort,
567-
APIRateLimit: apiRateLimit,
555+
Command: command,
556+
Names: normalizedContainerNames,
557+
Filter: filter,
558+
FilterDesc: filterDesc,
559+
RunOnce: runOnce,
560+
UpdateOnStart: updateOnStart,
561+
EnableUpdateAPI: enableUpdateAPI,
562+
EnableMetricsAPI: enableMetricsAPI,
563+
EnableContainersAPI: enableContainersAPI,
564+
UnblockHTTPAPI: unblockHTTPAPI,
565+
NoStartupMessage: noStartupMessage,
566+
APIToken: apiToken,
567+
APIHost: apiHost,
568+
APIPort: apiPort,
569+
APIRateLimit: apiRateLimit,
568570
}
569571

570572
// Execute core logic and exit with the returned status code (0 for success, 1 for failure).
@@ -782,6 +784,7 @@ func runMain(cfg types.RunConfig) int {
782784
RateLimit: cfg.APIRateLimit,
783785
EnableUpdateAPI: cfg.EnableUpdateAPI,
784786
EnableMetricsAPI: cfg.EnableMetricsAPI,
787+
EnableContainersAPI: cfg.EnableContainersAPI,
785788
UnblockHTTPAPI: cfg.UnblockHTTPAPI,
786789
NoStartupMessage: cfg.NoStartupMessage,
787790
Filter: cfg.Filter,

docs/advanced-features/http-api/index.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ Watchtower has an [optional](../../configuration/arguments/index.md#http_api_mod
99

1010
## Endpoints
1111

12-
| **Name** | **Method** | **Endpoint** | **Parameters** | **Description** |
13-
|:----------------------------------:|:----------:|:-------------:|:-------------------------------------------------------------------:|:--------------------------------------------------------------------:|
14-
| [Update](#http_api_update) | `POST` | `/v1/update` | [`image`](#image_parameter_usage), [`async`](#asynchronous_updates) | Triggers container updates and returns JSON results of the operation |
15-
| [Metrics](../metrics-api/index.md) | `GET` | `/v1/metrics` | | Exposes Prometheus-compatible metrics for monitoring and alerting |
12+
| **Name** | **Method** | **Endpoint** | **Parameters** | **Description** |
13+
|:----------------------------------:|:----------:|:----------------:|:-------------------------------------------------------------------:|:--------------------------------------------------------------------:|
14+
| [Update](#http_api_update) | `POST` | `/v1/update` | [`image`](#image_parameter_usage), [`async`](#asynchronous_updates) | Triggers container updates and returns JSON results of the operation |
15+
| [Metrics](../metrics-api/index.md) | `GET` | `/v1/metrics` | | Exposes Prometheus-compatible metrics for monitoring and alerting |
16+
| [Containers](#http_api_containers) | `GET` | `/v1/containers` | | Lists watched containers and their current running image digests |
1617

1718
!!! Note
1819
Endpoints enforce HTTP method restrictions using method-based routing.
@@ -306,3 +307,37 @@ services:
306307

307308
!!! Warning
308309
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.
310+
311+
### HTTP API Containers
312+
313+
To enable this read-only endpoint, use the `--http-api-containers` CLI argument or the `WATCHTOWER_HTTP_API_CONTAINERS` environment variable.
314+
315+
It lists the containers Watchtower watches along with their current image identity, so an external orchestrator can compare what is actually running against a registry without pulling any image layers.
316+
317+
#### Response Format
318+
319+
The `/v1/containers` endpoint returns a JSON array of watched containers:
320+
321+
```json
322+
{
323+
"containers": [
324+
{
325+
"name": "nginx",
326+
"image": "nginx:latest",
327+
"image_id": "sha256:1111...",
328+
"digest": "sha256:2222..."
329+
}
330+
],
331+
"count": 1,
332+
"timestamp": "2025-01-20T11:30:45Z",
333+
"api_version": "v1"
334+
}
335+
```
336+
337+
- `name`: Container name
338+
- `image`: Image reference with tag
339+
- `image_id`: Local image config ID
340+
- `digest`: Registry manifest digest the image was pulled from (from the image's `RepoDigests`), directly comparable to a registry's `Docker-Content-Digest`. Empty for locally-built images with no registry reference.
341+
342+
!!! Note
343+
`--http-api-containers` can be enabled alongside `--http-api-update` and `--http-api-metrics`.

docs/configuration/arguments/index.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,19 @@ Environment Variable: WATCHTOWER_HTTP_API_METRICS
763763

764764
!!! Note "See the [Metrics API documentation](../../advanced-features/metrics-api/index.md) for details"
765765

766+
### HTTP API Containers
767+
768+
Enables a read-only endpoint that lists watched containers and their current running image digests.
769+
770+
```text
771+
Argument: --http-api-containers
772+
Environment Variable: WATCHTOWER_HTTP_API_CONTAINERS
773+
Type: Boolean
774+
Default: false
775+
```
776+
777+
!!! Note "See the [HTTP API documentation](../../advanced-features/http-api/index.md#http_api_containers) for details"
778+
766779
### HTTP API Host
767780

768781
Sets the host interface for binding the HTTP API.

internal/api/api.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/spf13/cobra"
1515

1616
"github.com/nicholas-fedor/watchtower/pkg/api"
17+
containersAPI "github.com/nicholas-fedor/watchtower/pkg/api/containers"
1718
metricsAPI "github.com/nicholas-fedor/watchtower/pkg/api/metrics"
1819
"github.com/nicholas-fedor/watchtower/pkg/api/update"
1920
"github.com/nicholas-fedor/watchtower/pkg/container"
@@ -45,6 +46,8 @@ type Options struct {
4546
EnableUpdateAPI bool
4647
// EnableMetricsAPI enables the HTTP metrics API endpoint.
4748
EnableMetricsAPI bool
49+
// EnableContainersAPI enables the read-only containers API endpoint.
50+
EnableContainersAPI bool
4851
// UnblockHTTPAPI allows periodic polling alongside the HTTP API.
4952
UnblockHTTPAPI bool
5053
// NoStartupMessage suppresses startup messages if true.
@@ -170,6 +173,59 @@ func SetupAndStartAPI(
170173
httpAPI.RegisterHandler("GET "+metricsHandler.Path, metricsHandler.Handle)
171174
}
172175

176+
// Register the read-only containers API endpoint if enabled, exposing the
177+
// running image identity of each watched container for external orchestrators.
178+
if opts.EnableContainersAPI {
179+
client := opts.Client
180+
filter := opts.Filter
181+
182+
containersHandler := containersAPI.New(func(ctx context.Context) ([]containersAPI.Status, error) {
183+
var (
184+
list []types.Container
185+
err error
186+
)
187+
188+
if filter != nil {
189+
list, err = client.ListContainers(ctx, filter)
190+
} else {
191+
list, err = client.ListContainers(ctx)
192+
}
193+
194+
if err != nil {
195+
return nil, fmt.Errorf("failed to list containers: %w", err)
196+
}
197+
198+
statuses := make([]containersAPI.Status, 0, len(list))
199+
for _, c := range list {
200+
status := containersAPI.Status{
201+
Name: c.Name(),
202+
Image: c.ImageName(),
203+
ImageID: string(c.ImageID()),
204+
}
205+
206+
if info := c.ImageInfo(); info != nil {
207+
if digests := info.RepoDigests; len(digests) > 0 {
208+
_, digest, found := strings.Cut(digests[0], "@")
209+
if found {
210+
status.Digest = digest
211+
} else {
212+
logrus.WithFields(logrus.Fields{
213+
"container": c.Name(),
214+
"digest": digests[0],
215+
}).Debug("RepoDigest in unexpected format, missing @ separator")
216+
}
217+
}
218+
}
219+
220+
statuses = append(statuses, status)
221+
}
222+
223+
return statuses, nil
224+
})
225+
// Use Go 1.22+ method-based routing to restrict to GET only.
226+
httpAPI.RegisterFunc("GET "+containersHandler.Path, containersHandler.Handle)
227+
}
228+
173229
// Warn once at startup when self-update will be skipped due to host-bound port conflicts.
174230
if opts.SkipSelfUpdate {
175231
logrus.Warn("Skipping self-update to prevent port conflict: Watchtower container has host-bound ports")

internal/flags/flags.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,11 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
223223
"",
224224
envBool("WATCHTOWER_HTTP_API_METRICS"),
225225
"Runs Watchtower with the Prometheus metrics API enabled")
226+
flags.BoolP(
227+
"http-api-containers",
228+
"",
229+
envBool("WATCHTOWER_HTTP_API_CONTAINERS"),
230+
"Runs Watchtower with the read-only containers API enabled, exposing each watched container's current image digest")
226231

227232
flags.StringP(
228233
"http-api-host",

pkg/api/containers/containers.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package containers
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"net/http"
8+
"time"
9+
10+
"github.com/sirupsen/logrus"
11+
)
12+
13+
// Status describes a single watched container's current image identity.
14+
type Status struct {
15+
// Name is the container name.
16+
Name string `json:"name"`
17+
// Image is the image reference with tag (e.g. ethpandaops/lighthouse:latest).
18+
Image string `json:"image"`
19+
// ImageID is the local image config ID (sha256:...).
20+
ImageID string `json:"image_id"`
21+
// Digest is the registry manifest digest the image was pulled from
22+
// (sha256:...), derived from the image's RepoDigests. It is directly
23+
// comparable to a registry's Docker-Content-Digest. Empty for locally-built
24+
// images with no registry reference.
25+
Digest string `json:"digest"`
26+
}
27+
28+
// ListFunc returns the current status of all watched containers.
29+
type ListFunc func(ctx context.Context) ([]Status, error)
30+
31+
// Handler serves the /v1/containers endpoint.
32+
//
33+
// It holds the list function and endpoint path for the read-only
34+
// /v1/containers endpoint.
35+
type Handler struct {
36+
list ListFunc // Container status lookup function.
37+
Path string // API endpoint path (e.g., "/v1/containers").
38+
}
39+
40+
// New creates a new containers Handler backed by the given list function.
41+
//
42+
// Parameters:
43+
// - list: Function returning the current status of all watched containers.
44+
//
45+
// Returns:
46+
// - *Handler: Initialized handler serving /v1/containers.
47+
func New(list ListFunc) *Handler {
48+
return &Handler{
49+
list: list,
50+
Path: "/v1/containers",
51+
}
52+
}
53+
54+
// Handle responds with the JSON status of every watched container.
55+
//
56+
// Parameters:
57+
// - w: HTTP response writer for sending the JSON payload or error status.
58+
// - r: HTTP request; its context is propagated to the Docker calls.
59+
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
60+
logrus.WithFields(logrus.Fields{
61+
"method": r.Method,
62+
"path": r.URL.Path,
63+
}).Debug("Received HTTP API containers request")
64+
65+
statuses, err := h.list(r.Context())
66+
if err != nil {
67+
logrus.WithError(err).Error("Failed to list containers for API")
68+
http.Error(w, "failed to list containers", http.StatusInternalServerError)
69+
70+
return
71+
}
72+
73+
response := map[string]any{
74+
"containers": statuses,
75+
"count": len(statuses),
76+
"timestamp": time.Now().UTC().Format(time.RFC3339),
77+
"api_version": "v1",
78+
}
79+
80+
var buf bytes.Buffer
81+
82+
err = json.NewEncoder(&buf).Encode(response)
83+
if err != nil {
84+
logrus.WithError(err).Error("Failed to encode containers response")
85+
http.Error(w, "failed to encode response", http.StatusInternalServerError)
86+
87+
return
88+
}
89+
90+
w.Header().Set("Content-Type", "application/json")
91+
w.WriteHeader(http.StatusOK)
92+
93+
_, err = w.Write(buf.Bytes())
94+
if err != nil {
95+
logrus.WithError(err).Error("Failed to write containers response")
96+
}
97+
}

0 commit comments

Comments
 (0)