@@ -5,9 +5,12 @@ import (
55 "errors"
66 "fmt"
77 "os"
8+ "sync"
89 "time"
910
1011 "github.com/cenkalti/backoff/v5"
12+ "github.com/docker/docker/api/types/events"
13+ "github.com/docker/docker/api/types/filters"
1114 "github.com/steveiliop56/ding"
1215 "github.com/tinyauthapp/tinyauth/internal/model"
1316 "github.com/tinyauthapp/tinyauth/internal/utils/decoders"
@@ -23,11 +26,12 @@ var (
2326)
2427
2528type DockerService struct {
26- log * logger.Logger
27- client * client.Client
28- context context.Context
29+ log * logger.Logger
30+ client * client.Client
31+ ctx context.Context
2932
30- isConnected bool
33+ apps map [string ]model.App
34+ mu sync.RWMutex
3135}
3236
3337type DockerServiceInput struct {
@@ -40,124 +44,243 @@ type DockerServiceInput struct {
4044
4145func NewDockerService (i DockerServiceInput ) (* DockerService , error ) {
4246 service := & DockerService {
43- log : i .Log ,
44- context : i .Ctx ,
47+ log : i .Log ,
48+ ctx : i .Ctx ,
49+ apps : make (map [string ]model.App ),
50+ mu : sync.RWMutex {},
4551 }
4652
4753 service .log .App .Debug ().Msg ("Attempting to connect to Docker" )
4854
49- if os .Getenv ("DOCKER_HOST" ) == "" {
50- cli , err := service .connect ()
51- if err != nil {
52- if errors .Is (err , ErrPingFailed ) {
53- service .log .App .Debug ().Msg ("Docker not connected" )
54- return nil , nil
55- }
56- return nil , fmt .Errorf ("failed to connect to docker: %w" , err )
57- }
58- service .client = cli
55+ var cli * client.Client
56+ var err error
57+
58+ if os .Getenv ("DOCKER_HOST" ) != "" {
59+ cli , err = service .connectWithBackoff ()
5960 } else {
60- exp := backoff .NewExponentialBackOff ()
61- exp .InitialInterval = 3 * time .Second
62- exp .RandomizationFactor = 0.1
63- exp .Multiplier = 1.5
64- exp .Reset ()
65-
66- operation := func () (* client.Client , error ) {
67- if service .client != nil {
68- service .client .Close ()
69- }
70- cli , err := service .connect ()
71- if err != nil {
72- return nil , err
73- }
74- return cli , nil
61+ cli , err = service .connect ()
62+ }
63+
64+ if err != nil {
65+ if errors .Is (err , ErrPingFailed ) {
66+ service .log .App .Debug ().Msg ("Docker not connected, skipping" )
67+ return nil , nil
7568 }
69+ return nil , fmt .Errorf ("failed to connect to docker: %w" , err )
70+ }
7671
77- cli , err := backoff .Retry (service .context , operation , backoff .WithBackOff (exp ), backoff .WithMaxTries (3 ))
72+ if cli == nil {
73+ return nil , nil
74+ }
7875
79- if err != nil {
80- if errors .Is (err , ErrPingFailed ) {
81- service .log .App .Debug ().Msg ("Docker not connected after retrying" )
82- return nil , nil
83- }
84- return nil , fmt .Errorf ("failed to connect to docker after retrying: %w" , err )
85- }
76+ service .client = cli
77+
78+ // initial sync
79+ err = service .sync ()
8680
87- service .client = cli
81+ if err != nil {
82+ return nil , fmt .Errorf ("failed to do initial sync: %w" , err )
8883 }
8984
90- service .isConnected = true
9185 service .log .App .Debug ().Msg ("Docker connected successfully" )
9286
93- i .Ding .Go (service .watchAndClose , ding .RingMajor )
87+ i .Ding .Go (service .monitor , ding .RingMajor )
9488
9589 return service , nil
9690}
9791
92+ func (docker * DockerService ) connectWithBackoff () (* client.Client , error ) {
93+ exp := backoff .NewExponentialBackOff ()
94+ exp .InitialInterval = 3 * time .Second
95+ exp .RandomizationFactor = 0.1
96+ exp .Multiplier = 1.5
97+ exp .Reset ()
98+
99+ operation := func () (* client.Client , error ) {
100+ if docker .client != nil {
101+ docker .client .Close ()
102+ }
103+ return docker .connect ()
104+ }
105+
106+ return backoff .Retry (docker .ctx , operation , backoff .WithBackOff (exp ), backoff .WithMaxTries (3 ))
107+ }
108+
98109func (docker * DockerService ) connect () (* client.Client , error ) {
99110 cli , err := client .NewClientWithOpts (client .FromEnv , client .WithAPIVersionNegotiation ())
100111
101112 if err != nil {
102113 return nil , err
103114 }
104115
105- _ , err = cli .Ping (docker .context )
116+ _ , err = cli .Ping (docker .ctx )
106117
107118 if err != nil {
119+ cli .Close ()
108120 return nil , ErrPingFailed
109121 }
110122
111123 return cli , nil
112124}
113125
114126func (docker * DockerService ) getContainers () ([]container.Summary , error ) {
115- return docker .client .ContainerList (docker .context , container.ListOptions {})
127+ return docker .client .ContainerList (docker .ctx , container.ListOptions {})
116128}
117129
118130func (docker * DockerService ) inspectContainer (containerId string ) (container.InspectResponse , error ) {
119- return docker .client .ContainerInspect (docker .context , containerId )
131+ return docker .client .ContainerInspect (docker .ctx , containerId )
120132}
121133
122- func (docker * DockerService ) Lookup (locator func (name string , app * model.App )) error {
123- if ! docker .isConnected {
124- docker .log .App .Debug ().Msg ("Docker service not connected, returning empty labels" )
125- return nil
126- }
127-
134+ func (docker * DockerService ) sync () error {
128135 containers , err := docker .getContainers ()
129136 if err != nil {
130137 return fmt .Errorf ("failed to get containers: %w" , err )
131138 }
132139
140+ apps := make (map [string ]model.App )
141+
133142 for _ , ctr := range containers {
134- inspect , err := docker . inspectContainer (ctr .ID )
143+ labels , err := decoders . DecodeLabels [model. Apps ] (ctr .Labels , "apps" )
135144 if err != nil {
136- docker .log .App .Error ().Err (err ).Msgf ( " Failed to inspect container %s" , ctr . ID )
145+ docker .log .App .Warn ().Err (err ).Str ( "container_id" , ctr . ID ). Msg ( " Failed to decode container labels, skipping" )
137146 continue
138147 }
139148
140- labels , err := decoders .DecodeLabels [model.Apps ](inspect .Config .Labels , "apps" )
141- if err != nil {
142- docker .log .App .Warn ().Err (err ).Msgf ("Failed to decode labels for container %s" , ctr .ID )
149+ for app , config := range labels .Apps {
150+ apps [app ] = config
151+ }
152+ }
153+
154+ docker .mu .Lock ()
155+ docker .apps = apps
156+ docker .mu .Unlock ()
157+
158+ return nil
159+ }
160+
161+ func (docker * DockerService ) syncWithContainer (containerId string ) error {
162+ ctr , err := docker .inspectContainer (containerId )
163+ if err != nil {
164+ return fmt .Errorf ("failed to inspect container: %w" , err )
165+ }
166+ labels , err := decoders .DecodeLabels [model.Apps ](ctr .Config .Labels , "apps" )
167+ if err != nil {
168+ return fmt .Errorf ("failed to decode container labels: %w" , err )
169+ }
170+ docker .mu .Lock ()
171+ for app , config := range labels .Apps {
172+ if ! ctr .State .Running {
173+ delete (docker .apps , app )
143174 continue
144175 }
176+ docker .apps [app ] = config
177+ }
178+ docker .mu .Unlock ()
179+ return nil
180+ }
145181
146- for app , config := range labels .Apps {
147- locator (app , & config )
182+ func (docker * DockerService ) syncFromEvent (event events.Message ) {
183+ switch event .Action {
184+ case events .ActionStart , events .ActionRestart ,
185+ events .ActionStop , events .ActionKill , events .ActionDie :
186+
187+ if err := docker .syncWithContainer (event .Actor .ID ); err != nil {
188+ docker .log .App .Warn ().Err (err ).
189+ Msg ("Failed to sync container, performing full sync" )
190+
191+ if err := docker .sync (); err != nil {
192+ docker .log .App .Error ().Err (err ).
193+ Msg ("Failed to resync Docker apps" )
194+ }
148195 }
149196 }
197+ }
150198
151- return nil
199+ func (docker * DockerService ) buildEventsKV () filters.Args {
200+ vals := []string {"start" , "restart" , "stop" , "kill" , "die" }
201+ kv := make ([]filters.KeyValuePair , len (vals ))
202+ for i , v := range vals {
203+ kv [i ] = filters.KeyValuePair {
204+ Key : "event" ,
205+ Value : v ,
206+ }
207+ }
208+ return filters .NewArgs (kv ... )
152209}
153210
154- func (docker * DockerService ) watchAndClose (ctx context.Context ) {
155- <- ctx .Done ()
156- docker .log .App .Debug ().Msg ("Closing Docker client" )
157- if docker .client != nil {
158- err := docker .client .Close ()
159- if err != nil {
160- docker .log .App .Error ().Err (err ).Msg ("Error closing Docker client" )
211+ func (docker * DockerService ) monitor (ctx context.Context ) {
212+ defer func () {
213+ if docker .client != nil {
214+ _ = docker .client .Close ()
161215 }
216+ }()
217+
218+ for ctx .Err () == nil {
219+ streamCtx , cancel := context .WithCancel (ctx )
220+
221+ stream , chErr := docker .client .Events (streamCtx , events.ListOptions {
222+ Filters : docker .buildEventsKV (),
223+ })
224+
225+ if err := docker .sync (); err != nil {
226+ docker .log .App .Error ().Err (err ).Msg ("Failed to sync docker apps" )
227+ } else {
228+ watch:
229+ for {
230+ select {
231+ case <- ctx .Done ():
232+ cancel ()
233+ docker .log .App .Debug ().Msg ("Shutting down docker monitor" )
234+ return
235+
236+ case event , ok := <- stream :
237+ if ! ok {
238+ docker .log .App .Warn ().Msg ("Docker event stream closed" )
239+ break watch
240+ }
241+ docker .syncFromEvent (event )
242+
243+ case err , ok := <- chErr :
244+ if ok && err != nil {
245+ docker .log .App .Error ().Err (err ).Msg ("Docker event stream failed" )
246+ }
247+ break watch
248+ }
249+ }
250+ }
251+
252+ cancel ()
253+
254+ for ctx .Err () == nil {
255+ docker .log .App .Warn ().Msg ("Attempting to reconnect to docker" )
256+
257+ cli , err := docker .connect ()
258+
259+ if err == nil && cli != nil {
260+ docker .client .Close ()
261+ docker .client = cli
262+ docker .log .App .Info ().Msg ("Docker connection recovered" )
263+ break
264+ }
265+
266+ if err != nil {
267+ docker .log .App .Error ().Err (err ).Msg ("Failed to reconnect to docker" )
268+ }
269+
270+ select {
271+ case <- ctx .Done ():
272+ return
273+ case <- time .After (5 * time .Second ):
274+ }
275+ }
276+ }
277+ }
278+
279+ func (docker * DockerService ) Lookup (locator func (name string , app * model.App )) error {
280+ docker .mu .RLock ()
281+ for app , config := range docker .apps {
282+ locator (app , & config )
162283 }
284+ docker .mu .RUnlock ()
285+ return nil
163286}
0 commit comments