1package container
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/bnema/zerowrap"
8
9 "github.com/bnema/gordon/internal/boundaries/in"
10 "github.com/bnema/gordon/internal/domain"
11)
12
13// ImagePushedHandler handles image.pushed events.
14type ImagePushedHandler struct {
15 containerSvc in.ContainerService
16 configSvc in.ConfigService
17 ctx context.Context
18}
19
20// NewImagePushedHandler creates a new ImagePushedHandler.
21func NewImagePushedHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ImagePushedHandler {
22 return &ImagePushedHandler{
23 containerSvc: containerSvc,
24 configSvc: configSvc,
25 ctx: ctx,
26 }
27}
28
29// Handle handles an event.
30func (h *ImagePushedHandler) Handle(ctx context.Context, event domain.Event) error {
31 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
32 zerowrap.FieldLayer: "usecase",
33 zerowrap.FieldHandler: "ImagePushedHandler",
34 zerowrap.FieldEvent: string(event.Type),
35 "event_id": event.ID,
36 })
37 log := zerowrap.FromCtx(ctx)
38
39 if event.ImageName == "" {
40 return domain.ErrInvalidImageFormat
41 }
42
43 tag := event.Tag
44 if tag == "" {
45 tag = "latest"
46 }
47
48 fullImageName := fmt.Sprintf("%s:%s", event.ImageName, tag)
49
50 log.Info().Str("image", fullImageName).Msg("processing image push event")
51
52 routes := h.configSvc.FindRoutesByImage(ctx, fullImageName)
53 if len(routes) == 0 {
54 log.Debug().Str("image", fullImageName).Msg("no routes configured for pushed image")
55 return nil
56 }
57
58 // Mark context as internal deploy - the event originated from our own registry,
59 // so we can use internal registry auth when pulling images.
60 internalCtx := domain.WithInternalDeploy(ctx)
61
62 for _, route := range routes {
63 if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
64 log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain})
65 }
66 }
67
68 return nil
69}
70
71// CanHandle returns whether this handler can handle the given event type.
72func (h *ImagePushedHandler) CanHandle(eventType domain.EventType) bool {
73 return eventType == domain.EventImagePushed
74}
75
76// ConfigReloadHandler handles config.reload events.
77type ConfigReloadHandler struct {
78 containerSvc in.ContainerService
79 configSvc in.ConfigService
80 ctx context.Context
81}
82
83// NewConfigReloadHandler creates a new ConfigReloadHandler.
84func NewConfigReloadHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ConfigReloadHandler {
85 return &ConfigReloadHandler{
86 containerSvc: containerSvc,
87 configSvc: configSvc,
88 ctx: ctx,
89 }
90}
91
92// Handle handles an event.
93func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) error {
94 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95 zerowrap.FieldLayer: "usecase",
96 zerowrap.FieldHandler: "ConfigReloadHandler",
97 zerowrap.FieldEvent: string(event.Type),
98 "event_id": event.ID,
99 })
100 log := zerowrap.FromCtx(ctx)
101
102 log.Info().Msg("processing configuration reload event")
103
104 // Sync containers first to ensure we have accurate state
105 // This removes tracking for containers that were stopped externally
106 if err := h.containerSvc.SyncContainers(ctx); err != nil {
107 log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
108 }
109
110 // Propagate updated attachment configuration so new attachments take effect
111 // without requiring a Gordon restart (fixes issue #87 part 2).
112 attachments := h.configSvc.GetAllAttachments(ctx)
113 log.Debug().Int("attachment_groups", len(attachments)).Msg("propagating updated attachment configuration")
114 h.containerSvc.UpdateAttachments(attachments)
115 log.Debug().Int("attachment_groups", len(attachments)).Msg("attachment configuration propagated")
116
117 currentContainers := h.containerSvc.List(ctx)
118
119 activeRoutes := make(map[string]*domain.Container)
120 for _, container := range currentContainers {
121 if route, exists := container.Labels[domain.LabelRoute]; exists {
122 activeRoutes[route] = container
123 }
124 }
125
126 routes := h.configSvc.GetRoutes(ctx)
127 for _, route := range routes {
128 if container, exists := activeRoutes[route.Domain]; exists {
129 currentImage := container.Labels[domain.LabelImage]
130 if currentImage != route.Image {
131 log.Info().
132 Str("domain", route.Domain).
133 Str("old_image", currentImage).
134 Str("new_image", route.Image).
135 Msg("image changed for route, redeploying")
136
137 if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), route); err != nil {
138 log.WrapErrWithFields(err, "failed to redeploy container", map[string]any{"domain": route.Domain})
139 }
140 }
141 delete(activeRoutes, route.Domain)
142 } else {
143 // Route exists in config but no running container (new route or missing container)
144 log.Info().
145 Str("domain", route.Domain).
146 Str("image", route.Image).
147 Msg("route missing container, deploying")
148
149 if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), route); err != nil {
150 log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain})
151 }
152 }
153 }
154
155 for route, container := range activeRoutes {
156 log.Info().
157 Str("domain", route).
158 Str(zerowrap.FieldEntityID, container.ID).
159 Msg("route no longer configured, stopping container")
160
161 if err := h.containerSvc.Stop(ctx, container.ID); err != nil {
162 log.WrapErrWithFields(err, "failed to stop container", map[string]any{zerowrap.FieldEntityID: container.ID})
163 }
164
165 if err := h.containerSvc.Remove(ctx, container.ID, true); err != nil {
166 log.WrapErrWithFields(err, "failed to remove container", map[string]any{zerowrap.FieldEntityID: container.ID})
167 }
168 }
169
170 return nil
171}
172
173// CanHandle returns whether this handler can handle the given event type.
174func (h *ConfigReloadHandler) CanHandle(eventType domain.EventType) bool {
175 return eventType == domain.EventConfigReload
176}
177
178// ManualReloadHandler handles manual.reload events.
179type ManualReloadHandler struct {
180 containerSvc in.ContainerService
181 configSvc in.ConfigService
182 ctx context.Context
183}
184
185// NewManualReloadHandler creates a new ManualReloadHandler.
186func NewManualReloadHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualReloadHandler {
187 return &ManualReloadHandler{
188 containerSvc: containerSvc,
189 configSvc: configSvc,
190 ctx: ctx,
191 }
192}
193
194// Handle handles an event.
195// It starts containers for configured routes that don't have a running container.
196// Running containers are NEVER restarted to ensure 100% uptime.
197func (h *ManualReloadHandler) Handle(ctx context.Context, event domain.Event) error {
198 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
199 zerowrap.FieldLayer: "usecase",
200 zerowrap.FieldHandler: "ManualReloadHandler",
201 zerowrap.FieldEvent: string(event.Type),
202 "event_id": event.ID,
203 })
204 log := zerowrap.FromCtx(ctx)
205
206 log.Info().Msg("processing manual reload event - starting missing containers")
207
208 // Sync containers first to ensure we have accurate state
209 // This removes tracking for containers that were stopped externally
210 if err := h.containerSvc.SyncContainers(ctx); err != nil {
211 log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
212 }
213
214 routes := h.configSvc.GetRoutes(ctx)
215 startedCount := 0
216 skippedCount := 0
217 errorCount := 0
218
219 for _, route := range routes {
220 if _, exists := h.containerSvc.Get(ctx, route.Domain); exists {
221 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
222 skippedCount++
223 continue
224 }
225
226 log.Info().
227 Str("domain", route.Domain).
228 Str("image", route.Image).
229 Msg("starting container for route")
230
231 if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), route); err != nil {
232 log.WrapErrWithFields(err, "failed to start container", map[string]any{"domain": route.Domain})
233 errorCount++
234 continue
235 }
236
237 startedCount++
238 log.Info().Str("domain", route.Domain).Msg("container started successfully")
239 }
240
241 if errorCount > 0 {
242 log.Warn().
243 Int("started", startedCount).
244 Int("skipped", skippedCount).
245 Int("errors", errorCount).
246 Msg("manual reload completed with some errors")
247 return fmt.Errorf("manual reload completed with %d errors", errorCount)
248 }
249
250 log.Info().
251 Int("started", startedCount).
252 Int("skipped", skippedCount).
253 Int("total", len(routes)).
254 Msg("manual reload completed successfully")
255 return nil
256}
257
258// CanHandle returns whether this handler can handle the given event type.
259func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool {
260 return eventType == domain.EventManualReload
261}
262
263// ManualDeployHandler handles manual.deploy events for specific routes.
264type ManualDeployHandler struct {
265 containerSvc in.ContainerService
266 configSvc in.ConfigService
267 ctx context.Context
268}
269
270// NewManualDeployHandler creates a new ManualDeployHandler.
271func NewManualDeployHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualDeployHandler {
272 return &ManualDeployHandler{
273 containerSvc: containerSvc,
274 configSvc: configSvc,
275 ctx: ctx,
276 }
277}
278
279// Handle handles a manual deploy event for a specific route.
280func (h *ManualDeployHandler) Handle(ctx context.Context, event domain.Event) error {
281 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
282 zerowrap.FieldLayer: "usecase",
283 zerowrap.FieldHandler: "ManualDeployHandler",
284 zerowrap.FieldEvent: string(event.Type),
285 "event_id": event.ID,
286 })
287 log := zerowrap.FromCtx(ctx)
288
289 payload, ok := event.Data.(*domain.ManualDeployPayload)
290 if !ok || payload == nil || payload.Domain == "" {
291 return fmt.Errorf("invalid manual deploy payload")
292 }
293
294 log.Info().Str("domain", payload.Domain).Msg("processing manual deploy event")
295
296 // Find the route in configuration
297 routes := h.configSvc.GetRoutes(ctx)
298 var targetRoute *domain.Route
299 for _, r := range routes {
300 if r.Domain == payload.Domain {
301 targetRoute = &r
302 break
303 }
304 }
305
306 if targetRoute == nil {
307 return fmt.Errorf("route not found for domain: %s", payload.Domain)
308 }
309
310 // Manual deploy is an internal trigger, so use internal deploy context.
311 if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *targetRoute); err != nil {
312 return log.WrapErr(err, "failed to deploy container")
313 }
314
315 log.Info().Str("domain", payload.Domain).Msg("manual deploy completed successfully")
316 return nil
317}
318
319// CanHandle returns whether this handler can handle the given event type.
320func (h *ManualDeployHandler) CanHandle(eventType domain.EventType) bool {
321 return eventType == domain.EventManualDeploy
322}