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 domainName, container := range currentContainers {
121 if route := routeDomainForContainer(domainName, container); route != "" {
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, reconciling removed route runtime state")
160
161 if _, err := h.containerSvc.ReconcileRemovedRoute(ctx, route); err != nil {
162 log.WrapErrWithFields(err, "failed to reconcile removed route runtime state", map[string]any{"domain": route, zerowrap.FieldEntityID: container.ID})
163 }
164 }
165
166 return nil
167}
168
169// CanHandle returns whether this handler can handle the given event type.
170func (h *ConfigReloadHandler) CanHandle(eventType domain.EventType) bool {
171 return eventType == domain.EventConfigReload
172}
173
174func routeDomainForContainer(trackedDomain string, container *domain.Container) string {
175 if container == nil {
176 return ""
177 }
178 if container.Labels != nil {
179 if route := container.Labels[domain.LabelRoute]; route != "" {
180 return route
181 }
182 if route := container.Labels[domain.LabelDomain]; route != "" {
183 return route
184 }
185 }
186 return trackedDomain
187}
188
189// ManualDeployHandler handles manual.deploy events for specific routes.
190type ManualDeployHandler struct {
191 containerSvc in.ContainerService
192 configSvc in.ConfigService
193 ctx context.Context
194}
195
196// NewManualDeployHandler creates a new ManualDeployHandler.
197func NewManualDeployHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualDeployHandler {
198 return &ManualDeployHandler{
199 containerSvc: containerSvc,
200 configSvc: configSvc,
201 ctx: ctx,
202 }
203}
204
205// Handle handles a manual deploy event for a specific route.
206func (h *ManualDeployHandler) Handle(ctx context.Context, event domain.Event) error {
207 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
208 zerowrap.FieldLayer: "usecase",
209 zerowrap.FieldHandler: "ManualDeployHandler",
210 zerowrap.FieldEvent: string(event.Type),
211 "event_id": event.ID,
212 })
213 log := zerowrap.FromCtx(ctx)
214
215 payload, ok := event.Data.(*domain.ManualDeployPayload)
216 if !ok || payload == nil || payload.Domain == "" {
217 return fmt.Errorf("invalid manual deploy payload")
218 }
219
220 log.Info().Str("domain", payload.Domain).Msg("processing manual deploy event")
221
222 // Find the route in configuration
223 routes := h.configSvc.GetRoutes(ctx)
224 var targetRoute *domain.Route
225 for _, r := range routes {
226 if r.Domain == payload.Domain {
227 targetRoute = &r
228 break
229 }
230 }
231
232 if targetRoute == nil {
233 return fmt.Errorf("route not found for domain: %s", payload.Domain)
234 }
235
236 // Manual deploy is an internal trigger, so use internal deploy context.
237 if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *targetRoute); err != nil {
238 return log.WrapErr(err, "failed to deploy container")
239 }
240
241 log.Info().Str("domain", payload.Domain).Msg("manual deploy completed successfully")
242 return nil
243}
244
245// CanHandle returns whether this handler can handle the given event type.
246func (h *ManualDeployHandler) CanHandle(eventType domain.EventType) bool {
247 return eventType == domain.EventManualDeploy
248}