detached a1b18e2

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
4242 - **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare
4343 - **Zero-Downtime Updates**: Graceful container swaps
4444 - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically
45+- **Auto-Volume Management**: Zero-config persistent storage from Dockerfile VOLUME directives
4546
4647 ## Quick Start (5 minutes)
4748
@@ -220,6 +221,76 @@ podman push registry.mydomain.com/staging.example.com:latest
220221 # Creates: "staging.example.com" = "staging.example.com:latest"
221222 ```
222223
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+
223294 ## Advanced Configuration
224295
225296 ### Full Config Structure (with default values)
@@ -248,6 +319,13 @@ password = "your-secure-password" # No default (required when enabled)
248319 [auto_route]
249320 enabled = false # Default: false
250321
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+
251329 [env]
252330 dir = "{data_dir}/env" # Default: {data_dir}/env
253331 providers = ["pass", "sops"] # Default: ["pass", "sops"]
@@ -288,6 +366,18 @@ A: Yes, for SSL certificates and DDoS protection.
288366 **Q: Resource requirements?**
289367 A: Runs on 1GB RAM VPS. Gordon uses <15MB RAM.
290368
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+
291381 ## Philosophy
292382
293383 ```bash
@@ -42,6 +42,7 @@ podman push registry.mydomain.com/myapp:latest
42 - **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare42 - **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare
43 - **Zero-Downtime Updates**: Graceful container swaps43 - **Zero-Downtime Updates**: Graceful container swaps
44 - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically44 - **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 Configuration294 ## 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: false320 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}/env330 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 ## Philosophy381 ## Philosophy
292 382
293 ```bash383 ```bash
M examples/development.toml +11 -0
@@ -15,6 +15,12 @@ enabled = false # Disable auth for easier local development
1515 [auto_route]
1616 enabled = true # Enable automatic route creation from image names
1717
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+
1824 # Logging configuration for development
1925 [logging]
2026 enabled = true # Enable file-based logging
@@ -83,4 +89,9 @@ providers = ["pass"] # For development secrets
8389 # docker push registry.yourdomain.com/mytestapp.bamen.dev:latest
8490 # Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest
8591 #
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+#
8697 # 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 names16 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 development24 # Logging configuration for development
19 [logging]25 [logging]
20 enabled = true # Enable file-based logging26 enabled = true # Enable file-based logging
@@ -83,4 +89,9 @@ providers = ["pass"] # For development secrets
83 # docker push registry.yourdomain.com/mytestapp.bamen.dev:latest89 # docker push registry.yourdomain.com/mytestapp.bamen.dev:latest
84 # Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest90 # 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 {
1818 AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
1919 Env EnvConfig `mapstructure:"env"`
2020 Logging LoggingConfig `mapstructure:"logging"`
21+ Volumes VolumeConfig `mapstructure:"volumes"`
2122 }
2223
2324 type ServerConfig struct {
@@ -58,6 +59,12 @@ type LoggingConfig struct {
5859 Compress bool `mapstructure:"compress"`
5960 }
6061
62+type VolumeConfig struct {
63+ AutoCreate bool `mapstructure:"auto_create"`
64+ Prefix string `mapstructure:"prefix"`
65+ Preserve bool `mapstructure:"preserve"`
66+}
67+
6168 type SecretProvider struct {
6269 Type string `mapstructure:"type"`
6370 Config map[string]string `mapstructure:"config"`
@@ -98,6 +105,11 @@ func Load() (*Config, error) {
98105 viper.SetDefault("logging.max_age", 28) // 28 days
99106 viper.SetDefault("logging.compress", true)
100107
108+ // Volume defaults
109+ viper.SetDefault("volumes.auto_create", true)
110+ viper.SetDefault("volumes.prefix", "gordon")
111+ viper.SetDefault("volumes.preserve", true)
112+
101113 // Handle the routes manually since Viper struggles with domain names
102114 cfg.Routes = make(map[string]string)
103115
@@ -150,6 +162,11 @@ func Load() (*Config, error) {
150162 log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default")
151163 }
152164
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+
153170 // Get routes manually from the raw config
154171 routesRaw := viper.Get("routes")
155172 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 days105 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 names113 // 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 config170 // 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 (
1212 "github.com/docker/docker/api/types/container"
1313 "github.com/docker/docker/api/types/image"
1414 "github.com/docker/docker/api/types/registry"
15+ "github.com/docker/docker/api/types/volume"
1516 "github.com/docker/docker/client"
17+ "github.com/docker/docker/errdefs"
1618 "github.com/docker/go-connections/nat"
1719 "github.com/rs/zerolog/log"
1820
@@ -41,11 +43,11 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con
4143 // Convert ports to Docker format
4244 exposedPorts := make(nat.PortSet)
4345 portBindings := make(nat.PortMap)
44-
46+
4547 for _, port := range config.Ports {
4648 containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
4749 exposedPorts[containerPort] = struct{}{}
48-
50+
4951 // Bind to random available port on host
5052 portBindings[containerPort] = []nat.PortBinding{
5153 {
@@ -55,6 +57,16 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con
5557 }
5658 }
5759
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+
5870 // Create container configuration
5971 containerConfig := &container.Config{
6072 Image: config.Image,
@@ -68,6 +80,7 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con
6880 hostConfig := &container.HostConfig{
6981 PortBindings: portBindings,
7082 AutoRemove: config.AutoRemove,
83+ Binds: binds,
7184 }
7285
7386 // Create the container
@@ -216,7 +229,7 @@ func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string
216229 // PullImage pulls an image
217230 func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
218231 log.Info().Str("image", imageRef).Msg("Pulling image")
219-
232+
220233 reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{})
221234 if err != nil {
222235 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 {
236249 // PullImageWithAuth pulls an image with authentication
237250 func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
238251 log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication")
239-
252+
240253 // Create authentication configuration
241254 authConfig := registry.AuthConfig{
242255 Username: username,
243256 Password: password,
244257 }
245-
258+
246259 // Encode authentication to base64 JSON
247260 authConfigBytes, err := json.Marshal(authConfig)
248261 if err != nil {
249262 return fmt.Errorf("failed to marshal auth config: %w", err)
250263 }
251264 authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
252-
265+
253266 // Pull with authentication
254267 reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{
255268 RegistryAuth: authStr,
@@ -450,3 +463,69 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID
450463 return containerIP, selectedPort, nil
451464 }
452465
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 format43 // 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 host51 // 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 configuration70 // 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 container86 // Create the container
@@ -216,7 +229,7 @@ func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string
216 // PullImage pulls an image229 // 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 authentication249 // 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 configuration253 // 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 JSON259 // 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 authentication266 // 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, nil463 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 {
2828 func NewManager(cfg *config.Config) (*Manager, error) {
2929 // Test runtime connectivity with background context
3030 ctx := context.Background()
31-
31+
3232 // Create runtime using the factory
3333 rt, err := CreateRuntime(ctx, cfg)
3434 if err != nil {
@@ -49,7 +49,7 @@ func NewManager(cfg *config.Config) (*Manager, error) {
4949
5050 // Create environment loader
5151 envLoader := env.NewLoader(cfg)
52-
52+
5353 // Register secret providers based on config
5454 for _, providerName := range cfg.Env.Providers {
5555 switch providerName {
@@ -59,7 +59,7 @@ func NewManager(cfg *config.Config) (*Manager, error) {
5959 envLoader.RegisterSecretProvider("sops", env.NewSopsProvider())
6060 }
6161 }
62-
62+
6363 // Ensure env directory exists
6464 if err := envLoader.EnsureEnvDir(); err != nil {
6565 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
151151 } else {
152152 // Normalize image references for comparison
153153 normalizedImageRef := normalizeImageRef(imageRef)
154-
154+
155155 for _, localImage := range localImages {
156156 if normalizeImageRef(localImage) == normalizedImageRef {
157157 imageAvailable = true
@@ -173,7 +173,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
173173 Str("image", imageRef).
174174 Str("username", m.config.RegistryAuth.Username).
175175 Msg("Pulling image with authentication")
176-
176+
177177 err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
178178 if err != nil {
179179 // Provide better error message with context
@@ -181,16 +181,16 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
181181 for _, img := range localImages {
182182 availableImages = append(availableImages, img)
183183 }
184-
184+
185185 return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+
186186 "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",
188188 imageRef, m.config.Server.RegistryDomain, availableImages, err)
189189 }
190190 log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
191191 } else {
192192 log.Info().Str("image", imageRef).Msg("Pulling image without authentication")
193-
193+
194194 err := m.runtime.PullImage(ctx, imageRef)
195195 if err != nil {
196196 // Provide better error message with context
@@ -198,10 +198,10 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
198198 for _, img := range localImages {
199199 availableImages = append(availableImages, img)
200200 }
201-
201+
202202 return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+
203203 "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",
205205 imageRef, availableImages, err)
206206 }
207207 log.Info().Str("image", imageRef).Msg("Image pulled successfully")
@@ -222,12 +222,48 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
222222 return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err)
223223 }
224224
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+
225260 // Create container configuration
226261 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,
231267 Labels: map[string]string{
232268 "gordon.domain": route.Domain,
233269 "gordon.image": route.Image,
@@ -369,6 +405,17 @@ func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) erro
369405
370406 // RemoveContainer removes a container by ID
371407 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+
372419 if err := m.runtime.RemoveContainer(ctx, containerID, force); err != nil {
373420 return fmt.Errorf("failed to remove container %s: %w", containerID, err)
374421 }
@@ -376,6 +423,13 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force
376423 // Stop log collection for the container
377424 m.logManager.StopCollection(containerID)
378425
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+
379433 // Remove from our tracking map
380434 m.mu.Lock()
381435 defer m.mu.Unlock()
@@ -447,7 +501,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
447501
448502 // Create maps for quick lookup
449503 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
451505
452506 for _, container := range allContainers {
453507 // Track Gordon-managed containers by domain
@@ -456,7 +510,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
456510 gordonManagedContainers[domain] = container
457511 }
458512 }
459-
513+
460514 // Track all containers by image (normalize image name)
461515 normalizedImage := m.normalizeImageName(container.Image)
462516 containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container)
@@ -519,7 +573,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
519573 Str("domain", route.Domain).
520574 Msg("Failed to start log collection for auto-started container")
521575 }
522-
576+
523577 log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")
524578 continue
525579 }
@@ -535,7 +589,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
535589 }
536590
537591 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+
539593 // Stop the container if it's running
540594 isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID)
541595 if err != nil {
@@ -561,7 +615,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
561615
562616 // No existing container found, deploy a new one
563617 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route")
564-
618+
565619 _, err := m.DeployContainer(ctx, route)
566620 if err != nil {
567621 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 {
579633 // Remove tag if present, keep only repository name
580634 parts := strings.Split(image, ":")
581635 repo := parts[0]
582-
636+
583637 // If no registry domain and it's a simple name, it's from Docker Hub
584638 if !strings.Contains(repo, "/") {
585639 return "docker.io/library/" + repo
586640 }
587-
641+
588642 // If it has one slash and no domain, it's a user repo on Docker Hub
589643 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") {
590644 return "docker.io/" + repo
591645 }
592-
646+
593647 return repo
594648 }
595649
@@ -612,7 +666,7 @@ func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
612666 var errors []error
613667 for domain, container := range containers {
614668 log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container")
615-
669+
616670 if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
617671 log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container")
618672 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 {
665719 // UpdateConfig updates the manager's configuration and creates env files for new routes
666720 func (m *Manager) UpdateConfig(cfg *config.Config) {
667721 m.config = cfg
668-
722+
669723 // Update env loader config
670724 m.envLoader.UpdateConfig(cfg)
671-
725+
672726 // Create env files for any new routes
673727 if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil {
674728 log.Warn().Err(err).Msg("Failed to create env files for new routes during config update")
675729 }
676730 }
677731
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-
700732 // Shutdown gracefully shuts down the container manager
701733 func (m *Manager) Shutdown(ctx context.Context) error {
702734 log.Info().Msg("Shutting down container manager...")
@@ -712,3 +744,69 @@ func (m *Manager) Shutdown(ctx context.Context) error {
712744 log.Info().Msg("Container manager shutdown complete")
713745 return nil
714746 }
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 context29 // Test runtime connectivity with background context
30 ctx := context.Background()30 ctx := context.Background()
31- 31+
32 // Create runtime using the factory32 // 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 loader50 // Create environment loader
51 envLoader := env.NewLoader(cfg)51 envLoader := env.NewLoader(cfg)
52- 52+
53 // Register secret providers based on config53 // 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 exists63 // 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 comparison152 // 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 = true157 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 context179 // 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 context196 // 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 configuration260 // 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 ID406 // 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 container423 // 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 map433 // 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 lookup502 // Create maps for quick lookup
449 gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container503 gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container
450- containersByImage := make(map[string][]*runtime.Container) // image -> containers504+ 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 domain507 // Track Gordon-managed containers by domain
@@ -456,7 +510,7 @@ func (m *Manager) AutoStartContainers(ctx context.Context) error {
456 gordonManagedContainers[domain] = container510 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 continue578 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 running593 // 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 one616 // 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 name633 // 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 Hub637 // 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/" + repo639 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 Hub642 // 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/" + repo644 return "docker.io/" + repo
591 }645 }
592- 646+
593 return repo647 return repo
594 }648 }
595 649
@@ -612,7 +666,7 @@ func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
612 var errors []error666 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 routes719 // 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 = cfg721 m.config = cfg
668- 722+
669 // Update env loader config723 // Update env loader config
670 m.envLoader.UpdateConfig(cfg)724 m.envLoader.UpdateConfig(cfg)
671- 725+
672 // Create env files for any new routes726 // 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 manager732 // 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 nil745 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 {
2525 WorkingDir string
2626 Cmd []string
2727 AutoRemove bool
28+ Volumes map[string]string // map[containerPath]volumeName
2829 }
2930
3031 // Runtime interface defines the contract for container runtime implementations
@@ -59,4 +60,10 @@ type Runtime interface {
5960 GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error)
6061 GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error)
6162 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
6269 }⏎̸
@@ -25,6 +25,7 @@ type ContainerConfig struct {
25 WorkingDir string25 WorkingDir string
26 Cmd []string26 Cmd []string
27 AutoRemove bool27 AutoRemove bool
28+ Volumes map[string]string // map[containerPath]volumeName
28 }29 }
29 30
30 // Runtime interface defines the contract for container runtime implementations31 // 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 }⏎̸

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