feat(volumes): Implement automatic volume management for Docker containers
- Add functionality to automatically create and manage persistent volumes based on VOLUME directives in Dockerfiles. - Update configuration to include volume settings with defaults for auto-creation, naming prefix, and preservation. - Enhance container deployment to inspect images for volume directives and create volumes as needed. - Implement volume cleanup during container removal based on configuration settings. - Update README and example configurations to reflect new volume management features and usage instructions.
- commit
- a1b18e25f781d646dd268979ee6dea398433bbcf
- author
- bamen · · 2025-06-08 14:49 UTC
- parent
- c301000
- signature
- unsigned
7 files · +411 -53
M README.md +90 -0
| @@ -42,6 +42,7 @@ podman push registry.mydomain.com/myapp:latest | |||
| 42 | 42 | - **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare | |
| 43 | 43 | - **Zero-Downtime Updates**: Graceful container swaps | |
| 44 | 44 | - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically | |
| 45 | + | - **Auto-Volume Management**: Zero-config persistent storage from Dockerfile VOLUME directives | |
| 45 | 46 | ||
| 46 | 47 | ## Quick Start (5 minutes) | |
| 47 | 48 | ||
| @@ -220,6 +221,76 @@ podman push registry.mydomain.com/staging.example.com:latest | |||
| 220 | 221 | # Creates: "staging.example.com" = "staging.example.com:latest" | |
| 221 | 222 | ``` | |
| 222 | 223 | ||
| 224 | + | ## Persistent Data with Volumes | |
| 225 | + | ||
| 226 | + | Gordon automatically detects `VOLUME` directives in your Dockerfiles and creates persistent named volumes for your containers. **Zero configuration required** - just add `VOLUME` instructions to your Dockerfile. | |
| 227 | + | ||
| 228 | + | ### How It Works | |
| 229 | + | ||
| 230 | + | ```dockerfile | |
| 231 | + | # In your Dockerfile | |
| 232 | + | FROM node:18-alpine | |
| 233 | + | WORKDIR /app | |
| 234 | + | COPY package*.json ./ | |
| 235 | + | RUN npm install | |
| 236 | + | COPY . . | |
| 237 | + | ||
| 238 | + | # Gordon automatically creates volumes for these paths | |
| 239 | + | VOLUME ["/app/data", "/app/uploads"] | |
| 240 | + | ||
| 241 | + | EXPOSE 3000 | |
| 242 | + | CMD ["npm", "start"] | |
| 243 | + | ``` | |
| 244 | + | ||
| 245 | + | When you deploy this image, Gordon will: | |
| 246 | + | 1. **Detect** the `VOLUME ["/app/data", "/app/uploads"]` directive | |
| 247 | + | 2. **Create** named volumes like `gordon-app-example-com-a1b2c3d4` (one per path) | |
| 248 | + | 3. **Mount** them to your container automatically | |
| 249 | + | 4. **Preserve** data across container restarts and updates | |
| 250 | + | ||
| 251 | + | ### Volume Features | |
| 252 | + | ||
| 253 | + | - **Automatic Detection**: No config needed - Gordon reads VOLUME directives from your images | |
| 254 | + | - **Persistent Storage**: Data survives container restarts, updates, and VPS reboots | |
| 255 | + | - **Zero Downtime**: Volumes re-attach instantly when containers restart | |
| 256 | + | - **Predictable Naming**: `gordon-{domain}-{path-hash}` format for easy identification | |
| 257 | + | - **Cross-Platform**: Works with Docker and Podman | |
| 258 | + | ||
| 259 | + | ### Example Workflows | |
| 260 | + | ||
| 261 | + | #### Database Container | |
| 262 | + | ```dockerfile | |
| 263 | + | FROM postgres:15 | |
| 264 | + | # Gordon auto-creates persistent volume for PostgreSQL data | |
| 265 | + | VOLUME ["/var/lib/postgresql/data"] | |
| 266 | + | ``` | |
| 267 | + | ||
| 268 | + | #### Web App with File Uploads | |
| 269 | + | ```dockerfile | |
| 270 | + | FROM nginx:alpine | |
| 271 | + | COPY dist/ /usr/share/nginx/html/ | |
| 272 | + | # Gordon auto-creates persistent volume for user uploads | |
| 273 | + | VOLUME ["/usr/share/nginx/html/uploads"] | |
| 274 | + | ``` | |
| 275 | + | ||
| 276 | + | #### Multi-Volume Application | |
| 277 | + | ```dockerfile | |
| 278 | + | FROM myapp:latest | |
| 279 | + | # Gordon creates separate volumes for each path | |
| 280 | + | VOLUME ["/app/data", "/app/logs", "/app/cache"] | |
| 281 | + | ``` | |
| 282 | + | ||
| 283 | + | ### Volume Configuration (Optional) | |
| 284 | + | ||
| 285 | + | ```toml | |
| 286 | + | [volumes] | |
| 287 | + | auto_create = true # Default: true (set to false to disable) | |
| 288 | + | prefix = "gordon" # Default: "gordon" (volume name prefix) | |
| 289 | + | preserve = true # Default: true (keep volumes when removing containers) | |
| 290 | + | ``` | |
| 291 | + | ||
| 292 | + | **Most users never need to touch this configuration** - the defaults work perfectly for 99% of use cases. | |
| 293 | + | ||
| 223 | 294 | ## Advanced Configuration | |
| 224 | 295 | ||
| 225 | 296 | ### Full Config Structure (with default values) | |
| @@ -248,6 +319,13 @@ password = "your-secure-password" # No default (required when enabled) | |||
| 248 | 319 | [auto_route] | |
| 249 | 320 | enabled = false # Default: false | |
| 250 | 321 | ||
| 322 | + | [volumes] | |
| 323 | + | auto_create = true # Default: true (automatically create volumes from VOLUME directives) | |
| 324 | + | prefix = "gordon" # Default: "gordon" (volume name prefix) | |
| 325 | + | preserve = true # Default: true (keep volumes when containers are removed) | |
| 326 | + | # Volumes are auto-created from Dockerfile VOLUME directives | |
| 327 | + | # Naming: gordon-{domain}-{path-hash} (e.g., gordon-app-example-com-a1b2c3d4) | |
| 328 | + | ||
| 251 | 329 | [env] | |
| 252 | 330 | dir = "{data_dir}/env" # Default: {data_dir}/env | |
| 253 | 331 | providers = ["pass", "sops"] # Default: ["pass", "sops"] | |
| @@ -288,6 +366,18 @@ A: Yes, for SSL certificates and DDoS protection. | |||
| 288 | 366 | **Q: Resource requirements?** | |
| 289 | 367 | A: Runs on 1GB RAM VPS. Gordon uses <15MB RAM. | |
| 290 | 368 | ||
| 369 | + | **Q: How do I check if volumes are working?** | |
| 370 | + | A: Use `docker volume ls` or `podman volume ls` to see Gordon-managed volumes (named `gordon-*`). | |
| 371 | + | ||
| 372 | + | **Q: Can I disable automatic volumes?** | |
| 373 | + | A: Yes, set `volumes.auto_create = false` in your gordon.toml config. | |
| 374 | + | ||
| 375 | + | **Q: What happens to my data when I update containers?** | |
| 376 | + | A: Volumes are preserved by default. Your data persists across updates and reboots. | |
| 377 | + | ||
| 378 | + | **Q: How do I backup volume data?** | |
| 379 | + | A: Volumes are regular Docker/Podman volumes - use standard backup tools like `docker run --rm -v volume_name:/data -v $(pwd):/backup alpine tar czf /backup/backup.tar.gz -C /data .` | |
| 380 | + | ||
| 291 | 381 | ## Philosophy | |
| 292 | 382 | ||
| 293 | 383 | ```bash | |
| @@ -42,6 +42,7 @@ podman push registry.mydomain.com/myapp:latest | |||||
| 42 | - **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare | 42 | - **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare | ||
| 43 | - **Zero-Downtime Updates**: Graceful container swaps | 43 | - **Zero-Downtime Updates**: Graceful container swaps | ||
| 44 | - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically | 44 | - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically | ||
| 45 | + | - **Auto-Volume Management**: Zero-config persistent storage from Dockerfile VOLUME directives | |||
| 45 | 46 | ||||
| 46 | ## Quick Start (5 minutes) | 47 | ## Quick Start (5 minutes) | ||
| 47 | 48 | ||||
| @@ -220,6 +221,76 @@ podman push registry.mydomain.com/staging.example.com:latest | |||||
| 220 | # Creates: "staging.example.com" = "staging.example.com:latest" | 221 | # Creates: "staging.example.com" = "staging.example.com:latest" | ||
| 221 | ``` | 222 | ``` | ||
| 222 | 223 | ||||
| 224 | + | ## Persistent Data with Volumes | |||
| 225 | + | ||||
| 226 | + | Gordon automatically detects `VOLUME` directives in your Dockerfiles and creates persistent named volumes for your containers. **Zero configuration required** - just add `VOLUME` instructions to your Dockerfile. | |||
| 227 | + | ||||
| 228 | + | ### How It Works | |||
| 229 | + | ||||
| 230 | + | ```dockerfile | |||
| 231 | + | # In your Dockerfile | |||
| 232 | + | FROM node:18-alpine | |||
| 233 | + | WORKDIR /app | |||
| 234 | + | COPY package*.json ./ | |||
| 235 | + | RUN npm install | |||
| 236 | + | COPY . . | |||
| 237 | + | ||||
| 238 | + | # Gordon automatically creates volumes for these paths | |||
| 239 | + | VOLUME ["/app/data", "/app/uploads"] | |||
| 240 | + | ||||
| 241 | + | EXPOSE 3000 | |||
| 242 | + | CMD ["npm", "start"] | |||
| 243 | + | ``` | |||
| 244 | + | ||||
| 245 | + | When you deploy this image, Gordon will: | |||
| 246 | + | 1. **Detect** the `VOLUME ["/app/data", "/app/uploads"]` directive | |||
| 247 | + | 2. **Create** named volumes like `gordon-app-example-com-a1b2c3d4` (one per path) | |||
| 248 | + | 3. **Mount** them to your container automatically | |||
| 249 | + | 4. **Preserve** data across container restarts and updates | |||
| 250 | + | ||||
| 251 | + | ### Volume Features | |||
| 252 | + | ||||
| 253 | + | - **Automatic Detection**: No config needed - Gordon reads VOLUME directives from your images | |||
| 254 | + | - **Persistent Storage**: Data survives container restarts, updates, and VPS reboots | |||
| 255 | + | - **Zero Downtime**: Volumes re-attach instantly when containers restart | |||
| 256 | + | - **Predictable Naming**: `gordon-{domain}-{path-hash}` format for easy identification | |||
| 257 | + | - **Cross-Platform**: Works with Docker and Podman | |||
| 258 | + | ||||
| 259 | + | ### Example Workflows | |||
| 260 | + | ||||
| 261 | + | #### Database Container | |||
| 262 | + | ```dockerfile | |||
| 263 | + | FROM postgres:15 | |||
| 264 | + | # Gordon auto-creates persistent volume for PostgreSQL data | |||
| 265 | + | VOLUME ["/var/lib/postgresql/data"] | |||
| 266 | + | ``` | |||
| 267 | + | ||||
| 268 | + | #### Web App with File Uploads | |||
| 269 | + | ```dockerfile | |||
| 270 | + | FROM nginx:alpine | |||
| 271 | + | COPY dist/ /usr/share/nginx/html/ | |||
| 272 | + | # Gordon auto-creates persistent volume for user uploads | |||
| 273 | + | VOLUME ["/usr/share/nginx/html/uploads"] | |||
| 274 | + | ``` | |||
| 275 | + | ||||
| 276 | + | #### Multi-Volume Application | |||
| 277 | + | ```dockerfile | |||
| 278 | + | FROM myapp:latest | |||
| 279 | + | # Gordon creates separate volumes for each path | |||
| 280 | + | VOLUME ["/app/data", "/app/logs", "/app/cache"] | |||
| 281 | + | ``` | |||
| 282 | + | ||||
| 283 | + | ### Volume Configuration (Optional) | |||
| 284 | + | ||||
| 285 | + | ```toml | |||
| 286 | + | [volumes] | |||
| 287 | + | auto_create = true # Default: true (set to false to disable) | |||
| 288 | + | prefix = "gordon" # Default: "gordon" (volume name prefix) | |||
| 289 | + | preserve = true # Default: true (keep volumes when removing containers) | |||
| 290 | + | ``` | |||
| 291 | + | ||||
| 292 | + | **Most users never need to touch this configuration** - the defaults work perfectly for 99% of use cases. | |||
| 293 | + | ||||
| 223 | ## Advanced Configuration | 294 | ## Advanced Configuration | ||
| 224 | 295 | ||||
| 225 | ### Full Config Structure (with default values) | 296 | ### Full Config Structure (with default values) | ||
| @@ -248,6 +319,13 @@ password = "your-secure-password" # No default (required when enabled) | |||||
| 248 | [auto_route] | 319 | [auto_route] | ||
| 249 | enabled = false # Default: false | 320 | enabled = false # Default: false | ||
| 250 | 321 | ||||
| 322 | + | [volumes] | |||
| 323 | + | auto_create = true # Default: true (automatically create volumes from VOLUME directives) | |||
| 324 | + | prefix = "gordon" # Default: "gordon" (volume name prefix) | |||
| 325 | + | preserve = true # Default: true (keep volumes when containers are removed) | |||
| 326 | + | # Volumes are auto-created from Dockerfile VOLUME directives | |||
| 327 | + | # Naming: gordon-{domain}-{path-hash} (e.g., gordon-app-example-com-a1b2c3d4) | |||
| 328 | + | ||||
| 251 | [env] | 329 | [env] | ||
| 252 | dir = "{data_dir}/env" # Default: {data_dir}/env | 330 | dir = "{data_dir}/env" # Default: {data_dir}/env | ||
| 253 | providers = ["pass", "sops"] # Default: ["pass", "sops"] | 331 | providers = ["pass", "sops"] # Default: ["pass", "sops"] | ||
| @@ -288,6 +366,18 @@ A: Yes, for SSL certificates and DDoS protection. | |||||
| 288 | **Q: Resource requirements?** | 366 | **Q: Resource requirements?** | ||
| 289 | A: Runs on 1GB RAM VPS. Gordon uses <15MB RAM. | 367 | A: Runs on 1GB RAM VPS. Gordon uses <15MB RAM. | ||
| 290 | 368 | ||||
| 369 | + | **Q: How do I check if volumes are working?** | |||
| 370 | + | A: Use `docker volume ls` or `podman volume ls` to see Gordon-managed volumes (named `gordon-*`). | |||
| 371 | + | ||||
| 372 | + | **Q: Can I disable automatic volumes?** | |||
| 373 | + | A: Yes, set `volumes.auto_create = false` in your gordon.toml config. | |||
| 374 | + | ||||
| 375 | + | **Q: What happens to my data when I update containers?** | |||
| 376 | + | A: Volumes are preserved by default. Your data persists across updates and reboots. | |||
| 377 | + | ||||
| 378 | + | **Q: How do I backup volume data?** | |||
| 379 | + | A: Volumes are regular Docker/Podman volumes - use standard backup tools like `docker run --rm -v volume_name:/data -v $(pwd):/backup alpine tar czf /backup/backup.tar.gz -C /data .` | |||
| 380 | + | ||||
| 291 | ## Philosophy | 381 | ## Philosophy | ||
| 292 | 382 | ||||
| 293 | ```bash | 383 | ```bash | ||
M examples/development.toml +11 -0
| @@ -15,6 +15,12 @@ enabled = false # Disable auth for easier local development | |||
| 15 | 15 | [auto_route] | |
| 16 | 16 | enabled = true # Enable automatic route creation from image names | |
| 17 | 17 | ||
| 18 | + | # Volume configuration - auto-creates persistent storage from VOLUME directives | |
| 19 | + | [volumes] | |
| 20 | + | auto_create = true # Default: true (automatically handle VOLUME directives) | |
| 21 | + | prefix = "gordon" # Default: "gordon" (volume name prefix) | |
| 22 | + | preserve = true # Default: true (keep volumes when containers are removed) | |
| 23 | + | ||
| 18 | 24 | # Logging configuration for development | |
| 19 | 25 | [logging] | |
| 20 | 26 | enabled = true # Enable file-based logging | |
| @@ -83,4 +89,9 @@ providers = ["pass"] # For development secrets | |||
| 83 | 89 | # docker push registry.yourdomain.com/mytestapp.bamen.dev:latest | |
| 84 | 90 | # Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest | |
| 85 | 91 | # | |
| 92 | + | # VOLUME FEATURE: | |
| 93 | + | # Add VOLUME directives to your Dockerfile for automatic persistent storage: | |
| 94 | + | # VOLUME ["/app/data", "/app/uploads"] | |
| 95 | + | # Gordon automatically creates and mounts persistent volumes - no config needed! | |
| 96 | + | # | |
| 86 | 97 | # Cloudflare handles SSL and DNS for your remote Gordon server!⏎̸ | |
| @@ -15,6 +15,12 @@ enabled = false # Disable auth for easier local development | |||||
| 15 | [auto_route] | 15 | [auto_route] | ||
| 16 | enabled = true # Enable automatic route creation from image names | 16 | enabled = true # Enable automatic route creation from image names | ||
| 17 | 17 | ||||
| 18 | + | # Volume configuration - auto-creates persistent storage from VOLUME directives | |||
| 19 | + | [volumes] | |||
| 20 | + | auto_create = true # Default: true (automatically handle VOLUME directives) | |||
| 21 | + | prefix = "gordon" # Default: "gordon" (volume name prefix) | |||
| 22 | + | preserve = true # Default: true (keep volumes when containers are removed) | |||
| 23 | + | ||||
| 18 | # Logging configuration for development | 24 | # Logging configuration for development | ||
| 19 | [logging] | 25 | [logging] | ||
| 20 | enabled = true # Enable file-based logging | 26 | enabled = true # Enable file-based logging | ||
| @@ -83,4 +89,9 @@ providers = ["pass"] # For development secrets | |||||
| 83 | # docker push registry.yourdomain.com/mytestapp.bamen.dev:latest | 89 | # docker push registry.yourdomain.com/mytestapp.bamen.dev:latest | ||
| 84 | # Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest | 90 | # Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest | ||
| 85 | # | 91 | # | ||
| 92 | + | # VOLUME FEATURE: | |||
| 93 | + | # Add VOLUME directives to your Dockerfile for automatic persistent storage: | |||
| 94 | + | # VOLUME ["/app/data", "/app/uploads"] | |||
| 95 | + | # Gordon automatically creates and mounts persistent volumes - no config needed! | |||
| 96 | + | # | |||
| 86 | # Cloudflare handles SSL and DNS for your remote Gordon server!⏎̸ | 97 | # Cloudflare handles SSL and DNS for your remote Gordon server!⏎̸ | ||
M internal/config/config.go +17 -0
| @@ -18,6 +18,7 @@ type Config struct { | |||
| 18 | 18 | AutoRoute AutoRouteConfig `mapstructure:"auto_route"` | |
| 19 | 19 | Env EnvConfig `mapstructure:"env"` | |
| 20 | 20 | Logging LoggingConfig `mapstructure:"logging"` | |
| 21 | + | Volumes VolumeConfig `mapstructure:"volumes"` | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | type ServerConfig struct { | |
| @@ -58,6 +59,12 @@ type LoggingConfig struct { | |||
| 58 | 59 | Compress bool `mapstructure:"compress"` | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 62 | + | type VolumeConfig struct { | |
| 63 | + | AutoCreate bool `mapstructure:"auto_create"` | |
| 64 | + | Prefix string `mapstructure:"prefix"` | |
| 65 | + | Preserve bool `mapstructure:"preserve"` | |
| 66 | + | } | |
| 67 | + | ||
| 61 | 68 | type SecretProvider struct { | |
| 62 | 69 | Type string `mapstructure:"type"` | |
| 63 | 70 | Config map[string]string `mapstructure:"config"` | |
| @@ -98,6 +105,11 @@ func Load() (*Config, error) { | |||
| 98 | 105 | viper.SetDefault("logging.max_age", 28) // 28 days | |
| 99 | 106 | viper.SetDefault("logging.compress", true) | |
| 100 | 107 | ||
| 108 | + | // Volume defaults | |
| 109 | + | viper.SetDefault("volumes.auto_create", true) | |
| 110 | + | viper.SetDefault("volumes.prefix", "gordon") | |
| 111 | + | viper.SetDefault("volumes.preserve", true) | |
| 112 | + | ||
| 101 | 113 | // Handle the routes manually since Viper struggles with domain names | |
| 102 | 114 | cfg.Routes = make(map[string]string) | |
| 103 | 115 | ||
| @@ -150,6 +162,11 @@ func Load() (*Config, error) { | |||
| 150 | 162 | log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default") | |
| 151 | 163 | } | |
| 152 | 164 | ||
| 165 | + | // Get volumes config | |
| 166 | + | if err := viper.UnmarshalKey("volumes", &cfg.Volumes); err != nil { | |
| 167 | + | return nil, fmt.Errorf("unable to decode volumes config: %v", err) | |
| 168 | + | } | |
| 169 | + | ||
| 153 | 170 | // Get routes manually from the raw config | |
| 154 | 171 | routesRaw := viper.Get("routes") | |
| 155 | 172 | if routesRaw != nil { | |
| @@ -18,6 +18,7 @@ type Config struct { | |||||
| 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 | Logging LoggingConfig `mapstructure:"logging"` | ||
| 21 | + | Volumes VolumeConfig `mapstructure:"volumes"` | |||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| 23 | type ServerConfig struct { | 24 | type ServerConfig struct { | ||
| @@ -58,6 +59,12 @@ type LoggingConfig struct { | |||||
| 58 | Compress bool `mapstructure:"compress"` | 59 | Compress bool `mapstructure:"compress"` | ||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 62 | + | type VolumeConfig struct { | |||
| 63 | + | AutoCreate bool `mapstructure:"auto_create"` | |||
| 64 | + | Prefix string `mapstructure:"prefix"` | |||
| 65 | + | Preserve bool `mapstructure:"preserve"` | |||
| 66 | + | } | |||
| 67 | + | ||||
| 61 | type SecretProvider struct { | 68 | type SecretProvider struct { | ||
| 62 | Type string `mapstructure:"type"` | 69 | Type string `mapstructure:"type"` | ||
| 63 | Config map[string]string `mapstructure:"config"` | 70 | Config map[string]string `mapstructure:"config"` | ||
| @@ -98,6 +105,11 @@ func Load() (*Config, error) { | |||||
| 98 | viper.SetDefault("logging.max_age", 28) // 28 days | 105 | viper.SetDefault("logging.max_age", 28) // 28 days | ||
| 99 | viper.SetDefault("logging.compress", true) | 106 | viper.SetDefault("logging.compress", true) | ||
| 100 | 107 | ||||
| 108 | + | // Volume defaults | |||
| 109 | + | viper.SetDefault("volumes.auto_create", true) | |||
| 110 | + | viper.SetDefault("volumes.prefix", "gordon") | |||
| 111 | + | viper.SetDefault("volumes.preserve", true) | |||
| 112 | + | ||||
| 101 | // Handle the routes manually since Viper struggles with domain names | 113 | // Handle the routes manually since Viper struggles with domain names | ||
| 102 | cfg.Routes = make(map[string]string) | 114 | cfg.Routes = make(map[string]string) | ||
| 103 | 115 | ||||
| @@ -150,6 +162,11 @@ func Load() (*Config, error) { | |||||
| 150 | log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default") | 162 | log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default") | ||
| 151 | } | 163 | } | ||
| 152 | 164 | ||||
| 165 | + | // Get volumes config | |||
| 166 | + | if err := viper.UnmarshalKey("volumes", &cfg.Volumes); err != nil { | |||
| 167 | + | return nil, fmt.Errorf("unable to decode volumes config: %v", err) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 153 | // Get routes manually from the raw config | 170 | // Get routes manually from the raw config | ||
| 154 | routesRaw := viper.Get("routes") | 171 | routesRaw := viper.Get("routes") | ||
| 155 | if routesRaw != nil { | 172 | if routesRaw != nil { | ||
M internal/container/docker.go +85 -6
| @@ -12,7 +12,9 @@ import ( | |||
| 12 | 12 | "github.com/docker/docker/api/types/container" | |
| 13 | 13 | "github.com/docker/docker/api/types/image" | |
| 14 | 14 | "github.com/docker/docker/api/types/registry" | |
| 15 | + | "github.com/docker/docker/api/types/volume" | |
| 15 | 16 | "github.com/docker/docker/client" | |
| 17 | + | "github.com/docker/docker/errdefs" | |
| 16 | 18 | "github.com/docker/go-connections/nat" | |
| 17 | 19 | "github.com/rs/zerolog/log" | |
| 18 | 20 | ||
| @@ -41,11 +43,11 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con | |||
| 41 | 43 | // Convert ports to Docker format | |
| 42 | 44 | exposedPorts := make(nat.PortSet) | |
| 43 | 45 | portBindings := make(nat.PortMap) | |
| 44 | - | ||
| 46 | + | ||
| 45 | 47 | for _, port := range config.Ports { | |
| 46 | 48 | containerPort := nat.Port(fmt.Sprintf("%d/tcp", port)) | |
| 47 | 49 | exposedPorts[containerPort] = struct{}{} | |
| 48 | - | ||
| 50 | + | ||
| 49 | 51 | // Bind to random available port on host | |
| 50 | 52 | portBindings[containerPort] = []nat.PortBinding{ | |
| 51 | 53 | { | |
| @@ -55,6 +57,16 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con | |||
| 55 | 57 | } | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 60 | + | // Convert volumes to Docker format | |
| 61 | + | var binds []string | |
| 62 | + | if config.Volumes != nil { | |
| 63 | + | for containerPath, volumeName := range config.Volumes { | |
| 64 | + | bind := fmt.Sprintf("%s:%s", volumeName, containerPath) | |
| 65 | + | binds = append(binds, bind) | |
| 66 | + | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("Adding volume mount") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 58 | 70 | // Create container configuration | |
| 59 | 71 | containerConfig := &container.Config{ | |
| 60 | 72 | Image: config.Image, | |
| @@ -68,6 +80,7 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con | |||
| 68 | 80 | hostConfig := &container.HostConfig{ | |
| 69 | 81 | PortBindings: portBindings, | |
| 70 | 82 | AutoRemove: config.AutoRemove, | |
| 83 | + | Binds: binds, | |
| 71 | 84 | } | |
| 72 | 85 | ||
| 73 | 86 | // Create the container | |
| @@ -216,7 +229,7 @@ func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string | |||
| 216 | 229 | // PullImage pulls an image | |
| 217 | 230 | func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | |
| 218 | 231 | log.Info().Str("image", imageRef).Msg("Pulling image") | |
| 219 | - | ||
| 232 | + | ||
| 220 | 233 | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{}) | |
| 221 | 234 | if err != nil { | |
| 222 | 235 | return fmt.Errorf("failed to pull image %s: %w", imageRef, err) | |
| @@ -236,20 +249,20 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | |||
| 236 | 249 | // PullImageWithAuth pulls an image with authentication | |
| 237 | 250 | func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error { | |
| 238 | 251 | log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication") | |
| 239 | - | ||
| 252 | + | ||
| 240 | 253 | // Create authentication configuration | |
| 241 | 254 | authConfig := registry.AuthConfig{ | |
| 242 | 255 | Username: username, | |
| 243 | 256 | Password: password, | |
| 244 | 257 | } | |
| 245 | - | ||
| 258 | + | ||
| 246 | 259 | // Encode authentication to base64 JSON | |
| 247 | 260 | authConfigBytes, err := json.Marshal(authConfig) | |
| 248 | 261 | if err != nil { | |
| 249 | 262 | return fmt.Errorf("failed to marshal auth config: %w", err) | |
| 250 | 263 | } | |
| 251 | 264 | authStr := base64.URLEncoding.EncodeToString(authConfigBytes) | |
| 252 | - | ||
| 265 | + | ||
| 253 | 266 | // Pull with authentication | |
| 254 | 267 | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{ | |
| 255 | 268 | RegistryAuth: authStr, | |
| @@ -450,3 +463,69 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID | |||
| 450 | 463 | return containerIP, selectedPort, nil | |
| 451 | 464 | } | |
| 452 | 465 | ||
| 466 | + | // InspectImageVolumes gets the volume mount points declared in the image | |
| 467 | + | func (d *DockerRuntime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) { | |
| 468 | + | imageInspect, err := d.client.ImageInspect(ctx, imageRef) | |
| 469 | + | if err != nil { | |
| 470 | + | return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | var volumes []string | |
| 474 | + | if imageInspect.Config != nil && imageInspect.Config.Volumes != nil { | |
| 475 | + | for volumePath := range imageInspect.Config.Volumes { | |
| 476 | + | volumes = append(volumes, volumePath) | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | if len(volumes) > 0 { | |
| 481 | + | log.Info().Str("image", imageRef).Strs("volumes", volumes).Msg("Found VOLUME directives in image") | |
| 482 | + | } else { | |
| 483 | + | log.Debug().Str("image", imageRef).Msg("No VOLUME directives found in image") | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | return volumes, nil | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // VolumeExists checks if a Docker volume exists | |
| 490 | + | func (d *DockerRuntime) VolumeExists(ctx context.Context, volumeName string) (bool, error) { | |
| 491 | + | _, err := d.client.VolumeInspect(ctx, volumeName) | |
| 492 | + | if err != nil { | |
| 493 | + | if errdefs.IsNotFound(err) { | |
| 494 | + | return false, nil | |
| 495 | + | } | |
| 496 | + | return false, fmt.Errorf("failed to inspect volume %s: %w", volumeName, err) | |
| 497 | + | } | |
| 498 | + | return true, nil | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // CreateVolume creates a new Docker volume | |
| 502 | + | func (d *DockerRuntime) CreateVolume(ctx context.Context, volumeName string) error { | |
| 503 | + | _, err := d.client.VolumeCreate(ctx, volume.CreateOptions{ | |
| 504 | + | Name: volumeName, | |
| 505 | + | Labels: map[string]string{ | |
| 506 | + | "gordon.managed": "true", | |
| 507 | + | "gordon.created": "auto", | |
| 508 | + | }, | |
| 509 | + | }) | |
| 510 | + | if err != nil { | |
| 511 | + | return fmt.Errorf("failed to create volume %s: %w", volumeName, err) | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | log.Info().Str("volume", volumeName).Msg("Volume created") | |
| 515 | + | return nil | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // RemoveVolume removes a Docker volume | |
| 519 | + | func (d *DockerRuntime) RemoveVolume(ctx context.Context, volumeName string, force bool) error { | |
| 520 | + | err := d.client.VolumeRemove(ctx, volumeName, force) | |
| 521 | + | if err != nil { | |
| 522 | + | if errdefs.IsNotFound(err) { | |
| 523 | + | log.Debug().Str("volume", volumeName).Msg("Volume not found, already removed") | |
| 524 | + | return nil | |
| 525 | + | } | |
| 526 | + | return fmt.Errorf("failed to remove volume %s: %w", volumeName, err) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | log.Info().Str("volume", volumeName).Msg("Volume removed") | |
| 530 | + | return nil | |
| 531 | + | } | |
| @@ -12,7 +12,9 @@ import ( | |||||
| 12 | "github.com/docker/docker/api/types/container" | 12 | "github.com/docker/docker/api/types/container" | ||
| 13 | "github.com/docker/docker/api/types/image" | 13 | "github.com/docker/docker/api/types/image" | ||
| 14 | "github.com/docker/docker/api/types/registry" | 14 | "github.com/docker/docker/api/types/registry" | ||
| 15 | + | "github.com/docker/docker/api/types/volume" | |||
| 15 | "github.com/docker/docker/client" | 16 | "github.com/docker/docker/client" | ||
| 17 | + | "github.com/docker/docker/errdefs" | |||
| 16 | "github.com/docker/go-connections/nat" | 18 | "github.com/docker/go-connections/nat" | ||
| 17 | "github.com/rs/zerolog/log" | 19 | "github.com/rs/zerolog/log" | ||
| 18 | 20 | ||||
| @@ -41,11 +43,11 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con | |||||
| 41 | // Convert ports to Docker format | 43 | // Convert ports to Docker format | ||
| 42 | exposedPorts := make(nat.PortSet) | 44 | exposedPorts := make(nat.PortSet) | ||
| 43 | portBindings := make(nat.PortMap) | 45 | portBindings := make(nat.PortMap) | ||
| 44 | - | 46 | + | ||
| 45 | for _, port := range config.Ports { | 47 | for _, port := range config.Ports { | ||
| 46 | containerPort := nat.Port(fmt.Sprintf("%d/tcp", port)) | 48 | containerPort := nat.Port(fmt.Sprintf("%d/tcp", port)) | ||
| 47 | exposedPorts[containerPort] = struct{}{} | 49 | exposedPorts[containerPort] = struct{}{} | ||
| 48 | - | 50 | + | ||
| 49 | // Bind to random available port on host | 51 | // Bind to random available port on host | ||
| 50 | portBindings[containerPort] = []nat.PortBinding{ | 52 | portBindings[containerPort] = []nat.PortBinding{ | ||
| 51 | { | 53 | { | ||
| @@ -55,6 +57,16 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con | |||||
| 55 | } | 57 | } | ||
| 56 | } | 58 | } | ||
| 57 | 59 | ||||
| 60 | + | // Convert volumes to Docker format | |||
| 61 | + | var binds []string | |||
| 62 | + | if config.Volumes != nil { | |||
| 63 | + | for containerPath, volumeName := range config.Volumes { | |||
| 64 | + | bind := fmt.Sprintf("%s:%s", volumeName, containerPath) | |||
| 65 | + | binds = append(binds, bind) | |||
| 66 | + | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("Adding volume mount") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 58 | // Create container configuration | 70 | // Create container configuration | ||
| 59 | containerConfig := &container.Config{ | 71 | containerConfig := &container.Config{ | ||
| 60 | Image: config.Image, | 72 | Image: config.Image, | ||
| @@ -68,6 +80,7 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con | |||||
| 68 | hostConfig := &container.HostConfig{ | 80 | hostConfig := &container.HostConfig{ | ||
| 69 | PortBindings: portBindings, | 81 | PortBindings: portBindings, | ||
| 70 | AutoRemove: config.AutoRemove, | 82 | AutoRemove: config.AutoRemove, | ||
| 83 | + | Binds: binds, | |||
| 71 | } | 84 | } | ||
| 72 | 85 | ||||
| 73 | // Create the container | 86 | // Create the container | ||
| @@ -216,7 +229,7 @@ func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string | |||||
| 216 | // PullImage pulls an image | 229 | // PullImage pulls an image | ||
| 217 | func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | 230 | func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | ||
| 218 | log.Info().Str("image", imageRef).Msg("Pulling image") | 231 | log.Info().Str("image", imageRef).Msg("Pulling image") | ||
| 219 | - | 232 | + | ||
| 220 | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{}) | 233 | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{}) | ||
| 221 | if err != nil { | 234 | if err != nil { | ||
| 222 | return fmt.Errorf("failed to pull image %s: %w", imageRef, err) | 235 | return fmt.Errorf("failed to pull image %s: %w", imageRef, err) | ||
| @@ -236,20 +249,20 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | |||||
| 236 | // PullImageWithAuth pulls an image with authentication | 249 | // PullImageWithAuth pulls an image with authentication | ||
| 237 | func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error { | 250 | func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error { | ||
| 238 | log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication") | 251 | log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication") | ||
| 239 | - | 252 | + | ||
| 240 | // Create authentication configuration | 253 | // Create authentication configuration | ||
| 241 | authConfig := registry.AuthConfig{ | 254 | authConfig := registry.AuthConfig{ | ||
| 242 | Username: username, | 255 | Username: username, | ||
| 243 | Password: password, | 256 | Password: password, | ||
| 244 | } | 257 | } | ||
| 245 | - | 258 | + | ||
| 246 | // Encode authentication to base64 JSON | 259 | // Encode authentication to base64 JSON | ||
| 247 | authConfigBytes, err := json.Marshal(authConfig) | 260 | authConfigBytes, err := json.Marshal(authConfig) | ||
| 248 | if err != nil { | 261 | if err != nil { | ||
| 249 | return fmt.Errorf("failed to marshal auth config: %w", err) | 262 | return fmt.Errorf("failed to marshal auth config: %w", err) | ||
| 250 | } | 263 | } | ||
| 251 | authStr := base64.URLEncoding.EncodeToString(authConfigBytes) | 264 | authStr := base64.URLEncoding.EncodeToString(authConfigBytes) | ||
| 252 | - | 265 | + | ||
| 253 | // Pull with authentication | 266 | // Pull with authentication | ||
| 254 | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{ | 267 | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{ | ||
| 255 | RegistryAuth: authStr, | 268 | RegistryAuth: authStr, | ||
| @@ -450,3 +463,69 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID | |||||
| 450 | return containerIP, selectedPort, nil | 463 | return containerIP, selectedPort, nil | ||
| 451 | } | 464 | } | ||
| 452 | 465 | ||||
| 466 | + | // InspectImageVolumes gets the volume mount points declared in the image | |||
| 467 | + | func (d *DockerRuntime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) { | |||
| 468 | + | imageInspect, err := d.client.ImageInspect(ctx, imageRef) | |||
| 469 | + | if err != nil { | |||
| 470 | + | return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err) | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | var volumes []string | |||
| 474 | + | if imageInspect.Config != nil && imageInspect.Config.Volumes != nil { | |||
| 475 | + | for volumePath := range imageInspect.Config.Volumes { | |||
| 476 | + | volumes = append(volumes, volumePath) | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | if len(volumes) > 0 { | |||
| 481 | + | log.Info().Str("image", imageRef).Strs("volumes", volumes).Msg("Found VOLUME directives in image") | |||
| 482 | + | } else { | |||
| 483 | + | log.Debug().Str("image", imageRef).Msg("No VOLUME directives found in image") | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | return volumes, nil | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // VolumeExists checks if a Docker volume exists | |||
| 490 | + | func (d *DockerRuntime) VolumeExists(ctx context.Context, volumeName string) (bool, error) { | |||
| 491 | + | _, err := d.client.VolumeInspect(ctx, volumeName) | |||
| 492 | + | if err != nil { | |||
| 493 | + | if errdefs.IsNotFound(err) { | |||
| 494 | + | return false, nil | |||
| 495 | + | } | |||
| 496 | + | return false, fmt.Errorf("failed to inspect volume %s: %w", volumeName, err) | |||
| 497 | + | } | |||
| 498 | + | return true, nil | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | // CreateVolume creates a new Docker volume | |||
| 502 | + | func (d *DockerRuntime) CreateVolume(ctx context.Context, volumeName string) error { | |||
| 503 | + | _, err := d.client.VolumeCreate(ctx, volume.CreateOptions{ | |||
| 504 | + | Name: volumeName, | |||
| 505 | + | Labels: map[string]string{ | |||
| 506 | + | "gordon.managed": "true", | |||
| 507 | + | "gordon.created": "auto", | |||
| 508 | + | }, | |||
| 509 | + | }) | |||
| 510 | + | if err != nil { | |||
| 511 | + | return fmt.Errorf("failed to create volume %s: %w", volumeName, err) | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | log.Info().Str("volume", volumeName).Msg("Volume created") | |||
| 515 | + | return nil | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | // RemoveVolume removes a Docker volume | |||
| 519 | + | func (d *DockerRuntime) RemoveVolume(ctx context.Context, volumeName string, force bool) error { | |||
| 520 | + | err := d.client.VolumeRemove(ctx, volumeName, force) | |||
| 521 | + | if err != nil { | |||
| 522 | + | if errdefs.IsNotFound(err) { | |||
| 523 | + | log.Debug().Str("volume", volumeName).Msg("Volume not found, already removed") | |||
| 524 | + | return nil | |||
| 525 | + | } | |||
| 526 | + | return fmt.Errorf("failed to remove volume %s: %w", volumeName, err) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | log.Info().Str("volume", volumeName).Msg("Volume removed") | |||
| 530 | + | return nil | |||
| 531 | + | } | |||
A internal/container/helpers.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "fmt" | |
| 6 | + | "regexp" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // generateVolumeName creates a deterministic volume name for a domain and path | |
| 11 | + | func generateVolumeName(prefix, domain, path string) string { | |
| 12 | + | // Sanitize domain name for use in volume name | |
| 13 | + | sanitizedDomain := sanitizeDomainForVolume(domain) | |
| 14 | + | ||
| 15 | + | // Create a short hash of the path to handle long paths and special characters | |
| 16 | + | hasher := sha256.New() | |
| 17 | + | hasher.Write([]byte(path)) | |
| 18 | + | pathHash := fmt.Sprintf("%x", hasher.Sum(nil))[:8] | |
| 19 | + | ||
| 20 | + | return fmt.Sprintf("%s-%s-%s", prefix, sanitizedDomain, pathHash) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // sanitizeDomainForVolume converts domain to volume-safe format | |
| 24 | + | func sanitizeDomainForVolume(domain string) string { | |
| 25 | + | // Replace dots and other special characters with hyphens | |
| 26 | + | reg := regexp.MustCompile(`[^a-zA-Z0-9\-]`) | |
| 27 | + | sanitized := reg.ReplaceAllString(domain, "-") | |
| 28 | + | ||
| 29 | + | // Remove leading/trailing hyphens and convert to lowercase | |
| 30 | + | sanitized = strings.Trim(sanitized, "-") | |
| 31 | + | sanitized = strings.ToLower(sanitized) | |
| 32 | + | ||
| 33 | + | return sanitized | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // normalizeImageRef normalizes image references for comparison | |
| 37 | + | func normalizeImageRef(imageRef string) string { | |
| 38 | + | // Split image and tag | |
| 39 | + | parts := strings.Split(imageRef, ":") | |
| 40 | + | image := parts[0] | |
| 41 | + | tag := "latest" | |
| 42 | + | if len(parts) > 1 { | |
| 43 | + | tag = parts[1] | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Normalize Docker Hub references | |
| 47 | + | if !strings.Contains(image, "/") { | |
| 48 | + | // Official library image (e.g., "nginx" -> "docker.io/library/nginx") | |
| 49 | + | image = "docker.io/library/" + image | |
| 50 | + | } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") { | |
| 51 | + | // User repository (e.g., "user/repo" -> "docker.io/user/repo") | |
| 52 | + | image = "docker.io/" + image | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | return image + ":" + tag | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "fmt" | |||
| 6 | + | "regexp" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // generateVolumeName creates a deterministic volume name for a domain and path | |||
| 11 | + | func generateVolumeName(prefix, domain, path string) string { | |||
| 12 | + | // Sanitize domain name for use in volume name | |||
| 13 | + | sanitizedDomain := sanitizeDomainForVolume(domain) | |||
| 14 | + | ||||
| 15 | + | // Create a short hash of the path to handle long paths and special characters | |||
| 16 | + | hasher := sha256.New() | |||
| 17 | + | hasher.Write([]byte(path)) | |||
| 18 | + | pathHash := fmt.Sprintf("%x", hasher.Sum(nil))[:8] | |||
| 19 | + | ||||
| 20 | + | return fmt.Sprintf("%s-%s-%s", prefix, sanitizedDomain, pathHash) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // sanitizeDomainForVolume converts domain to volume-safe format | |||
| 24 | + | func sanitizeDomainForVolume(domain string) string { | |||
| 25 | + | // Replace dots and other special characters with hyphens | |||
| 26 | + | reg := regexp.MustCompile(`[^a-zA-Z0-9\-]`) | |||
| 27 | + | sanitized := reg.ReplaceAllString(domain, "-") | |||
| 28 | + | ||||
| 29 | + | // Remove leading/trailing hyphens and convert to lowercase | |||
| 30 | + | sanitized = strings.Trim(sanitized, "-") | |||
| 31 | + | sanitized = strings.ToLower(sanitized) | |||
| 32 | + | ||||
| 33 | + | return sanitized | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // normalizeImageRef normalizes image references for comparison | |||
| 37 | + | func normalizeImageRef(imageRef string) string { | |||
| 38 | + | // Split image and tag | |||
| 39 | + | parts := strings.Split(imageRef, ":") | |||
| 40 | + | image := parts[0] | |||
| 41 | + | tag := "latest" | |||
| 42 | + | if len(parts) > 1 { | |||
| 43 | + | tag = parts[1] | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Normalize Docker Hub references | |||
| 47 | + | if !strings.Contains(image, "/") { | |||
| 48 | + | // Official library image (e.g., "nginx" -> "docker.io/library/nginx") | |||
| 49 | + | image = "docker.io/library/" + image | |||
| 50 | + | } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") { | |||
| 51 | + | // User repository (e.g., "user/repo" -> "docker.io/user/repo") | |||
| 52 | + | image = "docker.io/" + image | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | return image + ":" + tag | |||
| 56 | + | } | |||
M internal/container/manager.go +145 -47
| @@ -28,7 +28,7 @@ type Manager struct { | |||
| 28 | 28 | func NewManager(cfg *config.Config) (*Manager, error) { | |
| 29 | 29 | // Test runtime connectivity with background context | |
| 30 | 30 | ctx := context.Background() | |
| 31 | - | ||
| 31 | + | ||
| 32 | 32 | // Create runtime using the factory | |
| 33 | 33 | rt, err := CreateRuntime(ctx, cfg) | |
| 34 | 34 | if err != nil { | |
| @@ -49,7 +49,7 @@ func NewManager(cfg *config.Config) (*Manager, error) { | |||
| 49 | 49 | ||
| 50 | 50 | // Create environment loader | |
| 51 | 51 | envLoader := env.NewLoader(cfg) | |
| 52 | - | ||
| 52 | + | ||
| 53 | 53 | // Register secret providers based on config | |
| 54 | 54 | for _, providerName := range cfg.Env.Providers { | |
| 55 | 55 | switch providerName { | |
| @@ -59,7 +59,7 @@ func NewManager(cfg *config.Config) (*Manager, error) { | |||
| 59 | 59 | envLoader.RegisterSecretProvider("sops", env.NewSopsProvider()) | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | - | ||
| 62 | + | ||
| 63 | 63 | // Ensure env directory exists | |
| 64 | 64 | if err := envLoader.EnsureEnvDir(); err != nil { | |
| 65 | 65 | log.Warn().Err(err).Msg("Failed to ensure env directory exists") | |
| @@ -151,7 +151,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 151 | 151 | } else { | |
| 152 | 152 | // Normalize image references for comparison | |
| 153 | 153 | normalizedImageRef := normalizeImageRef(imageRef) | |
| 154 | - | ||
| 154 | + | ||
| 155 | 155 | for _, localImage := range localImages { | |
| 156 | 156 | if normalizeImageRef(localImage) == normalizedImageRef { | |
| 157 | 157 | imageAvailable = true | |
| @@ -173,7 +173,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 173 | 173 | Str("image", imageRef). | |
| 174 | 174 | Str("username", m.config.RegistryAuth.Username). | |
| 175 | 175 | Msg("Pulling image with authentication") | |
| 176 | - | ||
| 176 | + | ||
| 177 | 177 | err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password) | |
| 178 | 178 | if err != nil { | |
| 179 | 179 | // Provide better error message with context | |
| @@ -181,16 +181,16 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 181 | 181 | for _, img := range localImages { | |
| 182 | 182 | availableImages = append(availableImages, img) | |
| 183 | 183 | } | |
| 184 | - | ||
| 184 | + | ||
| 185 | 185 | return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+ | |
| 186 | 186 | "Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+ | |
| 187 | - | "Available local images: %v. Error: %w", | |
| 187 | + | "Available local images: %v. Error: %w", | |
| 188 | 188 | imageRef, m.config.Server.RegistryDomain, availableImages, err) | |
| 189 | 189 | } | |
| 190 | 190 | log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication") | |
| 191 | 191 | } else { | |
| 192 | 192 | log.Info().Str("image", imageRef).Msg("Pulling image without authentication") | |
| 193 | - | ||
| 193 | + | ||
| 194 | 194 | err := m.runtime.PullImage(ctx, imageRef) | |
| 195 | 195 | if err != nil { | |
| 196 | 196 | // Provide better error message with context | |
| @@ -198,10 +198,10 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 198 | 198 | for _, img := range localImages { | |
| 199 | 199 | availableImages = append(availableImages, img) | |
| 200 | 200 | } | |
| 201 | - | ||
| 201 | + | ||
| 202 | 202 | return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+ | |
| 203 | 203 | "Please check: 1) Image name spelling, 2) Image exists in registry. "+ | |
| 204 | - | "Available local images: %v. Error: %w", | |
| 204 | + | "Available local images: %v. Error: %w", | |
| 205 | 205 | imageRef, availableImages, err) | |
| 206 | 206 | } | |
| 207 | 207 | log.Info().Str("image", imageRef).Msg("Image pulled successfully") | |
| @@ -222,12 +222,48 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 222 | 222 | return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err) | |
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | + | // Handle volume auto-creation if enabled | |
| 226 | + | volumes := make(map[string]string) | |
| 227 | + | if m.config.Volumes.AutoCreate { | |
| 228 | + | // Get volume paths from image VOLUME directives | |
| 229 | + | volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef) | |
| 230 | + | if err != nil { | |
| 231 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes, proceeding without volumes") | |
| 232 | + | } else if len(volumePaths) > 0 { | |
| 233 | + | log.Info().Str("image", imageRef).Strs("volume_paths", volumePaths).Msg("Found VOLUME directives in image") | |
| 234 | + | ||
| 235 | + | for _, volumePath := range volumePaths { | |
| 236 | + | volumeName := generateVolumeName(m.config.Volumes.Prefix, route.Domain, volumePath) | |
| 237 | + | ||
| 238 | + | // Check if volume exists, create if it doesn't | |
| 239 | + | exists, err := m.runtime.VolumeExists(ctx, volumeName) | |
| 240 | + | if err != nil { | |
| 241 | + | log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists") | |
| 242 | + | continue | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | if !exists { | |
| 246 | + | if err := m.runtime.CreateVolume(ctx, volumeName); err != nil { | |
| 247 | + | log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create volume") | |
| 248 | + | continue | |
| 249 | + | } | |
| 250 | + | log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for container") | |
| 251 | + | } else { | |
| 252 | + | log.Debug().Str("volume", volumeName).Str("path", volumePath).Msg("Volume already exists, reusing") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | volumes[volumePath] = volumeName | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 225 | 260 | // Create container configuration | |
| 226 | 261 | containerConfig := &runtime.ContainerConfig{ | |
| 227 | - | Image: imageRef, | |
| 228 | - | Name: fmt.Sprintf("gordon-%s", route.Domain), | |
| 229 | - | Ports: exposedPorts, | |
| 230 | - | Env: envVars, | |
| 262 | + | Image: imageRef, | |
| 263 | + | Name: fmt.Sprintf("gordon-%s", route.Domain), | |
| 264 | + | Ports: exposedPorts, | |
| 265 | + | Env: envVars, | |
| 266 | + | Volumes: volumes, | |
| 231 | 267 | Labels: map[string]string{ | |
| 232 | 268 | "gordon.domain": route.Domain, | |
| 233 | 269 | "gordon.image": route.Image, | |
| @@ -369,6 +405,17 @@ func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) erro | |||
| 369 | 405 | ||
| 370 | 406 | // RemoveContainer removes a container by ID | |
| 371 | 407 | func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force bool) error { | |
| 408 | + | // Find the domain for this container before removal for volume cleanup | |
| 409 | + | var containerDomain string | |
| 410 | + | m.mu.RLock() | |
| 411 | + | for domain, container := range m.containers { | |
| 412 | + | if container.ID == containerID { | |
| 413 | + | containerDomain = domain | |
| 414 | + | break | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | m.mu.RUnlock() | |
| 418 | + | ||
| 372 | 419 | if err := m.runtime.RemoveContainer(ctx, containerID, force); err != nil { | |
| 373 | 420 | return fmt.Errorf("failed to remove container %s: %w", containerID, err) | |
| 374 | 421 | } | |
| @@ -376,6 +423,13 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force | |||
| 376 | 423 | // Stop log collection for the container | |
| 377 | 424 | m.logManager.StopCollection(containerID) | |
| 378 | 425 | ||
| 426 | + | // Clean up volumes if preserve is disabled and we found the domain | |
| 427 | + | if containerDomain != "" && !m.config.Volumes.Preserve { | |
| 428 | + | if err := m.cleanupVolumesForDomain(ctx, containerDomain); err != nil { | |
| 429 | + | log.Warn().Err(err).Str("domain", containerDomain).Msg("Failed to cleanup volumes during container removal") | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 379 | 433 | // Remove from our tracking map | |
| 380 | 434 | m.mu.Lock() | |
| 381 | 435 | defer m.mu.Unlock() | |
| @@ -447,7 +501,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||
| 447 | 501 | ||
| 448 | 502 | // Create maps for quick lookup | |
| 449 | 503 | gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container | |
| 450 | - | containersByImage := make(map[string][]*runtime.Container) // image -> containers | |
| 504 | + | containersByImage := make(map[string][]*runtime.Container) // image -> containers | |
| 451 | 505 | ||
| 452 | 506 | for _, container := range allContainers { | |
| 453 | 507 | // Track Gordon-managed containers by domain | |
| @@ -456,7 +510,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||
| 456 | 510 | gordonManagedContainers[domain] = container | |
| 457 | 511 | } | |
| 458 | 512 | } | |
| 459 | - | ||
| 513 | + | ||
| 460 | 514 | // Track all containers by image (normalize image name) | |
| 461 | 515 | normalizedImage := m.normalizeImageName(container.Image) | |
| 462 | 516 | containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container) | |
| @@ -519,7 +573,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||
| 519 | 573 | Str("domain", route.Domain). | |
| 520 | 574 | Msg("Failed to start log collection for auto-started container") | |
| 521 | 575 | } | |
| 522 | - | ||
| 576 | + | ||
| 523 | 577 | log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully") | |
| 524 | 578 | continue | |
| 525 | 579 | } | |
| @@ -535,7 +589,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||
| 535 | 589 | } | |
| 536 | 590 | ||
| 537 | 591 | log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts") | |
| 538 | - | ||
| 592 | + | ||
| 539 | 593 | // Stop the container if it's running | |
| 540 | 594 | isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID) | |
| 541 | 595 | if err != nil { | |
| @@ -561,7 +615,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||
| 561 | 615 | ||
| 562 | 616 | // No existing container found, deploy a new one | |
| 563 | 617 | log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route") | |
| 564 | - | ||
| 618 | + | ||
| 565 | 619 | _, err := m.DeployContainer(ctx, route) | |
| 566 | 620 | if err != nil { | |
| 567 | 621 | log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route") | |
| @@ -579,17 +633,17 @@ func (m *Manager) normalizeImageName(image string) string { | |||
| 579 | 633 | // Remove tag if present, keep only repository name | |
| 580 | 634 | parts := strings.Split(image, ":") | |
| 581 | 635 | repo := parts[0] | |
| 582 | - | ||
| 636 | + | ||
| 583 | 637 | // If no registry domain and it's a simple name, it's from Docker Hub | |
| 584 | 638 | if !strings.Contains(repo, "/") { | |
| 585 | 639 | return "docker.io/library/" + repo | |
| 586 | 640 | } | |
| 587 | - | ||
| 641 | + | ||
| 588 | 642 | // If it has one slash and no domain, it's a user repo on Docker Hub | |
| 589 | 643 | if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") { | |
| 590 | 644 | return "docker.io/" + repo | |
| 591 | 645 | } | |
| 592 | - | ||
| 646 | + | ||
| 593 | 647 | return repo | |
| 594 | 648 | } | |
| 595 | 649 | ||
| @@ -612,7 +666,7 @@ func (m *Manager) StopAllManagedContainers(ctx context.Context) error { | |||
| 612 | 666 | var errors []error | |
| 613 | 667 | for domain, container := range containers { | |
| 614 | 668 | log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container") | |
| 615 | - | ||
| 669 | + | ||
| 616 | 670 | if err := m.runtime.StopContainer(ctx, container.ID); err != nil { | |
| 617 | 671 | log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container") | |
| 618 | 672 | errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err)) | |
| @@ -665,38 +719,16 @@ func (m *Manager) Runtime() runtime.Runtime { | |||
| 665 | 719 | // UpdateConfig updates the manager's configuration and creates env files for new routes | |
| 666 | 720 | func (m *Manager) UpdateConfig(cfg *config.Config) { | |
| 667 | 721 | m.config = cfg | |
| 668 | - | ||
| 722 | + | ||
| 669 | 723 | // Update env loader config | |
| 670 | 724 | m.envLoader.UpdateConfig(cfg) | |
| 671 | - | ||
| 725 | + | ||
| 672 | 726 | // Create env files for any new routes | |
| 673 | 727 | if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil { | |
| 674 | 728 | log.Warn().Err(err).Msg("Failed to create env files for new routes during config update") | |
| 675 | 729 | } | |
| 676 | 730 | } | |
| 677 | 731 | ||
| 678 | - | // normalizeImageRef normalizes image references for comparison | |
| 679 | - | func normalizeImageRef(imageRef string) string { | |
| 680 | - | // Split image and tag | |
| 681 | - | parts := strings.Split(imageRef, ":") | |
| 682 | - | image := parts[0] | |
| 683 | - | tag := "latest" | |
| 684 | - | if len(parts) > 1 { | |
| 685 | - | tag = parts[1] | |
| 686 | - | } | |
| 687 | - | ||
| 688 | - | // Normalize Docker Hub references | |
| 689 | - | if !strings.Contains(image, "/") { | |
| 690 | - | // Official library image (e.g., "nginx" -> "docker.io/library/nginx") | |
| 691 | - | image = "docker.io/library/" + image | |
| 692 | - | } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") { | |
| 693 | - | // User repository (e.g., "user/repo" -> "docker.io/user/repo") | |
| 694 | - | image = "docker.io/" + image | |
| 695 | - | } | |
| 696 | - | ||
| 697 | - | return image + ":" + tag | |
| 698 | - | } | |
| 699 | - | ||
| 700 | 732 | // Shutdown gracefully shuts down the container manager | |
| 701 | 733 | func (m *Manager) Shutdown(ctx context.Context) error { | |
| 702 | 734 | log.Info().Msg("Shutting down container manager...") | |
| @@ -712,3 +744,69 @@ func (m *Manager) Shutdown(ctx context.Context) error { | |||
| 712 | 744 | log.Info().Msg("Container manager shutdown complete") | |
| 713 | 745 | return nil | |
| 714 | 746 | } | |
| 747 | + | ||
| 748 | + | // cleanupVolumesForDomain removes all volumes associated with a domain | |
| 749 | + | func (m *Manager) cleanupVolumesForDomain(ctx context.Context, domain string) error { | |
| 750 | + | log.Info().Str("domain", domain).Msg("Cleaning up volumes for domain") | |
| 751 | + | ||
| 752 | + | // We need to get the route to know what image was used to determine volume paths | |
| 753 | + | var route *config.Route | |
| 754 | + | for _, r := range m.config.GetRoutes() { | |
| 755 | + | if r.Domain == domain { | |
| 756 | + | route = &r | |
| 757 | + | break | |
| 758 | + | } | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | if route == nil { | |
| 762 | + | log.Debug().Str("domain", domain).Msg("No route found for domain, skipping volume cleanup") | |
| 763 | + | return nil | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // Construct the full image reference like we do in DeployContainer | |
| 767 | + | imageRef := route.Image | |
| 768 | + | if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" { | |
| 769 | + | if !strings.Contains(strings.Split(imageRef, ":")[0], ".") { | |
| 770 | + | imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image) | |
| 771 | + | } | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | // Get volume paths from image VOLUME directives | |
| 775 | + | volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef) | |
| 776 | + | if err != nil { | |
| 777 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes during cleanup") | |
| 778 | + | return err | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | if len(volumePaths) == 0 { | |
| 782 | + | log.Debug().Str("domain", domain).Msg("No volumes found for cleanup") | |
| 783 | + | return nil | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | var errors []error | |
| 787 | + | for _, volumePath := range volumePaths { | |
| 788 | + | volumeName := generateVolumeName(m.config.Volumes.Prefix, domain, volumePath) | |
| 789 | + | ||
| 790 | + | // Check if volume exists before trying to remove | |
| 791 | + | exists, err := m.runtime.VolumeExists(ctx, volumeName) | |
| 792 | + | if err != nil { | |
| 793 | + | log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists during cleanup") | |
| 794 | + | continue | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | if exists { | |
| 798 | + | if err := m.runtime.RemoveVolume(ctx, volumeName, true); err != nil { | |
| 799 | + | log.Error().Err(err).Str("volume", volumeName).Str("domain", domain).Msg("Failed to remove volume during cleanup") | |
| 800 | + | errors = append(errors, err) | |
| 801 | + | } else { | |
| 802 | + | log.Info().Str("volume", volumeName).Str("domain", domain).Msg("Volume cleaned up successfully") | |
| 803 | + | } | |
| 804 | + | } | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | if len(errors) > 0 { | |
| 808 | + | return fmt.Errorf("failed to cleanup %d volumes for domain %s", len(errors), domain) | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | return nil | |
| 812 | + | } | |
| @@ -28,7 +28,7 @@ type Manager struct { | |||||
| 28 | func NewManager(cfg *config.Config) (*Manager, error) { | 28 | func NewManager(cfg *config.Config) (*Manager, error) { | ||
| 29 | // Test runtime connectivity with background context | 29 | // Test runtime connectivity with background context | ||
| 30 | ctx := context.Background() | 30 | ctx := context.Background() | ||
| 31 | - | 31 | + | ||
| 32 | // Create runtime using the factory | 32 | // Create runtime using the factory | ||
| 33 | rt, err := CreateRuntime(ctx, cfg) | 33 | rt, err := CreateRuntime(ctx, cfg) | ||
| 34 | if err != nil { | 34 | if err != nil { | ||
| @@ -49,7 +49,7 @@ func NewManager(cfg *config.Config) (*Manager, error) { | |||||
| 49 | 49 | ||||
| 50 | // Create environment loader | 50 | // Create environment loader | ||
| 51 | envLoader := env.NewLoader(cfg) | 51 | envLoader := env.NewLoader(cfg) | ||
| 52 | - | 52 | + | ||
| 53 | // Register secret providers based on config | 53 | // Register secret providers based on config | ||
| 54 | for _, providerName := range cfg.Env.Providers { | 54 | for _, providerName := range cfg.Env.Providers { | ||
| 55 | switch providerName { | 55 | switch providerName { | ||
| @@ -59,7 +59,7 @@ func NewManager(cfg *config.Config) (*Manager, error) { | |||||
| 59 | envLoader.RegisterSecretProvider("sops", env.NewSopsProvider()) | 59 | envLoader.RegisterSecretProvider("sops", env.NewSopsProvider()) | ||
| 60 | } | 60 | } | ||
| 61 | } | 61 | } | ||
| 62 | - | 62 | + | ||
| 63 | // Ensure env directory exists | 63 | // Ensure env directory exists | ||
| 64 | if err := envLoader.EnsureEnvDir(); err != nil { | 64 | if err := envLoader.EnsureEnvDir(); err != nil { | ||
| 65 | log.Warn().Err(err).Msg("Failed to ensure env directory exists") | 65 | log.Warn().Err(err).Msg("Failed to ensure env directory exists") | ||
| @@ -151,7 +151,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||||
| 151 | } else { | 151 | } else { | ||
| 152 | // Normalize image references for comparison | 152 | // Normalize image references for comparison | ||
| 153 | normalizedImageRef := normalizeImageRef(imageRef) | 153 | normalizedImageRef := normalizeImageRef(imageRef) | ||
| 154 | - | 154 | + | ||
| 155 | for _, localImage := range localImages { | 155 | for _, localImage := range localImages { | ||
| 156 | if normalizeImageRef(localImage) == normalizedImageRef { | 156 | if normalizeImageRef(localImage) == normalizedImageRef { | ||
| 157 | imageAvailable = true | 157 | imageAvailable = true | ||
| @@ -173,7 +173,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||||
| 173 | Str("image", imageRef). | 173 | Str("image", imageRef). | ||
| 174 | Str("username", m.config.RegistryAuth.Username). | 174 | Str("username", m.config.RegistryAuth.Username). | ||
| 175 | Msg("Pulling image with authentication") | 175 | Msg("Pulling image with authentication") | ||
| 176 | - | 176 | + | ||
| 177 | err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password) | 177 | err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password) | ||
| 178 | if err != nil { | 178 | if err != nil { | ||
| 179 | // Provide better error message with context | 179 | // Provide better error message with context | ||
| @@ -181,16 +181,16 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||||
| 181 | for _, img := range localImages { | 181 | for _, img := range localImages { | ||
| 182 | availableImages = append(availableImages, img) | 182 | availableImages = append(availableImages, img) | ||
| 183 | } | 183 | } | ||
| 184 | - | 184 | + | ||
| 185 | return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+ | 185 | return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+ | ||
| 186 | "Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+ | 186 | "Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+ | ||
| 187 | - | "Available local images: %v. Error: %w", | 187 | + | "Available local images: %v. Error: %w", |
| 188 | imageRef, m.config.Server.RegistryDomain, availableImages, err) | 188 | imageRef, m.config.Server.RegistryDomain, availableImages, err) | ||
| 189 | } | 189 | } | ||
| 190 | log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication") | 190 | log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication") | ||
| 191 | } else { | 191 | } else { | ||
| 192 | log.Info().Str("image", imageRef).Msg("Pulling image without authentication") | 192 | log.Info().Str("image", imageRef).Msg("Pulling image without authentication") | ||
| 193 | - | 193 | + | ||
| 194 | err := m.runtime.PullImage(ctx, imageRef) | 194 | err := m.runtime.PullImage(ctx, imageRef) | ||
| 195 | if err != nil { | 195 | if err != nil { | ||
| 196 | // Provide better error message with context | 196 | // Provide better error message with context | ||
| @@ -198,10 +198,10 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||||
| 198 | for _, img := range localImages { | 198 | for _, img := range localImages { | ||
| 199 | availableImages = append(availableImages, img) | 199 | availableImages = append(availableImages, img) | ||
| 200 | } | 200 | } | ||
| 201 | - | 201 | + | ||
| 202 | return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+ | 202 | return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+ | ||
| 203 | "Please check: 1) Image name spelling, 2) Image exists in registry. "+ | 203 | "Please check: 1) Image name spelling, 2) Image exists in registry. "+ | ||
| 204 | - | "Available local images: %v. Error: %w", | 204 | + | "Available local images: %v. Error: %w", |
| 205 | imageRef, availableImages, err) | 205 | imageRef, availableImages, err) | ||
| 206 | } | 206 | } | ||
| 207 | log.Info().Str("image", imageRef).Msg("Image pulled successfully") | 207 | log.Info().Str("image", imageRef).Msg("Image pulled successfully") | ||
| @@ -222,12 +222,48 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||||
| 222 | return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err) | 222 | return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err) | ||
| 223 | } | 223 | } | ||
| 224 | 224 | ||||
| 225 | + | // Handle volume auto-creation if enabled | |||
| 226 | + | volumes := make(map[string]string) | |||
| 227 | + | if m.config.Volumes.AutoCreate { | |||
| 228 | + | // Get volume paths from image VOLUME directives | |||
| 229 | + | volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef) | |||
| 230 | + | if err != nil { | |||
| 231 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes, proceeding without volumes") | |||
| 232 | + | } else if len(volumePaths) > 0 { | |||
| 233 | + | log.Info().Str("image", imageRef).Strs("volume_paths", volumePaths).Msg("Found VOLUME directives in image") | |||
| 234 | + | ||||
| 235 | + | for _, volumePath := range volumePaths { | |||
| 236 | + | volumeName := generateVolumeName(m.config.Volumes.Prefix, route.Domain, volumePath) | |||
| 237 | + | ||||
| 238 | + | // Check if volume exists, create if it doesn't | |||
| 239 | + | exists, err := m.runtime.VolumeExists(ctx, volumeName) | |||
| 240 | + | if err != nil { | |||
| 241 | + | log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists") | |||
| 242 | + | continue | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | if !exists { | |||
| 246 | + | if err := m.runtime.CreateVolume(ctx, volumeName); err != nil { | |||
| 247 | + | log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create volume") | |||
| 248 | + | continue | |||
| 249 | + | } | |||
| 250 | + | log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for container") | |||
| 251 | + | } else { | |||
| 252 | + | log.Debug().Str("volume", volumeName).Str("path", volumePath).Msg("Volume already exists, reusing") | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | volumes[volumePath] = volumeName | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 225 | // Create container configuration | 260 | // Create container configuration | ||
| 226 | containerConfig := &runtime.ContainerConfig{ | 261 | containerConfig := &runtime.ContainerConfig{ | ||
| 227 | - | Image: imageRef, | 262 | + | Image: imageRef, |
| 228 | - | Name: fmt.Sprintf("gordon-%s", route.Domain), | 263 | + | Name: fmt.Sprintf("gordon-%s", route.Domain), |
| 229 | - | Ports: exposedPorts, | 264 | + | Ports: exposedPorts, |
| 230 | - | Env: envVars, | 265 | + | Env: envVars, |
| 266 | + | Volumes: volumes, | |||
| 231 | Labels: map[string]string{ | 267 | Labels: map[string]string{ | ||
| 232 | "gordon.domain": route.Domain, | 268 | "gordon.domain": route.Domain, | ||
| 233 | "gordon.image": route.Image, | 269 | "gordon.image": route.Image, | ||
| @@ -369,6 +405,17 @@ func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) erro | |||||
| 369 | 405 | ||||
| 370 | // RemoveContainer removes a container by ID | 406 | // RemoveContainer removes a container by ID | ||
| 371 | func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force bool) error { | 407 | func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force bool) error { | ||
| 408 | + | // Find the domain for this container before removal for volume cleanup | |||
| 409 | + | var containerDomain string | |||
| 410 | + | m.mu.RLock() | |||
| 411 | + | for domain, container := range m.containers { | |||
| 412 | + | if container.ID == containerID { | |||
| 413 | + | containerDomain = domain | |||
| 414 | + | break | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 417 | + | m.mu.RUnlock() | |||
| 418 | + | ||||
| 372 | if err := m.runtime.RemoveContainer(ctx, containerID, force); err != nil { | 419 | if err := m.runtime.RemoveContainer(ctx, containerID, force); err != nil { | ||
| 373 | return fmt.Errorf("failed to remove container %s: %w", containerID, err) | 420 | return fmt.Errorf("failed to remove container %s: %w", containerID, err) | ||
| 374 | } | 421 | } | ||
| @@ -376,6 +423,13 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force | |||||
| 376 | // Stop log collection for the container | 423 | // Stop log collection for the container | ||
| 377 | m.logManager.StopCollection(containerID) | 424 | m.logManager.StopCollection(containerID) | ||
| 378 | 425 | ||||
| 426 | + | // Clean up volumes if preserve is disabled and we found the domain | |||
| 427 | + | if containerDomain != "" && !m.config.Volumes.Preserve { | |||
| 428 | + | if err := m.cleanupVolumesForDomain(ctx, containerDomain); err != nil { | |||
| 429 | + | log.Warn().Err(err).Str("domain", containerDomain).Msg("Failed to cleanup volumes during container removal") | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 379 | // Remove from our tracking map | 433 | // Remove from our tracking map | ||
| 380 | m.mu.Lock() | 434 | m.mu.Lock() | ||
| 381 | defer m.mu.Unlock() | 435 | defer m.mu.Unlock() | ||
| @@ -447,7 +501,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||||
| 447 | 501 | ||||
| 448 | // Create maps for quick lookup | 502 | // Create maps for quick lookup | ||
| 449 | gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container | 503 | gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container | ||
| 450 | - | containersByImage := make(map[string][]*runtime.Container) // image -> containers | 504 | + | containersByImage := make(map[string][]*runtime.Container) // image -> containers |
| 451 | 505 | ||||
| 452 | for _, container := range allContainers { | 506 | for _, container := range allContainers { | ||
| 453 | // Track Gordon-managed containers by domain | 507 | // Track Gordon-managed containers by domain | ||
| @@ -456,7 +510,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||||
| 456 | gordonManagedContainers[domain] = container | 510 | gordonManagedContainers[domain] = container | ||
| 457 | } | 511 | } | ||
| 458 | } | 512 | } | ||
| 459 | - | 513 | + | ||
| 460 | // Track all containers by image (normalize image name) | 514 | // Track all containers by image (normalize image name) | ||
| 461 | normalizedImage := m.normalizeImageName(container.Image) | 515 | normalizedImage := m.normalizeImageName(container.Image) | ||
| 462 | containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container) | 516 | containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container) | ||
| @@ -519,7 +573,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||||
| 519 | Str("domain", route.Domain). | 573 | Str("domain", route.Domain). | ||
| 520 | Msg("Failed to start log collection for auto-started container") | 574 | Msg("Failed to start log collection for auto-started container") | ||
| 521 | } | 575 | } | ||
| 522 | - | 576 | + | ||
| 523 | log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully") | 577 | log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully") | ||
| 524 | continue | 578 | continue | ||
| 525 | } | 579 | } | ||
| @@ -535,7 +589,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||||
| 535 | } | 589 | } | ||
| 536 | 590 | ||||
| 537 | log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts") | 591 | log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts") | ||
| 538 | - | 592 | + | ||
| 539 | // Stop the container if it's running | 593 | // Stop the container if it's running | ||
| 540 | isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID) | 594 | isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID) | ||
| 541 | if err != nil { | 595 | if err != nil { | ||
| @@ -561,7 +615,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error { | |||||
| 561 | 615 | ||||
| 562 | // No existing container found, deploy a new one | 616 | // No existing container found, deploy a new one | ||
| 563 | log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route") | 617 | log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route") | ||
| 564 | - | 618 | + | ||
| 565 | _, err := m.DeployContainer(ctx, route) | 619 | _, err := m.DeployContainer(ctx, route) | ||
| 566 | if err != nil { | 620 | if err != nil { | ||
| 567 | log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route") | 621 | log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route") | ||
| @@ -579,17 +633,17 @@ func (m *Manager) normalizeImageName(image string) string { | |||||
| 579 | // Remove tag if present, keep only repository name | 633 | // Remove tag if present, keep only repository name | ||
| 580 | parts := strings.Split(image, ":") | 634 | parts := strings.Split(image, ":") | ||
| 581 | repo := parts[0] | 635 | repo := parts[0] | ||
| 582 | - | 636 | + | ||
| 583 | // If no registry domain and it's a simple name, it's from Docker Hub | 637 | // If no registry domain and it's a simple name, it's from Docker Hub | ||
| 584 | if !strings.Contains(repo, "/") { | 638 | if !strings.Contains(repo, "/") { | ||
| 585 | return "docker.io/library/" + repo | 639 | return "docker.io/library/" + repo | ||
| 586 | } | 640 | } | ||
| 587 | - | 641 | + | ||
| 588 | // If it has one slash and no domain, it's a user repo on Docker Hub | 642 | // If it has one slash and no domain, it's a user repo on Docker Hub | ||
| 589 | if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") { | 643 | if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") { | ||
| 590 | return "docker.io/" + repo | 644 | return "docker.io/" + repo | ||
| 591 | } | 645 | } | ||
| 592 | - | 646 | + | ||
| 593 | return repo | 647 | return repo | ||
| 594 | } | 648 | } | ||
| 595 | 649 | ||||
| @@ -612,7 +666,7 @@ func (m *Manager) StopAllManagedContainers(ctx context.Context) error { | |||||
| 612 | var errors []error | 666 | var errors []error | ||
| 613 | for domain, container := range containers { | 667 | for domain, container := range containers { | ||
| 614 | log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container") | 668 | log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container") | ||
| 615 | - | 669 | + | ||
| 616 | if err := m.runtime.StopContainer(ctx, container.ID); err != nil { | 670 | if err := m.runtime.StopContainer(ctx, container.ID); err != nil { | ||
| 617 | log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container") | 671 | log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container") | ||
| 618 | errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err)) | 672 | errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err)) | ||
| @@ -665,38 +719,16 @@ func (m *Manager) Runtime() runtime.Runtime { | |||||
| 665 | // UpdateConfig updates the manager's configuration and creates env files for new routes | 719 | // UpdateConfig updates the manager's configuration and creates env files for new routes | ||
| 666 | func (m *Manager) UpdateConfig(cfg *config.Config) { | 720 | func (m *Manager) UpdateConfig(cfg *config.Config) { | ||
| 667 | m.config = cfg | 721 | m.config = cfg | ||
| 668 | - | 722 | + | ||
| 669 | // Update env loader config | 723 | // Update env loader config | ||
| 670 | m.envLoader.UpdateConfig(cfg) | 724 | m.envLoader.UpdateConfig(cfg) | ||
| 671 | - | 725 | + | ||
| 672 | // Create env files for any new routes | 726 | // Create env files for any new routes | ||
| 673 | if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil { | 727 | if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil { | ||
| 674 | log.Warn().Err(err).Msg("Failed to create env files for new routes during config update") | 728 | log.Warn().Err(err).Msg("Failed to create env files for new routes during config update") | ||
| 675 | } | 729 | } | ||
| 676 | } | 730 | } | ||
| 677 | 731 | ||||
| 678 | - | // normalizeImageRef normalizes image references for comparison | |||
| 679 | - | func normalizeImageRef(imageRef string) string { | |||
| 680 | - | // Split image and tag | |||
| 681 | - | parts := strings.Split(imageRef, ":") | |||
| 682 | - | image := parts[0] | |||
| 683 | - | tag := "latest" | |||
| 684 | - | if len(parts) > 1 { | |||
| 685 | - | tag = parts[1] | |||
| 686 | - | } | |||
| 687 | - | ||||
| 688 | - | // Normalize Docker Hub references | |||
| 689 | - | if !strings.Contains(image, "/") { | |||
| 690 | - | // Official library image (e.g., "nginx" -> "docker.io/library/nginx") | |||
| 691 | - | image = "docker.io/library/" + image | |||
| 692 | - | } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") { | |||
| 693 | - | // User repository (e.g., "user/repo" -> "docker.io/user/repo") | |||
| 694 | - | image = "docker.io/" + image | |||
| 695 | - | } | |||
| 696 | - | ||||
| 697 | - | return image + ":" + tag | |||
| 698 | - | } | |||
| 699 | - | ||||
| 700 | // Shutdown gracefully shuts down the container manager | 732 | // Shutdown gracefully shuts down the container manager | ||
| 701 | func (m *Manager) Shutdown(ctx context.Context) error { | 733 | func (m *Manager) Shutdown(ctx context.Context) error { | ||
| 702 | log.Info().Msg("Shutting down container manager...") | 734 | log.Info().Msg("Shutting down container manager...") | ||
| @@ -712,3 +744,69 @@ func (m *Manager) Shutdown(ctx context.Context) error { | |||||
| 712 | log.Info().Msg("Container manager shutdown complete") | 744 | log.Info().Msg("Container manager shutdown complete") | ||
| 713 | return nil | 745 | return nil | ||
| 714 | } | 746 | } | ||
| 747 | + | ||||
| 748 | + | // cleanupVolumesForDomain removes all volumes associated with a domain | |||
| 749 | + | func (m *Manager) cleanupVolumesForDomain(ctx context.Context, domain string) error { | |||
| 750 | + | log.Info().Str("domain", domain).Msg("Cleaning up volumes for domain") | |||
| 751 | + | ||||
| 752 | + | // We need to get the route to know what image was used to determine volume paths | |||
| 753 | + | var route *config.Route | |||
| 754 | + | for _, r := range m.config.GetRoutes() { | |||
| 755 | + | if r.Domain == domain { | |||
| 756 | + | route = &r | |||
| 757 | + | break | |||
| 758 | + | } | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | if route == nil { | |||
| 762 | + | log.Debug().Str("domain", domain).Msg("No route found for domain, skipping volume cleanup") | |||
| 763 | + | return nil | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | // Construct the full image reference like we do in DeployContainer | |||
| 767 | + | imageRef := route.Image | |||
| 768 | + | if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" { | |||
| 769 | + | if !strings.Contains(strings.Split(imageRef, ":")[0], ".") { | |||
| 770 | + | imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image) | |||
| 771 | + | } | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | // Get volume paths from image VOLUME directives | |||
| 775 | + | volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef) | |||
| 776 | + | if err != nil { | |||
| 777 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes during cleanup") | |||
| 778 | + | return err | |||
| 779 | + | } | |||
| 780 | + | ||||
| 781 | + | if len(volumePaths) == 0 { | |||
| 782 | + | log.Debug().Str("domain", domain).Msg("No volumes found for cleanup") | |||
| 783 | + | return nil | |||
| 784 | + | } | |||
| 785 | + | ||||
| 786 | + | var errors []error | |||
| 787 | + | for _, volumePath := range volumePaths { | |||
| 788 | + | volumeName := generateVolumeName(m.config.Volumes.Prefix, domain, volumePath) | |||
| 789 | + | ||||
| 790 | + | // Check if volume exists before trying to remove | |||
| 791 | + | exists, err := m.runtime.VolumeExists(ctx, volumeName) | |||
| 792 | + | if err != nil { | |||
| 793 | + | log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists during cleanup") | |||
| 794 | + | continue | |||
| 795 | + | } | |||
| 796 | + | ||||
| 797 | + | if exists { | |||
| 798 | + | if err := m.runtime.RemoveVolume(ctx, volumeName, true); err != nil { | |||
| 799 | + | log.Error().Err(err).Str("volume", volumeName).Str("domain", domain).Msg("Failed to remove volume during cleanup") | |||
| 800 | + | errors = append(errors, err) | |||
| 801 | + | } else { | |||
| 802 | + | log.Info().Str("volume", volumeName).Str("domain", domain).Msg("Volume cleaned up successfully") | |||
| 803 | + | } | |||
| 804 | + | } | |||
| 805 | + | } | |||
| 806 | + | ||||
| 807 | + | if len(errors) > 0 { | |||
| 808 | + | return fmt.Errorf("failed to cleanup %d volumes for domain %s", len(errors), domain) | |||
| 809 | + | } | |||
| 810 | + | ||||
| 811 | + | return nil | |||
| 812 | + | } | |||
M pkg/runtime/interface.go +7 -0
| @@ -25,6 +25,7 @@ type ContainerConfig struct { | |||
| 25 | 25 | WorkingDir string | |
| 26 | 26 | Cmd []string | |
| 27 | 27 | AutoRemove bool | |
| 28 | + | Volumes map[string]string // map[containerPath]volumeName | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | // Runtime interface defines the contract for container runtime implementations | |
| @@ -59,4 +60,10 @@ type Runtime interface { | |||
| 59 | 60 | GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) | |
| 60 | 61 | GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) | |
| 61 | 62 | GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) | |
| 63 | + | ||
| 64 | + | // Volume management | |
| 65 | + | InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) | |
| 66 | + | VolumeExists(ctx context.Context, volumeName string) (bool, error) | |
| 67 | + | CreateVolume(ctx context.Context, volumeName string) error | |
| 68 | + | RemoveVolume(ctx context.Context, volumeName string, force bool) error | |
| 62 | 69 | }⏎̸ | |
| @@ -25,6 +25,7 @@ type ContainerConfig struct { | |||||
| 25 | WorkingDir string | 25 | WorkingDir string | ||
| 26 | Cmd []string | 26 | Cmd []string | ||
| 27 | AutoRemove bool | 27 | AutoRemove bool | ||
| 28 | + | Volumes map[string]string // map[containerPath]volumeName | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | // Runtime interface defines the contract for container runtime implementations | 31 | // Runtime interface defines the contract for container runtime implementations | ||
| @@ -59,4 +60,10 @@ type Runtime interface { | |||||
| 59 | GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) | 60 | GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) | ||
| 60 | GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) | 61 | GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) | ||
| 61 | GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) | 62 | GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) | ||
| 63 | + | ||||
| 64 | + | // Volume management | |||
| 65 | + | InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) | |||
| 66 | + | VolumeExists(ctx context.Context, volumeName string) (bool, error) | |||
| 67 | + | CreateVolume(ctx context.Context, volumeName string) error | |||
| 68 | + | RemoveVolume(ctx context.Context, volumeName string, force bool) error | |||
| 62 | }⏎̸ | 69 | }⏎̸ | ||