detached 1a03c00

feat(auto-route): Implement automatic route creation from image names

- Add functionality to automatically create routes based on image names containing valid domain names.
- Update README to document the new auto-route feature and provide usage examples.
- Enhance configuration handling to support auto-route settings and ensure routes are added dynamically upon image push.
- Improve logging for auto-route creation and configuration updates.
commit
1a03c00378ef7dd974a16f3e21706a1d2a869a40
author
bamen · · 2025-06-07 14:37 UTC
parent
d9ab304
signature
unsigned
7 files · +503 -271
M README.md +67 -0
@@ -74,6 +74,9 @@ Push new versions anytime. Gordon handles graceful container swaps.
7474 ### Automatic Deployment
7575 Containers deploy instantly when you push new images. No manual deployment steps needed.
7676
77+### Auto-Route Creation
78+Automatically create routes from image names containing domains. Push an image named like `myapp.bamen.dev:latest` and Gordon creates the route automatically when enabled.
79+
7780 ## Quick Start (5 minutes)
7881
7982 ### 1. Get a VPS, Install Podman and UFW
@@ -157,6 +160,9 @@ password = "your-secure-password"
157160 "api.yourdomain.com" = "api:v1"
158161 "blog.yourdomain.com" = "wordpress:latest"
159162
163+[auto_route]
164+enabled = false # Enable automatic route creation
165+
160166 # Custom config file location (optional):
161167 # gordon --config /path/to/custom.toml start
162168 ```
@@ -279,6 +285,64 @@ podman push registry.yourdomain.com/myapp:latest
279285 "feature-xyz.yourdomain.com" = "myapp:feature-xyz" # Feature branch
280286 ```
281287
288+### Auto-Route Creation for Testing
289+
290+Gordon can automatically create routes from image names that contain valid domain names. This is perfect for testing deployments without manually editing config files.
291+
292+#### Enable Auto-Routes
293+```toml
294+[auto_route]
295+enabled = true # Default: false
296+```
297+
298+#### How It Works
299+When you push an image with a domain name as the image name, Gordon automatically creates a route:
300+
301+```bash
302+# Build your app normally
303+podman build -t myapp .
304+
305+# Tag with domain name as the image name
306+podman tag myapp:latest registry.yourdomain.com/myapp.bamen.dev:latest
307+podman push registry.yourdomain.com/myapp.bamen.dev:latest
308+
309+# Gordon automatically creates:
310+# "myapp.bamen.dev" = "myapp.bamen.dev:latest"
311+# The route is added to your config file and deployed instantly!
312+```
313+
314+#### Perfect for Testing Workflows
315+```bash
316+# Test different domains for the same app
317+podman build -t myapp .
318+
319+# Push to different test domains using image names
320+podman tag myapp:latest registry.yourdomain.com/staging.example.com:latest
321+podman push registry.yourdomain.com/staging.example.com:latest
322+
323+podman tag myapp:latest registry.yourdomain.com/demo.example.com:v1.0.0
324+podman push registry.yourdomain.com/demo.example.com:v1.0.0
325+
326+# Both automatically get their own routes:
327+# staging.example.com -> staging.example.com:latest
328+# demo.example.com -> demo.example.com:v1.0.0
329+```
330+
331+#### Domain Validation
332+Gordon only creates auto-routes for valid domain names in image names:
333+- ✅ `api.example.com:latest` → Valid domain name, route created
334+- ✅ `staging.dev:v1.0.0` → Valid subdomain name, route created
335+- ❌ `myapp:latest` → Not a domain, ignored
336+- ❌ `myapp:v1.0.0` → Not a domain, ignored
337+- ❌ `localhost:latest` → Not a valid domain, ignored
338+
339+#### Route Precedence
340+- Existing routes in `[routes]` are never overwritten
341+- Auto-routes are saved to your config file permanently
342+- You can manually edit or remove auto-created routes anytime
343+
344+```
345+
282346 ## FAQ
283347
284348 **Q: How is this different from Traefik/Nginx Proxy Manager?**
@@ -305,6 +369,9 @@ A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <50MB.
305369 **Q: What about secrets?**
306370 A: Use environment variables in your container or Docker secrets. Gordon doesn't interfere.
307371
372+**Q: How does auto-route creation work?**
373+A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits.
374+
308375 ## Philosophy: Build Local, Deploy Simple
309376
310377 ### Why No Build Servers?
@@ -74,6 +74,9 @@ Push new versions anytime. Gordon handles graceful container swaps.
74 ### Automatic Deployment74 ### Automatic Deployment
75 Containers deploy instantly when you push new images. No manual deployment steps needed.75 Containers deploy instantly when you push new images. No manual deployment steps needed.
76 76
77+### Auto-Route Creation
78+Automatically create routes from image names containing domains. Push an image named like `myapp.bamen.dev:latest` and Gordon creates the route automatically when enabled.
79+
77 ## Quick Start (5 minutes)80 ## Quick Start (5 minutes)
78 81
79 ### 1. Get a VPS, Install Podman and UFW82 ### 1. Get a VPS, Install Podman and UFW
@@ -157,6 +160,9 @@ password = "your-secure-password"
157 "api.yourdomain.com" = "api:v1"160 "api.yourdomain.com" = "api:v1"
158 "blog.yourdomain.com" = "wordpress:latest"161 "blog.yourdomain.com" = "wordpress:latest"
159 162
163+[auto_route]
164+enabled = false # Enable automatic route creation
165+
160 # Custom config file location (optional):166 # Custom config file location (optional):
161 # gordon --config /path/to/custom.toml start167 # gordon --config /path/to/custom.toml start
162 ```168 ```
@@ -279,6 +285,64 @@ podman push registry.yourdomain.com/myapp:latest
279 "feature-xyz.yourdomain.com" = "myapp:feature-xyz" # Feature branch285 "feature-xyz.yourdomain.com" = "myapp:feature-xyz" # Feature branch
280 ```286 ```
281 287
288+### Auto-Route Creation for Testing
289+
290+Gordon can automatically create routes from image names that contain valid domain names. This is perfect for testing deployments without manually editing config files.
291+
292+#### Enable Auto-Routes
293+```toml
294+[auto_route]
295+enabled = true # Default: false
296+```
297+
298+#### How It Works
299+When you push an image with a domain name as the image name, Gordon automatically creates a route:
300+
301+```bash
302+# Build your app normally
303+podman build -t myapp .
304+
305+# Tag with domain name as the image name
306+podman tag myapp:latest registry.yourdomain.com/myapp.bamen.dev:latest
307+podman push registry.yourdomain.com/myapp.bamen.dev:latest
308+
309+# Gordon automatically creates:
310+# "myapp.bamen.dev" = "myapp.bamen.dev:latest"
311+# The route is added to your config file and deployed instantly!
312+```
313+
314+#### Perfect for Testing Workflows
315+```bash
316+# Test different domains for the same app
317+podman build -t myapp .
318+
319+# Push to different test domains using image names
320+podman tag myapp:latest registry.yourdomain.com/staging.example.com:latest
321+podman push registry.yourdomain.com/staging.example.com:latest
322+
323+podman tag myapp:latest registry.yourdomain.com/demo.example.com:v1.0.0
324+podman push registry.yourdomain.com/demo.example.com:v1.0.0
325+
326+# Both automatically get their own routes:
327+# staging.example.com -> staging.example.com:latest
328+# demo.example.com -> demo.example.com:v1.0.0
329+```
330+
331+#### Domain Validation
332+Gordon only creates auto-routes for valid domain names in image names:
333+- ✅ `api.example.com:latest` → Valid domain name, route created
334+- ✅ `staging.dev:v1.0.0` → Valid subdomain name, route created
335+- ❌ `myapp:latest` → Not a domain, ignored
336+- ❌ `myapp:v1.0.0` → Not a domain, ignored
337+- ❌ `localhost:latest` → Not a valid domain, ignored
338+
339+#### Route Precedence
340+- Existing routes in `[routes]` are never overwritten
341+- Auto-routes are saved to your config file permanently
342+- You can manually edit or remove auto-created routes anytime
343+
344+```
345+
282 ## FAQ346 ## FAQ
283 347
284 **Q: How is this different from Traefik/Nginx Proxy Manager?** 348 **Q: How is this different from Traefik/Nginx Proxy Manager?**
@@ -305,6 +369,9 @@ A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <50MB.
305 **Q: What about secrets?** 369 **Q: What about secrets?**
306 A: Use environment variables in your container or Docker secrets. Gordon doesn't interfere.370 A: Use environment variables in your container or Docker secrets. Gordon doesn't interfere.
307 371
372+**Q: How does auto-route creation work?**
373+A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits.
374+
308 ## Philosophy: Build Local, Deploy Simple375 ## Philosophy: Build Local, Deploy Simple
309 376
310 ### Why No Build Servers?377 ### Why No Build Servers?
M cmd/start.go +78 -231
@@ -2,10 +2,8 @@ package cmd
22
33 import (
44 "context"
5- "fmt"
65 "os"
76 "os/signal"
8- "strings"
97 "sync"
108 "syscall"
119 "time"
@@ -67,10 +65,30 @@ func runStart(cmd *cobra.Command, args []string) {
6765 log.Fatal().Err(err).Msg("Failed to subscribe container event handler")
6866 }
6967
68+ // Create and subscribe auto-route event handler
69+ var autoRouteHandler *events.AutoRouteHandler
70+ autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
71+ if err := eventBus.Subscribe(autoRouteHandler); err != nil {
72+ log.Fatal().Err(err).Msg("Failed to subscribe auto-route event handler")
73+ }
74+
75+ // Start proxy server (needs to be accessible for config reload)
76+ proxyServer := proxy.NewServer(cfg, manager)
77+
78+ // Create and subscribe proxy event handler
79+ var proxyHandler *proxy.ProxyEventHandler
80+ proxyHandler = proxy.NewProxyEventHandler(proxyServer)
81+ if err := eventBus.Subscribe(proxyHandler); err != nil {
82+ log.Fatal().Err(err).Msg("Failed to subscribe proxy event handler")
83+ }
84+
7085 // Create context for the entire server lifecycle
7186 ctx, cancel := context.WithCancel(context.Background())
7287
73- // Setup config file watching for live reload
88+ // Setup config file watching for live reload with debouncing
89+ var reloadTimer *time.Timer
90+ var reloadMutex sync.Mutex
91+
7492 viper.WatchConfig()
7593 viper.OnConfigChange(func(e fsnotify.Event) {
7694 log.Info().
@@ -78,35 +96,67 @@ func runStart(cmd *cobra.Command, args []string) {
7896 Str("op", e.Op.String()).
7997 Msg("Configuration file changed, reloading...")
8098
81- // Reload configuration
82- newCfg, err := config.Load()
83- if err != nil {
84- log.Error().Err(err).Msg("Failed to reload configuration")
85- return
99+ // Debounce config reloads to prevent multiple events from a single logical change
100+ reloadMutex.Lock()
101+ defer reloadMutex.Unlock()
102+
103+ if reloadTimer != nil {
104+ reloadTimer.Stop()
86105 }
106+
107+ reloadTimer = time.AfterFunc(500*time.Millisecond, func() {
108+ // Reload configuration
109+ newCfg, err := config.Load()
110+ if err != nil {
111+ log.Error().Err(err).Msg("Failed to reload configuration")
112+ return
113+ }
87114
88- // Check for new routes and deploy containers if needed
89- if err := handleConfigReload(ctx, cfg, newCfg, manager); err != nil {
90- log.Error().Err(err).Msg("Failed to handle config reload")
91- return
92- }
115+ // Update the configuration reference
116+ cfg = newCfg
93117
94- // Update the configuration reference
95- cfg = newCfg
118+ // Update proxy server config
119+ proxyServer.UpdateConfig(cfg)
96120
97- // Update container handler config
98- containerHandler = events.NewContainerEventHandler(manager, cfg)
99- if err := eventBus.Subscribe(containerHandler); err != nil {
100- log.Error().Err(err).Msg("Failed to re-subscribe container event handler")
101- return
102- }
121+ // Unsubscribe old handlers before creating new ones
122+ if err := eventBus.Unsubscribe(containerHandler); err != nil {
123+ log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler")
124+ }
125+ if err := eventBus.Unsubscribe(autoRouteHandler); err != nil {
126+ log.Warn().Err(err).Msg("Failed to unsubscribe old auto-route event handler")
127+ }
128+ if err := eventBus.Unsubscribe(proxyHandler); err != nil {
129+ log.Warn().Err(err).Msg("Failed to unsubscribe old proxy event handler")
130+ }
103131
104- // Publish config reload event
105- if err := eventBus.Publish(events.ConfigReload, nil); err != nil {
106- log.Error().Err(err).Msg("Failed to publish config reload event")
107- } else {
108- log.Info().Msg("Configuration reloaded successfully")
109- }
132+ // Update container handler config
133+ containerHandler = events.NewContainerEventHandler(manager, cfg)
134+ if err := eventBus.Subscribe(containerHandler); err != nil {
135+ log.Error().Err(err).Msg("Failed to re-subscribe container event handler")
136+ return
137+ }
138+
139+ // Update auto-route handler config
140+ autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
141+ if err := eventBus.Subscribe(autoRouteHandler); err != nil {
142+ log.Error().Err(err).Msg("Failed to re-subscribe auto-route event handler")
143+ return
144+ }
145+
146+ // Re-subscribe proxy handler (config ref updated automatically)
147+ proxyHandler = proxy.NewProxyEventHandler(proxyServer)
148+ if err := eventBus.Subscribe(proxyHandler); err != nil {
149+ log.Error().Err(err).Msg("Failed to re-subscribe proxy event handler")
150+ return
151+ }
152+
153+ // Publish config reload event to handle route changes
154+ if err := eventBus.Publish(events.ConfigReload, nil); err != nil {
155+ log.Error().Err(err).Msg("Failed to publish config reload event")
156+ } else {
157+ log.Info().Msg("Configuration reloaded successfully")
158+ }
159+ })
110160 })
111161
112162 // Sync existing containers
@@ -135,8 +185,7 @@ func runStart(cmd *cobra.Command, args []string) {
135185 }
136186 }()
137187
138- // Start proxy server
139- proxyServer := proxy.NewServer(cfg, manager)
188+ // Start proxy server (already created above)
140189 wg.Add(1)
141190 go func() {
142191 defer wg.Done()
@@ -181,205 +230,3 @@ func runStart(cmd *cobra.Command, args []string) {
181230 log.Warn().Msg("Force shutdown after timeout")
182231 }
183232 }
184-
185-// handleConfigReload compares old and new configurations to detect new routes
186-// and automatically deploys containers for them if images are available
187-func handleConfigReload(ctx context.Context, oldCfg, newCfg *config.Config, manager *container.Manager) error {
188- oldRoutes := make(map[string]string) // domain -> image
189- newRoutes := make(map[string]string) // domain -> image
190-
191- // Build maps of routes for comparison
192- for _, route := range oldCfg.GetRoutes() {
193- oldRoutes[route.Domain] = route.Image
194- }
195-
196- for _, route := range newCfg.GetRoutes() {
197- newRoutes[route.Domain] = route.Image
198- }
199-
200- // Find new routes and changed routes
201- var routesToDeploy []config.Route
202-
203- for domain, newImage := range newRoutes {
204- if oldImage, exists := oldRoutes[domain]; !exists {
205- // New route detected
206- log.Info().Str("domain", domain).Str("image", newImage).Msg("New route detected in config")
207- routesToDeploy = append(routesToDeploy, config.Route{
208- Domain: domain,
209- Image: newImage,
210- HTTPS: true, // Default to HTTPS
211- })
212- } else if oldImage != newImage {
213- // Route with changed image
214- log.Info().
215- Str("domain", domain).
216- Str("old_image", oldImage).
217- Str("new_image", newImage).
218- Msg("Route image changed in config")
219- routesToDeploy = append(routesToDeploy, config.Route{
220- Domain: domain,
221- Image: newImage,
222- HTTPS: true, // Default to HTTPS
223- })
224- }
225- }
226-
227- // Check for removed routes
228- for domain := range oldRoutes {
229- if _, exists := newRoutes[domain]; !exists {
230- log.Info().Str("domain", domain).Msg("Route removed from config, stopping container")
231- if err := manager.StopContainerByDomain(ctx, domain); err != nil {
232- log.Warn().Err(err).Str("domain", domain).Msg("Failed to stop container for removed route")
233- }
234- if err := manager.RemoveContainerByDomain(ctx, domain, true); err != nil {
235- log.Warn().Err(err).Str("domain", domain).Msg("Failed to remove container for removed route")
236- }
237- }
238- }
239-
240- if len(routesToDeploy) == 0 {
241- log.Info().Msg("No new or changed routes detected")
242- return nil
243- }
244-
245- log.Info().Int("count", len(routesToDeploy)).Msg("Processing new/changed routes")
246-
247- // For each new/changed route, check if image is available and deploy
248- deployedCount := 0
249- for _, route := range routesToDeploy {
250- if err := deployRouteIfImageAvailable(ctx, route, newCfg, manager); err != nil {
251- log.Error().
252- Err(err).
253- Str("domain", route.Domain).
254- Str("image", route.Image).
255- Msg("Failed to deploy route")
256- } else {
257- deployedCount++
258- }
259- }
260-
261- log.Info().
262- Int("deployed", deployedCount).
263- Int("total", len(routesToDeploy)).
264- Msg("Config reload deployment completed")
265-
266- return nil
267-}
268-
269-// deployRouteIfImageAvailable checks if an image is available and deploys the container
270-func deployRouteIfImageAvailable(ctx context.Context, route config.Route, cfg *config.Config, manager *container.Manager) error {
271- // Build the full image reference
272- imageRef := route.Image
273-
274- // If registry auth is enabled and image doesn't already contain a registry domain, prepend it
275- if cfg.RegistryAuth.Enabled && cfg.Server.RegistryDomain != "" {
276- // Check if image already contains a registry domain (has a '.' and doesn't start with official Docker Hub libraries)
277- if !strings.Contains(strings.Split(imageRef, ":")[0], ".") {
278- imageRef = fmt.Sprintf("%s/%s", cfg.Server.RegistryDomain, route.Image)
279- }
280- }
281-
282- log.Info().
283- Str("domain", route.Domain).
284- Str("image", route.Image).
285- Str("full_image_ref", imageRef).
286- Msg("Checking if image is available for new route")
287-
288- // Check if image is available locally first
289- images, err := manager.Runtime().ListImages(ctx)
290- if err != nil {
291- log.Warn().Err(err).Msg("Failed to list local images, will attempt to pull")
292- } else {
293- // Check if image exists locally
294- imageAvailableLocally := false
295- normalizedImageRef := normalizeImageRef(imageRef)
296-
297- for _, localImage := range images {
298- if normalizeImageRef(localImage) == normalizedImageRef {
299- imageAvailableLocally = true
300- log.Info().
301- Str("domain", route.Domain).
302- Str("image", imageRef).
303- Msg("Image available locally, deploying container")
304- break
305- }
306- }
307-
308- if imageAvailableLocally {
309- // Image is available locally, deploy immediately
310- _, err := manager.DeployContainer(ctx, route)
311- if err != nil {
312- return fmt.Errorf("failed to deploy container for route %s: %w", route.Domain, err)
313- }
314-
315- log.Info().
316- Str("domain", route.Domain).
317- Str("image", route.Image).
318- Msg("Container deployed successfully for new route")
319-
320- return nil
321- }
322- }
323-
324- // Image not available locally, try to pull it
325- log.Info().
326- Str("domain", route.Domain).
327- Str("image", imageRef).
328- Msg("Image not available locally, attempting to pull")
329-
330- // Use authenticated pull if registry auth is configured
331- if cfg.RegistryAuth.Enabled {
332- err = manager.Runtime().PullImageWithAuth(ctx, imageRef, cfg.RegistryAuth.Username, cfg.RegistryAuth.Password)
333- } else {
334- err = manager.Runtime().PullImage(ctx, imageRef)
335- }
336-
337- if err != nil {
338- log.Warn().
339- Err(err).
340- Str("domain", route.Domain).
341- Str("image", imageRef).
342- Msg("Failed to pull image for new route, container will be deployed when image becomes available")
343- return fmt.Errorf("image %s not available for route %s: %w", imageRef, route.Domain, err)
344- }
345-
346- log.Info().
347- Str("domain", route.Domain).
348- Str("image", imageRef).
349- Msg("Image pulled successfully, deploying container")
350-
351- // Image pulled successfully, now deploy the container
352- _, err = manager.DeployContainer(ctx, route)
353- if err != nil {
354- return fmt.Errorf("failed to deploy container for route %s after pulling image: %w", route.Domain, err)
355- }
356-
357- log.Info().
358- Str("domain", route.Domain).
359- Str("image", route.Image).
360- Msg("Container deployed successfully for new route after pulling image")
361-
362- return nil
363-}
364-
365-// normalizeImageRef normalizes image references for comparison
366-func normalizeImageRef(imageRef string) string {
367- // Split image and tag
368- parts := strings.Split(imageRef, ":")
369- image := parts[0]
370- tag := "latest"
371- if len(parts) > 1 {
372- tag = parts[1]
373- }
374-
375- // Normalize Docker Hub references
376- if !strings.Contains(image, "/") {
377- // Official library image (e.g., "nginx" -> "docker.io/library/nginx")
378- image = "docker.io/library/" + image
379- } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") {
380- // User repository (e.g., "user/repo" -> "docker.io/user/repo")
381- image = "docker.io/" + image
382- }
383-
384- return image + ":" + tag
385-}
@@ -2,10 +2,8 @@ package cmd
2 2
3 import (3 import (
4 "context"4 "context"
5- "fmt"
6 "os"5 "os"
7 "os/signal"6 "os/signal"
8- "strings"
9 "sync"7 "sync"
10 "syscall"8 "syscall"
11 "time"9 "time"
@@ -67,10 +65,30 @@ func runStart(cmd *cobra.Command, args []string) {
67 log.Fatal().Err(err).Msg("Failed to subscribe container event handler")65 log.Fatal().Err(err).Msg("Failed to subscribe container event handler")
68 }66 }
69 67
68+ // Create and subscribe auto-route event handler
69+ var autoRouteHandler *events.AutoRouteHandler
70+ autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
71+ if err := eventBus.Subscribe(autoRouteHandler); err != nil {
72+ log.Fatal().Err(err).Msg("Failed to subscribe auto-route event handler")
73+ }
74+
75+ // Start proxy server (needs to be accessible for config reload)
76+ proxyServer := proxy.NewServer(cfg, manager)
77+
78+ // Create and subscribe proxy event handler
79+ var proxyHandler *proxy.ProxyEventHandler
80+ proxyHandler = proxy.NewProxyEventHandler(proxyServer)
81+ if err := eventBus.Subscribe(proxyHandler); err != nil {
82+ log.Fatal().Err(err).Msg("Failed to subscribe proxy event handler")
83+ }
84+
70 // Create context for the entire server lifecycle85 // Create context for the entire server lifecycle
71 ctx, cancel := context.WithCancel(context.Background())86 ctx, cancel := context.WithCancel(context.Background())
72 87
73- // Setup config file watching for live reload88+ // Setup config file watching for live reload with debouncing
89+ var reloadTimer *time.Timer
90+ var reloadMutex sync.Mutex
91+
74 viper.WatchConfig()92 viper.WatchConfig()
75 viper.OnConfigChange(func(e fsnotify.Event) {93 viper.OnConfigChange(func(e fsnotify.Event) {
76 log.Info().94 log.Info().
@@ -78,35 +96,67 @@ func runStart(cmd *cobra.Command, args []string) {
78 Str("op", e.Op.String()).96 Str("op", e.Op.String()).
79 Msg("Configuration file changed, reloading...")97 Msg("Configuration file changed, reloading...")
80 98
81- // Reload configuration99+ // Debounce config reloads to prevent multiple events from a single logical change
82- newCfg, err := config.Load()100+ reloadMutex.Lock()
83- if err != nil {101+ defer reloadMutex.Unlock()
84- log.Error().Err(err).Msg("Failed to reload configuration")102+
85- return103+ if reloadTimer != nil {
104+ reloadTimer.Stop()
86 }105 }
106+
107+ reloadTimer = time.AfterFunc(500*time.Millisecond, func() {
108+ // Reload configuration
109+ newCfg, err := config.Load()
110+ if err != nil {
111+ log.Error().Err(err).Msg("Failed to reload configuration")
112+ return
113+ }
87 114
88- // Check for new routes and deploy containers if needed115+ // Update the configuration reference
89- if err := handleConfigReload(ctx, cfg, newCfg, manager); err != nil {116+ cfg = newCfg
90- log.Error().Err(err).Msg("Failed to handle config reload")
91- return
92- }
93 117
94- // Update the configuration reference118+ // Update proxy server config
95- cfg = newCfg119+ proxyServer.UpdateConfig(cfg)
96 120
97- // Update container handler config121+ // Unsubscribe old handlers before creating new ones
98- containerHandler = events.NewContainerEventHandler(manager, cfg)122+ if err := eventBus.Unsubscribe(containerHandler); err != nil {
99- if err := eventBus.Subscribe(containerHandler); err != nil {123+ log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler")
100- log.Error().Err(err).Msg("Failed to re-subscribe container event handler")124+ }
101- return125+ if err := eventBus.Unsubscribe(autoRouteHandler); err != nil {
102- }126+ log.Warn().Err(err).Msg("Failed to unsubscribe old auto-route event handler")
127+ }
128+ if err := eventBus.Unsubscribe(proxyHandler); err != nil {
129+ log.Warn().Err(err).Msg("Failed to unsubscribe old proxy event handler")
130+ }
103 131
104- // Publish config reload event132+ // Update container handler config
105- if err := eventBus.Publish(events.ConfigReload, nil); err != nil {133+ containerHandler = events.NewContainerEventHandler(manager, cfg)
106- log.Error().Err(err).Msg("Failed to publish config reload event")134+ if err := eventBus.Subscribe(containerHandler); err != nil {
107- } else {135+ log.Error().Err(err).Msg("Failed to re-subscribe container event handler")
108- log.Info().Msg("Configuration reloaded successfully")136+ return
109- }137+ }
138+
139+ // Update auto-route handler config
140+ autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
141+ if err := eventBus.Subscribe(autoRouteHandler); err != nil {
142+ log.Error().Err(err).Msg("Failed to re-subscribe auto-route event handler")
143+ return
144+ }
145+
146+ // Re-subscribe proxy handler (config ref updated automatically)
147+ proxyHandler = proxy.NewProxyEventHandler(proxyServer)
148+ if err := eventBus.Subscribe(proxyHandler); err != nil {
149+ log.Error().Err(err).Msg("Failed to re-subscribe proxy event handler")
150+ return
151+ }
152+
153+ // Publish config reload event to handle route changes
154+ if err := eventBus.Publish(events.ConfigReload, nil); err != nil {
155+ log.Error().Err(err).Msg("Failed to publish config reload event")
156+ } else {
157+ log.Info().Msg("Configuration reloaded successfully")
158+ }
159+ })
110 })160 })
111 161
112 // Sync existing containers162 // Sync existing containers
@@ -135,8 +185,7 @@ func runStart(cmd *cobra.Command, args []string) {
135 }185 }
136 }()186 }()
137 187
138- // Start proxy server188+ // Start proxy server (already created above)
139- proxyServer := proxy.NewServer(cfg, manager)
140 wg.Add(1)189 wg.Add(1)
141 go func() {190 go func() {
142 defer wg.Done()191 defer wg.Done()
@@ -181,205 +230,3 @@ func runStart(cmd *cobra.Command, args []string) {
181 log.Warn().Msg("Force shutdown after timeout")230 log.Warn().Msg("Force shutdown after timeout")
182 }231 }
183 }232 }
184-
185-// handleConfigReload compares old and new configurations to detect new routes
186-// and automatically deploys containers for them if images are available
187-func handleConfigReload(ctx context.Context, oldCfg, newCfg *config.Config, manager *container.Manager) error {
188- oldRoutes := make(map[string]string) // domain -> image
189- newRoutes := make(map[string]string) // domain -> image
190-
191- // Build maps of routes for comparison
192- for _, route := range oldCfg.GetRoutes() {
193- oldRoutes[route.Domain] = route.Image
194- }
195-
196- for _, route := range newCfg.GetRoutes() {
197- newRoutes[route.Domain] = route.Image
198- }
199-
200- // Find new routes and changed routes
201- var routesToDeploy []config.Route
202-
203- for domain, newImage := range newRoutes {
204- if oldImage, exists := oldRoutes[domain]; !exists {
205- // New route detected
206- log.Info().Str("domain", domain).Str("image", newImage).Msg("New route detected in config")
207- routesToDeploy = append(routesToDeploy, config.Route{
208- Domain: domain,
209- Image: newImage,
210- HTTPS: true, // Default to HTTPS
211- })
212- } else if oldImage != newImage {
213- // Route with changed image
214- log.Info().
215- Str("domain", domain).
216- Str("old_image", oldImage).
217- Str("new_image", newImage).
218- Msg("Route image changed in config")
219- routesToDeploy = append(routesToDeploy, config.Route{
220- Domain: domain,
221- Image: newImage,
222- HTTPS: true, // Default to HTTPS
223- })
224- }
225- }
226-
227- // Check for removed routes
228- for domain := range oldRoutes {
229- if _, exists := newRoutes[domain]; !exists {
230- log.Info().Str("domain", domain).Msg("Route removed from config, stopping container")
231- if err := manager.StopContainerByDomain(ctx, domain); err != nil {
232- log.Warn().Err(err).Str("domain", domain).Msg("Failed to stop container for removed route")
233- }
234- if err := manager.RemoveContainerByDomain(ctx, domain, true); err != nil {
235- log.Warn().Err(err).Str("domain", domain).Msg("Failed to remove container for removed route")
236- }
237- }
238- }
239-
240- if len(routesToDeploy) == 0 {
241- log.Info().Msg("No new or changed routes detected")
242- return nil
243- }
244-
245- log.Info().Int("count", len(routesToDeploy)).Msg("Processing new/changed routes")
246-
247- // For each new/changed route, check if image is available and deploy
248- deployedCount := 0
249- for _, route := range routesToDeploy {
250- if err := deployRouteIfImageAvailable(ctx, route, newCfg, manager); err != nil {
251- log.Error().
252- Err(err).
253- Str("domain", route.Domain).
254- Str("image", route.Image).
255- Msg("Failed to deploy route")
256- } else {
257- deployedCount++
258- }
259- }
260-
261- log.Info().
262- Int("deployed", deployedCount).
263- Int("total", len(routesToDeploy)).
264- Msg("Config reload deployment completed")
265-
266- return nil
267-}
268-
269-// deployRouteIfImageAvailable checks if an image is available and deploys the container
270-func deployRouteIfImageAvailable(ctx context.Context, route config.Route, cfg *config.Config, manager *container.Manager) error {
271- // Build the full image reference
272- imageRef := route.Image
273-
274- // If registry auth is enabled and image doesn't already contain a registry domain, prepend it
275- if cfg.RegistryAuth.Enabled && cfg.Server.RegistryDomain != "" {
276- // Check if image already contains a registry domain (has a '.' and doesn't start with official Docker Hub libraries)
277- if !strings.Contains(strings.Split(imageRef, ":")[0], ".") {
278- imageRef = fmt.Sprintf("%s/%s", cfg.Server.RegistryDomain, route.Image)
279- }
280- }
281-
282- log.Info().
283- Str("domain", route.Domain).
284- Str("image", route.Image).
285- Str("full_image_ref", imageRef).
286- Msg("Checking if image is available for new route")
287-
288- // Check if image is available locally first
289- images, err := manager.Runtime().ListImages(ctx)
290- if err != nil {
291- log.Warn().Err(err).Msg("Failed to list local images, will attempt to pull")
292- } else {
293- // Check if image exists locally
294- imageAvailableLocally := false
295- normalizedImageRef := normalizeImageRef(imageRef)
296-
297- for _, localImage := range images {
298- if normalizeImageRef(localImage) == normalizedImageRef {
299- imageAvailableLocally = true
300- log.Info().
301- Str("domain", route.Domain).
302- Str("image", imageRef).
303- Msg("Image available locally, deploying container")
304- break
305- }
306- }
307-
308- if imageAvailableLocally {
309- // Image is available locally, deploy immediately
310- _, err := manager.DeployContainer(ctx, route)
311- if err != nil {
312- return fmt.Errorf("failed to deploy container for route %s: %w", route.Domain, err)
313- }
314-
315- log.Info().
316- Str("domain", route.Domain).
317- Str("image", route.Image).
318- Msg("Container deployed successfully for new route")
319-
320- return nil
321- }
322- }
323-
324- // Image not available locally, try to pull it
325- log.Info().
326- Str("domain", route.Domain).
327- Str("image", imageRef).
328- Msg("Image not available locally, attempting to pull")
329-
330- // Use authenticated pull if registry auth is configured
331- if cfg.RegistryAuth.Enabled {
332- err = manager.Runtime().PullImageWithAuth(ctx, imageRef, cfg.RegistryAuth.Username, cfg.RegistryAuth.Password)
333- } else {
334- err = manager.Runtime().PullImage(ctx, imageRef)
335- }
336-
337- if err != nil {
338- log.Warn().
339- Err(err).
340- Str("domain", route.Domain).
341- Str("image", imageRef).
342- Msg("Failed to pull image for new route, container will be deployed when image becomes available")
343- return fmt.Errorf("image %s not available for route %s: %w", imageRef, route.Domain, err)
344- }
345-
346- log.Info().
347- Str("domain", route.Domain).
348- Str("image", imageRef).
349- Msg("Image pulled successfully, deploying container")
350-
351- // Image pulled successfully, now deploy the container
352- _, err = manager.DeployContainer(ctx, route)
353- if err != nil {
354- return fmt.Errorf("failed to deploy container for route %s after pulling image: %w", route.Domain, err)
355- }
356-
357- log.Info().
358- Str("domain", route.Domain).
359- Str("image", route.Image).
360- Msg("Container deployed successfully for new route after pulling image")
361-
362- return nil
363-}
364-
365-// normalizeImageRef normalizes image references for comparison
366-func normalizeImageRef(imageRef string) string {
367- // Split image and tag
368- parts := strings.Split(imageRef, ":")
369- image := parts[0]
370- tag := "latest"
371- if len(parts) > 1 {
372- tag = parts[1]
373- }
374-
375- // Normalize Docker Hub references
376- if !strings.Contains(image, "/") {
377- // Official library image (e.g., "nginx" -> "docker.io/library/nginx")
378- image = "docker.io/library/" + image
379- } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") {
380- // User repository (e.g., "user/repo" -> "docker.io/user/repo")
381- image = "docker.io/" + image
382- }
383-
384- return image + ":" + tag
385-}
M examples/development.toml +30 -23
@@ -4,7 +4,7 @@
44 [server]
55 port = 8080
66 registry_port = 5000
7-registry_domain = "localhost:8080" # Use localhost for local development
7+registry_domain = "registry.yourdomain.com" # Your Gordon registry domain
88 runtime = "auto" # auto-detects Docker/Podman
99 ssl_email = "dev@localhost"
1010 data_dir = "./dev-data"
@@ -12,33 +12,40 @@ data_dir = "./dev-data"
1212 [registry_auth]
1313 enabled = false # Disable auth for easier local development
1414
15-# Local development routes using .local domains
15+[auto_route]
16+enabled = true # Enable automatic route creation from image names
17+
18+# Development routes using your domain
1619 [routes]
17-"app.local" = "myapp:latest" # Main app - latest build
18-"api.local" = "myapi:latest" # API service
19-"admin.local" = "admin:latest" # Admin interface
20-"docs.local" = "docs:latest" # Documentation
20+"app.yourdomain.com" = "myapp:latest" # Main app - latest build
21+"api.yourdomain.com" = "myapi:latest" # API service
22+"admin.yourdomain.com" = "admin:latest" # Admin interface
23+"docs.yourdomain.com" = "docs:latest" # Documentation
2124
22-# Test different versions locally
23-"app-v1.local" = "myapp:v1.0.0" # Test older version
24-"app-v2.local" = "myapp:v2.0.0" # Test newer version
25+# Test different versions
26+"app-v1.yourdomain.com" = "myapp:v1.0.0" # Test older version
27+"app-v2.yourdomain.com" = "myapp:v2.0.0" # Test newer version
2528
2629 # Third-party services for development
27-"db-admin.local" = "phpmyadmin:latest" # Database admin
28-"mail.local" = "mailhog/mailhog:latest" # Email testing
30+"db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin
31+"mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing
2932
30-# Local development workflow:
31-# 1. Add to /etc/hosts:
32-# 127.0.0.1 app.local
33-# 127.0.0.1 api.local
34-# 127.0.0.1 admin.local
35-# 127.0.0.1 docs.local
36-# 127.0.0.1 localhost
33+# Development workflow:
34+# 1. Point DNS to your Gordon server:
35+# A *.yourdomain.com → YOUR_GORDON_SERVER_IP
36+# A yourdomain.com → YOUR_GORDON_SERVER_IP
3737 #
3838 # 2. Build your app: docker build -t myapp:latest .
39-# 3. Push to local registry:
40-# docker tag myapp:latest localhost:8080/myapp:latest
41-# docker push localhost:8080/myapp:latest
42-# 4. Access at http://app.local:8080
39+# 3. Push to Gordon registry:
40+# docker tag myapp:latest registry.yourdomain.com/myapp:latest
41+# docker push registry.yourdomain.com/myapp:latest
42+# 4. Access at https://app.yourdomain.com
43+#
44+# AUTO-ROUTE FEATURE:
45+# With auto_route.enabled = true, you can also push images with domain names as image names:
46+# docker build -t myapp .
47+# docker tag myapp:latest registry.yourdomain.com/mytestapp.bamen.dev:latest
48+# docker push registry.yourdomain.com/mytestapp.bamen.dev:latest
49+# Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest
4350 #
44-# No Cloudflare needed - everything runs locally!⏎̸
51+# Cloudflare handles SSL and DNS for your remote Gordon server!⏎̸
@@ -4,7 +4,7 @@
4 [server]4 [server]
5 port = 80805 port = 8080
6 registry_port = 50006 registry_port = 5000
7-registry_domain = "localhost:8080" # Use localhost for local development7+registry_domain = "registry.yourdomain.com" # Your Gordon registry domain
8 runtime = "auto" # auto-detects Docker/Podman8 runtime = "auto" # auto-detects Docker/Podman
9 ssl_email = "dev@localhost"9 ssl_email = "dev@localhost"
10 data_dir = "./dev-data"10 data_dir = "./dev-data"
@@ -12,33 +12,40 @@ data_dir = "./dev-data"
12 [registry_auth]12 [registry_auth]
13 enabled = false # Disable auth for easier local development13 enabled = false # Disable auth for easier local development
14 14
15-# Local development routes using .local domains15+[auto_route]
16+enabled = true # Enable automatic route creation from image names
17+
18+# Development routes using your domain
16 [routes]19 [routes]
17-"app.local" = "myapp:latest" # Main app - latest build20+"app.yourdomain.com" = "myapp:latest" # Main app - latest build
18-"api.local" = "myapi:latest" # API service 21+"api.yourdomain.com" = "myapi:latest" # API service
19-"admin.local" = "admin:latest" # Admin interface22+"admin.yourdomain.com" = "admin:latest" # Admin interface
20-"docs.local" = "docs:latest" # Documentation23+"docs.yourdomain.com" = "docs:latest" # Documentation
21 24
22-# Test different versions locally25+# Test different versions
23-"app-v1.local" = "myapp:v1.0.0" # Test older version26+"app-v1.yourdomain.com" = "myapp:v1.0.0" # Test older version
24-"app-v2.local" = "myapp:v2.0.0" # Test newer version27+"app-v2.yourdomain.com" = "myapp:v2.0.0" # Test newer version
25 28
26 # Third-party services for development29 # Third-party services for development
27-"db-admin.local" = "phpmyadmin:latest" # Database admin30+"db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin
28-"mail.local" = "mailhog/mailhog:latest" # Email testing31+"mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing
29 32
30-# Local development workflow:33+# Development workflow:
31-# 1. Add to /etc/hosts:34+# 1. Point DNS to your Gordon server:
32-# 127.0.0.1 app.local35+# A *.yourdomain.com → YOUR_GORDON_SERVER_IP
33-# 127.0.0.1 api.local 36+# A yourdomain.com → YOUR_GORDON_SERVER_IP
34-# 127.0.0.1 admin.local
35-# 127.0.0.1 docs.local
36-# 127.0.0.1 localhost
37 #37 #
38 # 2. Build your app: docker build -t myapp:latest .38 # 2. Build your app: docker build -t myapp:latest .
39-# 3. Push to local registry: 39+# 3. Push to Gordon registry:
40-# docker tag myapp:latest localhost:8080/myapp:latest40+# docker tag myapp:latest registry.yourdomain.com/myapp:latest
41-# docker push localhost:8080/myapp:latest41+# docker push registry.yourdomain.com/myapp:latest
42-# 4. Access at http://app.local:808042+# 4. Access at https://app.yourdomain.com
43+#
44+# AUTO-ROUTE FEATURE:
45+# With auto_route.enabled = true, you can also push images with domain names as image names:
46+# docker build -t myapp .
47+# docker tag myapp:latest registry.yourdomain.com/mytestapp.bamen.dev:latest
48+# docker push registry.yourdomain.com/mytestapp.bamen.dev:latest
49+# Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest
43 #50 #
44-# No Cloudflare needed - everything runs locally!⏎̸51+# Cloudflare handles SSL and DNS for your remote Gordon server!⏎̸
M internal/config/config.go +105 -4
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "os"
66 "path/filepath"
7+ "regexp"
78 "strings"
89
910 "github.com/rs/zerolog/log"
@@ -14,6 +15,7 @@ type Config struct {
1415 Server ServerConfig `mapstructure:"server"`
1516 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
1617 Routes map[string]string `mapstructure:"routes"`
18+ AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
1719 }
1820
1921 type ServerConfig struct {
@@ -32,6 +34,10 @@ type RegistryAuthConfig struct {
3234 Password string `mapstructure:"password"`
3335 }
3436
37+type AutoRouteConfig struct {
38+ Enabled bool `mapstructure:"enabled"`
39+}
40+
3541 type Route struct {
3642 Domain string
3743 Image string
@@ -51,6 +57,7 @@ func Load() (*Config, error) {
5157 defaultDataDir := getDefaultDataDir()
5258 viper.SetDefault("server.data_dir", defaultDataDir)
5359 viper.SetDefault("registry_auth.enabled", true)
60+ viper.SetDefault("auto_route.enabled", false)
5461
5562 // Handle the routes manually since Viper struggles with domain names
5663 cfg.Routes = make(map[string]string)
@@ -76,6 +83,11 @@ func Load() (*Config, error) {
7683 if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {
7784 return nil, fmt.Errorf("unable to decode registry auth config: %v", err)
7885 }
86+
87+ // Get auto route config
88+ if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {
89+ return nil, fmt.Errorf("unable to decode auto route config: %v", err)
90+ }
7991
8092 // Get routes manually from the raw config
8193 routesRaw := viper.Get("routes")
@@ -89,10 +101,8 @@ func Load() (*Config, error) {
89101 }
90102 }
91103
92- // Validate required fields
93- if cfg.Server.SSLEmail == "" {
94- return nil, fmt.Errorf("server.ssl_email is required")
95- }
104+ // Note: ssl_email is optional and only needed for future Let's Encrypt integration
105+ // Currently using Cloudflare for HTTPS
96106
97107 validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}
98108 isValid := false
@@ -167,4 +177,95 @@ func getDefaultDataDir() string {
167177
168178 // For root or when home directory is not available, use relative path
169179 return "./data"
180+}
181+
182+// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest"
183+func ExtractDomainFromImageName(imageName string) (string, bool) {
184+ // Split by colon to separate image name from tag
185+ parts := strings.Split(imageName, ":")
186+ imageNamePart := parts[0]
187+
188+ // Domain pattern: should contain at least one dot and valid domain characters
189+ domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
190+
191+ if domainPattern.MatchString(imageNamePart) {
192+ return imageNamePart, true
193+ }
194+
195+ return "", false
196+}
197+
198+// AddRoute adds a new route to the configuration and saves it
199+func (c *Config) AddRoute(domain, image string) error {
200+ if c.Routes == nil {
201+ c.Routes = make(map[string]string)
202+ }
203+
204+ c.Routes[domain] = image
205+
206+ // Read the current config file to preserve formatting
207+ configFile := viper.ConfigFileUsed()
208+ if configFile == "" {
209+ return fmt.Errorf("no config file path available")
210+ }
211+
212+ // Read existing content
213+ content, err := os.ReadFile(configFile)
214+ if err != nil {
215+ return fmt.Errorf("failed to read config file: %w", err)
216+ }
217+
218+ // Parse and update only the routes section while preserving other formatting
219+ lines := strings.Split(string(content), "\n")
220+ var newLines []string
221+ inRoutesSection := false
222+ routeAdded := false
223+
224+ for _, line := range lines {
225+ trimmed := strings.TrimSpace(line)
226+
227+ // Check if we're entering or leaving the routes section
228+ if trimmed == "[routes]" {
229+ inRoutesSection = true
230+ newLines = append(newLines, line)
231+ continue
232+ }
233+
234+ // Check if we're entering a new section
235+ if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
236+ // If we were in routes section and haven't added the route yet, add it
237+ if inRoutesSection && !routeAdded {
238+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
239+ routeAdded = true
240+ }
241+ inRoutesSection = false
242+ }
243+
244+ // If we're in routes section, check if this route already exists (check both quote styles)
245+ if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
246+ // Update existing route using double quotes for consistency
247+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
248+ routeAdded = true
249+ } else {
250+ newLines = append(newLines, line)
251+ }
252+ }
253+
254+ // If we were in routes section at the end and haven't added the route yet, add it
255+ if inRoutesSection && !routeAdded {
256+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
257+ }
258+
259+ // Write the updated content back
260+ newContent := strings.Join(newLines, "\n")
261+ if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
262+ return fmt.Errorf("failed to write config file: %w", err)
263+ }
264+
265+ log.Info().
266+ Str("domain", domain).
267+ Str("image", image).
268+ Msg("Auto-added route to configuration")
269+
270+ return nil
170271 }⏎̸
@@ -4,6 +4,7 @@ import (
4 "fmt"4 "fmt"
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7+ "regexp"
7 "strings"8 "strings"
8 9
9 "github.com/rs/zerolog/log"10 "github.com/rs/zerolog/log"
@@ -14,6 +15,7 @@ type Config struct {
14 Server ServerConfig `mapstructure:"server"`15 Server ServerConfig `mapstructure:"server"`
15 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`16 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
16 Routes map[string]string `mapstructure:"routes"`17 Routes map[string]string `mapstructure:"routes"`
18+ AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
17 }19 }
18 20
19 type ServerConfig struct {21 type ServerConfig struct {
@@ -32,6 +34,10 @@ type RegistryAuthConfig struct {
32 Password string `mapstructure:"password"`34 Password string `mapstructure:"password"`
33 }35 }
34 36
37+type AutoRouteConfig struct {
38+ Enabled bool `mapstructure:"enabled"`
39+}
40+
35 type Route struct {41 type Route struct {
36 Domain string42 Domain string
37 Image string43 Image string
@@ -51,6 +57,7 @@ func Load() (*Config, error) {
51 defaultDataDir := getDefaultDataDir()57 defaultDataDir := getDefaultDataDir()
52 viper.SetDefault("server.data_dir", defaultDataDir)58 viper.SetDefault("server.data_dir", defaultDataDir)
53 viper.SetDefault("registry_auth.enabled", true)59 viper.SetDefault("registry_auth.enabled", true)
60+ viper.SetDefault("auto_route.enabled", false)
54 61
55 // Handle the routes manually since Viper struggles with domain names62 // Handle the routes manually since Viper struggles with domain names
56 cfg.Routes = make(map[string]string)63 cfg.Routes = make(map[string]string)
@@ -76,6 +83,11 @@ func Load() (*Config, error) {
76 if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {83 if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {
77 return nil, fmt.Errorf("unable to decode registry auth config: %v", err)84 return nil, fmt.Errorf("unable to decode registry auth config: %v", err)
78 }85 }
86+
87+ // Get auto route config
88+ if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {
89+ return nil, fmt.Errorf("unable to decode auto route config: %v", err)
90+ }
79 91
80 // Get routes manually from the raw config92 // Get routes manually from the raw config
81 routesRaw := viper.Get("routes")93 routesRaw := viper.Get("routes")
@@ -89,10 +101,8 @@ func Load() (*Config, error) {
89 }101 }
90 }102 }
91 103
92- // Validate required fields104+ // Note: ssl_email is optional and only needed for future Let's Encrypt integration
93- if cfg.Server.SSLEmail == "" {105+ // Currently using Cloudflare for HTTPS
94- return nil, fmt.Errorf("server.ssl_email is required")
95- }
96 106
97 validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}107 validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}
98 isValid := false108 isValid := false
@@ -167,4 +177,95 @@ func getDefaultDataDir() string {
167 177
168 // For root or when home directory is not available, use relative path178 // For root or when home directory is not available, use relative path
169 return "./data"179 return "./data"
180+}
181+
182+// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest"
183+func ExtractDomainFromImageName(imageName string) (string, bool) {
184+ // Split by colon to separate image name from tag
185+ parts := strings.Split(imageName, ":")
186+ imageNamePart := parts[0]
187+
188+ // Domain pattern: should contain at least one dot and valid domain characters
189+ domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
190+
191+ if domainPattern.MatchString(imageNamePart) {
192+ return imageNamePart, true
193+ }
194+
195+ return "", false
196+}
197+
198+// AddRoute adds a new route to the configuration and saves it
199+func (c *Config) AddRoute(domain, image string) error {
200+ if c.Routes == nil {
201+ c.Routes = make(map[string]string)
202+ }
203+
204+ c.Routes[domain] = image
205+
206+ // Read the current config file to preserve formatting
207+ configFile := viper.ConfigFileUsed()
208+ if configFile == "" {
209+ return fmt.Errorf("no config file path available")
210+ }
211+
212+ // Read existing content
213+ content, err := os.ReadFile(configFile)
214+ if err != nil {
215+ return fmt.Errorf("failed to read config file: %w", err)
216+ }
217+
218+ // Parse and update only the routes section while preserving other formatting
219+ lines := strings.Split(string(content), "\n")
220+ var newLines []string
221+ inRoutesSection := false
222+ routeAdded := false
223+
224+ for _, line := range lines {
225+ trimmed := strings.TrimSpace(line)
226+
227+ // Check if we're entering or leaving the routes section
228+ if trimmed == "[routes]" {
229+ inRoutesSection = true
230+ newLines = append(newLines, line)
231+ continue
232+ }
233+
234+ // Check if we're entering a new section
235+ if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
236+ // If we were in routes section and haven't added the route yet, add it
237+ if inRoutesSection && !routeAdded {
238+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
239+ routeAdded = true
240+ }
241+ inRoutesSection = false
242+ }
243+
244+ // If we're in routes section, check if this route already exists (check both quote styles)
245+ if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
246+ // Update existing route using double quotes for consistency
247+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
248+ routeAdded = true
249+ } else {
250+ newLines = append(newLines, line)
251+ }
252+ }
253+
254+ // If we were in routes section at the end and haven't added the route yet, add it
255+ if inRoutesSection && !routeAdded {
256+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
257+ }
258+
259+ // Write the updated content back
260+ newContent := strings.Join(newLines, "\n")
261+ if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
262+ return fmt.Errorf("failed to write config file: %w", err)
263+ }
264+
265+ log.Info().
266+ Str("domain", domain).
267+ Str("image", image).
268+ Msg("Auto-added route to configuration")
269+
270+ return nil
170 }⏎̸271 }⏎̸
M internal/container/manager.go +112 -12
@@ -71,6 +71,35 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
7171 delete(m.containers, route.Domain)
7272 }
7373
74+ // Also check for orphaned containers with the same name in the runtime
75+ expectedContainerName := fmt.Sprintf("gordon-%s", route.Domain)
76+ allContainers, err := m.runtime.ListContainers(ctx, true) // include stopped containers
77+ if err != nil {
78+ log.Warn().Err(err).Msg("Failed to list all containers, proceeding with deployment")
79+ } else {
80+ for _, container := range allContainers {
81+ if container.Name == expectedContainerName {
82+ log.Info().
83+ Str("domain", route.Domain).
84+ Str("container", container.ID).
85+ Str("status", container.Status).
86+ Msg("Found orphaned container with same name, removing")
87+
88+ // Stop if running
89+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
90+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop orphaned container")
91+ }
92+
93+ // Force remove
94+ if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
95+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove orphaned container")
96+ } else {
97+ log.Info().Str("container", container.ID).Msg("Successfully removed orphaned container")
98+ }
99+ }
100+ }
101+ }
102+
74103 // Construct the full image reference
75104 imageRef := route.Image
76105
@@ -82,19 +111,68 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
82111 }
83112 }
84113
85- // Pull the image first
86- log.Info().Str("image", imageRef).Msg("Pulling image")
87-
88- // Use authenticated pull if registry auth is configured
89- if m.config.RegistryAuth.Enabled {
90- err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
91- if err != nil {
92- return nil, fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
93- }
114+ // Check if image exists locally first
115+ imageAvailable := false
116+ localImages, err := m.runtime.ListImages(ctx)
117+ if err != nil {
118+ log.Warn().Err(err).Msg("Failed to list local images, will attempt to pull from registry")
94119 } else {
95- err := m.runtime.PullImage(ctx, imageRef)
96- if err != nil {
97- return nil, fmt.Errorf("failed to pull image %s: %w", imageRef, err)
120+ // Normalize image references for comparison
121+ normalizedImageRef := normalizeImageRef(imageRef)
122+
123+ for _, localImage := range localImages {
124+ if normalizeImageRef(localImage) == normalizedImageRef {
125+ imageAvailable = true
126+ log.Info().
127+ Str("image", imageRef).
128+ Msg("Image found locally, skipping pull")
129+ break
130+ }
131+ }
132+ }
133+
134+ // Pull the image only if not available locally
135+ if !imageAvailable {
136+ log.Info().Str("image", imageRef).Msg("Image not found locally, pulling from registry")
137+
138+ // Use authenticated pull if registry auth is configured
139+ if m.config.RegistryAuth.Enabled {
140+ log.Info().
141+ Str("image", imageRef).
142+ Str("username", m.config.RegistryAuth.Username).
143+ Msg("Pulling image with authentication")
144+
145+ err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
146+ if err != nil {
147+ // Provide better error message with context
148+ availableImages := make([]string, 0, len(localImages))
149+ for _, img := range localImages {
150+ availableImages = append(availableImages, img)
151+ }
152+
153+ return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+
154+ "Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+
155+ "Available local images: %v. Error: %w",
156+ imageRef, m.config.Server.RegistryDomain, availableImages, err)
157+ }
158+ log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
159+ } else {
160+ log.Info().Str("image", imageRef).Msg("Pulling image without authentication")
161+
162+ err := m.runtime.PullImage(ctx, imageRef)
163+ if err != nil {
164+ // Provide better error message with context
165+ availableImages := make([]string, 0, len(localImages))
166+ for _, img := range localImages {
167+ availableImages = append(availableImages, img)
168+ }
169+
170+ return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+
171+ "Please check: 1) Image name spelling, 2) Image exists in registry. "+
172+ "Available local images: %v. Error: %w",
173+ imageRef, availableImages, err)
174+ }
175+ log.Info().Str("image", imageRef).Msg("Image pulled successfully")
98176 }
99177 }
100178
@@ -508,3 +586,25 @@ func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
508586 func (m *Manager) Runtime() runtime.Runtime {
509587 return m.runtime
510588 }
589+
590+// normalizeImageRef normalizes image references for comparison
591+func normalizeImageRef(imageRef string) string {
592+ // Split image and tag
593+ parts := strings.Split(imageRef, ":")
594+ image := parts[0]
595+ tag := "latest"
596+ if len(parts) > 1 {
597+ tag = parts[1]
598+ }
599+
600+ // Normalize Docker Hub references
601+ if !strings.Contains(image, "/") {
602+ // Official library image (e.g., "nginx" -> "docker.io/library/nginx")
603+ image = "docker.io/library/" + image
604+ } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") {
605+ // User repository (e.g., "user/repo" -> "docker.io/user/repo")
606+ image = "docker.io/" + image
607+ }
608+
609+ return image + ":" + tag
610+}
@@ -71,6 +71,35 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
71 delete(m.containers, route.Domain)71 delete(m.containers, route.Domain)
72 }72 }
73 73
74+ // Also check for orphaned containers with the same name in the runtime
75+ expectedContainerName := fmt.Sprintf("gordon-%s", route.Domain)
76+ allContainers, err := m.runtime.ListContainers(ctx, true) // include stopped containers
77+ if err != nil {
78+ log.Warn().Err(err).Msg("Failed to list all containers, proceeding with deployment")
79+ } else {
80+ for _, container := range allContainers {
81+ if container.Name == expectedContainerName {
82+ log.Info().
83+ Str("domain", route.Domain).
84+ Str("container", container.ID).
85+ Str("status", container.Status).
86+ Msg("Found orphaned container with same name, removing")
87+
88+ // Stop if running
89+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
90+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop orphaned container")
91+ }
92+
93+ // Force remove
94+ if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
95+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove orphaned container")
96+ } else {
97+ log.Info().Str("container", container.ID).Msg("Successfully removed orphaned container")
98+ }
99+ }
100+ }
101+ }
102+
74 // Construct the full image reference103 // Construct the full image reference
75 imageRef := route.Image104 imageRef := route.Image
76 105
@@ -82,19 +111,68 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
82 }111 }
83 }112 }
84 113
85- // Pull the image first114+ // Check if image exists locally first
86- log.Info().Str("image", imageRef).Msg("Pulling image")115+ imageAvailable := false
87-116+ localImages, err := m.runtime.ListImages(ctx)
88- // Use authenticated pull if registry auth is configured117+ if err != nil {
89- if m.config.RegistryAuth.Enabled {118+ log.Warn().Err(err).Msg("Failed to list local images, will attempt to pull from registry")
90- err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
91- if err != nil {
92- return nil, fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
93- }
94 } else {119 } else {
95- err := m.runtime.PullImage(ctx, imageRef)120+ // Normalize image references for comparison
96- if err != nil {121+ normalizedImageRef := normalizeImageRef(imageRef)
97- return nil, fmt.Errorf("failed to pull image %s: %w", imageRef, err)122+
123+ for _, localImage := range localImages {
124+ if normalizeImageRef(localImage) == normalizedImageRef {
125+ imageAvailable = true
126+ log.Info().
127+ Str("image", imageRef).
128+ Msg("Image found locally, skipping pull")
129+ break
130+ }
131+ }
132+ }
133+
134+ // Pull the image only if not available locally
135+ if !imageAvailable {
136+ log.Info().Str("image", imageRef).Msg("Image not found locally, pulling from registry")
137+
138+ // Use authenticated pull if registry auth is configured
139+ if m.config.RegistryAuth.Enabled {
140+ log.Info().
141+ Str("image", imageRef).
142+ Str("username", m.config.RegistryAuth.Username).
143+ Msg("Pulling image with authentication")
144+
145+ err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
146+ if err != nil {
147+ // Provide better error message with context
148+ availableImages := make([]string, 0, len(localImages))
149+ for _, img := range localImages {
150+ availableImages = append(availableImages, img)
151+ }
152+
153+ return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+
154+ "Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+
155+ "Available local images: %v. Error: %w",
156+ imageRef, m.config.Server.RegistryDomain, availableImages, err)
157+ }
158+ log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
159+ } else {
160+ log.Info().Str("image", imageRef).Msg("Pulling image without authentication")
161+
162+ err := m.runtime.PullImage(ctx, imageRef)
163+ if err != nil {
164+ // Provide better error message with context
165+ availableImages := make([]string, 0, len(localImages))
166+ for _, img := range localImages {
167+ availableImages = append(availableImages, img)
168+ }
169+
170+ return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+
171+ "Please check: 1) Image name spelling, 2) Image exists in registry. "+
172+ "Available local images: %v. Error: %w",
173+ imageRef, availableImages, err)
174+ }
175+ log.Info().Str("image", imageRef).Msg("Image pulled successfully")
98 }176 }
99 }177 }
100 178
@@ -508,3 +586,25 @@ func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
508 func (m *Manager) Runtime() runtime.Runtime {586 func (m *Manager) Runtime() runtime.Runtime {
509 return m.runtime587 return m.runtime
510 }588 }
589+
590+// normalizeImageRef normalizes image references for comparison
591+func normalizeImageRef(imageRef string) string {
592+ // Split image and tag
593+ parts := strings.Split(imageRef, ":")
594+ image := parts[0]
595+ tag := "latest"
596+ if len(parts) > 1 {
597+ tag = parts[1]
598+ }
599+
600+ // Normalize Docker Hub references
601+ if !strings.Contains(image, "/") {
602+ // Official library image (e.g., "nginx" -> "docker.io/library/nginx")
603+ image = "docker.io/library/" + image
604+ } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") {
605+ // User repository (e.g., "user/repo" -> "docker.io/user/repo")
606+ image = "docker.io/" + image
607+ }
608+
609+ return image + ":" + tag
610+}
M internal/events/handlers.go +74 -1
@@ -265,4 +265,77 @@ func (h *ContainerEventHandler) deployContainerForRoute(route, imageName string)
265265 Msg("Successfully deployed container for route")
266266
267267 return nil
268-}⏎̸
268+}
269+
270+type AutoRouteHandler struct {
271+ config *config.Config
272+ manager *container.Manager
273+}
274+
275+func NewAutoRouteHandler(cfg *config.Config, manager *container.Manager) *AutoRouteHandler {
276+ return &AutoRouteHandler{
277+ config: cfg,
278+ manager: manager,
279+ }
280+}
281+
282+func (h *AutoRouteHandler) CanHandle(eventType EventType) bool {
283+ return eventType == ImagePushed
284+}
285+
286+func (h *AutoRouteHandler) Handle(event Event) error {
287+ if !h.config.AutoRoute.Enabled {
288+ return nil
289+ }
290+
291+ if event.Type != ImagePushed {
292+ return nil
293+ }
294+
295+ payload, ok := event.Data.(ImagePushedPayload)
296+ if !ok {
297+ return fmt.Errorf("invalid payload type for ImagePushed event")
298+ }
299+
300+ imageName := payload.Name
301+ reference := payload.Reference
302+ fullImageName := fmt.Sprintf("%s:%s", imageName, reference)
303+
304+ // Build the full registry image path for deployment
305+ registryImageName := fullImageName
306+ if h.config.Server.RegistryDomain != "" {
307+ registryImageName = fmt.Sprintf("%s/%s", h.config.Server.RegistryDomain, fullImageName)
308+ }
309+
310+ // Try to extract domain from the image name part (before the colon)
311+ domain, isDomain := config.ExtractDomainFromImageName(imageName)
312+ if !isDomain {
313+ log.Debug().
314+ Str("image", imageName).
315+ Msg("Image name does not contain a valid domain, skipping auto-route creation")
316+ return nil
317+ }
318+
319+ // Check if route already exists
320+ if _, exists := h.config.Routes[domain]; exists {
321+ log.Debug().
322+ Str("domain", domain).
323+ Str("existing_image", h.config.Routes[domain]).
324+ Str("new_image", fullImageName).
325+ Msg("Route already exists for domain, skipping auto-route creation")
326+ return nil
327+ }
328+
329+ // Add the route to config with the full registry image path
330+ if err := h.config.AddRoute(domain, registryImageName); err != nil {
331+ return fmt.Errorf("failed to add auto-route for domain %s: %w", domain, err)
332+ }
333+
334+ log.Info().
335+ Str("domain", domain).
336+ Str("image", registryImageName).
337+ Msg("Successfully auto-created route - deployment will be handled by config reload event")
338+
339+ return nil
340+}
341+
@@ -265,4 +265,77 @@ func (h *ContainerEventHandler) deployContainerForRoute(route, imageName string)
265 Msg("Successfully deployed container for route")265 Msg("Successfully deployed container for route")
266 266
267 return nil267 return nil
268-}⏎̸268+}
269+
270+type AutoRouteHandler struct {
271+ config *config.Config
272+ manager *container.Manager
273+}
274+
275+func NewAutoRouteHandler(cfg *config.Config, manager *container.Manager) *AutoRouteHandler {
276+ return &AutoRouteHandler{
277+ config: cfg,
278+ manager: manager,
279+ }
280+}
281+
282+func (h *AutoRouteHandler) CanHandle(eventType EventType) bool {
283+ return eventType == ImagePushed
284+}
285+
286+func (h *AutoRouteHandler) Handle(event Event) error {
287+ if !h.config.AutoRoute.Enabled {
288+ return nil
289+ }
290+
291+ if event.Type != ImagePushed {
292+ return nil
293+ }
294+
295+ payload, ok := event.Data.(ImagePushedPayload)
296+ if !ok {
297+ return fmt.Errorf("invalid payload type for ImagePushed event")
298+ }
299+
300+ imageName := payload.Name
301+ reference := payload.Reference
302+ fullImageName := fmt.Sprintf("%s:%s", imageName, reference)
303+
304+ // Build the full registry image path for deployment
305+ registryImageName := fullImageName
306+ if h.config.Server.RegistryDomain != "" {
307+ registryImageName = fmt.Sprintf("%s/%s", h.config.Server.RegistryDomain, fullImageName)
308+ }
309+
310+ // Try to extract domain from the image name part (before the colon)
311+ domain, isDomain := config.ExtractDomainFromImageName(imageName)
312+ if !isDomain {
313+ log.Debug().
314+ Str("image", imageName).
315+ Msg("Image name does not contain a valid domain, skipping auto-route creation")
316+ return nil
317+ }
318+
319+ // Check if route already exists
320+ if _, exists := h.config.Routes[domain]; exists {
321+ log.Debug().
322+ Str("domain", domain).
323+ Str("existing_image", h.config.Routes[domain]).
324+ Str("new_image", fullImageName).
325+ Msg("Route already exists for domain, skipping auto-route creation")
326+ return nil
327+ }
328+
329+ // Add the route to config with the full registry image path
330+ if err := h.config.AddRoute(domain, registryImageName); err != nil {
331+ return fmt.Errorf("failed to add auto-route for domain %s: %w", domain, err)
332+ }
333+
334+ log.Info().
335+ Str("domain", domain).
336+ Str("image", registryImageName).
337+ Msg("Successfully auto-created route - deployment will be handled by config reload event")
338+
339+ return nil
340+}
341+
M internal/proxy/server.go +37 -0
@@ -13,6 +13,7 @@ import (
1313 "github.com/rs/zerolog/log"
1414 "gordon/internal/config"
1515 "gordon/internal/container"
16+ "gordon/internal/events"
1617 "gordon/internal/middleware"
1718 )
1819
@@ -212,6 +213,19 @@ func (s *Server) isRunningInContainer() bool {
212213 return false
213214 }
214215
216+// UpdateConfig updates the config reference and refreshes routes
217+func (s *Server) UpdateConfig(newConfig *config.Config) {
218+ s.config = newConfig
219+ s.routes = s.config.GetRoutes()
220+ log.Debug().Int("route_count", len(s.routes)).Msg("Config and routes updated in proxy server")
221+}
222+
223+// UpdateRoutes refreshes the routes from the current config
224+func (s *Server) UpdateRoutes() {
225+ s.routes = s.config.GetRoutes()
226+ log.Debug().Int("route_count", len(s.routes)).Msg("Routes updated in proxy server")
227+}
228+
215229 func (s *Server) Start(ctx context.Context) error {
216230 addr := ":" + strconv.Itoa(s.config.Server.Port)
217231
@@ -248,3 +262,26 @@ func (s *Server) Start(ctx context.Context) error {
248262 }
249263 }
250264
265+// ProxyEventHandler handles events for the proxy server
266+type ProxyEventHandler struct {
267+ server *Server
268+}
269+
270+func NewProxyEventHandler(server *Server) *ProxyEventHandler {
271+ return &ProxyEventHandler{
272+ server: server,
273+ }
274+}
275+
276+func (h *ProxyEventHandler) CanHandle(eventType events.EventType) bool {
277+ return eventType == events.ConfigReload
278+}
279+
280+func (h *ProxyEventHandler) Handle(event events.Event) error {
281+ if event.Type == events.ConfigReload {
282+ h.server.UpdateRoutes()
283+ return nil
284+ }
285+ return fmt.Errorf("unsupported event type: %s", event.Type)
286+}
287+
@@ -13,6 +13,7 @@ import (
13 "github.com/rs/zerolog/log"13 "github.com/rs/zerolog/log"
14 "gordon/internal/config"14 "gordon/internal/config"
15 "gordon/internal/container"15 "gordon/internal/container"
16+ "gordon/internal/events"
16 "gordon/internal/middleware"17 "gordon/internal/middleware"
17 )18 )
18 19
@@ -212,6 +213,19 @@ func (s *Server) isRunningInContainer() bool {
212 return false213 return false
213 }214 }
214 215
216+// UpdateConfig updates the config reference and refreshes routes
217+func (s *Server) UpdateConfig(newConfig *config.Config) {
218+ s.config = newConfig
219+ s.routes = s.config.GetRoutes()
220+ log.Debug().Int("route_count", len(s.routes)).Msg("Config and routes updated in proxy server")
221+}
222+
223+// UpdateRoutes refreshes the routes from the current config
224+func (s *Server) UpdateRoutes() {
225+ s.routes = s.config.GetRoutes()
226+ log.Debug().Int("route_count", len(s.routes)).Msg("Routes updated in proxy server")
227+}
228+
215 func (s *Server) Start(ctx context.Context) error {229 func (s *Server) Start(ctx context.Context) error {
216 addr := ":" + strconv.Itoa(s.config.Server.Port)230 addr := ":" + strconv.Itoa(s.config.Server.Port)
217 231
@@ -248,3 +262,26 @@ func (s *Server) Start(ctx context.Context) error {
248 }262 }
249 }263 }
250 264
265+// ProxyEventHandler handles events for the proxy server
266+type ProxyEventHandler struct {
267+ server *Server
268+}
269+
270+func NewProxyEventHandler(server *Server) *ProxyEventHandler {
271+ return &ProxyEventHandler{
272+ server: server,
273+ }
274+}
275+
276+func (h *ProxyEventHandler) CanHandle(eventType events.EventType) bool {
277+ return eventType == events.ConfigReload
278+}
279+
280+func (h *ProxyEventHandler) Handle(event events.Event) error {
281+ if event.Type == events.ConfigReload {
282+ h.server.UpdateRoutes()
283+ return nil
284+ }
285+ return fmt.Errorf("unsupported event type: %s", event.Type)
286+}
287+

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close