detached b1ef8a5

feat(network): Implement network isolation and service attachment features

- Add support for per-app network isolation, allowing each application to have its own isolated network with attached services.
- Introduce configuration options for network isolation and service attachments in both development and production environments.
- Enhance container management to create and manage networks dynamically based on application requirements.
- Update README and example configurations to reflect new network isolation features and usage instructions.
commit
b1ef8a53f5df104e1c910aecde7af7cd5b347b28
author
bamen · · 2025-06-08 15:33 UTC
parent
0da72ac
signature
unsigned
8 files · +996 -17
M README.md +7 -0
@@ -41,6 +41,7 @@ podman push registry.mydomain.com/myapp:latest
4141 - **Push-to-Deploy**: New images deploy instantly
4242 - **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare
4343 - **Zero-Downtime Updates**: Graceful container swaps
44+- **Network Isolation**: Each app gets its own isolated network with attached services (databases, caches, etc.)
4445 - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically
4546 - **Auto-Volume Management**: Zero-config persistent storage from Dockerfile VOLUME directives
4647 - **Auto-Environment Injection**: Dockerfile ENV directives automatically merge with your custom environment variables
@@ -333,6 +334,12 @@ password = "your-secure-password" # No default (required when enabled)
333334 "app.mydomain.com" = "myapp:latest" # Default: {} (empty map)
334335 # Routes default to HTTPS unless prefixed with http://
335336
337+# Network isolation - each app gets its own isolated network
338+[attachments]
339+"app.mydomain.com" = ["my-postgres:latest", "my-redis:latest"]
340+# Services accessible via: postgres:5432, redis:6379 (internal DNS)
341+# Create custom Dockerfiles with VOLUME directives for persistence
342+
336343 [auto_route]
337344 enabled = false # Default: false
338345
@@ -41,6 +41,7 @@ podman push registry.mydomain.com/myapp:latest
41 - **Push-to-Deploy**: New images deploy instantly41 - **Push-to-Deploy**: New images deploy instantly
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+- **Network Isolation**: Each app gets its own isolated network with attached services (databases, caches, etc.)
44 - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically45 - **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically
45 - **Auto-Volume Management**: Zero-config persistent storage from Dockerfile VOLUME directives46 - **Auto-Volume Management**: Zero-config persistent storage from Dockerfile VOLUME directives
46 - **Auto-Environment Injection**: Dockerfile ENV directives automatically merge with your custom environment variables47 - **Auto-Environment Injection**: Dockerfile ENV directives automatically merge with your custom environment variables
@@ -333,6 +334,12 @@ password = "your-secure-password" # No default (required when enabled)
333 "app.mydomain.com" = "myapp:latest" # Default: {} (empty map)334 "app.mydomain.com" = "myapp:latest" # Default: {} (empty map)
334 # Routes default to HTTPS unless prefixed with http://335 # Routes default to HTTPS unless prefixed with http://
335 336
337+# Network isolation - each app gets its own isolated network
338+[attachments]
339+"app.mydomain.com" = ["my-postgres:latest", "my-redis:latest"]
340+# Services accessible via: postgres:5432, redis:6379 (internal DNS)
341+# Create custom Dockerfiles with VOLUME directives for persistence
342+
336 [auto_route]343 [auto_route]
337 enabled = false # Default: false344 enabled = false # Default: false
338 345
A docs/network-isolation-implementation-plan.md +410 -0
@@ -0,0 +1,410 @@
1+# Gordon Network Isolation Implementation Plan
2+
3+## Overview
4+Implement per-app network isolation where each app creates its own Docker network and can have services (databases, caches, etc.) attached to it. Network groups are optional for advanced use cases.
5+
6+## Configuration Design
7+
8+### Basic Usage (Most Common)
9+```toml
10+[routes]
11+"myapp.example.com" = "myapp:latest"
12+"api.example.com" = "api:latest"
13+
14+[attachments]
15+# Each app gets its own isolated network with attached services
16+"myapp.example.com" = ["postgres:15", "redis:7-alpine"]
17+"api.example.com" = ["mongodb:6", "redis:7-alpine"] # Each gets own Redis instance
18+```
19+
20+### Advanced: Network Groups (Optional)
21+```toml
22+[routes]
23+"app1.example.com" = "app1:latest"
24+"app2.example.com" = "app2:latest"
25+"app3.example.com" = "app3:latest"
26+
27+[network_groups] # Optional section
28+"backend" = ["app1.example.com", "app2.example.com"]
29+"frontend" = ["app3.example.com"]
30+
31+[attachments]
32+# Attach to network groups
33+"backend" = ["redis:7-alpine", "rabbitmq:3"] # Shared by app1 & app2
34+"frontend" = ["redis:7-alpine"] # Separate Redis for app3
35+"app1.example.com" = ["postgres:15"] # app1-specific database
36+```
37+
38+## Implementation Details
39+
40+### 1. Config Schema Changes
41+
42+**File: `internal/config/config.go`**
43+
44+```go
45+type Config struct {
46+ // ... existing fields ...
47+
48+ // Network isolation
49+ Attachments map[string][]string `toml:"attachments"` // domain/group -> services
50+ NetworkGroups map[string][]string `toml:"network_groups"` // Optional: group -> domains
51+ NetworkIsolation NetworkIsolationConfig `toml:"network_isolation"`
52+}
53+
54+type NetworkIsolationConfig struct {
55+ Enabled bool `toml:"enabled"` // Default: true
56+ NetworkPrefix string `toml:"network_prefix"` // Default: "gordon"
57+ DNSSuffix string `toml:"dns_suffix"` // Default: ".internal"
58+}
59+
60+// Default values
61+func defaultConfig() *Config {
62+ return &Config{
63+ // ... existing defaults ...
64+ Attachments: make(map[string][]string),
65+ NetworkGroups: make(map[string][]string), // Empty by default
66+ NetworkIsolation: NetworkIsolationConfig{
67+ Enabled: true,
68+ NetworkPrefix: "gordon",
69+ DNSSuffix: ".internal",
70+ },
71+ }
72+}
73+```
74+
75+### 2. Container Manager Network Logic
76+
77+**File: `internal/container/manager.go`**
78+
79+```go
80+// Network name generation
81+func (m *Manager) GetNetworkForApp(domain string) string {
82+ // Check if domain is part of a network group
83+ for groupName, domains := range m.config.NetworkGroups {
84+ for _, d := range domains {
85+ if d == domain {
86+ return m.generateNetworkName(groupName)
87+ }
88+ }
89+ }
90+
91+ // Default: app gets its own network
92+ return m.generateNetworkName(domain)
93+}
94+
95+func (m *Manager) generateNetworkName(identifier string) string {
96+ // "myapp.example.com" -> "gordon-myapp-example-com"
97+ // "backend" -> "gordon-backend"
98+ return fmt.Sprintf("%s-%s",
99+ m.config.NetworkIsolation.NetworkPrefix,
100+ strings.ReplaceAll(identifier, ".", "-"))
101+}
102+
103+// Get all apps that should have access to a network
104+func (m *Manager) GetAppsForNetwork(identifier string) []string {
105+ // Check if it's a network group
106+ if apps, ok := m.config.NetworkGroups[identifier]; ok {
107+ return apps
108+ }
109+
110+ // Single app network
111+ return []string{identifier}
112+}
113+
114+// Deploy attached service
115+func (m *Manager) DeployAttachedService(identifier, serviceName, image string) error {
116+ networkName := m.generateNetworkName(identifier)
117+
118+ // For network groups, we need a different container naming scheme
119+ apps := m.GetAppsForNetwork(identifier)
120+ var containerName string
121+
122+ if len(apps) > 1 {
123+ // Shared service for network group
124+ containerName = fmt.Sprintf("%s-shared-%s", m.config.NetworkIsolation.NetworkPrefix, serviceName)
125+ } else {
126+ // App-specific service
127+ containerName = fmt.Sprintf("%s-%s", identifier, serviceName)
128+ }
129+
130+ // Check if already running
131+ if m.IsContainerRunning(containerName) {
132+ log.Printf("Service %s already running", containerName)
133+ return nil
134+ }
135+
136+ // Load environment (inherit from first app in group or specific app)
137+ var env map[string]string
138+ if len(apps) > 0 {
139+ envFile := m.getEnvFileForDomain(apps[0])
140+ env = m.loadEnvironment(envFile)
141+ }
142+
143+ // Deploy to network
144+ return m.DeployWithNetwork(image, containerName, networkName, env, nil)
145+}
146+```
147+
148+### 3. Deployment Flow
149+
150+**File: `internal/deploy/deploy.go`**
151+
152+```go
153+func (d *Deployer) Deploy(domain, image string) error {
154+ // Step 1: Determine which network this app should use
155+ networkName := d.containerManager.GetNetworkForApp(domain)
156+
157+ // Step 2: Create network if it doesn't exist
158+ err := d.containerManager.CreateNetwork(networkName)
159+ if err != nil {
160+ return fmt.Errorf("failed to create network: %w", err)
161+ }
162+
163+ // Step 3: Deploy main application
164+ err = d.deployMainApp(domain, image, networkName)
165+ if err != nil {
166+ return fmt.Errorf("failed to deploy main app: %w", err)
167+ }
168+
169+ // Step 4: Deploy attachments for this specific app
170+ if attachments, ok := d.config.Attachments[domain]; ok {
171+ for _, serviceImage := range attachments {
172+ err = d.deployAttachedService(domain, serviceImage)
173+ if err != nil {
174+ log.Printf("Failed to deploy attachment %s: %v", serviceImage, err)
175+ }
176+ }
177+ }
178+
179+ // Step 5: Check if app is part of a network group with attachments
180+ for groupName, domains := range d.config.NetworkGroups {
181+ for _, d := range domains {
182+ if d == domain {
183+ // Deploy group attachments if not already deployed
184+ if attachments, ok := d.config.Attachments[groupName]; ok {
185+ for _, serviceImage := range attachments {
186+ err = d.deployAttachedService(groupName, serviceImage)
187+ if err != nil {
188+ log.Printf("Failed to deploy group attachment %s: %v", serviceImage, err)
189+ }
190+ }
191+ }
192+ }
193+ }
194+ }
195+
196+ return nil
197+}
198+```
199+
200+### 4. Volume Management for Attached Services
201+
202+**Problem**: Standard database images (postgres:15, mysql:8, etc.) don't have VOLUME directives, so Gordon's auto-volume feature won't work for persistent data.
203+
204+**Solution**: Users must create custom Dockerfiles with VOLUME directives for services that need persistent storage.
205+
206+**Example Setup for Persistent Database:**
207+
208+1. **Create a Dockerfile for your database:**
209+```dockerfile
210+# Dockerfile.postgres
211+FROM postgres:15
212+VOLUME ["/var/lib/postgresql/data"]
213+```
214+
215+2. **Build and push to your registry:**
216+```bash
217+docker build -f Dockerfile.postgres -t registry.yourdomain.com/my-postgres:latest .
218+docker push registry.yourdomain.com/my-postgres:latest
219+```
220+
221+3. **Use in your Gordon config:**
222+```toml
223+[routes]
224+"myapp.example.com" = "myapp:latest"
225+
226+[attachments]
227+"myapp.example.com" = ["my-postgres:latest", "redis:7-alpine"]
228+```
229+
230+**Why This Approach:**
231+- **Explicit**: User controls exactly which directories get persistent volumes
232+- **Simple**: No magic or guessing - follows Gordon's existing volume system
233+- **Consistent**: Uses the same VOLUME directive pattern for all containers
234+- **Flexible**: User can add custom configuration, environment variables, or initialization scripts
235+
236+### 5. Service Discovery & DNS
237+
238+Services are accessible by their simple names within the network:
239+
240+```yaml
241+# From myapp container
242+DATABASE_URL: postgresql://postgres:5432/mydb
243+REDIS_URL: redis://redis:6379
244+MONGO_URL: mongodb://mongodb:27017/myapp
245+
246+# These hostnames resolve only within the network
247+```
248+
249+### 6. Documentation for Users
250+
251+**In Gordon Documentation:**
252+
253+```markdown
254+## Using Attached Services with Persistent Data
255+
256+For services that need persistent storage (databases, etc.), you must create a custom Dockerfile:
257+
258+### PostgreSQL Example:
259+```dockerfile
260+# Dockerfile.postgres
261+FROM postgres:15
262+VOLUME ["/var/lib/postgresql/data"]
263+ENV POSTGRES_DB=myapp
264+ENV POSTGRES_USER=appuser
265+ENV POSTGRES_PASSWORD=secret
266+```
267+
268+### MySQL Example:
269+```dockerfile
270+# Dockerfile.mysql
271+FROM mysql:8
272+VOLUME ["/var/lib/mysql"]
273+ENV MYSQL_DATABASE=myapp
274+ENV MYSQL_USER=appuser
275+ENV MYSQL_PASSWORD=secret
276+ENV MYSQL_ROOT_PASSWORD=rootsecret
277+```
278+
279+### Redis with Persistence Example:
280+```dockerfile
281+# Dockerfile.redis
282+FROM redis:7-alpine
283+VOLUME ["/data"]
284+CMD ["redis-server", "--appendonly", "yes"]
285+```
286+
287+Build and push these to your Gordon registry, then reference them in your config.
288+```
289+
290+### 7. Runtime Interface Updates
291+
292+**File: `pkg/runtime/interface.go`**
293+
294+```go
295+type Runtime interface {
296+ // ... existing methods ...
297+
298+ // Network management
299+ CreateNetwork(name string, options map[string]string) error
300+ RemoveNetwork(name string) error
301+ ListNetworks() ([]NetworkInfo, error)
302+ ConnectContainerToNetwork(containerName, networkName string) error
303+ DisconnectContainerFromNetwork(containerName, networkName string) error
304+}
305+
306+type CreateOptions struct {
307+ // ... existing fields ...
308+ NetworkMode string // Network to join
309+ Hostname string // Container hostname for DNS
310+ Aliases []string // Additional network aliases
311+}
312+```
313+
314+## Examples
315+
316+### Example 1: Simple App with Database
317+```toml
318+[routes]
319+"blog.company.com" = "wordpress:latest"
320+
321+[attachments]
322+"blog.company.com" = ["my-mysql:latest", "my-redis:latest"]
323+
324+# Prerequisites: User created Dockerfiles with VOLUME directives:
325+# - my-mysql:latest (FROM mysql:8 + VOLUME ["/var/lib/mysql"])
326+# - my-redis:latest (FROM redis:7-alpine + VOLUME ["/data"])
327+#
328+# Creates network: gordon-blog-company-com
329+# Containers: blog.company.com, blog.company.com-my-mysql, blog.company.com-my-redis
330+# Volumes: Auto-created based on VOLUME directives in the custom images
331+```
332+
333+### Example 2: Microservices with Shared Cache
334+```toml
335+[routes]
336+"api.company.com" = "api:latest"
337+"worker.company.com" = "worker:latest"
338+"admin.company.com" = "admin:latest"
339+
340+[network_groups]
341+"backend" = ["api.company.com", "worker.company.com"]
342+
343+[attachments]
344+# Shared services for backend group
345+"backend" = ["redis:7-alpine", "rabbitmq:3"]
346+# Individual databases
347+"api.company.com" = ["postgres:15"]
348+"admin.company.com" = ["postgres:15"]
349+
350+# Creates:
351+# - Network: gordon-backend (contains api, worker, redis, rabbitmq, api's postgres)
352+# - Network: gordon-admin-company-com (contains admin, admin's postgres)
353+```
354+
355+### Example 3: Multi-Tenant SaaS
356+```toml
357+[routes]
358+"tenant1.app.com" = "app:v2.0"
359+"tenant2.app.com" = "app:v2.0"
360+"tenant3.app.com" = "app:v2.0"
361+
362+[attachments]
363+# Each tenant gets isolated services
364+"tenant1.app.com" = ["postgres:15", "redis:7"]
365+"tenant2.app.com" = ["postgres:15", "redis:7"]
366+"tenant3.app.com" = ["postgres:15", "redis:7"]
367+
368+# Complete isolation between tenants
369+```
370+
371+## Migration Strategy
372+
373+1. **Backward Compatible**: Apps without attachments use default bridge network
374+2. **Opt-in**: Only apps with attachments get isolated networks
375+3. **Gradual Migration**:
376+ ```toml
377+ [network_isolation]
378+ enabled = false # Start disabled
379+ ```
380+
381+## Testing Plan
382+
383+### Test Cases
384+1. **Single App Isolation**: App + attached services can communicate
385+2. **Network Groups**: Multiple apps share services correctly
386+3. **Mixed Mode**: Some apps grouped, some isolated
387+4. **Service Discovery**: DNS names resolve correctly
388+5. **Environment Inheritance**: Attached services get parent's env
389+6. **Cleanup**: Networks removed when last app removed
390+
391+### Edge Cases
392+- App with no attachments (uses default network)
393+- Empty network group (ignored)
394+- Conflicting names (app and group with same name)
395+- Service image not found (graceful failure)
396+- Network already exists (reuse it)
397+
398+## Implementation Priority
399+
400+1. **Phase 1**: Basic attachments (no network groups)
401+2. **Phase 2**: Add network groups support
402+3. **Phase 3**: Advanced features (custom DNS, network policies)
403+
404+## Summary
405+
406+This design provides:
407+- **Simple by default**: Just use `[attachments]` section
408+- **Powerful when needed**: Network groups for complex scenarios
409+- **Zero breaking changes**: Existing configs work unchanged
410+- **Gordon philosophy**: Convention over configuration⏎̸
@@ -0,0 +1,410 @@
1+# Gordon Network Isolation Implementation Plan
2+
3+## Overview
4+Implement per-app network isolation where each app creates its own Docker network and can have services (databases, caches, etc.) attached to it. Network groups are optional for advanced use cases.
5+
6+## Configuration Design
7+
8+### Basic Usage (Most Common)
9+```toml
10+[routes]
11+"myapp.example.com" = "myapp:latest"
12+"api.example.com" = "api:latest"
13+
14+[attachments]
15+# Each app gets its own isolated network with attached services
16+"myapp.example.com" = ["postgres:15", "redis:7-alpine"]
17+"api.example.com" = ["mongodb:6", "redis:7-alpine"] # Each gets own Redis instance
18+```
19+
20+### Advanced: Network Groups (Optional)
21+```toml
22+[routes]
23+"app1.example.com" = "app1:latest"
24+"app2.example.com" = "app2:latest"
25+"app3.example.com" = "app3:latest"
26+
27+[network_groups] # Optional section
28+"backend" = ["app1.example.com", "app2.example.com"]
29+"frontend" = ["app3.example.com"]
30+
31+[attachments]
32+# Attach to network groups
33+"backend" = ["redis:7-alpine", "rabbitmq:3"] # Shared by app1 & app2
34+"frontend" = ["redis:7-alpine"] # Separate Redis for app3
35+"app1.example.com" = ["postgres:15"] # app1-specific database
36+```
37+
38+## Implementation Details
39+
40+### 1. Config Schema Changes
41+
42+**File: `internal/config/config.go`**
43+
44+```go
45+type Config struct {
46+ // ... existing fields ...
47+
48+ // Network isolation
49+ Attachments map[string][]string `toml:"attachments"` // domain/group -> services
50+ NetworkGroups map[string][]string `toml:"network_groups"` // Optional: group -> domains
51+ NetworkIsolation NetworkIsolationConfig `toml:"network_isolation"`
52+}
53+
54+type NetworkIsolationConfig struct {
55+ Enabled bool `toml:"enabled"` // Default: true
56+ NetworkPrefix string `toml:"network_prefix"` // Default: "gordon"
57+ DNSSuffix string `toml:"dns_suffix"` // Default: ".internal"
58+}
59+
60+// Default values
61+func defaultConfig() *Config {
62+ return &Config{
63+ // ... existing defaults ...
64+ Attachments: make(map[string][]string),
65+ NetworkGroups: make(map[string][]string), // Empty by default
66+ NetworkIsolation: NetworkIsolationConfig{
67+ Enabled: true,
68+ NetworkPrefix: "gordon",
69+ DNSSuffix: ".internal",
70+ },
71+ }
72+}
73+```
74+
75+### 2. Container Manager Network Logic
76+
77+**File: `internal/container/manager.go`**
78+
79+```go
80+// Network name generation
81+func (m *Manager) GetNetworkForApp(domain string) string {
82+ // Check if domain is part of a network group
83+ for groupName, domains := range m.config.NetworkGroups {
84+ for _, d := range domains {
85+ if d == domain {
86+ return m.generateNetworkName(groupName)
87+ }
88+ }
89+ }
90+
91+ // Default: app gets its own network
92+ return m.generateNetworkName(domain)
93+}
94+
95+func (m *Manager) generateNetworkName(identifier string) string {
96+ // "myapp.example.com" -> "gordon-myapp-example-com"
97+ // "backend" -> "gordon-backend"
98+ return fmt.Sprintf("%s-%s",
99+ m.config.NetworkIsolation.NetworkPrefix,
100+ strings.ReplaceAll(identifier, ".", "-"))
101+}
102+
103+// Get all apps that should have access to a network
104+func (m *Manager) GetAppsForNetwork(identifier string) []string {
105+ // Check if it's a network group
106+ if apps, ok := m.config.NetworkGroups[identifier]; ok {
107+ return apps
108+ }
109+
110+ // Single app network
111+ return []string{identifier}
112+}
113+
114+// Deploy attached service
115+func (m *Manager) DeployAttachedService(identifier, serviceName, image string) error {
116+ networkName := m.generateNetworkName(identifier)
117+
118+ // For network groups, we need a different container naming scheme
119+ apps := m.GetAppsForNetwork(identifier)
120+ var containerName string
121+
122+ if len(apps) > 1 {
123+ // Shared service for network group
124+ containerName = fmt.Sprintf("%s-shared-%s", m.config.NetworkIsolation.NetworkPrefix, serviceName)
125+ } else {
126+ // App-specific service
127+ containerName = fmt.Sprintf("%s-%s", identifier, serviceName)
128+ }
129+
130+ // Check if already running
131+ if m.IsContainerRunning(containerName) {
132+ log.Printf("Service %s already running", containerName)
133+ return nil
134+ }
135+
136+ // Load environment (inherit from first app in group or specific app)
137+ var env map[string]string
138+ if len(apps) > 0 {
139+ envFile := m.getEnvFileForDomain(apps[0])
140+ env = m.loadEnvironment(envFile)
141+ }
142+
143+ // Deploy to network
144+ return m.DeployWithNetwork(image, containerName, networkName, env, nil)
145+}
146+```
147+
148+### 3. Deployment Flow
149+
150+**File: `internal/deploy/deploy.go`**
151+
152+```go
153+func (d *Deployer) Deploy(domain, image string) error {
154+ // Step 1: Determine which network this app should use
155+ networkName := d.containerManager.GetNetworkForApp(domain)
156+
157+ // Step 2: Create network if it doesn't exist
158+ err := d.containerManager.CreateNetwork(networkName)
159+ if err != nil {
160+ return fmt.Errorf("failed to create network: %w", err)
161+ }
162+
163+ // Step 3: Deploy main application
164+ err = d.deployMainApp(domain, image, networkName)
165+ if err != nil {
166+ return fmt.Errorf("failed to deploy main app: %w", err)
167+ }
168+
169+ // Step 4: Deploy attachments for this specific app
170+ if attachments, ok := d.config.Attachments[domain]; ok {
171+ for _, serviceImage := range attachments {
172+ err = d.deployAttachedService(domain, serviceImage)
173+ if err != nil {
174+ log.Printf("Failed to deploy attachment %s: %v", serviceImage, err)
175+ }
176+ }
177+ }
178+
179+ // Step 5: Check if app is part of a network group with attachments
180+ for groupName, domains := range d.config.NetworkGroups {
181+ for _, d := range domains {
182+ if d == domain {
183+ // Deploy group attachments if not already deployed
184+ if attachments, ok := d.config.Attachments[groupName]; ok {
185+ for _, serviceImage := range attachments {
186+ err = d.deployAttachedService(groupName, serviceImage)
187+ if err != nil {
188+ log.Printf("Failed to deploy group attachment %s: %v", serviceImage, err)
189+ }
190+ }
191+ }
192+ }
193+ }
194+ }
195+
196+ return nil
197+}
198+```
199+
200+### 4. Volume Management for Attached Services
201+
202+**Problem**: Standard database images (postgres:15, mysql:8, etc.) don't have VOLUME directives, so Gordon's auto-volume feature won't work for persistent data.
203+
204+**Solution**: Users must create custom Dockerfiles with VOLUME directives for services that need persistent storage.
205+
206+**Example Setup for Persistent Database:**
207+
208+1. **Create a Dockerfile for your database:**
209+```dockerfile
210+# Dockerfile.postgres
211+FROM postgres:15
212+VOLUME ["/var/lib/postgresql/data"]
213+```
214+
215+2. **Build and push to your registry:**
216+```bash
217+docker build -f Dockerfile.postgres -t registry.yourdomain.com/my-postgres:latest .
218+docker push registry.yourdomain.com/my-postgres:latest
219+```
220+
221+3. **Use in your Gordon config:**
222+```toml
223+[routes]
224+"myapp.example.com" = "myapp:latest"
225+
226+[attachments]
227+"myapp.example.com" = ["my-postgres:latest", "redis:7-alpine"]
228+```
229+
230+**Why This Approach:**
231+- **Explicit**: User controls exactly which directories get persistent volumes
232+- **Simple**: No magic or guessing - follows Gordon's existing volume system
233+- **Consistent**: Uses the same VOLUME directive pattern for all containers
234+- **Flexible**: User can add custom configuration, environment variables, or initialization scripts
235+
236+### 5. Service Discovery & DNS
237+
238+Services are accessible by their simple names within the network:
239+
240+```yaml
241+# From myapp container
242+DATABASE_URL: postgresql://postgres:5432/mydb
243+REDIS_URL: redis://redis:6379
244+MONGO_URL: mongodb://mongodb:27017/myapp
245+
246+# These hostnames resolve only within the network
247+```
248+
249+### 6. Documentation for Users
250+
251+**In Gordon Documentation:**
252+
253+```markdown
254+## Using Attached Services with Persistent Data
255+
256+For services that need persistent storage (databases, etc.), you must create a custom Dockerfile:
257+
258+### PostgreSQL Example:
259+```dockerfile
260+# Dockerfile.postgres
261+FROM postgres:15
262+VOLUME ["/var/lib/postgresql/data"]
263+ENV POSTGRES_DB=myapp
264+ENV POSTGRES_USER=appuser
265+ENV POSTGRES_PASSWORD=secret
266+```
267+
268+### MySQL Example:
269+```dockerfile
270+# Dockerfile.mysql
271+FROM mysql:8
272+VOLUME ["/var/lib/mysql"]
273+ENV MYSQL_DATABASE=myapp
274+ENV MYSQL_USER=appuser
275+ENV MYSQL_PASSWORD=secret
276+ENV MYSQL_ROOT_PASSWORD=rootsecret
277+```
278+
279+### Redis with Persistence Example:
280+```dockerfile
281+# Dockerfile.redis
282+FROM redis:7-alpine
283+VOLUME ["/data"]
284+CMD ["redis-server", "--appendonly", "yes"]
285+```
286+
287+Build and push these to your Gordon registry, then reference them in your config.
288+```
289+
290+### 7. Runtime Interface Updates
291+
292+**File: `pkg/runtime/interface.go`**
293+
294+```go
295+type Runtime interface {
296+ // ... existing methods ...
297+
298+ // Network management
299+ CreateNetwork(name string, options map[string]string) error
300+ RemoveNetwork(name string) error
301+ ListNetworks() ([]NetworkInfo, error)
302+ ConnectContainerToNetwork(containerName, networkName string) error
303+ DisconnectContainerFromNetwork(containerName, networkName string) error
304+}
305+
306+type CreateOptions struct {
307+ // ... existing fields ...
308+ NetworkMode string // Network to join
309+ Hostname string // Container hostname for DNS
310+ Aliases []string // Additional network aliases
311+}
312+```
313+
314+## Examples
315+
316+### Example 1: Simple App with Database
317+```toml
318+[routes]
319+"blog.company.com" = "wordpress:latest"
320+
321+[attachments]
322+"blog.company.com" = ["my-mysql:latest", "my-redis:latest"]
323+
324+# Prerequisites: User created Dockerfiles with VOLUME directives:
325+# - my-mysql:latest (FROM mysql:8 + VOLUME ["/var/lib/mysql"])
326+# - my-redis:latest (FROM redis:7-alpine + VOLUME ["/data"])
327+#
328+# Creates network: gordon-blog-company-com
329+# Containers: blog.company.com, blog.company.com-my-mysql, blog.company.com-my-redis
330+# Volumes: Auto-created based on VOLUME directives in the custom images
331+```
332+
333+### Example 2: Microservices with Shared Cache
334+```toml
335+[routes]
336+"api.company.com" = "api:latest"
337+"worker.company.com" = "worker:latest"
338+"admin.company.com" = "admin:latest"
339+
340+[network_groups]
341+"backend" = ["api.company.com", "worker.company.com"]
342+
343+[attachments]
344+# Shared services for backend group
345+"backend" = ["redis:7-alpine", "rabbitmq:3"]
346+# Individual databases
347+"api.company.com" = ["postgres:15"]
348+"admin.company.com" = ["postgres:15"]
349+
350+# Creates:
351+# - Network: gordon-backend (contains api, worker, redis, rabbitmq, api's postgres)
352+# - Network: gordon-admin-company-com (contains admin, admin's postgres)
353+```
354+
355+### Example 3: Multi-Tenant SaaS
356+```toml
357+[routes]
358+"tenant1.app.com" = "app:v2.0"
359+"tenant2.app.com" = "app:v2.0"
360+"tenant3.app.com" = "app:v2.0"
361+
362+[attachments]
363+# Each tenant gets isolated services
364+"tenant1.app.com" = ["postgres:15", "redis:7"]
365+"tenant2.app.com" = ["postgres:15", "redis:7"]
366+"tenant3.app.com" = ["postgres:15", "redis:7"]
367+
368+# Complete isolation between tenants
369+```
370+
371+## Migration Strategy
372+
373+1. **Backward Compatible**: Apps without attachments use default bridge network
374+2. **Opt-in**: Only apps with attachments get isolated networks
375+3. **Gradual Migration**:
376+ ```toml
377+ [network_isolation]
378+ enabled = false # Start disabled
379+ ```
380+
381+## Testing Plan
382+
383+### Test Cases
384+1. **Single App Isolation**: App + attached services can communicate
385+2. **Network Groups**: Multiple apps share services correctly
386+3. **Mixed Mode**: Some apps grouped, some isolated
387+4. **Service Discovery**: DNS names resolve correctly
388+5. **Environment Inheritance**: Attached services get parent's env
389+6. **Cleanup**: Networks removed when last app removed
390+
391+### Edge Cases
392+- App with no attachments (uses default network)
393+- Empty network group (ignored)
394+- Conflicting names (app and group with same name)
395+- Service image not found (graceful failure)
396+- Network already exists (reuse it)
397+
398+## Implementation Priority
399+
400+1. **Phase 1**: Basic attachments (no network groups)
401+2. **Phase 2**: Add network groups support
402+3. **Phase 3**: Advanced features (custom DNS, network policies)
403+
404+## Summary
405+
406+This design provides:
407+- **Simple by default**: Just use `[attachments]` section
408+- **Powerful when needed**: Network groups for complex scenarios
409+- **Zero breaking changes**: Existing configs work unchanged
410+- **Gordon philosophy**: Convention over configuration⏎̸
M examples/development.toml +43 -1
@@ -58,6 +58,28 @@ providers = ["pass"] # For development secrets
5858 "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin
5959 "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing
6060
61+# Network isolation configuration
62+[network_isolation]
63+enabled = true # Enable per-app network isolation
64+network_prefix = "gordon" # Default prefix for network names
65+dns_suffix = ".internal" # DNS suffix for internal services
66+
67+# Service attachments - each app gets its own isolated network with attached services
68+[attachments]
69+"app.yourdomain.com" = ["my-postgres:latest", "my-redis:latest"] # App with database and cache
70+"api.yourdomain.com" = ["my-postgres:latest"] # API with its own database
71+"admin.yourdomain.com" = ["my-mysql:latest"] # Admin with different database
72+
73+# Optional: Network groups for shared services between apps
74+# [network_groups]
75+# "backend" = ["app.yourdomain.com", "api.yourdomain.com"] # Share services between app and api
76+#
77+# [attachments]
78+# "backend" = ["my-redis:latest", "rabbitmq:latest"] # Shared cache and queue
79+# "app.yourdomain.com" = ["my-postgres:latest"] # App-specific database
80+# "api.yourdomain.com" = ["my-postgres:latest"] # API-specific database
81+# "admin.yourdomain.com" = ["my-mysql:latest"] # Admin gets its own isolated network
82+
6183 # Environment Variables Usage:
6284 #
6385 # For development, create .env files for your routes:
@@ -112,4 +134,24 @@ providers = ["pass"] # For development secrets
112134 # EXPOSE 3000
113135 # CMD ["npm", "start"]
114136 #
115-# Cloudflare handles SSL and DNS for your remote Gordon server!⏎̸
137+# Cloudflare handles SSL and DNS for your remote Gordon server!
138+#
139+# NETWORK ISOLATION FEATURE:
140+# With attachments, each app gets its own isolated network with attached services.
141+# Services are accessible by their simple names within the network:
142+# - postgresql://postgres:5432/mydb
143+# - redis://redis:6379
144+# - mysql://mysql:3306/mydb
145+#
146+# To use persistent storage with attachments, create custom Dockerfiles:
147+#
148+# Example my-postgres Dockerfile:
149+# FROM postgres:15
150+# VOLUME ["/var/lib/postgresql/data"]
151+# ENV POSTGRES_DB=myapp
152+# ENV POSTGRES_USER=appuser
153+# ENV POSTGRES_PASSWORD=secret
154+#
155+# Build and push to registry:
156+# docker build -f Dockerfile.postgres -t registry.yourdomain.com/my-postgres:latest .
157+# docker push registry.yourdomain.com/my-postgres:latest⏎̸
@@ -58,6 +58,28 @@ providers = ["pass"] # For development secrets
58 "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin58 "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin
59 "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing59 "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing
60 60
61+# Network isolation configuration
62+[network_isolation]
63+enabled = true # Enable per-app network isolation
64+network_prefix = "gordon" # Default prefix for network names
65+dns_suffix = ".internal" # DNS suffix for internal services
66+
67+# Service attachments - each app gets its own isolated network with attached services
68+[attachments]
69+"app.yourdomain.com" = ["my-postgres:latest", "my-redis:latest"] # App with database and cache
70+"api.yourdomain.com" = ["my-postgres:latest"] # API with its own database
71+"admin.yourdomain.com" = ["my-mysql:latest"] # Admin with different database
72+
73+# Optional: Network groups for shared services between apps
74+# [network_groups]
75+# "backend" = ["app.yourdomain.com", "api.yourdomain.com"] # Share services between app and api
76+#
77+# [attachments]
78+# "backend" = ["my-redis:latest", "rabbitmq:latest"] # Shared cache and queue
79+# "app.yourdomain.com" = ["my-postgres:latest"] # App-specific database
80+# "api.yourdomain.com" = ["my-postgres:latest"] # API-specific database
81+# "admin.yourdomain.com" = ["my-mysql:latest"] # Admin gets its own isolated network
82+
61 # Environment Variables Usage:83 # Environment Variables Usage:
62 # 84 #
63 # For development, create .env files for your routes:85 # For development, create .env files for your routes:
@@ -112,4 +134,24 @@ providers = ["pass"] # For development secrets
112 # EXPOSE 3000134 # EXPOSE 3000
113 # CMD ["npm", "start"]135 # CMD ["npm", "start"]
114 #136 #
115-# Cloudflare handles SSL and DNS for your remote Gordon server!⏎̸137+# Cloudflare handles SSL and DNS for your remote Gordon server!
138+#
139+# NETWORK ISOLATION FEATURE:
140+# With attachments, each app gets its own isolated network with attached services.
141+# Services are accessible by their simple names within the network:
142+# - postgresql://postgres:5432/mydb
143+# - redis://redis:6379
144+# - mysql://mysql:3306/mydb
145+#
146+# To use persistent storage with attachments, create custom Dockerfiles:
147+#
148+# Example my-postgres Dockerfile:
149+# FROM postgres:15
150+# VOLUME ["/var/lib/postgresql/data"]
151+# ENV POSTGRES_DB=myapp
152+# ENV POSTGRES_USER=appuser
153+# ENV POSTGRES_PASSWORD=secret
154+#
155+# Build and push to registry:
156+# docker build -f Dockerfile.postgres -t registry.yourdomain.com/my-postgres:latest .
157+# docker push registry.yourdomain.com/my-postgres:latest⏎̸
M examples/production.toml +43 -1
@@ -49,6 +49,28 @@ providers = ["pass", "sops"]
4949 "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard
5050 "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection
5151
52+# Network isolation for production security
53+[network_isolation]
54+enabled = true # Enable per-app network isolation
55+network_prefix = "prod" # Production network prefix
56+dns_suffix = ".internal" # Internal DNS suffix
57+
58+# Network groups for microservices that need to communicate
59+[network_groups]
60+"backend" = ["app.company.com", "api.company.com"] # App and API share services
61+"monitoring" = ["grafana.company.com", "prometheus.company.com"] # Monitoring stack
62+
63+# Service attachments with isolated networks
64+[attachments]
65+# Shared backend services
66+"backend" = ["company-redis:latest", "company-rabbitmq:latest"]
67+# Individual databases for isolation
68+"app.company.com" = ["company-postgres:latest"]
69+"api.company.com" = ["company-postgres:latest"]
70+"admin.company.com" = ["company-mysql:latest"]
71+# Monitoring services get their own network
72+"monitoring" = ["company-prometheus-data:latest"]
73+
5274 # Environment Variables Usage:
5375 #
5476 # For each route, create a .env file named after the domain:
@@ -72,4 +94,24 @@ providers = ["pass", "sops"]
7294 # 4. Create/update env file: /var/lib/gordon/env/app_company_com.env
7395 # 5. Restart Gordon to reload config
7496 # 6. Push to deploy: docker push registry.company.com/company-app:v2.1.1
75-# 7. Gordon automatically deploys to app.company.com with environment variables!⏎̸
97+# 7. Gordon automatically deploys to app.company.com with environment variables!
98+#
99+# NETWORK ISOLATION IN PRODUCTION:
100+# - Each app gets its own isolated network (prod-app-company-com, prod-admin-company-com)
101+# - Network groups allow controlled sharing (prod-backend, prod-monitoring)
102+# - Services communicate via simple DNS names (redis:6379, postgres:5432)
103+# - Complete isolation between tenant/service boundaries
104+#
105+# Example service Dockerfiles for persistent storage:
106+#
107+# company-postgres/Dockerfile:
108+# FROM postgres:15
109+# VOLUME ["/var/lib/postgresql/data"]
110+# ENV POSTGRES_DB=production
111+# ENV POSTGRES_USER=app
112+# ENV POSTGRES_PASSWORD=supersecret
113+#
114+# company-redis/Dockerfile:
115+# FROM redis:7-alpine
116+# VOLUME ["/data"]
117+# CMD ["redis-server", "--appendonly", "yes"]⏎̸
@@ -49,6 +49,28 @@ providers = ["pass", "sops"]
49 "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard49 "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard
50 "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection50 "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection
51 51
52+# Network isolation for production security
53+[network_isolation]
54+enabled = true # Enable per-app network isolation
55+network_prefix = "prod" # Production network prefix
56+dns_suffix = ".internal" # Internal DNS suffix
57+
58+# Network groups for microservices that need to communicate
59+[network_groups]
60+"backend" = ["app.company.com", "api.company.com"] # App and API share services
61+"monitoring" = ["grafana.company.com", "prometheus.company.com"] # Monitoring stack
62+
63+# Service attachments with isolated networks
64+[attachments]
65+# Shared backend services
66+"backend" = ["company-redis:latest", "company-rabbitmq:latest"]
67+# Individual databases for isolation
68+"app.company.com" = ["company-postgres:latest"]
69+"api.company.com" = ["company-postgres:latest"]
70+"admin.company.com" = ["company-mysql:latest"]
71+# Monitoring services get their own network
72+"monitoring" = ["company-prometheus-data:latest"]
73+
52 # Environment Variables Usage:74 # Environment Variables Usage:
53 # 75 #
54 # For each route, create a .env file named after the domain:76 # For each route, create a .env file named after the domain:
@@ -72,4 +94,24 @@ providers = ["pass", "sops"]
72 # 4. Create/update env file: /var/lib/gordon/env/app_company_com.env94 # 4. Create/update env file: /var/lib/gordon/env/app_company_com.env
73 # 5. Restart Gordon to reload config95 # 5. Restart Gordon to reload config
74 # 6. Push to deploy: docker push registry.company.com/company-app:v2.1.196 # 6. Push to deploy: docker push registry.company.com/company-app:v2.1.1
75-# 7. Gordon automatically deploys to app.company.com with environment variables!⏎̸97+# 7. Gordon automatically deploys to app.company.com with environment variables!
98+#
99+# NETWORK ISOLATION IN PRODUCTION:
100+# - Each app gets its own isolated network (prod-app-company-com, prod-admin-company-com)
101+# - Network groups allow controlled sharing (prod-backend, prod-monitoring)
102+# - Services communicate via simple DNS names (redis:6379, postgres:5432)
103+# - Complete isolation between tenant/service boundaries
104+#
105+# Example service Dockerfiles for persistent storage:
106+#
107+# company-postgres/Dockerfile:
108+# FROM postgres:15
109+# VOLUME ["/var/lib/postgresql/data"]
110+# ENV POSTGRES_DB=production
111+# ENV POSTGRES_USER=app
112+# ENV POSTGRES_PASSWORD=supersecret
113+#
114+# company-redis/Dockerfile:
115+# FROM redis:7-alpine
116+# VOLUME ["/data"]
117+# CMD ["redis-server", "--appendonly", "yes"]⏎̸
M internal/config/config.go +64 -7
@@ -12,13 +12,16 @@ import (
1212 )
1313
1414 type Config struct {
15- Server ServerConfig `mapstructure:"server"`
16- RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17- Routes map[string]string `mapstructure:"routes"`
18- AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19- Env EnvConfig `mapstructure:"env"`
20- Logging LoggingConfig `mapstructure:"logging"`
21- Volumes VolumeConfig `mapstructure:"volumes"`
15+ Server ServerConfig `mapstructure:"server"`
16+ RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17+ Routes map[string]string `mapstructure:"routes"`
18+ AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19+ Env EnvConfig `mapstructure:"env"`
20+ Logging LoggingConfig `mapstructure:"logging"`
21+ Volumes VolumeConfig `mapstructure:"volumes"`
22+ Attachments map[string][]string `mapstructure:"attachments"`
23+ NetworkGroups map[string][]string `mapstructure:"network_groups"`
24+ NetworkIsolation NetworkIsolationConfig `mapstructure:"network_isolation"`
2225 }
2326
2427 type ServerConfig struct {
@@ -65,6 +68,12 @@ type VolumeConfig struct {
6568 Preserve bool `mapstructure:"preserve"`
6669 }
6770
71+type NetworkIsolationConfig struct {
72+ Enabled bool `mapstructure:"enabled"`
73+ NetworkPrefix string `mapstructure:"network_prefix"`
74+ DNSSuffix string `mapstructure:"dns_suffix"`
75+}
76+
6877 type SecretProvider struct {
6978 Type string `mapstructure:"type"`
7079 Config map[string]string `mapstructure:"config"`
@@ -110,8 +119,15 @@ func Load() (*Config, error) {
110119 viper.SetDefault("volumes.prefix", "gordon")
111120 viper.SetDefault("volumes.preserve", true)
112121
122+ // Network isolation defaults
123+ viper.SetDefault("network_isolation.enabled", true)
124+ viper.SetDefault("network_isolation.network_prefix", "gordon")
125+ viper.SetDefault("network_isolation.dns_suffix", ".internal")
126+
113127 // Handle the routes manually since Viper struggles with domain names
114128 cfg.Routes = make(map[string]string)
129+ cfg.Attachments = make(map[string][]string)
130+ cfg.NetworkGroups = make(map[string][]string)
115131
116132 // Get server config first
117133 if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
@@ -167,6 +183,11 @@ func Load() (*Config, error) {
167183 return nil, fmt.Errorf("unable to decode volumes config: %v", err)
168184 }
169185
186+ // Get network isolation config
187+ if err := viper.UnmarshalKey("network_isolation", &cfg.NetworkIsolation); err != nil {
188+ return nil, fmt.Errorf("unable to decode network isolation config: %v", err)
189+ }
190+
170191 // Get routes manually from the raw config
171192 routesRaw := viper.Get("routes")
172193 if routesRaw != nil {
@@ -179,6 +200,42 @@ func Load() (*Config, error) {
179200 }
180201 }
181202
203+ // Get attachments manually from the raw config
204+ attachmentsRaw := viper.Get("attachments")
205+ if attachmentsRaw != nil {
206+ if attachments, ok := attachmentsRaw.(map[string]interface{}); ok {
207+ for identifier, services := range attachments {
208+ if servicesList, ok := services.([]interface{}); ok {
209+ var serviceStrings []string
210+ for _, service := range servicesList {
211+ if serviceStr, ok := service.(string); ok {
212+ serviceStrings = append(serviceStrings, serviceStr)
213+ }
214+ }
215+ cfg.Attachments[identifier] = serviceStrings
216+ }
217+ }
218+ }
219+ }
220+
221+ // Get network_groups manually from the raw config
222+ networkGroupsRaw := viper.Get("network_groups")
223+ if networkGroupsRaw != nil {
224+ if networkGroups, ok := networkGroupsRaw.(map[string]interface{}); ok {
225+ for groupName, domains := range networkGroups {
226+ if domainsList, ok := domains.([]interface{}); ok {
227+ var domainStrings []string
228+ for _, domain := range domainsList {
229+ if domainStr, ok := domain.(string); ok {
230+ domainStrings = append(domainStrings, domainStr)
231+ }
232+ }
233+ cfg.NetworkGroups[groupName] = domainStrings
234+ }
235+ }
236+ }
237+ }
238+
182239 // Note: ssl_email is optional and only needed for future Let's Encrypt integration
183240 // Currently using Cloudflare for HTTPS
184241
@@ -12,13 +12,16 @@ import (
12 )12 )
13 13
14 type Config struct {14 type Config struct {
15- Server ServerConfig `mapstructure:"server"`15+ Server ServerConfig `mapstructure:"server"`
16- RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`16+ RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17- Routes map[string]string `mapstructure:"routes"`17+ Routes map[string]string `mapstructure:"routes"`
18- AutoRoute AutoRouteConfig `mapstructure:"auto_route"`18+ AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19- Env EnvConfig `mapstructure:"env"`19+ Env EnvConfig `mapstructure:"env"`
20- Logging LoggingConfig `mapstructure:"logging"`20+ Logging LoggingConfig `mapstructure:"logging"`
21- Volumes VolumeConfig `mapstructure:"volumes"`21+ Volumes VolumeConfig `mapstructure:"volumes"`
22+ Attachments map[string][]string `mapstructure:"attachments"`
23+ NetworkGroups map[string][]string `mapstructure:"network_groups"`
24+ NetworkIsolation NetworkIsolationConfig `mapstructure:"network_isolation"`
22 }25 }
23 26
24 type ServerConfig struct {27 type ServerConfig struct {
@@ -65,6 +68,12 @@ type VolumeConfig struct {
65 Preserve bool `mapstructure:"preserve"`68 Preserve bool `mapstructure:"preserve"`
66 }69 }
67 70
71+type NetworkIsolationConfig struct {
72+ Enabled bool `mapstructure:"enabled"`
73+ NetworkPrefix string `mapstructure:"network_prefix"`
74+ DNSSuffix string `mapstructure:"dns_suffix"`
75+}
76+
68 type SecretProvider struct {77 type SecretProvider struct {
69 Type string `mapstructure:"type"`78 Type string `mapstructure:"type"`
70 Config map[string]string `mapstructure:"config"`79 Config map[string]string `mapstructure:"config"`
@@ -110,8 +119,15 @@ func Load() (*Config, error) {
110 viper.SetDefault("volumes.prefix", "gordon")119 viper.SetDefault("volumes.prefix", "gordon")
111 viper.SetDefault("volumes.preserve", true)120 viper.SetDefault("volumes.preserve", true)
112 121
122+ // Network isolation defaults
123+ viper.SetDefault("network_isolation.enabled", true)
124+ viper.SetDefault("network_isolation.network_prefix", "gordon")
125+ viper.SetDefault("network_isolation.dns_suffix", ".internal")
126+
113 // Handle the routes manually since Viper struggles with domain names127 // Handle the routes manually since Viper struggles with domain names
114 cfg.Routes = make(map[string]string)128 cfg.Routes = make(map[string]string)
129+ cfg.Attachments = make(map[string][]string)
130+ cfg.NetworkGroups = make(map[string][]string)
115 131
116 // Get server config first132 // Get server config first
117 if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {133 if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
@@ -167,6 +183,11 @@ func Load() (*Config, error) {
167 return nil, fmt.Errorf("unable to decode volumes config: %v", err)183 return nil, fmt.Errorf("unable to decode volumes config: %v", err)
168 }184 }
169 185
186+ // Get network isolation config
187+ if err := viper.UnmarshalKey("network_isolation", &cfg.NetworkIsolation); err != nil {
188+ return nil, fmt.Errorf("unable to decode network isolation config: %v", err)
189+ }
190+
170 // Get routes manually from the raw config191 // Get routes manually from the raw config
171 routesRaw := viper.Get("routes")192 routesRaw := viper.Get("routes")
172 if routesRaw != nil {193 if routesRaw != nil {
@@ -179,6 +200,42 @@ func Load() (*Config, error) {
179 }200 }
180 }201 }
181 202
203+ // Get attachments manually from the raw config
204+ attachmentsRaw := viper.Get("attachments")
205+ if attachmentsRaw != nil {
206+ if attachments, ok := attachmentsRaw.(map[string]interface{}); ok {
207+ for identifier, services := range attachments {
208+ if servicesList, ok := services.([]interface{}); ok {
209+ var serviceStrings []string
210+ for _, service := range servicesList {
211+ if serviceStr, ok := service.(string); ok {
212+ serviceStrings = append(serviceStrings, serviceStr)
213+ }
214+ }
215+ cfg.Attachments[identifier] = serviceStrings
216+ }
217+ }
218+ }
219+ }
220+
221+ // Get network_groups manually from the raw config
222+ networkGroupsRaw := viper.Get("network_groups")
223+ if networkGroupsRaw != nil {
224+ if networkGroups, ok := networkGroupsRaw.(map[string]interface{}); ok {
225+ for groupName, domains := range networkGroups {
226+ if domainsList, ok := domains.([]interface{}); ok {
227+ var domainStrings []string
228+ for _, domain := range domainsList {
229+ if domainStr, ok := domain.(string); ok {
230+ domainStrings = append(domainStrings, domainStr)
231+ }
232+ }
233+ cfg.NetworkGroups[groupName] = domainStrings
234+ }
235+ }
236+ }
237+ }
238+
182 // Note: ssl_email is optional and only needed for future Let's Encrypt integration239 // Note: ssl_email is optional and only needed for future Let's Encrypt integration
183 // Currently using Cloudflare for HTTPS240 // Currently using Cloudflare for HTTPS
184 241
M internal/container/docker.go +124 -1
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/docker/docker/api/types/container"
1313 "github.com/docker/docker/api/types/image"
14+ "github.com/docker/docker/api/types/network"
1415 "github.com/docker/docker/api/types/registry"
1516 "github.com/docker/docker/api/types/volume"
1617 "github.com/docker/docker/client"
@@ -81,10 +82,26 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con
8182 PortBindings: portBindings,
8283 AutoRemove: config.AutoRemove,
8384 Binds: binds,
85+ NetworkMode: container.NetworkMode(config.NetworkMode),
86+ }
87+
88+ // Create network configuration for container
89+ var networkConfig *network.NetworkingConfig
90+ if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
91+ networkConfig = &network.NetworkingConfig{
92+ EndpointsConfig: map[string]*network.EndpointSettings{
93+ config.NetworkMode: {
94+ Aliases: config.Aliases,
95+ },
96+ },
97+ }
98+ if config.Hostname != "" {
99+ networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
100+ }
84101 }
85102
86103 // Create the container
87- resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name)
104+ resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
88105 if err != nil {
89106 return nil, fmt.Errorf("failed to create container: %w", err)
90107 }
@@ -550,3 +567,109 @@ func (d *DockerRuntime) InspectImageEnv(ctx context.Context, imageRef string) ([
550567
551568 return envVars, nil
552569 }
570+
571+// CreateNetwork creates a new Docker network
572+func (d *DockerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
573+ // Set default driver to bridge if not specified
574+ driver := "bridge"
575+ if driverOption, exists := options["driver"]; exists {
576+ driver = driverOption
577+ }
578+
579+ createOptions := network.CreateOptions{
580+ Driver: driver,
581+ Labels: map[string]string{
582+ "gordon.managed": "true",
583+ },
584+ }
585+
586+ // Add any additional options to labels
587+ for key, value := range options {
588+ if key != "driver" {
589+ createOptions.Labels["gordon."+key] = value
590+ }
591+ }
592+
593+ _, err := d.client.NetworkCreate(ctx, name, createOptions)
594+ if err != nil {
595+ return fmt.Errorf("failed to create network %s: %w", name, err)
596+ }
597+
598+ log.Info().Str("network", name).Str("driver", driver).Msg("Network created")
599+ return nil
600+}
601+
602+// RemoveNetwork removes a Docker network
603+func (d *DockerRuntime) RemoveNetwork(ctx context.Context, name string) error {
604+ err := d.client.NetworkRemove(ctx, name)
605+ if err != nil {
606+ if errdefs.IsNotFound(err) {
607+ log.Debug().Str("network", name).Msg("Network not found, already removed")
608+ return nil
609+ }
610+ return fmt.Errorf("failed to remove network %s: %w", name, err)
611+ }
612+
613+ log.Info().Str("network", name).Msg("Network removed")
614+ return nil
615+}
616+
617+// ListNetworks lists all Docker networks
618+func (d *DockerRuntime) ListNetworks(ctx context.Context) ([]*runtime.NetworkInfo, error) {
619+ networks, err := d.client.NetworkList(ctx, network.ListOptions{})
620+ if err != nil {
621+ return nil, fmt.Errorf("failed to list networks: %w", err)
622+ }
623+
624+ var result []*runtime.NetworkInfo
625+ for _, net := range networks {
626+ var containers []string
627+ for containerID := range net.Containers {
628+ containers = append(containers, containerID)
629+ }
630+
631+ result = append(result, &runtime.NetworkInfo{
632+ ID: net.ID,
633+ Name: net.Name,
634+ Driver: net.Driver,
635+ Containers: containers,
636+ Labels: net.Labels,
637+ })
638+ }
639+
640+ return result, nil
641+}
642+
643+// NetworkExists checks if a Docker network exists
644+func (d *DockerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
645+ _, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{})
646+ if err != nil {
647+ if errdefs.IsNotFound(err) {
648+ return false, nil
649+ }
650+ return false, fmt.Errorf("failed to inspect network %s: %w", name, err)
651+ }
652+ return true, nil
653+}
654+
655+// ConnectContainerToNetwork connects a container to a network
656+func (d *DockerRuntime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
657+ err := d.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
658+ if err != nil {
659+ return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
660+ }
661+
662+ log.Info().Str("container", containerName).Str("network", networkName).Msg("Container connected to network")
663+ return nil
664+}
665+
666+// DisconnectContainerFromNetwork disconnects a container from a network
667+func (d *DockerRuntime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
668+ err := d.client.NetworkDisconnect(ctx, networkName, containerName, false)
669+ if err != nil {
670+ return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
671+ }
672+
673+ log.Info().Str("container", containerName).Str("network", networkName).Msg("Container disconnected from network")
674+ return nil
675+}
@@ -11,6 +11,7 @@ import (
11 11
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/network"
14 "github.com/docker/docker/api/types/registry"15 "github.com/docker/docker/api/types/registry"
15 "github.com/docker/docker/api/types/volume"16 "github.com/docker/docker/api/types/volume"
16 "github.com/docker/docker/client"17 "github.com/docker/docker/client"
@@ -81,10 +82,26 @@ func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.Con
81 PortBindings: portBindings,82 PortBindings: portBindings,
82 AutoRemove: config.AutoRemove,83 AutoRemove: config.AutoRemove,
83 Binds: binds,84 Binds: binds,
85+ NetworkMode: container.NetworkMode(config.NetworkMode),
86+ }
87+
88+ // Create network configuration for container
89+ var networkConfig *network.NetworkingConfig
90+ if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
91+ networkConfig = &network.NetworkingConfig{
92+ EndpointsConfig: map[string]*network.EndpointSettings{
93+ config.NetworkMode: {
94+ Aliases: config.Aliases,
95+ },
96+ },
97+ }
98+ if config.Hostname != "" {
99+ networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
100+ }
84 }101 }
85 102
86 // Create the container103 // Create the container
87- resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name)104+ resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
88 if err != nil {105 if err != nil {
89 return nil, fmt.Errorf("failed to create container: %w", err)106 return nil, fmt.Errorf("failed to create container: %w", err)
90 }107 }
@@ -550,3 +567,109 @@ func (d *DockerRuntime) InspectImageEnv(ctx context.Context, imageRef string) ([
550 567
551 return envVars, nil568 return envVars, nil
552 }569 }
570+
571+// CreateNetwork creates a new Docker network
572+func (d *DockerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
573+ // Set default driver to bridge if not specified
574+ driver := "bridge"
575+ if driverOption, exists := options["driver"]; exists {
576+ driver = driverOption
577+ }
578+
579+ createOptions := network.CreateOptions{
580+ Driver: driver,
581+ Labels: map[string]string{
582+ "gordon.managed": "true",
583+ },
584+ }
585+
586+ // Add any additional options to labels
587+ for key, value := range options {
588+ if key != "driver" {
589+ createOptions.Labels["gordon."+key] = value
590+ }
591+ }
592+
593+ _, err := d.client.NetworkCreate(ctx, name, createOptions)
594+ if err != nil {
595+ return fmt.Errorf("failed to create network %s: %w", name, err)
596+ }
597+
598+ log.Info().Str("network", name).Str("driver", driver).Msg("Network created")
599+ return nil
600+}
601+
602+// RemoveNetwork removes a Docker network
603+func (d *DockerRuntime) RemoveNetwork(ctx context.Context, name string) error {
604+ err := d.client.NetworkRemove(ctx, name)
605+ if err != nil {
606+ if errdefs.IsNotFound(err) {
607+ log.Debug().Str("network", name).Msg("Network not found, already removed")
608+ return nil
609+ }
610+ return fmt.Errorf("failed to remove network %s: %w", name, err)
611+ }
612+
613+ log.Info().Str("network", name).Msg("Network removed")
614+ return nil
615+}
616+
617+// ListNetworks lists all Docker networks
618+func (d *DockerRuntime) ListNetworks(ctx context.Context) ([]*runtime.NetworkInfo, error) {
619+ networks, err := d.client.NetworkList(ctx, network.ListOptions{})
620+ if err != nil {
621+ return nil, fmt.Errorf("failed to list networks: %w", err)
622+ }
623+
624+ var result []*runtime.NetworkInfo
625+ for _, net := range networks {
626+ var containers []string
627+ for containerID := range net.Containers {
628+ containers = append(containers, containerID)
629+ }
630+
631+ result = append(result, &runtime.NetworkInfo{
632+ ID: net.ID,
633+ Name: net.Name,
634+ Driver: net.Driver,
635+ Containers: containers,
636+ Labels: net.Labels,
637+ })
638+ }
639+
640+ return result, nil
641+}
642+
643+// NetworkExists checks if a Docker network exists
644+func (d *DockerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
645+ _, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{})
646+ if err != nil {
647+ if errdefs.IsNotFound(err) {
648+ return false, nil
649+ }
650+ return false, fmt.Errorf("failed to inspect network %s: %w", name, err)
651+ }
652+ return true, nil
653+}
654+
655+// ConnectContainerToNetwork connects a container to a network
656+func (d *DockerRuntime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
657+ err := d.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
658+ if err != nil {
659+ return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
660+ }
661+
662+ log.Info().Str("container", containerName).Str("network", networkName).Msg("Container connected to network")
663+ return nil
664+}
665+
666+// DisconnectContainerFromNetwork disconnects a container from a network
667+func (d *DockerRuntime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
668+ err := d.client.NetworkDisconnect(ctx, networkName, containerName, false)
669+ if err != nil {
670+ return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
671+ }
672+
673+ log.Info().Str("container", containerName).Str("network", networkName).Msg("Container disconnected from network")
674+ return nil
675+}
M internal/container/manager.go +285 -7
@@ -269,13 +269,23 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
269269 }
270270 }
271271
272+ // Determine which network to use for this app
273+ networkName := m.GetNetworkForApp(route.Domain)
274+
275+ // Create network if it doesn't exist
276+ if err := m.CreateNetworkIfNeeded(ctx, networkName); err != nil {
277+ return nil, fmt.Errorf("failed to create network for %s: %w", route.Domain, err)
278+ }
279+
272280 // Create container configuration
273281 containerConfig := &runtime.ContainerConfig{
274- Image: imageRef,
275- Name: fmt.Sprintf("gordon-%s", route.Domain),
276- Ports: exposedPorts,
277- Env: envVars,
278- Volumes: volumes,
282+ Image: imageRef,
283+ Name: fmt.Sprintf("gordon-%s", route.Domain),
284+ Ports: exposedPorts,
285+ Env: envVars,
286+ Volumes: volumes,
287+ NetworkMode: networkName,
288+ Hostname: route.Domain,
279289 Labels: map[string]string{
280290 "gordon.domain": route.Domain,
281291 "gordon.image": route.Image,
@@ -325,8 +335,37 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
325335 Str("image", route.Image).
326336 Str("container", container.ID).
327337 Ints("ports", container.Ports).
338+ Str("network", networkName).
328339 Msg("Container deployed successfully")
329340
341+ // Deploy attachments for this specific app
342+ if attachments, ok := m.config.Attachments[route.Domain]; ok {
343+ for _, serviceImage := range attachments {
344+ if err := m.DeployAttachedService(ctx, route.Domain, serviceImage); err != nil {
345+ log.Error().Err(err).Str("service", serviceImage).Str("domain", route.Domain).Msg("Failed to deploy attachment")
346+ // Don't fail the main deployment if attachment fails
347+ }
348+ }
349+ }
350+
351+ // Check if app is part of a network group with attachments
352+ for groupName, domains := range m.config.NetworkGroups {
353+ for _, d := range domains {
354+ if d == route.Domain {
355+ // Deploy group attachments if not already deployed
356+ if attachments, ok := m.config.Attachments[groupName]; ok {
357+ for _, serviceImage := range attachments {
358+ if err := m.DeployAttachedService(ctx, groupName, serviceImage); err != nil {
359+ log.Error().Err(err).Str("service", serviceImage).Str("group", groupName).Msg("Failed to deploy group attachment")
360+ // Don't fail the main deployment if attachment fails
361+ }
362+ }
363+ }
364+ break
365+ }
366+ }
367+ }
368+
330369 return container, nil
331370 }
332371
@@ -442,16 +481,26 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force
442481 }
443482 }
444483
445- // Remove from our tracking map
484+ // Remove from our tracking map and cleanup network if needed
446485 m.mu.Lock()
447- defer m.mu.Unlock()
486+ var removedDomain string
448487 for domain, container := range m.containers {
449488 if container.ID == containerID {
450489 delete(m.containers, domain)
490+ removedDomain = domain
451491 log.Info().Str("domain", domain).Str("container", containerID).Msg("Container removed")
452492 break
453493 }
454494 }
495+ m.mu.Unlock()
496+
497+ // If we removed an app container, check if we need to cleanup its network
498+ if removedDomain != "" && m.config.NetworkIsolation.Enabled {
499+ networkName := m.GetNetworkForApp(removedDomain)
500+ if err := m.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
501+ log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup network after container removal")
502+ }
503+ }
455504
456505 return nil
457506 }
@@ -822,3 +871,232 @@ func (m *Manager) cleanupVolumesForDomain(ctx context.Context, domain string) er
822871
823872 return nil
824873 }
874+
875+// cleanupNetworkIfEmpty removes a network if it has no containers
876+func (m *Manager) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
877+ if networkName == "bridge" || networkName == "default" {
878+ return nil // Don't try to remove default networks
879+ }
880+
881+ networks, err := m.runtime.ListNetworks(ctx)
882+ if err != nil {
883+ return fmt.Errorf("failed to list networks: %w", err)
884+ }
885+
886+ for _, network := range networks {
887+ if network.Name == networkName {
888+ // Check if network has any containers
889+ if len(network.Containers) == 0 {
890+ // Network is empty, remove it
891+ if err := m.runtime.RemoveNetwork(ctx, networkName); err != nil {
892+ log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup empty network")
893+ return err
894+ }
895+ log.Info().Str("network", networkName).Msg("Cleaned up empty network")
896+ }
897+ break
898+ }
899+ }
900+
901+ return nil
902+}
903+
904+// DeployAttachedService deploys a service attached to an app or network group
905+func (m *Manager) DeployAttachedService(ctx context.Context, identifier, serviceImage string) error {
906+ networkName := m.generateNetworkName(identifier)
907+
908+ // Extract service name from image for container naming
909+ serviceName := strings.Split(serviceImage, ":")[0]
910+ if strings.Contains(serviceName, "/") {
911+ parts := strings.Split(serviceName, "/")
912+ serviceName = parts[len(parts)-1]
913+ }
914+
915+ // For network groups, we need a different container naming scheme
916+ apps := m.GetAppsForNetwork(identifier)
917+ var containerName string
918+
919+ if len(apps) > 1 {
920+ // Shared service for network group
921+ containerName = fmt.Sprintf("%s-shared-%s", m.config.NetworkIsolation.NetworkPrefix, serviceName)
922+ } else {
923+ // App-specific service
924+ containerName = fmt.Sprintf("%s-%s", strings.ReplaceAll(identifier, ".", "-"), serviceName)
925+ }
926+
927+ // Check if service is already running
928+ containers, err := m.runtime.ListContainers(ctx, false)
929+ if err != nil {
930+ return fmt.Errorf("failed to list containers: %w", err)
931+ }
932+
933+ for _, container := range containers {
934+ if container.Name == containerName {
935+ log.Info().Str("service", containerName).Msg("Service already running")
936+ return nil
937+ }
938+ }
939+
940+ // Get exposed ports from the service image
941+ exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, serviceImage)
942+ if err != nil {
943+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to get exposed ports from service image, using empty ports")
944+ exposedPorts = []int{} // Services don't need external ports by default
945+ }
946+
947+ // Load environment for service (inherit from first app in group or specific app)
948+ var envVars []string
949+ if len(apps) > 0 {
950+ userEnvVars, err := m.envLoader.LoadEnvForRoute(apps[0])
951+ if err != nil {
952+ log.Warn().Err(err).Str("domain", apps[0]).Msg("Failed to load environment for service, using empty env")
953+ userEnvVars = []string{}
954+ }
955+
956+ // Get environment variables from image ENV directives
957+ dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, serviceImage)
958+ if err != nil {
959+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image environment variables")
960+ dockerfileEnvVars = []string{}
961+ }
962+
963+ // Merge environments
964+ envVars = mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars)
965+ }
966+
967+ // Handle volumes for the service
968+ volumes := make(map[string]string)
969+ if m.config.Volumes.AutoCreate {
970+ volumePaths, err := m.runtime.InspectImageVolumes(ctx, serviceImage)
971+ if err != nil {
972+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image volumes")
973+ } else if len(volumePaths) > 0 {
974+ for _, volumePath := range volumePaths {
975+ volumeName := fmt.Sprintf("%s-%s-%s",
976+ m.config.Volumes.Prefix,
977+ strings.ReplaceAll(identifier, ".", "-"),
978+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
979+
980+ // Create volume if it doesn't exist
981+ exists, err := m.runtime.VolumeExists(ctx, volumeName)
982+ if err != nil {
983+ log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check service volume")
984+ continue
985+ }
986+
987+ if !exists {
988+ if err := m.runtime.CreateVolume(ctx, volumeName); err != nil {
989+ log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create service volume")
990+ continue
991+ }
992+ log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for service")
993+ }
994+
995+ volumes[volumePath] = volumeName
996+ }
997+ }
998+ }
999+
1000+ // Create service container configuration
1001+ serviceConfig := &runtime.ContainerConfig{
1002+ Image: serviceImage,
1003+ Name: containerName,
1004+ Ports: exposedPorts,
1005+ Env: envVars,
1006+ Volumes: volumes,
1007+ NetworkMode: networkName,
1008+ Hostname: serviceName,
1009+ Labels: map[string]string{
1010+ "gordon.managed": "true",
1011+ "gordon.service": "true",
1012+ "gordon.attached": identifier,
1013+ "gordon.image": serviceImage,
1014+ },
1015+ AutoRemove: false,
1016+ }
1017+
1018+ // Create and start the service container
1019+ container, err := m.runtime.CreateContainer(ctx, serviceConfig)
1020+ if err != nil {
1021+ return fmt.Errorf("failed to create service container %s: %w", containerName, err)
1022+ }
1023+
1024+ if err := m.runtime.StartContainer(ctx, container.ID); err != nil {
1025+ m.runtime.RemoveContainer(ctx, container.ID, true)
1026+ return fmt.Errorf("failed to start service container %s: %w", containerName, err)
1027+ }
1028+
1029+ log.Info().
1030+ Str("service", containerName).
1031+ Str("image", serviceImage).
1032+ Str("network", networkName).
1033+ Str("attached_to", identifier).
1034+ Msg("Service deployed successfully")
1035+
1036+ return nil
1037+}
1038+
1039+// GetNetworkForApp determines which network an app should use
1040+func (m *Manager) GetNetworkForApp(domain string) string {
1041+ if !m.config.NetworkIsolation.Enabled {
1042+ return "bridge" // Use default Docker bridge network
1043+ }
1044+
1045+ // Check if domain is part of a network group
1046+ for groupName, domains := range m.config.NetworkGroups {
1047+ for _, d := range domains {
1048+ if d == domain {
1049+ return m.generateNetworkName(groupName)
1050+ }
1051+ }
1052+ }
1053+
1054+ // Default: app gets its own network
1055+ return m.generateNetworkName(domain)
1056+}
1057+
1058+// generateNetworkName creates a network name from an identifier
1059+func (m *Manager) generateNetworkName(identifier string) string {
1060+ // "myapp.example.com" -> "gordon-myapp-example-com"
1061+ // "backend" -> "gordon-backend"
1062+ return fmt.Sprintf("%s-%s",
1063+ m.config.NetworkIsolation.NetworkPrefix,
1064+ strings.ReplaceAll(identifier, ".", "-"))
1065+}
1066+
1067+// GetAppsForNetwork returns all apps that should have access to a network
1068+func (m *Manager) GetAppsForNetwork(identifier string) []string {
1069+ // Check if it's a network group
1070+ if apps, ok := m.config.NetworkGroups[identifier]; ok {
1071+ return apps
1072+ }
1073+
1074+ // Single app network
1075+ return []string{identifier}
1076+}
1077+
1078+// CreateNetworkIfNeeded creates a network if it doesn't exist
1079+func (m *Manager) CreateNetworkIfNeeded(ctx context.Context, networkName string) error {
1080+ if networkName == "bridge" || networkName == "default" {
1081+ return nil // Don't try to create default networks
1082+ }
1083+
1084+ exists, err := m.runtime.NetworkExists(ctx, networkName)
1085+ if err != nil {
1086+ return fmt.Errorf("failed to check if network exists: %w", err)
1087+ }
1088+
1089+ if !exists {
1090+ options := map[string]string{
1091+ "driver": "bridge",
1092+ }
1093+
1094+ if err := m.runtime.CreateNetwork(ctx, networkName, options); err != nil {
1095+ return fmt.Errorf("failed to create network %s: %w", networkName, err)
1096+ }
1097+
1098+ log.Info().Str("network", networkName).Msg("Created network for app isolation")
1099+ }
1100+
1101+ return nil
1102+}
@@ -269,13 +269,23 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
269 }269 }
270 }270 }
271 271
272+ // Determine which network to use for this app
273+ networkName := m.GetNetworkForApp(route.Domain)
274+
275+ // Create network if it doesn't exist
276+ if err := m.CreateNetworkIfNeeded(ctx, networkName); err != nil {
277+ return nil, fmt.Errorf("failed to create network for %s: %w", route.Domain, err)
278+ }
279+
272 // Create container configuration280 // Create container configuration
273 containerConfig := &runtime.ContainerConfig{281 containerConfig := &runtime.ContainerConfig{
274- Image: imageRef,282+ Image: imageRef,
275- Name: fmt.Sprintf("gordon-%s", route.Domain),283+ Name: fmt.Sprintf("gordon-%s", route.Domain),
276- Ports: exposedPorts,284+ Ports: exposedPorts,
277- Env: envVars,285+ Env: envVars,
278- Volumes: volumes,286+ Volumes: volumes,
287+ NetworkMode: networkName,
288+ Hostname: route.Domain,
279 Labels: map[string]string{289 Labels: map[string]string{
280 "gordon.domain": route.Domain,290 "gordon.domain": route.Domain,
281 "gordon.image": route.Image,291 "gordon.image": route.Image,
@@ -325,8 +335,37 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
325 Str("image", route.Image).335 Str("image", route.Image).
326 Str("container", container.ID).336 Str("container", container.ID).
327 Ints("ports", container.Ports).337 Ints("ports", container.Ports).
338+ Str("network", networkName).
328 Msg("Container deployed successfully")339 Msg("Container deployed successfully")
329 340
341+ // Deploy attachments for this specific app
342+ if attachments, ok := m.config.Attachments[route.Domain]; ok {
343+ for _, serviceImage := range attachments {
344+ if err := m.DeployAttachedService(ctx, route.Domain, serviceImage); err != nil {
345+ log.Error().Err(err).Str("service", serviceImage).Str("domain", route.Domain).Msg("Failed to deploy attachment")
346+ // Don't fail the main deployment if attachment fails
347+ }
348+ }
349+ }
350+
351+ // Check if app is part of a network group with attachments
352+ for groupName, domains := range m.config.NetworkGroups {
353+ for _, d := range domains {
354+ if d == route.Domain {
355+ // Deploy group attachments if not already deployed
356+ if attachments, ok := m.config.Attachments[groupName]; ok {
357+ for _, serviceImage := range attachments {
358+ if err := m.DeployAttachedService(ctx, groupName, serviceImage); err != nil {
359+ log.Error().Err(err).Str("service", serviceImage).Str("group", groupName).Msg("Failed to deploy group attachment")
360+ // Don't fail the main deployment if attachment fails
361+ }
362+ }
363+ }
364+ break
365+ }
366+ }
367+ }
368+
330 return container, nil369 return container, nil
331 }370 }
332 371
@@ -442,16 +481,26 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force
442 }481 }
443 }482 }
444 483
445- // Remove from our tracking map484+ // Remove from our tracking map and cleanup network if needed
446 m.mu.Lock()485 m.mu.Lock()
447- defer m.mu.Unlock()486+ var removedDomain string
448 for domain, container := range m.containers {487 for domain, container := range m.containers {
449 if container.ID == containerID {488 if container.ID == containerID {
450 delete(m.containers, domain)489 delete(m.containers, domain)
490+ removedDomain = domain
451 log.Info().Str("domain", domain).Str("container", containerID).Msg("Container removed")491 log.Info().Str("domain", domain).Str("container", containerID).Msg("Container removed")
452 break492 break
453 }493 }
454 }494 }
495+ m.mu.Unlock()
496+
497+ // If we removed an app container, check if we need to cleanup its network
498+ if removedDomain != "" && m.config.NetworkIsolation.Enabled {
499+ networkName := m.GetNetworkForApp(removedDomain)
500+ if err := m.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
501+ log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup network after container removal")
502+ }
503+ }
455 504
456 return nil505 return nil
457 }506 }
@@ -822,3 +871,232 @@ func (m *Manager) cleanupVolumesForDomain(ctx context.Context, domain string) er
822 871
823 return nil872 return nil
824 }873 }
874+
875+// cleanupNetworkIfEmpty removes a network if it has no containers
876+func (m *Manager) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
877+ if networkName == "bridge" || networkName == "default" {
878+ return nil // Don't try to remove default networks
879+ }
880+
881+ networks, err := m.runtime.ListNetworks(ctx)
882+ if err != nil {
883+ return fmt.Errorf("failed to list networks: %w", err)
884+ }
885+
886+ for _, network := range networks {
887+ if network.Name == networkName {
888+ // Check if network has any containers
889+ if len(network.Containers) == 0 {
890+ // Network is empty, remove it
891+ if err := m.runtime.RemoveNetwork(ctx, networkName); err != nil {
892+ log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup empty network")
893+ return err
894+ }
895+ log.Info().Str("network", networkName).Msg("Cleaned up empty network")
896+ }
897+ break
898+ }
899+ }
900+
901+ return nil
902+}
903+
904+// DeployAttachedService deploys a service attached to an app or network group
905+func (m *Manager) DeployAttachedService(ctx context.Context, identifier, serviceImage string) error {
906+ networkName := m.generateNetworkName(identifier)
907+
908+ // Extract service name from image for container naming
909+ serviceName := strings.Split(serviceImage, ":")[0]
910+ if strings.Contains(serviceName, "/") {
911+ parts := strings.Split(serviceName, "/")
912+ serviceName = parts[len(parts)-1]
913+ }
914+
915+ // For network groups, we need a different container naming scheme
916+ apps := m.GetAppsForNetwork(identifier)
917+ var containerName string
918+
919+ if len(apps) > 1 {
920+ // Shared service for network group
921+ containerName = fmt.Sprintf("%s-shared-%s", m.config.NetworkIsolation.NetworkPrefix, serviceName)
922+ } else {
923+ // App-specific service
924+ containerName = fmt.Sprintf("%s-%s", strings.ReplaceAll(identifier, ".", "-"), serviceName)
925+ }
926+
927+ // Check if service is already running
928+ containers, err := m.runtime.ListContainers(ctx, false)
929+ if err != nil {
930+ return fmt.Errorf("failed to list containers: %w", err)
931+ }
932+
933+ for _, container := range containers {
934+ if container.Name == containerName {
935+ log.Info().Str("service", containerName).Msg("Service already running")
936+ return nil
937+ }
938+ }
939+
940+ // Get exposed ports from the service image
941+ exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, serviceImage)
942+ if err != nil {
943+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to get exposed ports from service image, using empty ports")
944+ exposedPorts = []int{} // Services don't need external ports by default
945+ }
946+
947+ // Load environment for service (inherit from first app in group or specific app)
948+ var envVars []string
949+ if len(apps) > 0 {
950+ userEnvVars, err := m.envLoader.LoadEnvForRoute(apps[0])
951+ if err != nil {
952+ log.Warn().Err(err).Str("domain", apps[0]).Msg("Failed to load environment for service, using empty env")
953+ userEnvVars = []string{}
954+ }
955+
956+ // Get environment variables from image ENV directives
957+ dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, serviceImage)
958+ if err != nil {
959+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image environment variables")
960+ dockerfileEnvVars = []string{}
961+ }
962+
963+ // Merge environments
964+ envVars = mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars)
965+ }
966+
967+ // Handle volumes for the service
968+ volumes := make(map[string]string)
969+ if m.config.Volumes.AutoCreate {
970+ volumePaths, err := m.runtime.InspectImageVolumes(ctx, serviceImage)
971+ if err != nil {
972+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image volumes")
973+ } else if len(volumePaths) > 0 {
974+ for _, volumePath := range volumePaths {
975+ volumeName := fmt.Sprintf("%s-%s-%s",
976+ m.config.Volumes.Prefix,
977+ strings.ReplaceAll(identifier, ".", "-"),
978+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
979+
980+ // Create volume if it doesn't exist
981+ exists, err := m.runtime.VolumeExists(ctx, volumeName)
982+ if err != nil {
983+ log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check service volume")
984+ continue
985+ }
986+
987+ if !exists {
988+ if err := m.runtime.CreateVolume(ctx, volumeName); err != nil {
989+ log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create service volume")
990+ continue
991+ }
992+ log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for service")
993+ }
994+
995+ volumes[volumePath] = volumeName
996+ }
997+ }
998+ }
999+
1000+ // Create service container configuration
1001+ serviceConfig := &runtime.ContainerConfig{
1002+ Image: serviceImage,
1003+ Name: containerName,
1004+ Ports: exposedPorts,
1005+ Env: envVars,
1006+ Volumes: volumes,
1007+ NetworkMode: networkName,
1008+ Hostname: serviceName,
1009+ Labels: map[string]string{
1010+ "gordon.managed": "true",
1011+ "gordon.service": "true",
1012+ "gordon.attached": identifier,
1013+ "gordon.image": serviceImage,
1014+ },
1015+ AutoRemove: false,
1016+ }
1017+
1018+ // Create and start the service container
1019+ container, err := m.runtime.CreateContainer(ctx, serviceConfig)
1020+ if err != nil {
1021+ return fmt.Errorf("failed to create service container %s: %w", containerName, err)
1022+ }
1023+
1024+ if err := m.runtime.StartContainer(ctx, container.ID); err != nil {
1025+ m.runtime.RemoveContainer(ctx, container.ID, true)
1026+ return fmt.Errorf("failed to start service container %s: %w", containerName, err)
1027+ }
1028+
1029+ log.Info().
1030+ Str("service", containerName).
1031+ Str("image", serviceImage).
1032+ Str("network", networkName).
1033+ Str("attached_to", identifier).
1034+ Msg("Service deployed successfully")
1035+
1036+ return nil
1037+}
1038+
1039+// GetNetworkForApp determines which network an app should use
1040+func (m *Manager) GetNetworkForApp(domain string) string {
1041+ if !m.config.NetworkIsolation.Enabled {
1042+ return "bridge" // Use default Docker bridge network
1043+ }
1044+
1045+ // Check if domain is part of a network group
1046+ for groupName, domains := range m.config.NetworkGroups {
1047+ for _, d := range domains {
1048+ if d == domain {
1049+ return m.generateNetworkName(groupName)
1050+ }
1051+ }
1052+ }
1053+
1054+ // Default: app gets its own network
1055+ return m.generateNetworkName(domain)
1056+}
1057+
1058+// generateNetworkName creates a network name from an identifier
1059+func (m *Manager) generateNetworkName(identifier string) string {
1060+ // "myapp.example.com" -> "gordon-myapp-example-com"
1061+ // "backend" -> "gordon-backend"
1062+ return fmt.Sprintf("%s-%s",
1063+ m.config.NetworkIsolation.NetworkPrefix,
1064+ strings.ReplaceAll(identifier, ".", "-"))
1065+}
1066+
1067+// GetAppsForNetwork returns all apps that should have access to a network
1068+func (m *Manager) GetAppsForNetwork(identifier string) []string {
1069+ // Check if it's a network group
1070+ if apps, ok := m.config.NetworkGroups[identifier]; ok {
1071+ return apps
1072+ }
1073+
1074+ // Single app network
1075+ return []string{identifier}
1076+}
1077+
1078+// CreateNetworkIfNeeded creates a network if it doesn't exist
1079+func (m *Manager) CreateNetworkIfNeeded(ctx context.Context, networkName string) error {
1080+ if networkName == "bridge" || networkName == "default" {
1081+ return nil // Don't try to create default networks
1082+ }
1083+
1084+ exists, err := m.runtime.NetworkExists(ctx, networkName)
1085+ if err != nil {
1086+ return fmt.Errorf("failed to check if network exists: %w", err)
1087+ }
1088+
1089+ if !exists {
1090+ options := map[string]string{
1091+ "driver": "bridge",
1092+ }
1093+
1094+ if err := m.runtime.CreateNetwork(ctx, networkName, options); err != nil {
1095+ return fmt.Errorf("failed to create network %s: %w", networkName, err)
1096+ }
1097+
1098+ log.Info().Str("network", networkName).Msg("Created network for app isolation")
1099+ }
1100+
1101+ return nil
1102+}
M pkg/runtime/interface.go +20 -0
@@ -15,6 +15,15 @@ type Container struct {
1515 Labels map[string]string
1616 }
1717
18+// NetworkInfo represents a network
19+type NetworkInfo struct {
20+ ID string
21+ Name string
22+ Driver string
23+ Containers []string
24+ Labels map[string]string
25+}
26+
1827 // ContainerConfig holds configuration for creating a container
1928 type ContainerConfig struct {
2029 Image string
@@ -26,6 +35,9 @@ type ContainerConfig struct {
2635 Cmd []string
2736 AutoRemove bool
2837 Volumes map[string]string // map[containerPath]volumeName
38+ NetworkMode string // Network to join
39+ Hostname string // Container hostname for DNS
40+ Aliases []string // Additional network aliases
2941 }
3042
3143 // Runtime interface defines the contract for container runtime implementations
@@ -69,4 +81,12 @@ type Runtime interface {
6981
7082 // Environment inspection
7183 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
84+
85+ // Network management
86+ CreateNetwork(ctx context.Context, name string, options map[string]string) error
87+ RemoveNetwork(ctx context.Context, name string) error
88+ ListNetworks(ctx context.Context) ([]*NetworkInfo, error)
89+ NetworkExists(ctx context.Context, name string) (bool, error)
90+ ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error
91+ DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
7292 }⏎̸
@@ -15,6 +15,15 @@ type Container struct {
15 Labels map[string]string15 Labels map[string]string
16 }16 }
17 17
18+// NetworkInfo represents a network
19+type NetworkInfo struct {
20+ ID string
21+ Name string
22+ Driver string
23+ Containers []string
24+ Labels map[string]string
25+}
26+
18 // ContainerConfig holds configuration for creating a container27 // ContainerConfig holds configuration for creating a container
19 type ContainerConfig struct {28 type ContainerConfig struct {
20 Image string29 Image string
@@ -26,6 +35,9 @@ type ContainerConfig struct {
26 Cmd []string35 Cmd []string
27 AutoRemove bool36 AutoRemove bool
28 Volumes map[string]string // map[containerPath]volumeName37 Volumes map[string]string // map[containerPath]volumeName
38+ NetworkMode string // Network to join
39+ Hostname string // Container hostname for DNS
40+ Aliases []string // Additional network aliases
29 }41 }
30 42
31 // Runtime interface defines the contract for container runtime implementations43 // Runtime interface defines the contract for container runtime implementations
@@ -69,4 +81,12 @@ type Runtime interface {
69 81
70 // Environment inspection82 // Environment inspection
71 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)83 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
84+
85+ // Network management
86+ CreateNetwork(ctx context.Context, name string, options map[string]string) error
87+ RemoveNetwork(ctx context.Context, name string) error
88+ ListNetworks(ctx context.Context) ([]*NetworkInfo, error)
89+ NetworkExists(ctx context.Context, name string) (bool, error)
90+ ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error
91+ DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
72 }⏎̸92 }⏎̸

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