detached 33eb2a5

feat(logging): Implement comprehensive logging system with file rotation and container log management

- Introduce a logging configuration structure in the config file to enable file-based logging with customizable settings.
- Implement a log manager to handle log collection for individual containers, including starting and stopping log collection.
- Enhance the logging setup to support different log levels and file rotation using the lumberjack package.
- Update the application to initialize logging based on the configuration and ensure secure permissions for log files.
- Add logging examples in configuration files for development, production, and minimal setups.
- Improve request logging in middleware to utilize the new logging system for better traceability.
commit
33eb2a5acf72ac108fa5eed338d9d234f1ce7bfc
author
bamen · · 2025-06-08 09:57 UTC
parent
57617f5
signature
unsigned
14 files · +812 -46
M cmd/start.go +19 -7
@@ -13,6 +13,7 @@ import (
1313 "gordon/internal/config"
1414 "gordon/internal/container"
1515 "gordon/internal/events"
16+ "gordon/internal/logging"
1617 "gordon/internal/proxy"
1718 "gordon/internal/registry"
1819
@@ -35,15 +36,21 @@ func init() {
3536 }
3637
3738 func runStart(cmd *cobra.Command, args []string) {
38- // Configure zerolog
39- zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
40- log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
41-
39+ // Load configuration first
4240 cfg, err := config.Load()
4341 if err != nil {
42+ // Use basic console logging for config errors
43+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
44+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
4445 log.Fatal().Err(err).Msg("Failed to load config")
4546 }
4647
48+ // Initialize logging system
49+ if err := logging.Setup(cfg); err != nil {
50+ log.Fatal().Err(err).Msg("Failed to initialize logging")
51+ }
52+ defer logging.Close()
53+
4754 log.Info().Msg("Starting Gordon server...")
4855 log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")
4956 log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")
@@ -123,6 +130,11 @@ func runStart(cmd *cobra.Command, args []string) {
123130 // Update the configuration reference
124131 cfg = newCfg
125132
133+ // Reinitialize logging with new config
134+ if err := logging.Setup(cfg); err != nil {
135+ log.Error().Err(err).Msg("Failed to reinitialize logging after config reload")
136+ }
137+
126138 // Update proxy server config
127139 proxyServer.UpdateConfig(cfg)
128140
@@ -237,9 +249,9 @@ shutdown:
237249 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
238250 defer shutdownCancel()
239251
240- log.Info().Msg("Stopping all managed containers...")
241- if err := manager.StopAllManagedContainers(shutdownCtx); err != nil {
242- log.Error().Err(err).Msg("Failed to stop managed containers")
252+ log.Info().Msg("Shutting down container manager...")
253+ if err := manager.Shutdown(shutdownCtx); err != nil {
254+ log.Error().Err(err).Msg("Failed to shutdown container manager")
243255 }
244256
245257 // Stop event bus
@@ -13,6 +13,7 @@ import (
13 "gordon/internal/config"13 "gordon/internal/config"
14 "gordon/internal/container"14 "gordon/internal/container"
15 "gordon/internal/events"15 "gordon/internal/events"
16+ "gordon/internal/logging"
16 "gordon/internal/proxy"17 "gordon/internal/proxy"
17 "gordon/internal/registry"18 "gordon/internal/registry"
18 19
@@ -35,15 +36,21 @@ func init() {
35 }36 }
36 37
37 func runStart(cmd *cobra.Command, args []string) {38 func runStart(cmd *cobra.Command, args []string) {
38- // Configure zerolog39+ // Load configuration first
39- zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
40- log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
41-
42 cfg, err := config.Load()40 cfg, err := config.Load()
43 if err != nil {41 if err != nil {
42+ // Use basic console logging for config errors
43+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
44+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
44 log.Fatal().Err(err).Msg("Failed to load config")45 log.Fatal().Err(err).Msg("Failed to load config")
45 }46 }
46 47
48+ // Initialize logging system
49+ if err := logging.Setup(cfg); err != nil {
50+ log.Fatal().Err(err).Msg("Failed to initialize logging")
51+ }
52+ defer logging.Close()
53+
47 log.Info().Msg("Starting Gordon server...")54 log.Info().Msg("Starting Gordon server...")
48 log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")55 log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")
49 log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")56 log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")
@@ -123,6 +130,11 @@ func runStart(cmd *cobra.Command, args []string) {
123 // Update the configuration reference130 // Update the configuration reference
124 cfg = newCfg131 cfg = newCfg
125 132
133+ // Reinitialize logging with new config
134+ if err := logging.Setup(cfg); err != nil {
135+ log.Error().Err(err).Msg("Failed to reinitialize logging after config reload")
136+ }
137+
126 // Update proxy server config138 // Update proxy server config
127 proxyServer.UpdateConfig(cfg)139 proxyServer.UpdateConfig(cfg)
128 140
@@ -237,9 +249,9 @@ shutdown:
237 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)249 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
238 defer shutdownCancel()250 defer shutdownCancel()
239 251
240- log.Info().Msg("Stopping all managed containers...")252+ log.Info().Msg("Shutting down container manager...")
241- if err := manager.StopAllManagedContainers(shutdownCtx); err != nil {253+ if err := manager.Shutdown(shutdownCtx); err != nil {
242- log.Error().Err(err).Msg("Failed to stop managed containers")254+ log.Error().Err(err).Msg("Failed to shutdown container manager")
243 }255 }
244 256
245 // Stop event bus257 // Stop event bus
M examples/README.md +27 -0
@@ -39,6 +39,13 @@ This directory contains configuration examples for different use cases and envir
3939 - Shared application architecture
4040 - Enterprise features
4141
42+### 📊 [`logging.toml`](logging.toml)
43+**Comprehensive logging configuration**
44+- Complete logging setup examples
45+- Different logging levels and configurations
46+- Production and development logging strategies
47+- Log rotation and monitoring examples
48+
4249 ## 🚀 Quick Start
4350
4451 1. **Choose an example** that matches your use case
@@ -96,6 +103,26 @@ export CONTAINER_HOST=tcp://remote-docker:2376
96103 gordon start
97104 ```
98105
106+### Logging Configuration
107+Enable comprehensive logging to monitor your deployments:
108+```toml
109+[logging]
110+enabled = true # Enable file-based logging
111+level = "info" # Log level: trace, debug, info, warn, error
112+dir = "./logs" # Directory for log files
113+main_log_file = "gordon.log" # Main application logs
114+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
115+container_log_dir = "containers" # Container logs subdirectory
116+
117+# Log rotation settings
118+max_size = 100 # Max log file size in MB
119+max_backups = 5 # Number of old log files to keep
120+max_age = 30 # Max age in days
121+compress = true # Compress old log files
122+```
123+
124+See [`logging.toml`](logging.toml) for comprehensive logging examples.
125+
99126 ### Environment Variables
100127 For production, use environment variables for sensitive data:
101128 ```bash
@@ -39,6 +39,13 @@ This directory contains configuration examples for different use cases and envir
39 - Shared application architecture39 - Shared application architecture
40 - Enterprise features40 - Enterprise features
41 41
42+### 📊 [`logging.toml`](logging.toml)
43+**Comprehensive logging configuration**
44+- Complete logging setup examples
45+- Different logging levels and configurations
46+- Production and development logging strategies
47+- Log rotation and monitoring examples
48+
42 ## 🚀 Quick Start49 ## 🚀 Quick Start
43 50
44 1. **Choose an example** that matches your use case51 1. **Choose an example** that matches your use case
@@ -96,6 +103,26 @@ export CONTAINER_HOST=tcp://remote-docker:2376
96 gordon start103 gordon start
97 ```104 ```
98 105
106+### Logging Configuration
107+Enable comprehensive logging to monitor your deployments:
108+```toml
109+[logging]
110+enabled = true # Enable file-based logging
111+level = "info" # Log level: trace, debug, info, warn, error
112+dir = "./logs" # Directory for log files
113+main_log_file = "gordon.log" # Main application logs
114+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
115+container_log_dir = "containers" # Container logs subdirectory
116+
117+# Log rotation settings
118+max_size = 100 # Max log file size in MB
119+max_backups = 5 # Number of old log files to keep
120+max_age = 30 # Max age in days
121+compress = true # Compress old log files
122+```
123+
124+See [`logging.toml`](logging.toml) for comprehensive logging examples.
125+
99 ### Environment Variables126 ### Environment Variables
100 For production, use environment variables for sensitive data:127 For production, use environment variables for sensitive data:
101 ```bash128 ```bash
M examples/development.toml +15 -0
@@ -15,6 +15,21 @@ enabled = false # Disable auth for easier local development
1515 [auto_route]
1616 enabled = true # Enable automatic route creation from image names
1717
18+# Logging configuration for development
19+[logging]
20+enabled = true # Enable file-based logging
21+level = "debug" # Verbose logging for development
22+dir = "./dev-data/logs" # Store logs in dev data directory
23+main_log_file = "gordon.log" # Main application logs
24+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
25+container_log_dir = "containers" # Container logs subdirectory
26+
27+# Log rotation settings - smaller for development
28+max_size = 10 # Max 10MB per log file
29+max_backups = 3 # Keep 3 old files
30+max_age = 7 # Keep logs for 1 week
31+compress = true # Compress old logs
32+
1833 # Environment variables configuration
1934 [env]
2035 # Directory where .env files are stored for each route
@@ -15,6 +15,21 @@ enabled = false # Disable auth for easier local development
15 [auto_route]15 [auto_route]
16 enabled = true # Enable automatic route creation from image names16 enabled = true # Enable automatic route creation from image names
17 17
18+# Logging configuration for development
19+[logging]
20+enabled = true # Enable file-based logging
21+level = "debug" # Verbose logging for development
22+dir = "./dev-data/logs" # Store logs in dev data directory
23+main_log_file = "gordon.log" # Main application logs
24+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
25+container_log_dir = "containers" # Container logs subdirectory
26+
27+# Log rotation settings - smaller for development
28+max_size = 10 # Max 10MB per log file
29+max_backups = 3 # Keep 3 old files
30+max_age = 7 # Keep logs for 1 week
31+compress = true # Compress old logs
32+
18 # Environment variables configuration33 # Environment variables configuration
19 [env]34 [env]
20 # Directory where .env files are stored for each route35 # Directory where .env files are stored for each route
A examples/logging.toml +180 -0
@@ -0,0 +1,180 @@
1+# Gordon v2 - Comprehensive Logging Configuration Example
2+# Demonstrates all logging features and configurations
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.yourdomain.com"
8+runtime = "auto"
9+data_dir = "./data"
10+
11+[registry_auth]
12+enabled = true
13+username = "admin"
14+password = "secure-password"
15+
16+# ============================================================================
17+# LOGGING CONFIGURATION EXAMPLES
18+# ============================================================================
19+
20+# Basic logging setup - good for most users
21+[logging]
22+enabled = true # Enable file-based logging (default: false)
23+level = "info" # Log level: trace, debug, info, warn, error (default: info)
24+dir = "./logs" # Directory for log files (default: ./logs)
25+main_log_file = "gordon.log" # Main application log file (default: gordon.log)
26+proxy_log_file = "proxy.log" # HTTP proxy traffic log file (default: proxy.log)
27+container_log_dir = "containers" # Container logs subdirectory (default: containers)
28+
29+# Log rotation settings
30+max_size = 100 # Max log file size in MB before rotation (default: 100)
31+max_backups = 5 # Number of old log files to keep (default: 5)
32+max_age = 30 # Max age in days to keep old log files (default: 30)
33+compress = true # Compress old log files with gzip (default: true)
34+
35+# ============================================================================
36+# ALTERNATIVE LOGGING CONFIGURATIONS
37+# ============================================================================
38+
39+# Development logging configuration (uncomment to use)
40+# [logging]
41+# enabled = true
42+# level = "debug" # More verbose for debugging
43+# dir = "./dev-logs"
44+# main_log_file = "gordon-dev.log"
45+# proxy_log_file = "proxy-dev.log"
46+# container_log_dir = "containers"
47+# max_size = 10 # Smaller files for development
48+# max_backups = 3 # Less history needed
49+# max_age = 7 # Keep logs for 1 week
50+# compress = false # No compression for faster access
51+
52+# Production logging configuration (uncomment to use)
53+# [logging]
54+# enabled = true
55+# level = "warn" # Only warnings and errors in production
56+# dir = "/var/log/gordon" # System log directory
57+# main_log_file = "gordon.log"
58+# proxy_log_file = "proxy.log"
59+# container_log_dir = "containers"
60+# max_size = 200 # Larger files for production
61+# max_backups = 15 # More history for analysis
62+# max_age = 90 # Keep logs for 3 months
63+# compress = true # Save disk space
64+
65+# High-traffic logging configuration (uncomment to use)
66+# [logging]
67+# enabled = true
68+# level = "error" # Only errors to reduce log volume
69+# dir = "/var/log/gordon"
70+# main_log_file = "gordon.log"
71+# proxy_log_file = "proxy.log"
72+# container_log_dir = "containers"
73+# max_size = 500 # Very large files for high traffic
74+# max_backups = 20 # Extensive history
75+# max_age = 180 # Keep logs for 6 months
76+# compress = true # Essential for large files
77+
78+# Minimal logging configuration (uncomment to use)
79+# [logging]
80+# enabled = true
81+# level = "error" # Only log errors
82+# dir = "./logs"
83+# max_size = 50 # Small files
84+# max_backups = 2 # Minimal history
85+# max_age = 14 # 2 weeks retention
86+# compress = true
87+
88+# Disable logging (console output only) - uncomment to use
89+# [logging]
90+# enabled = false # Only console output, no files
91+
92+# ============================================================================
93+# LOG STRUCTURE CREATED BY GORDON
94+# ============================================================================
95+#
96+# When logging is enabled, Gordon creates this structure:
97+#
98+# ./logs/ # Main log directory (permissions: 0700)
99+# ├── gordon.log # Main application logs (permissions: 0600)
100+# ├── gordon.log.1.gz # Rotated logs (compressed)
101+# ├── gordon.log.2.gz
102+# ├── proxy.log # HTTP proxy traffic logs (permissions: 0600)
103+# ├── proxy.log.1.gz # Rotated proxy logs
104+# └── containers/ # Container logs directory (permissions: 0700)
105+# ├── abc123def456.log # Container logs by ID (permissions: 0600)
106+# ├── def456ghi789.log
107+# ├── myapp_yourdomain_com.log # Symlink to container ID for easier access
108+# └── api_yourdomain_com.log # Symlink to container ID for easier access
109+#
110+# ============================================================================
111+# WHAT GETS LOGGED
112+# ============================================================================
113+#
114+# Main Application Logs (gordon.log):
115+# - Server startup and shutdown events
116+# - Container lifecycle events (deploy, start, stop, remove)
117+# - Configuration changes and hot reloads
118+# - Registry operations (push/pull events)
119+# - Error conditions and warnings
120+# - Authentication events
121+# - Auto-route creation events
122+#
123+# Proxy Logs (proxy.log):
124+# - All HTTP/HTTPS requests through the reverse proxy
125+# - Request method, URL, response code, response time
126+# - Client IP addresses and user agents
127+# - SSL/TLS connection information
128+# - Upstream container routing decisions
129+#
130+# Container Logs (containers/*.log):
131+# - Real-time stdout/stderr from each container
132+# - Timestamped application output
133+# - Container startup and shutdown messages
134+# - Application errors and debug output
135+# - Custom application logging
136+#
137+# ============================================================================
138+# SECURITY FEATURES
139+# ============================================================================
140+#
141+# - Log directories created with 0700 permissions (owner-only access)
142+# - Log files created with 0600 permissions (owner read/write only)
143+# - Automatic log rotation prevents disk space exhaustion
144+# - Sensitive information (passwords, tokens) is automatically redacted
145+# - Container logs are isolated per container for security analysis
146+#
147+# ============================================================================
148+# LOG MONITORING COMMANDS
149+# ============================================================================
150+#
151+# Real-time monitoring:
152+# tail -f logs/gordon.log # Watch main application logs
153+# tail -f logs/proxy.log # Monitor HTTP traffic
154+# tail -f logs/containers/*.log # Follow all container logs
155+#
156+# Log analysis:
157+# grep -i error logs/gordon.log # Find errors in main logs
158+# grep "deploy" logs/gordon.log # Find deployment events
159+# awk '{print $9}' logs/proxy.log | sort | uniq -c # HTTP status code analysis
160+# grep "container" logs/gordon.log | tail -20 # Recent container events
161+#
162+# ============================================================================
163+
164+[routes]
165+"app.yourdomain.com" = "myapp:latest"
166+"api.yourdomain.com" = "myapi:latest"
167+"logs.yourdomain.com" = "logviewer:latest" # Example log viewing service
168+
169+# Example environment variables configuration
170+[env]
171+dir = "./data/env"
172+providers = ["pass"]
173+
174+# With comprehensive logging enabled, you can:
175+# 1. Monitor all HTTP traffic to your applications
176+# 2. Debug container deployment issues
177+# 3. Analyze application performance
178+# 4. Track security events and access patterns
179+# 5. Maintain audit trails for compliance
180+# 6. Troubleshoot network and routing issues⏎̸
@@ -0,0 +1,180 @@
1+# Gordon v2 - Comprehensive Logging Configuration Example
2+# Demonstrates all logging features and configurations
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.yourdomain.com"
8+runtime = "auto"
9+data_dir = "./data"
10+
11+[registry_auth]
12+enabled = true
13+username = "admin"
14+password = "secure-password"
15+
16+# ============================================================================
17+# LOGGING CONFIGURATION EXAMPLES
18+# ============================================================================
19+
20+# Basic logging setup - good for most users
21+[logging]
22+enabled = true # Enable file-based logging (default: false)
23+level = "info" # Log level: trace, debug, info, warn, error (default: info)
24+dir = "./logs" # Directory for log files (default: ./logs)
25+main_log_file = "gordon.log" # Main application log file (default: gordon.log)
26+proxy_log_file = "proxy.log" # HTTP proxy traffic log file (default: proxy.log)
27+container_log_dir = "containers" # Container logs subdirectory (default: containers)
28+
29+# Log rotation settings
30+max_size = 100 # Max log file size in MB before rotation (default: 100)
31+max_backups = 5 # Number of old log files to keep (default: 5)
32+max_age = 30 # Max age in days to keep old log files (default: 30)
33+compress = true # Compress old log files with gzip (default: true)
34+
35+# ============================================================================
36+# ALTERNATIVE LOGGING CONFIGURATIONS
37+# ============================================================================
38+
39+# Development logging configuration (uncomment to use)
40+# [logging]
41+# enabled = true
42+# level = "debug" # More verbose for debugging
43+# dir = "./dev-logs"
44+# main_log_file = "gordon-dev.log"
45+# proxy_log_file = "proxy-dev.log"
46+# container_log_dir = "containers"
47+# max_size = 10 # Smaller files for development
48+# max_backups = 3 # Less history needed
49+# max_age = 7 # Keep logs for 1 week
50+# compress = false # No compression for faster access
51+
52+# Production logging configuration (uncomment to use)
53+# [logging]
54+# enabled = true
55+# level = "warn" # Only warnings and errors in production
56+# dir = "/var/log/gordon" # System log directory
57+# main_log_file = "gordon.log"
58+# proxy_log_file = "proxy.log"
59+# container_log_dir = "containers"
60+# max_size = 200 # Larger files for production
61+# max_backups = 15 # More history for analysis
62+# max_age = 90 # Keep logs for 3 months
63+# compress = true # Save disk space
64+
65+# High-traffic logging configuration (uncomment to use)
66+# [logging]
67+# enabled = true
68+# level = "error" # Only errors to reduce log volume
69+# dir = "/var/log/gordon"
70+# main_log_file = "gordon.log"
71+# proxy_log_file = "proxy.log"
72+# container_log_dir = "containers"
73+# max_size = 500 # Very large files for high traffic
74+# max_backups = 20 # Extensive history
75+# max_age = 180 # Keep logs for 6 months
76+# compress = true # Essential for large files
77+
78+# Minimal logging configuration (uncomment to use)
79+# [logging]
80+# enabled = true
81+# level = "error" # Only log errors
82+# dir = "./logs"
83+# max_size = 50 # Small files
84+# max_backups = 2 # Minimal history
85+# max_age = 14 # 2 weeks retention
86+# compress = true
87+
88+# Disable logging (console output only) - uncomment to use
89+# [logging]
90+# enabled = false # Only console output, no files
91+
92+# ============================================================================
93+# LOG STRUCTURE CREATED BY GORDON
94+# ============================================================================
95+#
96+# When logging is enabled, Gordon creates this structure:
97+#
98+# ./logs/ # Main log directory (permissions: 0700)
99+# ├── gordon.log # Main application logs (permissions: 0600)
100+# ├── gordon.log.1.gz # Rotated logs (compressed)
101+# ├── gordon.log.2.gz
102+# ├── proxy.log # HTTP proxy traffic logs (permissions: 0600)
103+# ├── proxy.log.1.gz # Rotated proxy logs
104+# └── containers/ # Container logs directory (permissions: 0700)
105+# ├── abc123def456.log # Container logs by ID (permissions: 0600)
106+# ├── def456ghi789.log
107+# ├── myapp_yourdomain_com.log # Symlink to container ID for easier access
108+# └── api_yourdomain_com.log # Symlink to container ID for easier access
109+#
110+# ============================================================================
111+# WHAT GETS LOGGED
112+# ============================================================================
113+#
114+# Main Application Logs (gordon.log):
115+# - Server startup and shutdown events
116+# - Container lifecycle events (deploy, start, stop, remove)
117+# - Configuration changes and hot reloads
118+# - Registry operations (push/pull events)
119+# - Error conditions and warnings
120+# - Authentication events
121+# - Auto-route creation events
122+#
123+# Proxy Logs (proxy.log):
124+# - All HTTP/HTTPS requests through the reverse proxy
125+# - Request method, URL, response code, response time
126+# - Client IP addresses and user agents
127+# - SSL/TLS connection information
128+# - Upstream container routing decisions
129+#
130+# Container Logs (containers/*.log):
131+# - Real-time stdout/stderr from each container
132+# - Timestamped application output
133+# - Container startup and shutdown messages
134+# - Application errors and debug output
135+# - Custom application logging
136+#
137+# ============================================================================
138+# SECURITY FEATURES
139+# ============================================================================
140+#
141+# - Log directories created with 0700 permissions (owner-only access)
142+# - Log files created with 0600 permissions (owner read/write only)
143+# - Automatic log rotation prevents disk space exhaustion
144+# - Sensitive information (passwords, tokens) is automatically redacted
145+# - Container logs are isolated per container for security analysis
146+#
147+# ============================================================================
148+# LOG MONITORING COMMANDS
149+# ============================================================================
150+#
151+# Real-time monitoring:
152+# tail -f logs/gordon.log # Watch main application logs
153+# tail -f logs/proxy.log # Monitor HTTP traffic
154+# tail -f logs/containers/*.log # Follow all container logs
155+#
156+# Log analysis:
157+# grep -i error logs/gordon.log # Find errors in main logs
158+# grep "deploy" logs/gordon.log # Find deployment events
159+# awk '{print $9}' logs/proxy.log | sort | uniq -c # HTTP status code analysis
160+# grep "container" logs/gordon.log | tail -20 # Recent container events
161+#
162+# ============================================================================
163+
164+[routes]
165+"app.yourdomain.com" = "myapp:latest"
166+"api.yourdomain.com" = "myapi:latest"
167+"logs.yourdomain.com" = "logviewer:latest" # Example log viewing service
168+
169+# Example environment variables configuration
170+[env]
171+dir = "./data/env"
172+providers = ["pass"]
173+
174+# With comprehensive logging enabled, you can:
175+# 1. Monitor all HTTP traffic to your applications
176+# 2. Debug container deployment issues
177+# 3. Analyze application performance
178+# 4. Track security events and access patterns
179+# 5. Maintain audit trails for compliance
180+# 6. Troubleshoot network and routing issues⏎̸
M examples/minimal.toml +6 -0
@@ -9,6 +9,12 @@ registry_domain = "registry.local"
99 [registry_auth]
1010 enabled = false # Disabled for simplicity
1111
12+# Optional: Enable basic logging
13+# [logging]
14+# enabled = true
15+# level = "info"
16+# dir = "./logs"
17+
1218 # Environment variables (optional)
1319 [env]
1420 dir = "./data/env" # Default location
@@ -9,6 +9,12 @@ registry_domain = "registry.local"
9 [registry_auth]9 [registry_auth]
10 enabled = false # Disabled for simplicity10 enabled = false # Disabled for simplicity
11 11
12+# Optional: Enable basic logging
13+# [logging]
14+# enabled = true
15+# level = "info"
16+# dir = "./logs"
17+
12 # Environment variables (optional)18 # Environment variables (optional)
13 [env]19 [env]
14 dir = "./data/env" # Default location20 dir = "./data/env" # Default location
M examples/production.toml +15 -0
@@ -15,6 +15,21 @@ enabled = true
1515 username = "deploy-bot"
1616 password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!
1717
18+# Production logging configuration
19+[logging]
20+enabled = true # Enable comprehensive logging
21+level = "info" # Standard production logging level
22+dir = "/var/log/gordon" # System log directory
23+main_log_file = "gordon.log" # Main application logs
24+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
25+container_log_dir = "containers" # Container logs subdirectory
26+
27+# Production log rotation settings
28+max_size = 100 # Max 100MB per log file
29+max_backups = 10 # Keep 10 old files for history
30+max_age = 90 # Keep logs for 3 months
31+compress = true # Compress old logs to save space
32+
1833 # Environment variables configuration
1934 [env]
2035 # Directory where .env files are stored for each route
@@ -15,6 +15,21 @@ enabled = true
15 username = "deploy-bot"15 username = "deploy-bot"
16 password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!16 password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!
17 17
18+# Production logging configuration
19+[logging]
20+enabled = true # Enable comprehensive logging
21+level = "info" # Standard production logging level
22+dir = "/var/log/gordon" # System log directory
23+main_log_file = "gordon.log" # Main application logs
24+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
25+container_log_dir = "containers" # Container logs subdirectory
26+
27+# Production log rotation settings
28+max_size = 100 # Max 100MB per log file
29+max_backups = 10 # Keep 10 old files for history
30+max_age = 90 # Keep logs for 3 months
31+compress = true # Compress old logs to save space
32+
18 # Environment variables configuration33 # Environment variables configuration
19 [env]34 [env]
20 # Directory where .env files are stored for each route35 # Directory where .env files are stored for each route
M examples/staging.toml +15 -0
@@ -14,6 +14,21 @@ enabled = true
1414 username = "staging-user"
1515 password = "staging-pass-123"
1616
17+# Staging logging configuration
18+[logging]
19+enabled = true # Enable logging for staging debugging
20+level = "debug" # Verbose logging for troubleshooting
21+dir = "./staging-logs" # Staging-specific log directory
22+main_log_file = "gordon.log" # Main application logs
23+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
24+container_log_dir = "containers" # Container logs subdirectory
25+
26+# Staging log rotation settings
27+max_size = 50 # Moderate file sizes for staging
28+max_backups = 5 # Keep reasonable history
29+max_age = 30 # Keep logs for 1 month
30+compress = true # Compress to save space
31+
1732 # Staging environment routes - using branch-based deployments
1833 [routes]
1934 "staging.company.com" = "company-app:staging" # Main staging branch
@@ -14,6 +14,21 @@ enabled = true
14 username = "staging-user"14 username = "staging-user"
15 password = "staging-pass-123"15 password = "staging-pass-123"
16 16
17+# Staging logging configuration
18+[logging]
19+enabled = true # Enable logging for staging debugging
20+level = "debug" # Verbose logging for troubleshooting
21+dir = "./staging-logs" # Staging-specific log directory
22+main_log_file = "gordon.log" # Main application logs
23+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
24+container_log_dir = "containers" # Container logs subdirectory
25+
26+# Staging log rotation settings
27+max_size = 50 # Moderate file sizes for staging
28+max_backups = 5 # Keep reasonable history
29+max_age = 30 # Keep logs for 1 month
30+compress = true # Compress to save space
31+
17 # Staging environment routes - using branch-based deployments32 # Staging environment routes - using branch-based deployments
18 [routes]33 [routes]
19 "staging.company.com" = "company-app:staging" # Main staging branch34 "staging.company.com" = "company-app:staging" # Main staging branch
M go.mod +2 -0
@@ -10,6 +10,8 @@ require (
1010 github.com/spf13/viper v1.20.1
1111 )
1212
13+require gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
14+
1315 require (
1416 github.com/Microsoft/go-winio v0.4.14 // indirect
1517 github.com/containerd/errdefs v1.0.0 // indirect
@@ -10,6 +10,8 @@ require (
10 github.com/spf13/viper v1.20.110 github.com/spf13/viper v1.20.1
11 )11 )
12 12
13+require gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
14+
13 require (15 require (
14 github.com/Microsoft/go-winio v0.4.14 // indirect16 github.com/Microsoft/go-winio v0.4.14 // indirect
15 github.com/containerd/errdefs v1.0.0 // indirect17 github.com/containerd/errdefs v1.0.0 // indirect
M go.sum +2 -0 generated file: collapsed
M internal/config/config.go +72 -35
@@ -12,11 +12,12 @@ import (
1212 )
1313
1414 type Config struct {
15- Server ServerConfig `mapstructure:"server"`
15+ Server ServerConfig `mapstructure:"server"`
1616 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17- Routes map[string]string `mapstructure:"routes"`
18- AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19- Env EnvConfig `mapstructure:"env"`
17+ Routes map[string]string `mapstructure:"routes"`
18+ AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19+ Env EnvConfig `mapstructure:"env"`
20+ Logging LoggingConfig `mapstructure:"logging"`
2021 }
2122
2223 type ServerConfig struct {
@@ -44,6 +45,19 @@ type EnvConfig struct {
4445 Providers []string `mapstructure:"providers"`
4546 }
4647
48+type LoggingConfig struct {
49+ Enabled bool `mapstructure:"enabled"`
50+ Level string `mapstructure:"level"`
51+ Dir string `mapstructure:"dir"`
52+ MainLogFile string `mapstructure:"main_log_file"`
53+ ProxyLogFile string `mapstructure:"proxy_log_file"`
54+ ContainerLogDir string `mapstructure:"container_log_dir"`
55+ MaxSize int `mapstructure:"max_size"`
56+ MaxBackups int `mapstructure:"max_backups"`
57+ MaxAge int `mapstructure:"max_age"`
58+ Compress bool `mapstructure:"compress"`
59+}
60+
4761 type SecretProvider struct {
4862 Type string `mapstructure:"type"`
4963 Config map[string]string `mapstructure:"config"`
@@ -57,13 +71,13 @@ type Route struct {
5771
5872 func Load() (*Config, error) {
5973 var cfg Config
60-
74+
6175 // Set defaults
6276 viper.SetDefault("server.port", 8080)
6377 viper.SetDefault("server.registry_port", 5000)
6478 viper.SetDefault("server.runtime", "auto")
6579 viper.SetDefault("server.socket_path", "")
66-
80+
6781 // Set data_dir default based on environment
6882 defaultDataDir := getDefaultDataDir()
6983 viper.SetDefault("server.data_dir", defaultDataDir)
@@ -72,26 +86,38 @@ func Load() (*Config, error) {
7286 viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))
7387 viper.SetDefault("env.providers", []string{"pass", "sops"})
7488
89+ // Logging defaults
90+ viper.SetDefault("logging.enabled", true)
91+ viper.SetDefault("logging.level", "info")
92+ viper.SetDefault("logging.dir", filepath.Join(defaultDataDir, "logs"))
93+ viper.SetDefault("logging.main_log_file", "gordon.log")
94+ viper.SetDefault("logging.proxy_log_file", "proxy.log")
95+ viper.SetDefault("logging.container_log_dir", "containers")
96+ viper.SetDefault("logging.max_size", 100) // 100MB
97+ viper.SetDefault("logging.max_backups", 3) // Keep 3 old files
98+ viper.SetDefault("logging.max_age", 28) // 28 days
99+ viper.SetDefault("logging.compress", true)
100+
75101 // Handle the routes manually since Viper struggles with domain names
76102 cfg.Routes = make(map[string]string)
77-
103+
78104 // Get server config first
79105 if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
80106 return nil, fmt.Errorf("unable to decode server config: %v", err)
81107 }
82-
108+
83109 // If data_dir is empty after loading config, use the default
84110 if cfg.Server.DataDir == "" {
85111 cfg.Server.DataDir = defaultDataDir
86112 log.Debug().Str("data_dir", cfg.Server.DataDir).Msg("Config had empty data_dir, using default")
87113 }
88-
114+
89115 // If registry_port is 0 after loading config, use the default
90116 if cfg.Server.RegistryPort == 0 {
91117 cfg.Server.RegistryPort = 5000
92118 log.Debug().Int("registry_port", cfg.Server.RegistryPort).Msg("Config had empty registry_port, using default")
93119 }
94-
120+
95121 // Get registry auth config
96122 if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {
97123 return nil, fmt.Errorf("unable to decode registry auth config: %v", err)
@@ -112,7 +138,18 @@ func Load() (*Config, error) {
112138 cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")
113139 log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")
114140 }
115-
141+
142+ // Get logging config
143+ if err := viper.UnmarshalKey("logging", &cfg.Logging); err != nil {
144+ return nil, fmt.Errorf("unable to decode logging config: %v", err)
145+ }
146+
147+ // If logging.dir is empty after loading config, use the default
148+ if cfg.Logging.Dir == "" {
149+ cfg.Logging.Dir = filepath.Join(cfg.Server.DataDir, "logs")
150+ log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default")
151+ }
152+
116153 // Get routes manually from the raw config
117154 routesRaw := viper.Get("routes")
118155 if routesRaw != nil {
@@ -165,13 +202,13 @@ func Load() (*Config, error) {
165202
166203 func (c *Config) GetRoutes() []Route {
167204 var routes []Route
168-
205+
169206 for domain, image := range c.Routes {
170207 route := Route{
171208 Image: image,
172209 HTTPS: true, // Default to HTTPS
173210 }
174-
211+
175212 // Check if domain has http:// prefix
176213 if strings.HasPrefix(domain, "http://") {
177214 route.Domain = strings.TrimPrefix(domain, "http://")
@@ -179,10 +216,10 @@ func (c *Config) GetRoutes() []Route {
179216 } else {
180217 route.Domain = domain
181218 }
182-
219+
183220 routes = append(routes, route)
184221 }
185-
222+
186223 return routes
187224 }
188225
@@ -190,7 +227,7 @@ func (c *Config) GetRoutes() []Route {
190227 func getDefaultDataDir() string {
191228 uid := os.Getuid()
192229 log.Debug().Int("uid", uid).Msg("Detecting default data directory")
193-
230+
194231 // Check if we're running in a rootless environment
195232 if uid != 0 {
196233 // For rootless environments, use user's home directory or current directory
@@ -203,7 +240,7 @@ func getDefaultDataDir() string {
203240 } else {
204241 log.Debug().Msg("Running as root, using ./data")
205242 }
206-
243+
207244 // For root or when home directory is not available, use relative path
208245 return "./data"
209246 }
@@ -213,14 +250,14 @@ func ExtractDomainFromImageName(imageName string) (string, bool) {
213250 // Split by colon to separate image name from tag
214251 parts := strings.Split(imageName, ":")
215252 imageNamePart := parts[0]
216-
253+
217254 // Domain pattern: should contain at least one dot and valid domain characters
218255 domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
219-
256+
220257 if domainPattern.MatchString(imageNamePart) {
221258 return imageNamePart, true
222259 }
223-
260+
224261 return "", false
225262 }
226263
@@ -229,37 +266,37 @@ func (c *Config) AddRoute(domain, image string) error {
229266 if c.Routes == nil {
230267 c.Routes = make(map[string]string)
231268 }
232-
269+
233270 c.Routes[domain] = image
234-
271+
235272 // Read the current config file to preserve formatting
236273 configFile := viper.ConfigFileUsed()
237274 if configFile == "" {
238275 return fmt.Errorf("no config file path available")
239276 }
240-
277+
241278 // Read existing content
242279 content, err := os.ReadFile(configFile)
243280 if err != nil {
244281 return fmt.Errorf("failed to read config file: %w", err)
245282 }
246-
283+
247284 // Parse and update only the routes section while preserving other formatting
248285 lines := strings.Split(string(content), "\n")
249286 var newLines []string
250287 inRoutesSection := false
251288 routeAdded := false
252-
289+
253290 for _, line := range lines {
254291 trimmed := strings.TrimSpace(line)
255-
292+
256293 // Check if we're entering or leaving the routes section
257294 if trimmed == "[routes]" {
258295 inRoutesSection = true
259296 newLines = append(newLines, line)
260297 continue
261298 }
262-
299+
263300 // Check if we're entering a new section
264301 if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
265302 // If we were in routes section and haven't added the route yet, add it
@@ -269,7 +306,7 @@ func (c *Config) AddRoute(domain, image string) error {
269306 }
270307 inRoutesSection = false
271308 }
272-
309+
273310 // If we're in routes section, check if this route already exists (check both quote styles)
274311 if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
275312 // Update existing route using double quotes for consistency
@@ -279,18 +316,18 @@ func (c *Config) AddRoute(domain, image string) error {
279316 newLines = append(newLines, line)
280317 }
281318 }
282-
319+
283320 // If we were in routes section at the end and haven't added the route yet, add it
284321 if inRoutesSection && !routeAdded {
285322 newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
286323 }
287-
324+
288325 // Write the updated content back
289326 newContent := strings.Join(newLines, "\n")
290327 if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
291328 return fmt.Errorf("failed to write config file: %w", err)
292329 }
293-
330+
294331 log.Info().
295332 Str("domain", domain).
296333 Str("image", image).
@@ -298,7 +335,7 @@ func (c *Config) AddRoute(domain, image string) error {
298335
299336 // Create env file for the new route (if env loader is available)
300337 // This will be called from the auto-route handler which has access to the env loader
301-
338+
302339 return nil
303340 }
304341
@@ -326,7 +363,7 @@ func validateEnvConfig(envCfg *EnvConfig) error {
326363 if providerName == "" {
327364 return fmt.Errorf("empty provider name in env.providers")
328365 }
329-
366+
330367 isValid := false
331368 for _, validType := range validProviders {
332369 if providerName == validType {
@@ -335,10 +372,10 @@ func validateEnvConfig(envCfg *EnvConfig) error {
335372 }
336373 }
337374 if !isValid {
338- return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
375+ return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
339376 providerName, strings.Join(validProviders, ", "))
340377 }
341378 }
342379
343380 return nil
344-}⏎̸
381+}
@@ -12,11 +12,12 @@ import (
12 )12 )
13 13
14 type Config struct {14 type Config struct {
15- Server ServerConfig `mapstructure:"server"`15+ Server ServerConfig `mapstructure:"server"`
16 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`16 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17- Routes map[string]string `mapstructure:"routes"`17+ Routes map[string]string `mapstructure:"routes"`
18- AutoRoute AutoRouteConfig `mapstructure:"auto_route"`18+ AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19- Env EnvConfig `mapstructure:"env"`19+ Env EnvConfig `mapstructure:"env"`
20+ Logging LoggingConfig `mapstructure:"logging"`
20 }21 }
21 22
22 type ServerConfig struct {23 type ServerConfig struct {
@@ -44,6 +45,19 @@ type EnvConfig struct {
44 Providers []string `mapstructure:"providers"`45 Providers []string `mapstructure:"providers"`
45 }46 }
46 47
48+type LoggingConfig struct {
49+ Enabled bool `mapstructure:"enabled"`
50+ Level string `mapstructure:"level"`
51+ Dir string `mapstructure:"dir"`
52+ MainLogFile string `mapstructure:"main_log_file"`
53+ ProxyLogFile string `mapstructure:"proxy_log_file"`
54+ ContainerLogDir string `mapstructure:"container_log_dir"`
55+ MaxSize int `mapstructure:"max_size"`
56+ MaxBackups int `mapstructure:"max_backups"`
57+ MaxAge int `mapstructure:"max_age"`
58+ Compress bool `mapstructure:"compress"`
59+}
60+
47 type SecretProvider struct {61 type SecretProvider struct {
48 Type string `mapstructure:"type"`62 Type string `mapstructure:"type"`
49 Config map[string]string `mapstructure:"config"`63 Config map[string]string `mapstructure:"config"`
@@ -57,13 +71,13 @@ type Route struct {
57 71
58 func Load() (*Config, error) {72 func Load() (*Config, error) {
59 var cfg Config73 var cfg Config
60- 74+
61 // Set defaults75 // Set defaults
62 viper.SetDefault("server.port", 8080)76 viper.SetDefault("server.port", 8080)
63 viper.SetDefault("server.registry_port", 5000)77 viper.SetDefault("server.registry_port", 5000)
64 viper.SetDefault("server.runtime", "auto")78 viper.SetDefault("server.runtime", "auto")
65 viper.SetDefault("server.socket_path", "")79 viper.SetDefault("server.socket_path", "")
66- 80+
67 // Set data_dir default based on environment81 // Set data_dir default based on environment
68 defaultDataDir := getDefaultDataDir()82 defaultDataDir := getDefaultDataDir()
69 viper.SetDefault("server.data_dir", defaultDataDir)83 viper.SetDefault("server.data_dir", defaultDataDir)
@@ -72,26 +86,38 @@ func Load() (*Config, error) {
72 viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))86 viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))
73 viper.SetDefault("env.providers", []string{"pass", "sops"})87 viper.SetDefault("env.providers", []string{"pass", "sops"})
74 88
89+ // Logging defaults
90+ viper.SetDefault("logging.enabled", true)
91+ viper.SetDefault("logging.level", "info")
92+ viper.SetDefault("logging.dir", filepath.Join(defaultDataDir, "logs"))
93+ viper.SetDefault("logging.main_log_file", "gordon.log")
94+ viper.SetDefault("logging.proxy_log_file", "proxy.log")
95+ viper.SetDefault("logging.container_log_dir", "containers")
96+ viper.SetDefault("logging.max_size", 100) // 100MB
97+ viper.SetDefault("logging.max_backups", 3) // Keep 3 old files
98+ viper.SetDefault("logging.max_age", 28) // 28 days
99+ viper.SetDefault("logging.compress", true)
100+
75 // Handle the routes manually since Viper struggles with domain names101 // Handle the routes manually since Viper struggles with domain names
76 cfg.Routes = make(map[string]string)102 cfg.Routes = make(map[string]string)
77- 103+
78 // Get server config first104 // Get server config first
79 if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {105 if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
80 return nil, fmt.Errorf("unable to decode server config: %v", err)106 return nil, fmt.Errorf("unable to decode server config: %v", err)
81 }107 }
82- 108+
83 // If data_dir is empty after loading config, use the default109 // If data_dir is empty after loading config, use the default
84 if cfg.Server.DataDir == "" {110 if cfg.Server.DataDir == "" {
85 cfg.Server.DataDir = defaultDataDir111 cfg.Server.DataDir = defaultDataDir
86 log.Debug().Str("data_dir", cfg.Server.DataDir).Msg("Config had empty data_dir, using default")112 log.Debug().Str("data_dir", cfg.Server.DataDir).Msg("Config had empty data_dir, using default")
87 }113 }
88- 114+
89 // If registry_port is 0 after loading config, use the default115 // If registry_port is 0 after loading config, use the default
90 if cfg.Server.RegistryPort == 0 {116 if cfg.Server.RegistryPort == 0 {
91 cfg.Server.RegistryPort = 5000117 cfg.Server.RegistryPort = 5000
92 log.Debug().Int("registry_port", cfg.Server.RegistryPort).Msg("Config had empty registry_port, using default")118 log.Debug().Int("registry_port", cfg.Server.RegistryPort).Msg("Config had empty registry_port, using default")
93 }119 }
94- 120+
95 // Get registry auth config121 // Get registry auth config
96 if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {122 if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {
97 return nil, fmt.Errorf("unable to decode registry auth config: %v", err)123 return nil, fmt.Errorf("unable to decode registry auth config: %v", err)
@@ -112,7 +138,18 @@ func Load() (*Config, error) {
112 cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")138 cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")
113 log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")139 log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")
114 }140 }
115- 141+
142+ // Get logging config
143+ if err := viper.UnmarshalKey("logging", &cfg.Logging); err != nil {
144+ return nil, fmt.Errorf("unable to decode logging config: %v", err)
145+ }
146+
147+ // If logging.dir is empty after loading config, use the default
148+ if cfg.Logging.Dir == "" {
149+ cfg.Logging.Dir = filepath.Join(cfg.Server.DataDir, "logs")
150+ log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default")
151+ }
152+
116 // Get routes manually from the raw config153 // Get routes manually from the raw config
117 routesRaw := viper.Get("routes")154 routesRaw := viper.Get("routes")
118 if routesRaw != nil {155 if routesRaw != nil {
@@ -165,13 +202,13 @@ func Load() (*Config, error) {
165 202
166 func (c *Config) GetRoutes() []Route {203 func (c *Config) GetRoutes() []Route {
167 var routes []Route204 var routes []Route
168- 205+
169 for domain, image := range c.Routes {206 for domain, image := range c.Routes {
170 route := Route{207 route := Route{
171 Image: image,208 Image: image,
172 HTTPS: true, // Default to HTTPS209 HTTPS: true, // Default to HTTPS
173 }210 }
174- 211+
175 // Check if domain has http:// prefix212 // Check if domain has http:// prefix
176 if strings.HasPrefix(domain, "http://") {213 if strings.HasPrefix(domain, "http://") {
177 route.Domain = strings.TrimPrefix(domain, "http://")214 route.Domain = strings.TrimPrefix(domain, "http://")
@@ -179,10 +216,10 @@ func (c *Config) GetRoutes() []Route {
179 } else {216 } else {
180 route.Domain = domain217 route.Domain = domain
181 }218 }
182- 219+
183 routes = append(routes, route)220 routes = append(routes, route)
184 }221 }
185- 222+
186 return routes223 return routes
187 }224 }
188 225
@@ -190,7 +227,7 @@ func (c *Config) GetRoutes() []Route {
190 func getDefaultDataDir() string {227 func getDefaultDataDir() string {
191 uid := os.Getuid()228 uid := os.Getuid()
192 log.Debug().Int("uid", uid).Msg("Detecting default data directory")229 log.Debug().Int("uid", uid).Msg("Detecting default data directory")
193- 230+
194 // Check if we're running in a rootless environment231 // Check if we're running in a rootless environment
195 if uid != 0 {232 if uid != 0 {
196 // For rootless environments, use user's home directory or current directory233 // For rootless environments, use user's home directory or current directory
@@ -203,7 +240,7 @@ func getDefaultDataDir() string {
203 } else {240 } else {
204 log.Debug().Msg("Running as root, using ./data")241 log.Debug().Msg("Running as root, using ./data")
205 }242 }
206- 243+
207 // For root or when home directory is not available, use relative path244 // For root or when home directory is not available, use relative path
208 return "./data"245 return "./data"
209 }246 }
@@ -213,14 +250,14 @@ func ExtractDomainFromImageName(imageName string) (string, bool) {
213 // Split by colon to separate image name from tag250 // Split by colon to separate image name from tag
214 parts := strings.Split(imageName, ":")251 parts := strings.Split(imageName, ":")
215 imageNamePart := parts[0]252 imageNamePart := parts[0]
216- 253+
217 // Domain pattern: should contain at least one dot and valid domain characters254 // Domain pattern: should contain at least one dot and valid domain characters
218 domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)255 domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
219- 256+
220 if domainPattern.MatchString(imageNamePart) {257 if domainPattern.MatchString(imageNamePart) {
221 return imageNamePart, true258 return imageNamePart, true
222 }259 }
223- 260+
224 return "", false261 return "", false
225 }262 }
226 263
@@ -229,37 +266,37 @@ func (c *Config) AddRoute(domain, image string) error {
229 if c.Routes == nil {266 if c.Routes == nil {
230 c.Routes = make(map[string]string)267 c.Routes = make(map[string]string)
231 }268 }
232- 269+
233 c.Routes[domain] = image270 c.Routes[domain] = image
234- 271+
235 // Read the current config file to preserve formatting272 // Read the current config file to preserve formatting
236 configFile := viper.ConfigFileUsed()273 configFile := viper.ConfigFileUsed()
237 if configFile == "" {274 if configFile == "" {
238 return fmt.Errorf("no config file path available")275 return fmt.Errorf("no config file path available")
239 }276 }
240- 277+
241 // Read existing content278 // Read existing content
242 content, err := os.ReadFile(configFile)279 content, err := os.ReadFile(configFile)
243 if err != nil {280 if err != nil {
244 return fmt.Errorf("failed to read config file: %w", err)281 return fmt.Errorf("failed to read config file: %w", err)
245 }282 }
246- 283+
247 // Parse and update only the routes section while preserving other formatting284 // Parse and update only the routes section while preserving other formatting
248 lines := strings.Split(string(content), "\n")285 lines := strings.Split(string(content), "\n")
249 var newLines []string286 var newLines []string
250 inRoutesSection := false287 inRoutesSection := false
251 routeAdded := false288 routeAdded := false
252- 289+
253 for _, line := range lines {290 for _, line := range lines {
254 trimmed := strings.TrimSpace(line)291 trimmed := strings.TrimSpace(line)
255- 292+
256 // Check if we're entering or leaving the routes section293 // Check if we're entering or leaving the routes section
257 if trimmed == "[routes]" {294 if trimmed == "[routes]" {
258 inRoutesSection = true295 inRoutesSection = true
259 newLines = append(newLines, line)296 newLines = append(newLines, line)
260 continue297 continue
261 }298 }
262- 299+
263 // Check if we're entering a new section300 // Check if we're entering a new section
264 if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {301 if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
265 // If we were in routes section and haven't added the route yet, add it302 // If we were in routes section and haven't added the route yet, add it
@@ -269,7 +306,7 @@ func (c *Config) AddRoute(domain, image string) error {
269 }306 }
270 inRoutesSection = false307 inRoutesSection = false
271 }308 }
272- 309+
273 // If we're in routes section, check if this route already exists (check both quote styles)310 // If we're in routes section, check if this route already exists (check both quote styles)
274 if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {311 if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
275 // Update existing route using double quotes for consistency312 // Update existing route using double quotes for consistency
@@ -279,18 +316,18 @@ func (c *Config) AddRoute(domain, image string) error {
279 newLines = append(newLines, line)316 newLines = append(newLines, line)
280 }317 }
281 }318 }
282- 319+
283 // If we were in routes section at the end and haven't added the route yet, add it320 // If we were in routes section at the end and haven't added the route yet, add it
284 if inRoutesSection && !routeAdded {321 if inRoutesSection && !routeAdded {
285 newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))322 newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
286 }323 }
287- 324+
288 // Write the updated content back325 // Write the updated content back
289 newContent := strings.Join(newLines, "\n")326 newContent := strings.Join(newLines, "\n")
290 if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {327 if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
291 return fmt.Errorf("failed to write config file: %w", err)328 return fmt.Errorf("failed to write config file: %w", err)
292 }329 }
293- 330+
294 log.Info().331 log.Info().
295 Str("domain", domain).332 Str("domain", domain).
296 Str("image", image).333 Str("image", image).
@@ -298,7 +335,7 @@ func (c *Config) AddRoute(domain, image string) error {
298 335
299 // Create env file for the new route (if env loader is available)336 // Create env file for the new route (if env loader is available)
300 // This will be called from the auto-route handler which has access to the env loader337 // This will be called from the auto-route handler which has access to the env loader
301- 338+
302 return nil339 return nil
303 }340 }
304 341
@@ -326,7 +363,7 @@ func validateEnvConfig(envCfg *EnvConfig) error {
326 if providerName == "" {363 if providerName == "" {
327 return fmt.Errorf("empty provider name in env.providers")364 return fmt.Errorf("empty provider name in env.providers")
328 }365 }
329- 366+
330 isValid := false367 isValid := false
331 for _, validType := range validProviders {368 for _, validType := range validProviders {
332 if providerName == validType {369 if providerName == validType {
@@ -335,10 +372,10 @@ func validateEnvConfig(envCfg *EnvConfig) error {
335 }372 }
336 }373 }
337 if !isValid {374 if !isValid {
338- return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s", 375+ return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
339 providerName, strings.Join(validProviders, ", "))376 providerName, strings.Join(validProviders, ", "))
340 }377 }
341 }378 }
342 379
343 return nil380 return nil
344-}⏎̸381+}
M internal/container/manager.go +57 -0
@@ -10,6 +10,7 @@ import (
1010
1111 "gordon/internal/config"
1212 "gordon/internal/env"
13+ "gordon/internal/logging"
1314 "gordon/pkg/runtime"
1415 )
1516
@@ -19,6 +20,7 @@ type Manager struct {
1920 config *config.Config
2021 containers map[string]*runtime.Container // map[domain] -> container
2122 envLoader *env.Loader
23+ logManager *logging.LogManager
2224 mu sync.RWMutex
2325 }
2426
@@ -68,11 +70,15 @@ func NewManager(cfg *config.Config) (*Manager, error) {
6870 log.Warn().Err(err).Msg("Failed to create env files for routes")
6971 }
7072
73+ // Create log manager
74+ logManager := logging.NewLogManager(cfg, rt)
75+
7176 return &Manager{
7277 runtime: rt,
7378 config: cfg,
7479 containers: make(map[string]*runtime.Container),
7580 envLoader: envLoader,
81+ logManager: logManager,
7682 }, nil
7783 }
7884
@@ -253,6 +259,19 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
253259 // Store the container mapping
254260 m.containers[route.Domain] = container
255261
262+ // Start log collection for the container
263+ containerName := container.Name
264+ if containerName == "" {
265+ containerName = route.Domain
266+ }
267+ if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
268+ log.Warn().
269+ Err(err).
270+ Str("container", container.ID).
271+ Str("domain", route.Domain).
272+ Msg("Failed to start log collection for container")
273+ }
274+
256275 log.Info().
257276 Str("domain", route.Domain).
258277 Str("image", route.Image).
@@ -328,6 +347,9 @@ func (m *Manager) StopContainer(ctx context.Context, containerID string) error {
328347 return fmt.Errorf("failed to stop container %s: %w", containerID, err)
329348 }
330349
350+ // Stop log collection for the container
351+ m.logManager.StopCollection(containerID)
352+
331353 log.Info().Str("container", containerID).Msg("Container stopped")
332354 return nil
333355 }
@@ -351,6 +373,9 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force
351373 return fmt.Errorf("failed to remove container %s: %w", containerID, err)
352374 }
353375
376+ // Stop log collection for the container
377+ m.logManager.StopCollection(containerID)
378+
354379 // Remove from our tracking map
355380 m.mu.Lock()
356381 defer m.mu.Unlock()
@@ -481,6 +506,19 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
481506 m.containers[route.Domain] = container
482507 m.mu.Unlock()
483508 startedCount++
509+
510+ // Start log collection for the container
511+ containerName := container.Name
512+ if containerName == "" {
513+ containerName = route.Domain
514+ }
515+ if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
516+ log.Warn().
517+ Err(err).
518+ Str("container", container.ID).
519+ Str("domain", route.Domain).
520+ Msg("Failed to start log collection for auto-started container")
521+ }
484522
485523 log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")
486524 continue
@@ -581,6 +619,9 @@ func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
581619 continue
582620 }
583621
622+ // Stop log collection for the container
623+ m.logManager.StopCollection(container.ID)
624+
584625 // Remove from tracking
585626 m.mu.Lock()
586627 delete(m.containers, domain)
@@ -655,3 +696,19 @@ func normalizeImageRef(imageRef string) string {
655696
656697 return image + ":" + tag
657698 }
699+
700+// Shutdown gracefully shuts down the container manager
701+func (m *Manager) Shutdown(ctx context.Context) error {
702+ log.Info().Msg("Shutting down container manager...")
703+
704+ // Stop all managed containers (this also stops log collection for each)
705+ if err := m.StopAllManagedContainers(ctx); err != nil {
706+ log.Error().Err(err).Msg("Failed to stop all managed containers during shutdown")
707+ }
708+
709+ // Stop all remaining log collection
710+ m.logManager.StopAll()
711+
712+ log.Info().Msg("Container manager shutdown complete")
713+ return nil
714+}
@@ -10,6 +10,7 @@ import (
10 10
11 "gordon/internal/config"11 "gordon/internal/config"
12 "gordon/internal/env"12 "gordon/internal/env"
13+ "gordon/internal/logging"
13 "gordon/pkg/runtime"14 "gordon/pkg/runtime"
14 )15 )
15 16
@@ -19,6 +20,7 @@ type Manager struct {
19 config *config.Config20 config *config.Config
20 containers map[string]*runtime.Container // map[domain] -> container21 containers map[string]*runtime.Container // map[domain] -> container
21 envLoader *env.Loader22 envLoader *env.Loader
23+ logManager *logging.LogManager
22 mu sync.RWMutex24 mu sync.RWMutex
23 }25 }
24 26
@@ -68,11 +70,15 @@ func NewManager(cfg *config.Config) (*Manager, error) {
68 log.Warn().Err(err).Msg("Failed to create env files for routes")70 log.Warn().Err(err).Msg("Failed to create env files for routes")
69 }71 }
70 72
73+ // Create log manager
74+ logManager := logging.NewLogManager(cfg, rt)
75+
71 return &Manager{76 return &Manager{
72 runtime: rt,77 runtime: rt,
73 config: cfg,78 config: cfg,
74 containers: make(map[string]*runtime.Container),79 containers: make(map[string]*runtime.Container),
75 envLoader: envLoader,80 envLoader: envLoader,
81+ logManager: logManager,
76 }, nil82 }, nil
77 }83 }
78 84
@@ -253,6 +259,19 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
253 // Store the container mapping259 // Store the container mapping
254 m.containers[route.Domain] = container260 m.containers[route.Domain] = container
255 261
262+ // Start log collection for the container
263+ containerName := container.Name
264+ if containerName == "" {
265+ containerName = route.Domain
266+ }
267+ if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
268+ log.Warn().
269+ Err(err).
270+ Str("container", container.ID).
271+ Str("domain", route.Domain).
272+ Msg("Failed to start log collection for container")
273+ }
274+
256 log.Info().275 log.Info().
257 Str("domain", route.Domain).276 Str("domain", route.Domain).
258 Str("image", route.Image).277 Str("image", route.Image).
@@ -328,6 +347,9 @@ func (m *Manager) StopContainer(ctx context.Context, containerID string) error {
328 return fmt.Errorf("failed to stop container %s: %w", containerID, err)347 return fmt.Errorf("failed to stop container %s: %w", containerID, err)
329 }348 }
330 349
350+ // Stop log collection for the container
351+ m.logManager.StopCollection(containerID)
352+
331 log.Info().Str("container", containerID).Msg("Container stopped")353 log.Info().Str("container", containerID).Msg("Container stopped")
332 return nil354 return nil
333 }355 }
@@ -351,6 +373,9 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force
351 return fmt.Errorf("failed to remove container %s: %w", containerID, err)373 return fmt.Errorf("failed to remove container %s: %w", containerID, err)
352 }374 }
353 375
376+ // Stop log collection for the container
377+ m.logManager.StopCollection(containerID)
378+
354 // Remove from our tracking map379 // Remove from our tracking map
355 m.mu.Lock()380 m.mu.Lock()
356 defer m.mu.Unlock()381 defer m.mu.Unlock()
@@ -481,6 +506,19 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
481 m.containers[route.Domain] = container506 m.containers[route.Domain] = container
482 m.mu.Unlock()507 m.mu.Unlock()
483 startedCount++508 startedCount++
509+
510+ // Start log collection for the container
511+ containerName := container.Name
512+ if containerName == "" {
513+ containerName = route.Domain
514+ }
515+ if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
516+ log.Warn().
517+ Err(err).
518+ Str("container", container.ID).
519+ Str("domain", route.Domain).
520+ Msg("Failed to start log collection for auto-started container")
521+ }
484 522
485 log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")523 log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")
486 continue524 continue
@@ -581,6 +619,9 @@ func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
581 continue619 continue
582 }620 }
583 621
622+ // Stop log collection for the container
623+ m.logManager.StopCollection(container.ID)
624+
584 // Remove from tracking625 // Remove from tracking
585 m.mu.Lock()626 m.mu.Lock()
586 delete(m.containers, domain)627 delete(m.containers, domain)
@@ -655,3 +696,19 @@ func normalizeImageRef(imageRef string) string {
655 696
656 return image + ":" + tag697 return image + ":" + tag
657 }698 }
699+
700+// Shutdown gracefully shuts down the container manager
701+func (m *Manager) Shutdown(ctx context.Context) error {
702+ log.Info().Msg("Shutting down container manager...")
703+
704+ // Stop all managed containers (this also stops log collection for each)
705+ if err := m.StopAllManagedContainers(ctx); err != nil {
706+ log.Error().Err(err).Msg("Failed to stop all managed containers during shutdown")
707+ }
708+
709+ // Stop all remaining log collection
710+ m.logManager.StopAll()
711+
712+ log.Info().Msg("Container manager shutdown complete")
713+ return nil
714+}
A internal/logging/collector.go +236 -0
@@ -0,0 +1,236 @@
1+package logging
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "gordon/internal/config"
11+ "gordon/pkg/runtime"
12+
13+ "github.com/rs/zerolog/log"
14+ "gopkg.in/natefinch/lumberjack.v2"
15+)
16+
17+// LogCollector manages log collection for a single container
18+type LogCollector struct {
19+ containerID string
20+ containerName string
21+ runtime runtime.Runtime
22+ logWriter *lumberjack.Logger
23+ ctx context.Context
24+ cancel context.CancelFunc
25+ wg sync.WaitGroup
26+}
27+
28+// LogManager manages all container log collectors
29+type LogManager struct {
30+ collectors map[string]*LogCollector
31+ config *config.Config
32+ runtime runtime.Runtime
33+ mu sync.RWMutex
34+}
35+
36+// NewLogManager creates a new log manager
37+func NewLogManager(cfg *config.Config, rt runtime.Runtime) *LogManager {
38+ return &LogManager{
39+ collectors: make(map[string]*LogCollector),
40+ config: cfg,
41+ runtime: rt,
42+ }
43+}
44+
45+// StartCollection starts log collection for a container
46+func (lm *LogManager) StartCollection(containerID, containerName string) error {
47+ if !lm.config.Logging.Enabled {
48+ return nil // Logging disabled, nothing to do
49+ }
50+
51+ lm.mu.Lock()
52+ defer lm.mu.Unlock()
53+
54+ // Stop existing collector if it exists
55+ if existing, exists := lm.collectors[containerID]; exists {
56+ log.Debug().Str("container_id", containerID).Msg("Stopping existing log collector")
57+ existing.Stop()
58+ delete(lm.collectors, containerID)
59+ }
60+
61+ // Create log writer for this container
62+ logWriter, err := GetContainerLogWriter(lm.config, containerID, containerName)
63+ if err != nil {
64+ return fmt.Errorf("failed to create log writer for container %s: %w", containerID, err)
65+ }
66+
67+ // Create collector context
68+ ctx, cancel := context.WithCancel(context.Background())
69+
70+ collector := &LogCollector{
71+ containerID: containerID,
72+ containerName: containerName,
73+ runtime: lm.runtime,
74+ logWriter: logWriter,
75+ ctx: ctx,
76+ cancel: cancel,
77+ }
78+
79+ // Start collection goroutine
80+ collector.wg.Add(1)
81+ go collector.collectLogs()
82+
83+ // Store collector
84+ lm.collectors[containerID] = collector
85+
86+ log.Info().
87+ Str("container_id", containerID).
88+ Str("container_name", containerName).
89+ Msg("Started container log collection")
90+
91+ return nil
92+}
93+
94+// StopCollection stops log collection for a container
95+func (lm *LogManager) StopCollection(containerID string) {
96+ lm.mu.Lock()
97+ defer lm.mu.Unlock()
98+
99+ if collector, exists := lm.collectors[containerID]; exists {
100+ log.Debug().Str("container_id", containerID).Msg("Stopping log collection")
101+ collector.Stop()
102+ delete(lm.collectors, containerID)
103+ }
104+}
105+
106+// StopAll stops all log collection
107+func (lm *LogManager) StopAll() {
108+ lm.mu.Lock()
109+ defer lm.mu.Unlock()
110+
111+ for containerID, collector := range lm.collectors {
112+ log.Debug().Str("container_id", containerID).Msg("Stopping log collection")
113+ collector.Stop()
114+ }
115+ lm.collectors = make(map[string]*LogCollector)
116+}
117+
118+// GetActiveCollectors returns the number of active collectors
119+func (lm *LogManager) GetActiveCollectors() int {
120+ lm.mu.RLock()
121+ defer lm.mu.RUnlock()
122+ return len(lm.collectors)
123+}
124+
125+// collectLogs runs the log collection loop for a single container
126+func (lc *LogCollector) collectLogs() {
127+ defer lc.wg.Done()
128+
129+ log.Debug().
130+ Str("container_id", lc.containerID).
131+ Str("container_name", lc.containerName).
132+ Msg("Starting log collection goroutine")
133+
134+ // Retry loop for log collection
135+ retryDelay := time.Second
136+ maxRetryDelay := 30 * time.Second
137+
138+ for {
139+ select {
140+ case <-lc.ctx.Done():
141+ log.Debug().Str("container_id", lc.containerID).Msg("Log collection context cancelled")
142+ return
143+ default:
144+ // Attempt to collect logs
145+ if err := lc.collectLogsOnce(); err != nil {
146+ log.Warn().
147+ Err(err).
148+ Str("container_id", lc.containerID).
149+ Dur("retry_delay", retryDelay).
150+ Msg("Log collection failed, retrying")
151+
152+ // Wait before retrying, with exponential backoff
153+ select {
154+ case <-lc.ctx.Done():
155+ return
156+ case <-time.After(retryDelay):
157+ // Increase retry delay, up to maximum
158+ retryDelay *= 2
159+ if retryDelay > maxRetryDelay {
160+ retryDelay = maxRetryDelay
161+ }
162+ }
163+ continue
164+ }
165+
166+ // Reset retry delay on success
167+ retryDelay = time.Second
168+ }
169+ }
170+}
171+
172+// collectLogsOnce attempts to collect logs once
173+func (lc *LogCollector) collectLogsOnce() error {
174+ // Get log stream from runtime
175+ logStream, err := lc.runtime.GetContainerLogs(lc.ctx, lc.containerID, true)
176+ if err != nil {
177+ return fmt.Errorf("failed to get log stream: %w", err)
178+ }
179+ defer logStream.Close()
180+
181+ log.Debug().Str("container_id", lc.containerID).Msg("Started reading container logs")
182+
183+ // Read logs line by line and write to file
184+ scanner := bufio.NewScanner(logStream)
185+ for scanner.Scan() {
186+ select {
187+ case <-lc.ctx.Done():
188+ return nil
189+ default:
190+ // Get log line
191+ line := scanner.Text()
192+
193+ // Add timestamp and write to log file
194+ timestampedLine := fmt.Sprintf("%s %s\n",
195+ time.Now().Format(time.RFC3339),
196+ line)
197+
198+ if _, err := lc.logWriter.Write([]byte(timestampedLine)); err != nil {
199+ log.Warn().
200+ Err(err).
201+ Str("container_id", lc.containerID).
202+ Msg("Failed to write container log line")
203+ }
204+ }
205+ }
206+
207+ // Check for scanner errors
208+ if err := scanner.Err(); err != nil {
209+ return fmt.Errorf("error reading log stream: %w", err)
210+ }
211+
212+ return nil
213+}
214+
215+// Stop gracefully stops the log collector
216+func (lc *LogCollector) Stop() {
217+ log.Debug().Str("container_id", lc.containerID).Msg("Stopping log collector")
218+
219+ // Cancel context to stop goroutines
220+ lc.cancel()
221+
222+ // Wait for collection goroutine to finish
223+ lc.wg.Wait()
224+
225+ // Close log writer
226+ if lc.logWriter != nil {
227+ if err := lc.logWriter.Close(); err != nil {
228+ log.Warn().
229+ Err(err).
230+ Str("container_id", lc.containerID).
231+ Msg("Failed to close log writer")
232+ }
233+ }
234+
235+ log.Debug().Str("container_id", lc.containerID).Msg("Log collector stopped")
236+}
@@ -0,0 +1,236 @@
1+package logging
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "gordon/internal/config"
11+ "gordon/pkg/runtime"
12+
13+ "github.com/rs/zerolog/log"
14+ "gopkg.in/natefinch/lumberjack.v2"
15+)
16+
17+// LogCollector manages log collection for a single container
18+type LogCollector struct {
19+ containerID string
20+ containerName string
21+ runtime runtime.Runtime
22+ logWriter *lumberjack.Logger
23+ ctx context.Context
24+ cancel context.CancelFunc
25+ wg sync.WaitGroup
26+}
27+
28+// LogManager manages all container log collectors
29+type LogManager struct {
30+ collectors map[string]*LogCollector
31+ config *config.Config
32+ runtime runtime.Runtime
33+ mu sync.RWMutex
34+}
35+
36+// NewLogManager creates a new log manager
37+func NewLogManager(cfg *config.Config, rt runtime.Runtime) *LogManager {
38+ return &LogManager{
39+ collectors: make(map[string]*LogCollector),
40+ config: cfg,
41+ runtime: rt,
42+ }
43+}
44+
45+// StartCollection starts log collection for a container
46+func (lm *LogManager) StartCollection(containerID, containerName string) error {
47+ if !lm.config.Logging.Enabled {
48+ return nil // Logging disabled, nothing to do
49+ }
50+
51+ lm.mu.Lock()
52+ defer lm.mu.Unlock()
53+
54+ // Stop existing collector if it exists
55+ if existing, exists := lm.collectors[containerID]; exists {
56+ log.Debug().Str("container_id", containerID).Msg("Stopping existing log collector")
57+ existing.Stop()
58+ delete(lm.collectors, containerID)
59+ }
60+
61+ // Create log writer for this container
62+ logWriter, err := GetContainerLogWriter(lm.config, containerID, containerName)
63+ if err != nil {
64+ return fmt.Errorf("failed to create log writer for container %s: %w", containerID, err)
65+ }
66+
67+ // Create collector context
68+ ctx, cancel := context.WithCancel(context.Background())
69+
70+ collector := &LogCollector{
71+ containerID: containerID,
72+ containerName: containerName,
73+ runtime: lm.runtime,
74+ logWriter: logWriter,
75+ ctx: ctx,
76+ cancel: cancel,
77+ }
78+
79+ // Start collection goroutine
80+ collector.wg.Add(1)
81+ go collector.collectLogs()
82+
83+ // Store collector
84+ lm.collectors[containerID] = collector
85+
86+ log.Info().
87+ Str("container_id", containerID).
88+ Str("container_name", containerName).
89+ Msg("Started container log collection")
90+
91+ return nil
92+}
93+
94+// StopCollection stops log collection for a container
95+func (lm *LogManager) StopCollection(containerID string) {
96+ lm.mu.Lock()
97+ defer lm.mu.Unlock()
98+
99+ if collector, exists := lm.collectors[containerID]; exists {
100+ log.Debug().Str("container_id", containerID).Msg("Stopping log collection")
101+ collector.Stop()
102+ delete(lm.collectors, containerID)
103+ }
104+}
105+
106+// StopAll stops all log collection
107+func (lm *LogManager) StopAll() {
108+ lm.mu.Lock()
109+ defer lm.mu.Unlock()
110+
111+ for containerID, collector := range lm.collectors {
112+ log.Debug().Str("container_id", containerID).Msg("Stopping log collection")
113+ collector.Stop()
114+ }
115+ lm.collectors = make(map[string]*LogCollector)
116+}
117+
118+// GetActiveCollectors returns the number of active collectors
119+func (lm *LogManager) GetActiveCollectors() int {
120+ lm.mu.RLock()
121+ defer lm.mu.RUnlock()
122+ return len(lm.collectors)
123+}
124+
125+// collectLogs runs the log collection loop for a single container
126+func (lc *LogCollector) collectLogs() {
127+ defer lc.wg.Done()
128+
129+ log.Debug().
130+ Str("container_id", lc.containerID).
131+ Str("container_name", lc.containerName).
132+ Msg("Starting log collection goroutine")
133+
134+ // Retry loop for log collection
135+ retryDelay := time.Second
136+ maxRetryDelay := 30 * time.Second
137+
138+ for {
139+ select {
140+ case <-lc.ctx.Done():
141+ log.Debug().Str("container_id", lc.containerID).Msg("Log collection context cancelled")
142+ return
143+ default:
144+ // Attempt to collect logs
145+ if err := lc.collectLogsOnce(); err != nil {
146+ log.Warn().
147+ Err(err).
148+ Str("container_id", lc.containerID).
149+ Dur("retry_delay", retryDelay).
150+ Msg("Log collection failed, retrying")
151+
152+ // Wait before retrying, with exponential backoff
153+ select {
154+ case <-lc.ctx.Done():
155+ return
156+ case <-time.After(retryDelay):
157+ // Increase retry delay, up to maximum
158+ retryDelay *= 2
159+ if retryDelay > maxRetryDelay {
160+ retryDelay = maxRetryDelay
161+ }
162+ }
163+ continue
164+ }
165+
166+ // Reset retry delay on success
167+ retryDelay = time.Second
168+ }
169+ }
170+}
171+
172+// collectLogsOnce attempts to collect logs once
173+func (lc *LogCollector) collectLogsOnce() error {
174+ // Get log stream from runtime
175+ logStream, err := lc.runtime.GetContainerLogs(lc.ctx, lc.containerID, true)
176+ if err != nil {
177+ return fmt.Errorf("failed to get log stream: %w", err)
178+ }
179+ defer logStream.Close()
180+
181+ log.Debug().Str("container_id", lc.containerID).Msg("Started reading container logs")
182+
183+ // Read logs line by line and write to file
184+ scanner := bufio.NewScanner(logStream)
185+ for scanner.Scan() {
186+ select {
187+ case <-lc.ctx.Done():
188+ return nil
189+ default:
190+ // Get log line
191+ line := scanner.Text()
192+
193+ // Add timestamp and write to log file
194+ timestampedLine := fmt.Sprintf("%s %s\n",
195+ time.Now().Format(time.RFC3339),
196+ line)
197+
198+ if _, err := lc.logWriter.Write([]byte(timestampedLine)); err != nil {
199+ log.Warn().
200+ Err(err).
201+ Str("container_id", lc.containerID).
202+ Msg("Failed to write container log line")
203+ }
204+ }
205+ }
206+
207+ // Check for scanner errors
208+ if err := scanner.Err(); err != nil {
209+ return fmt.Errorf("error reading log stream: %w", err)
210+ }
211+
212+ return nil
213+}
214+
215+// Stop gracefully stops the log collector
216+func (lc *LogCollector) Stop() {
217+ log.Debug().Str("container_id", lc.containerID).Msg("Stopping log collector")
218+
219+ // Cancel context to stop goroutines
220+ lc.cancel()
221+
222+ // Wait for collection goroutine to finish
223+ lc.wg.Wait()
224+
225+ // Close log writer
226+ if lc.logWriter != nil {
227+ if err := lc.logWriter.Close(); err != nil {
228+ log.Warn().
229+ Err(err).
230+ Str("container_id", lc.containerID).
231+ Msg("Failed to close log writer")
232+ }
233+ }
234+
235+ log.Debug().Str("container_id", lc.containerID).Msg("Log collector stopped")
236+}
A internal/logging/setup.go +161 -0
@@ -0,0 +1,161 @@
1+package logging
2+
3+import (
4+ "fmt"
5+ "io"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/rs/zerolog"
10+ "github.com/rs/zerolog/log"
11+ "gopkg.in/natefinch/lumberjack.v2"
12+ "gordon/internal/config"
13+)
14+
15+var (
16+ MainLogger zerolog.Logger
17+ ProxyLogger zerolog.Logger
18+)
19+
20+// Setup initializes the logging system based on the configuration
21+func Setup(cfg *config.Config) error {
22+ if !cfg.Logging.Enabled {
23+ // Keep console logging only
24+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
25+ MainLogger = log.Logger
26+ ProxyLogger = log.Logger
27+ return nil
28+ }
29+
30+ // Create logs directory with secure permissions (0700 - owner only)
31+ if err := os.MkdirAll(cfg.Logging.Dir, 0700); err != nil {
32+ return fmt.Errorf("failed to create logs directory: %w", err)
33+ }
34+
35+ // Create container logs directory with secure permissions
36+ containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir)
37+ if err := os.MkdirAll(containerLogDir, 0700); err != nil {
38+ return fmt.Errorf("failed to create container logs directory: %w", err)
39+ }
40+
41+ // Set log level
42+ level, err := zerolog.ParseLevel(cfg.Logging.Level)
43+ if err != nil {
44+ level = zerolog.InfoLevel
45+ log.Warn().Str("invalid_level", cfg.Logging.Level).Msg("Invalid log level, using info")
46+ }
47+ zerolog.SetGlobalLevel(level)
48+
49+ // Configure time format
50+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
51+
52+ // Setup main application logger
53+ mainLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.MainLogFile)
54+ mainFileWriter := &lumberjack.Logger{
55+ Filename: mainLogFile,
56+ MaxSize: cfg.Logging.MaxSize,
57+ MaxBackups: cfg.Logging.MaxBackups,
58+ MaxAge: cfg.Logging.MaxAge,
59+ Compress: cfg.Logging.Compress,
60+ }
61+
62+ // Set file permissions to be secure (readable only by owner)
63+ if err := os.Chmod(mainLogFile, 0600); err != nil && !os.IsNotExist(err) {
64+ log.Warn().Err(err).Str("file", mainLogFile).Msg("Failed to set secure permissions on log file")
65+ }
66+
67+ // Setup proxy logger
68+ proxyLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.ProxyLogFile)
69+ proxyFileWriter := &lumberjack.Logger{
70+ Filename: proxyLogFile,
71+ MaxSize: cfg.Logging.MaxSize,
72+ MaxBackups: cfg.Logging.MaxBackups,
73+ MaxAge: cfg.Logging.MaxAge,
74+ Compress: cfg.Logging.Compress,
75+ }
76+
77+ // Set file permissions to be secure (readable only by owner)
78+ if err := os.Chmod(proxyLogFile, 0600); err != nil && !os.IsNotExist(err) {
79+ log.Warn().Err(err).Str("file", proxyLogFile).Msg("Failed to set secure permissions on log file")
80+ }
81+
82+ // Create multi-writers (console + file)
83+ consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr}
84+
85+ mainMultiWriter := io.MultiWriter(consoleWriter, mainFileWriter)
86+ proxyMultiWriter := io.MultiWriter(consoleWriter, proxyFileWriter)
87+
88+ // Initialize loggers
89+ MainLogger = zerolog.New(mainMultiWriter).With().Timestamp().Logger()
90+ ProxyLogger = zerolog.New(proxyMultiWriter).With().Timestamp().Logger()
91+
92+ // Set the global logger to the main logger
93+ log.Logger = MainLogger
94+
95+ log.Info().
96+ Str("main_log", mainLogFile).
97+ Str("proxy_log", proxyLogFile).
98+ Str("container_log_dir", containerLogDir).
99+ Str("level", level.String()).
100+ Msg("File logging initialized")
101+
102+ return nil
103+}
104+
105+// GetContainerLogWriter returns a log writer for a specific container
106+func GetContainerLogWriter(cfg *config.Config, containerID string, containerName string) (*lumberjack.Logger, error) {
107+ if !cfg.Logging.Enabled {
108+ return nil, fmt.Errorf("logging is disabled")
109+ }
110+
111+ containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir)
112+
113+ // Ensure container log directory exists with secure permissions
114+ if err := os.MkdirAll(containerLogDir, 0700); err != nil {
115+ return nil, fmt.Errorf("failed to create container log directory: %w", err)
116+ }
117+
118+ // Use container ID as the main log file name
119+ logFile := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerID))
120+
121+ writer := &lumberjack.Logger{
122+ Filename: logFile,
123+ MaxSize: cfg.Logging.MaxSize,
124+ MaxBackups: cfg.Logging.MaxBackups,
125+ MaxAge: cfg.Logging.MaxAge,
126+ Compress: cfg.Logging.Compress,
127+ }
128+
129+ // Set file permissions to be secure (readable only by owner)
130+ if err := os.Chmod(logFile, 0600); err != nil && !os.IsNotExist(err) {
131+ log.Warn().Err(err).Str("file", logFile).Msg("Failed to set secure permissions on container log file")
132+ }
133+
134+ // Create a symlink for easier access using container name (if provided)
135+ if containerName != "" && containerName != containerID {
136+ symlinkPath := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerName))
137+
138+ // Remove existing symlink if it exists
139+ if _, err := os.Lstat(symlinkPath); err == nil {
140+ os.Remove(symlinkPath)
141+ }
142+
143+ // Create new symlink
144+ relativeLogFile := fmt.Sprintf("%s.log", containerID)
145+ if err := os.Symlink(relativeLogFile, symlinkPath); err != nil {
146+ log.Warn().
147+ Err(err).
148+ Str("symlink", symlinkPath).
149+ Str("target", relativeLogFile).
150+ Msg("Failed to create container name symlink")
151+ }
152+ }
153+
154+ return writer, nil
155+}
156+
157+// Close gracefully closes all logging writers
158+func Close() {
159+ // Lumberjack loggers will be closed automatically when the program exits
160+ // This function is provided for future extensibility
161+}⏎̸
@@ -0,0 +1,161 @@
1+package logging
2+
3+import (
4+ "fmt"
5+ "io"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/rs/zerolog"
10+ "github.com/rs/zerolog/log"
11+ "gopkg.in/natefinch/lumberjack.v2"
12+ "gordon/internal/config"
13+)
14+
15+var (
16+ MainLogger zerolog.Logger
17+ ProxyLogger zerolog.Logger
18+)
19+
20+// Setup initializes the logging system based on the configuration
21+func Setup(cfg *config.Config) error {
22+ if !cfg.Logging.Enabled {
23+ // Keep console logging only
24+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
25+ MainLogger = log.Logger
26+ ProxyLogger = log.Logger
27+ return nil
28+ }
29+
30+ // Create logs directory with secure permissions (0700 - owner only)
31+ if err := os.MkdirAll(cfg.Logging.Dir, 0700); err != nil {
32+ return fmt.Errorf("failed to create logs directory: %w", err)
33+ }
34+
35+ // Create container logs directory with secure permissions
36+ containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir)
37+ if err := os.MkdirAll(containerLogDir, 0700); err != nil {
38+ return fmt.Errorf("failed to create container logs directory: %w", err)
39+ }
40+
41+ // Set log level
42+ level, err := zerolog.ParseLevel(cfg.Logging.Level)
43+ if err != nil {
44+ level = zerolog.InfoLevel
45+ log.Warn().Str("invalid_level", cfg.Logging.Level).Msg("Invalid log level, using info")
46+ }
47+ zerolog.SetGlobalLevel(level)
48+
49+ // Configure time format
50+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
51+
52+ // Setup main application logger
53+ mainLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.MainLogFile)
54+ mainFileWriter := &lumberjack.Logger{
55+ Filename: mainLogFile,
56+ MaxSize: cfg.Logging.MaxSize,
57+ MaxBackups: cfg.Logging.MaxBackups,
58+ MaxAge: cfg.Logging.MaxAge,
59+ Compress: cfg.Logging.Compress,
60+ }
61+
62+ // Set file permissions to be secure (readable only by owner)
63+ if err := os.Chmod(mainLogFile, 0600); err != nil && !os.IsNotExist(err) {
64+ log.Warn().Err(err).Str("file", mainLogFile).Msg("Failed to set secure permissions on log file")
65+ }
66+
67+ // Setup proxy logger
68+ proxyLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.ProxyLogFile)
69+ proxyFileWriter := &lumberjack.Logger{
70+ Filename: proxyLogFile,
71+ MaxSize: cfg.Logging.MaxSize,
72+ MaxBackups: cfg.Logging.MaxBackups,
73+ MaxAge: cfg.Logging.MaxAge,
74+ Compress: cfg.Logging.Compress,
75+ }
76+
77+ // Set file permissions to be secure (readable only by owner)
78+ if err := os.Chmod(proxyLogFile, 0600); err != nil && !os.IsNotExist(err) {
79+ log.Warn().Err(err).Str("file", proxyLogFile).Msg("Failed to set secure permissions on log file")
80+ }
81+
82+ // Create multi-writers (console + file)
83+ consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr}
84+
85+ mainMultiWriter := io.MultiWriter(consoleWriter, mainFileWriter)
86+ proxyMultiWriter := io.MultiWriter(consoleWriter, proxyFileWriter)
87+
88+ // Initialize loggers
89+ MainLogger = zerolog.New(mainMultiWriter).With().Timestamp().Logger()
90+ ProxyLogger = zerolog.New(proxyMultiWriter).With().Timestamp().Logger()
91+
92+ // Set the global logger to the main logger
93+ log.Logger = MainLogger
94+
95+ log.Info().
96+ Str("main_log", mainLogFile).
97+ Str("proxy_log", proxyLogFile).
98+ Str("container_log_dir", containerLogDir).
99+ Str("level", level.String()).
100+ Msg("File logging initialized")
101+
102+ return nil
103+}
104+
105+// GetContainerLogWriter returns a log writer for a specific container
106+func GetContainerLogWriter(cfg *config.Config, containerID string, containerName string) (*lumberjack.Logger, error) {
107+ if !cfg.Logging.Enabled {
108+ return nil, fmt.Errorf("logging is disabled")
109+ }
110+
111+ containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir)
112+
113+ // Ensure container log directory exists with secure permissions
114+ if err := os.MkdirAll(containerLogDir, 0700); err != nil {
115+ return nil, fmt.Errorf("failed to create container log directory: %w", err)
116+ }
117+
118+ // Use container ID as the main log file name
119+ logFile := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerID))
120+
121+ writer := &lumberjack.Logger{
122+ Filename: logFile,
123+ MaxSize: cfg.Logging.MaxSize,
124+ MaxBackups: cfg.Logging.MaxBackups,
125+ MaxAge: cfg.Logging.MaxAge,
126+ Compress: cfg.Logging.Compress,
127+ }
128+
129+ // Set file permissions to be secure (readable only by owner)
130+ if err := os.Chmod(logFile, 0600); err != nil && !os.IsNotExist(err) {
131+ log.Warn().Err(err).Str("file", logFile).Msg("Failed to set secure permissions on container log file")
132+ }
133+
134+ // Create a symlink for easier access using container name (if provided)
135+ if containerName != "" && containerName != containerID {
136+ symlinkPath := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerName))
137+
138+ // Remove existing symlink if it exists
139+ if _, err := os.Lstat(symlinkPath); err == nil {
140+ os.Remove(symlinkPath)
141+ }
142+
143+ // Create new symlink
144+ relativeLogFile := fmt.Sprintf("%s.log", containerID)
145+ if err := os.Symlink(relativeLogFile, symlinkPath); err != nil {
146+ log.Warn().
147+ Err(err).
148+ Str("symlink", symlinkPath).
149+ Str("target", relativeLogFile).
150+ Msg("Failed to create container name symlink")
151+ }
152+ }
153+
154+ return writer, nil
155+}
156+
157+// Close gracefully closes all logging writers
158+func Close() {
159+ // Lumberjack loggers will be closed automatically when the program exits
160+ // This function is provided for future extensibility
161+}⏎̸
M internal/middleware/logging.go +5 -4
@@ -4,6 +4,7 @@ import (
44 "net/http"
55 "time"
66
7+ "gordon/internal/logging"
78 "github.com/rs/zerolog/log"
89 )
910
@@ -57,8 +58,8 @@ func RequestLogger(next http.Handler) http.Handler {
5758 // Calculate duration
5859 duration := time.Since(start)
5960
60- // Log the request
61- log.Info().
61+ // Log the request using proxy logger
62+ logging.ProxyLogger.Info().
6263 Str("method", r.Method).
6364 Str("path", r.URL.Path).
6465 Str("query", r.URL.RawQuery).
@@ -119,8 +120,8 @@ func ContainerRequestLogger(containerID string, domain string) func(http.Handler
119120 // Calculate duration
120121 duration := time.Since(start)
121122
122- // Log the request with container information
123- log.Info().
123+ // Log the request with container information using proxy logger
124+ logging.ProxyLogger.Info().
124125 Str("method", r.Method).
125126 Str("path", r.URL.Path).
126127 Str("query", r.URL.RawQuery).
@@ -4,6 +4,7 @@ import (
4 "net/http"4 "net/http"
5 "time"5 "time"
6 6
7+ "gordon/internal/logging"
7 "github.com/rs/zerolog/log"8 "github.com/rs/zerolog/log"
8 )9 )
9 10
@@ -57,8 +58,8 @@ func RequestLogger(next http.Handler) http.Handler {
57 // Calculate duration58 // Calculate duration
58 duration := time.Since(start)59 duration := time.Since(start)
59 60
60- // Log the request61+ // Log the request using proxy logger
61- log.Info().62+ logging.ProxyLogger.Info().
62 Str("method", r.Method).63 Str("method", r.Method).
63 Str("path", r.URL.Path).64 Str("path", r.URL.Path).
64 Str("query", r.URL.RawQuery).65 Str("query", r.URL.RawQuery).
@@ -119,8 +120,8 @@ func ContainerRequestLogger(containerID string, domain string) func(http.Handler
119 // Calculate duration120 // Calculate duration
120 duration := time.Since(start)121 duration := time.Since(start)
121 122
122- // Log the request with container information123+ // Log the request with container information using proxy logger
123- log.Info().124+ logging.ProxyLogger.Info().
124 Str("method", r.Method).125 Str("method", r.Method).
125 Str("path", r.URL.Path).126 Str("path", r.URL.Path).
126 Str("query", r.URL.RawQuery).127 Str("query", r.URL.RawQuery).

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