detached 9cb47e4

Merge pull request #3 from bnema/gordon-v2

Gordon v2: Complete platform rewrite with enhanced container management
commit
9cb47e411b5e8f5d5611ad02724e640b19d0786e
author
bnema · · 2025-06-09 16:41 UTC
committer
GitHub · · 2025-06-09 16:41 UTC
merge of
1358ff2 + 4803e67 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
215 files · +20708 -12766
D .air.toml +0 -44
@@ -1,44 +0,0 @@
1-root = "."
2-testdata_dir = "testdata"
3-tmp_dir = "tmp"
4-
5-[build]
6- args_bin = []
7- bin = "./dist/tmp/main"
8- cmd = "go build -o ./dist/tmp/main cmd/cli/main.go"
9- delay = 0
10- exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
11- exclude_file = []
12- exclude_regex = ["_test.go"]
13- exclude_unchanged = false
14- follow_symlink = false
15- full_bin = "./dist/tmp/main"
16- include_dir = []
17- include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"]
18- include_file = []
19- kill_delay = "0s"
20- log = "build-errors.log"
21- poll = false
22- poll_interval = 0
23- rerun = false
24- rerun_delay = 1000
25- send_interrupt = true
26- stop_on_error = false
27-
28-[color]
29- app = ""
30- build = "yellow"
31- main = "magenta"
32- runner = "green"
33- watcher = "cyan"
34-
35-[log]
36- main_only = false
37- time = false
38-
39-[misc]
40- clean_on_exit = false
41-
42-[screen]
43- clear_on_rebuild = true
44- keep_scroll = true
D .env.example +0 -1
@@ -1,1 +0,0 @@
1-# its empty here⏎̸
M .gitignore +2 -16
@@ -1,17 +1,3 @@
1-.env
2-node_modules/
3-*tmp/
4-tmp/*
5-config.yml
6-bun.lockb
7-*.log
8-*.txt
9-*sync-conflict*
10-main
111 gordon
12-dist/
13-data/
14-setup.sh
15-.iscontainer
16-sqlite.db
17-.vscode/
2+.env
3+dist/⏎̸
M .goreleaser.yaml +0 -1
@@ -2,7 +2,6 @@ version: 2
22
33 before:
44 hooks:
5- - go mod tidy
65
76 release:
87 draft: true
D .vscode/launch.json +0 -18
@@ -1,18 +0,0 @@
1-{
2- // Utilisez IntelliSense pour en savoir plus sur les attributs possibles.
3- // Pointez pour afficher la description des attributs existants.
4- // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
5- "version": "0.2.0",
6- "configurations": [
7- {
8- "name": "Launch file",
9- "type": "go",
10- "request": "launch",
11- "mode": "debug",
12- "program": "./main.go",
13- }
14-
15-
16- ]
17-
18-}⏎̸
M Dockerfile +69 -5
@@ -1,8 +1,72 @@
1-FROM --platform=$TARGETPLATFORM alpine:latest
1+# Gordon v2 - Production Dockerfile
2+# Multi-stage build for optimized container image
23
3-COPY gordon /gordon
4+# Build stage
5+FROM golang:1.24-alpine AS builder
46
5-RUN touch /.iscontainer
7+# Install build dependencies
8+RUN apk add --no-cache git ca-certificates tzdata
69
7-ENTRYPOINT ["/gordon"]
8-CMD ["serve"]
10+# Set working directory
11+WORKDIR /app
12+
13+# Copy go mod files
14+COPY go.mod go.sum ./
15+
16+# Download dependencies
17+RUN go mod download
18+
19+# Copy source code
20+COPY . .
21+
22+# Build the binary
23+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
24+ -ldflags='-w -s -extldflags "-static"' \
25+ -a -installsuffix cgo \
26+ -o gordon .
27+
28+# Runtime stage
29+FROM alpine:latest
30+
31+# Install runtime dependencies
32+RUN apk add --no-cache \
33+ ca-certificates \
34+ docker-cli \
35+ curl \
36+ wget \
37+ tzdata \
38+ && rm -rf /var/cache/apk/*
39+
40+# Create non-root user
41+RUN adduser -D -s /bin/sh gordon
42+
43+# Set working directory
44+WORKDIR /app
45+
46+# Copy binary from builder
47+COPY --from=builder /app/gordon .
48+
49+# Create data directory
50+RUN mkdir -p /data && chown gordon:gordon /data
51+
52+# Copy default configuration (optional)
53+COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example
54+
55+# Switch to non-root user
56+USER gordon
57+
58+# Expose ports
59+EXPOSE 8080 5000
60+
61+# Health check
62+HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
63+ CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
64+
65+# Default command
66+CMD ["./gordon", "start"]
67+
68+# Metadata
69+LABEL maintainer="bnemam"
70+LABEL version="2.0"
71+LABEL description="Event-driven container deployment platform"
72+LABEL org.opencontainers.image.source="https://github.com/bnema/gordon"⏎̸
M Makefile +45 -3
@@ -1,7 +1,8 @@
11 # This Makefile is used for dev purposes
22 # Variables
33 REPO := ghcr.io/bnema/gordon
4-TAG := dev
4+TAG := v2-dev
5+DEV_TAG := v2-dev-$(shell date +%Y%m%d-%H%M%S)
56 DIST_DIR := ./dist
67 ENGINE := podman
78
@@ -20,7 +21,7 @@ LDFLAGS := -s -w \
2021 ARCHS := amd64 arm64
2122
2223 # Phony targets
23-.PHONY: all build build-push clean
24+.PHONY: all build build-push clean dev-release test test-unit test-coverage test-race clean-test
2425
2526 # Default target
2627 all: build
@@ -68,9 +69,50 @@ build-push: build
6869
6970 @echo "Script completed successfully."
7071
72+# Create dev GitHub release (separate from GoReleaser)
73+dev-release: build
74+ @echo "Creating dev GitHub release..."
75+ @if [ -z "$(shell which gh)" ]; then \
76+ echo "Error: GitHub CLI (gh) is not installed. Please install it first."; \
77+ exit 1; \
78+ fi
79+ @echo "Creating dev release $(DEV_TAG)..."
80+ @gh release create $(DEV_TAG) \
81+ --title "Gordon Dev Build $(DEV_TAG)" \
82+ --notes "🚧 **Development Build** 🚧\n\nThis is an automated development build for testing purposes.\n\n**Commit:** $(COMMIT)\n**Build Date:** $(BUILD_DATE)\n\n⚠️ This is not a stable release. Use at your own risk." \
83+ --prerelease \
84+ --draft=false \
85+ $(DIST_DIR)/gordon-linux-amd64 \
86+ $(DIST_DIR)/gordon-linux-arm64
87+ @echo "Dev release created successfully!"
88+ @echo ""
89+ @echo "📦 Download URLs:"
90+ @echo " AMD64: wget https://github.com/bnema/gordon/releases/download/$(DEV_TAG)/gordon-linux-amd64"
91+ @echo " ARM64: wget https://github.com/bnema/gordon/releases/download/$(DEV_TAG)/gordon-linux-arm64"
92+ @echo ""
93+ @echo "🔗 Release page: https://github.com/bnema/gordon/releases/tag/$(DEV_TAG)"
94+
95+# Test targets
96+test: test-unit
97+
98+test-unit:
99+ go test -v ./...
100+
101+test-race:
102+ go test -race -v ./...
103+
104+test-coverage:
105+ go test -coverprofile=coverage.out ./...
106+ go tool cover -html=coverage.out -o coverage.html
107+ @echo "Coverage report generated: coverage.html"
108+
109+
110+clean-test:
111+ rm -f coverage.out coverage.html
112+
71113 # Clean up
72114 clean:
73115 @echo "Cleaning up..."
74116 @rm -rf $(DIST_DIR)
75117 @$(ENGINE) system prune -f
76- @echo "Cleanup completed."
118+ @echo "Cleanup completed."⏎̸
M README.md +414 -53
@@ -1,77 +1,438 @@
1-<h1 align="center">
2- <br>
3- <img src="https://github.com/bnema/gordon/blob/main/internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png?raw=true" alt="Gordon" width="192">
4- <br>
5- Gordon
6- <br>
7-</h1>
1+# Gordon
82
9-<h4 align="center">Minimalist self-hosted containerized app deployment tool.</h4>
3+<div align="center">
4+ <img src="assets/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" width="200">
5+ <h3>The Smart Way to Deploy Containers on Your VPS</h3>
6+ <p><em>Push image → Auto-deploy → Zero complexity</em></p>
7+</div>
108
9+## Why Gordon?
1110
12-Gordon is a tool that aims to simplify the deployment of your containerized web applications. It automates the process of integrating your locally built image into your self-hosted setup without the need of a container registry.
11+**You have a $5 VPS. You want to run multiple apps. You don't want complex CI/CD pipelines, expensive PaaS solutions, or overcomplicated orchestration.**
1312
14-Optionally, Gordon can coordinate domain routing through Traefik (which needs to be pre-installed). This allows you to quickly deploy your applications to your desired domain or subdomain.
13+Gordon is the missing piece that makes container deployment on budget VPS servers as simple as expensive PaaS solutions. One binary, one config file, unlimited apps.
1514
16-## **Key Features**
15+### Perfect For
16+- **Solo developers** with multiple side projects on one VPS
17+- **Small teams** who want Heroku-like simplicity without the cost
18+- **Agencies** deploying client apps across VPS servers
19+- **Anyone** tired of complex deployment pipelines
1720
18-- **Ease of use**:
19- - Push your container images effortlessly using the **`gordon push`** command. A container creation URL is generated upon successful push.
21+## How It Works
2022
21- ![Demo Push](assets/vhs/demo_push.gif?raw=true)
23+```bash
24+# 1. Build & test locally
25+podman build -t myapp .
26+podman run -p 8080:8080 myapp # Works? Great!
2227
23- - Deploy your web applications quickly with **`gordon deploy`**.
28+# 2. Push to deploy
29+podman tag myapp registry.mydomain.com/myapp:latest
30+podman push registry.mydomain.com/myapp:latest
2431
25- ![Demo Deploy](assets/vhs/demo_deploy.gif?raw=true)
32+# 3. That's it. Gordon deploys it automatically.
33+```
2634
27-- **Self-Hosted**: Operates within your existing self-hosted environment without interfering with your setup.
28-- **Self-Updated**: The client can update itself to the latest version with `gordon update`. The backend is updated by pulling the latest image from ghcr.io.
29-- **Minimal WebUI**: Provides a [simple web UI](assets/screenshots/webui-image-deploy.png) to manage your deployments, domains, exposed ports. Using a very minimal stack (sqlite3, Go templates, HTMX and Tailwind CSS).
30-- **Simple authentication**: Use GitHub OAuth for the web UI and OAuth 2.0 device authorization grant (device flow) for the CLI client.
31-- **Automated Routing**: Collaborates with Traefik to automatically route your application to your desired domain or subdomain.
32-- **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities.
35+**No build servers. No CI/CD complexity. If it runs on your machine, it runs in production.**
3336
34-## **Why ?**
37+## Key Features
3538
36-I needed a simple tool that would automate the process of quickly spin up my web apps and preferably not on someone else's computer. And since I wanted to learn Go, I decided to build it myself.
39+- **Local-First Development**: Your machine is the build server
40+- **Built-in Container Registry**: Your VPS becomes a private registry
41+- **Push-to-Deploy**: New images deploy instantly
42+- **Multi-Domain Routing**: Unlimited apps with automatic HTTPS via Cloudflare
43+- **Zero-Downtime Updates**: Graceful container swaps
44+- **Network Isolation**: Each app gets its own isolated network with attached services (databases, caches, etc.)
45+- **Auto-Route Creation** (Optional): Push `myapp.mydomain.com:latest` → route created automatically
46+- **Auto-Volume Management**: Zero-config persistent storage from Dockerfile VOLUME directives
47+- **Auto-Environment Injection**: Dockerfile ENV directives automatically merge with your custom environment variables
3748
38-## **Installation / Getting Started / Usage**
49+## Quick Start (5 minutes)
3950
40-For detailed instructions, please refer to the [wiki](https://github.com/bnema/gordon/wiki/)
51+### 1. Setup VPS
52+```bash
53+# Install essentials on Ubuntu/Debian
54+sudo apt update && sudo apt install -y podman ufw
4155
42-- [Setting up Gordon’s Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon%E2%80%99s-Backend)
43-- [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client)
44-- [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment)
56+# Configure firewall
57+sudo ufw --force enable
58+sudo ufw default deny incoming
59+sudo ufw default allow outgoing
60+sudo ufw allow 22/tcp 80/tcp 443/tcp
61+sudo ufw enable
62+```
4563
64+### 2. Redirect Ports
65+```bash
66+# Redirect 80/443 to 8080
67+sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080
68+sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080
69+sudo apt install -y iptables-persistent
70+sudo netfilter-persistent save
71+```
4672
47-## **Development Status & Disclaimer**
73+### 3. Enable Rootless Containers
74+```bash
75+echo 'user.max_user_namespaces=28633' | sudo tee -a /etc/sysctl.conf
76+sudo sysctl -p
77+sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
78+systemctl --user enable --now podman.socket
79+```
4880
49-🛠️ **Note**: Gordon is under heavy development and is absolutely not ready for production. Thank you in advance for taking the time to test it and report any issues you may encounter.
81+### 4. Configure Registry
82+```bash
83+mkdir -p ~/.config/containers
84+tee ~/.config/containers/registries.conf > /dev/null <<EOF
85+[registries.search]
86+registries = ['docker.io', 'registry.mydomain.com']
5087
51-I also want to emphasize that I am far from being a Go expert yet. Hence, if you notice any poor practices, I welcome your feedback—respectfully, of course. It's a valuable part of the learning process!
88+[registries.insecure]
89+registries = ['registry.mydomain.com']
90+EOF
91+```
5292
53-## **Roadmap beyond 0.1**
93+### 5. Install Gordon
94+```bash
95+wget https://github.com/bnema/gordon/releases/latest/download/gordon-linux-amd64
96+chmod +x gordon-linux-amd64
97+sudo mv gordon-linux-amd64 /usr/local/bin/gordon
98+```
5499
55-- [x] Improved error handling
56-- [x] Improved logging
57-- [x] Refined CLI to backend authentication process (utilizing GitHub OAuth Device flow)
58-- [x] `push` command
59-- [x] `version` command
60-- [x] `update` command
61-- [x] `deploy` command
62-- [x] Survey(s) on deploy command (stop/remove if already exists)
63-- [ ] Improved web UI
64-- [ ] Quick database deploy templates (libsql, postgresql)
65-- [ ] GitHub Repository Integration:
66- - Repository selection and branch/tag filtering
67- - Dockerfile detection and validation
68- - Build configuration UI
69- - Build logs streaming
70- - Automated deployment triggers
71- - Build status notifications
100+### 6. Configure Gordon
101+```bash
102+# Gordon creates a default config file on first run
103+# Edit it to set your registry domain and credentials:
104+# gordon.toml location: ./ → ~/.config/gordon/ → ~/.gordon/ → ~/ → /etc/gordon/
72105
73-Have suggestions? Feel free to open an issue!
106+# Key settings to update:
107+# - registry_domain = "registry.mydomain.com"
108+# - registry_auth username/password
109+# - routes for your domains
110+```
74111
75-## **License**
112+### 7. Point DNS
113+```
114+A *.mydomain.com → YOUR_VPS_IP
115+A mydomain.com → YOUR_VPS_IP
116+```
76117
77-Gordon is licensed under the GPL-3.0 license. Please see the LICENSE file for more details.
118+### 8. Create Service
119+```bash
120+mkdir -p ~/.config/systemd/user
121+tee ~/.config/systemd/user/gordon.service > /dev/null <<EOF
122+[Unit]
123+Description=Gordon Container Platform
124+After=podman.socket
125+
126+[Service]
127+Type=simple
128+Restart=always
129+RestartSec=5
130+Environment=CONTAINER_HOST=unix://%t/podman/podman.sock
131+ExecStart=/usr/local/bin/gordon start
132+WorkingDirectory=%h
133+
134+[Install]
135+WantedBy=default.target
136+EOF
137+
138+systemctl --user daemon-reload
139+systemctl --user enable --now gordon
140+sudo loginctl enable-linger $USER
141+```
142+
143+### 9. Deploy Your First App
144+```bash
145+# From your dev machine:
146+podman login registry.mydomain.com
147+# (Use the credentials from your gordon.toml)
148+
149+podman tag myapp:latest registry.mydomain.com/myapp:latest
150+podman push registry.mydomain.com/myapp:latest
151+# Visit https://app.mydomain.com
152+```
153+
154+## Real-World Examples
155+
156+### Push-to-Deploy Workflow
157+```bash
158+# Build locally
159+podman build -t myapp .
160+
161+# Push latest = instant deploy
162+podman push registry.mydomain.com/myapp:latest
163+```
164+
165+### Version Control & Rollbacks
166+
167+Gordon supports multiple rollback strategies. Choose the approach that fits your workflow:
168+
169+#### Method 1: Tag-Based Rollbacks (Simple)
170+```bash
171+# Tag and push a specific version
172+podman tag myapp:v1.0.1 registry.mydomain.com/myapp:v1.0.1
173+podman push registry.mydomain.com/myapp:v1.0.1
174+
175+# Tag and push latest (v1.0.2 in this example)
176+podman tag myapp:v1.0.2 registry.mydomain.com/myapp:v1.0.2 && podman tag myapp:v1.0.2 registry.mydomain.com/myapp:latest
177+podman push registry.mydomain.com/myapp:v1.0.2 && podman push registry.mydomain.com/myapp:latest
178+
179+# Rollback: Update gordon.toml and reload
180+# From: "app.mydomain.com" = "myapp:latest"
181+# To: "app.mydomain.com" = "myapp:v1.0.1"
182+# Then: systemctl --user reload gordon
183+```
184+
185+#### Method 2: Manifest Annotation Deployment (Advanced)
186+```bash
187+# Build and push your versioned images first
188+export VERSION=v1.0.1
189+podman build --tag myapp:$VERSION --tag registry.mydomain.com/myapp:$VERSION .
190+podman push registry.mydomain.com/myapp:$VERSION
191+
192+export VERSION=v1.0.2
193+podman build --tag myapp:$VERSION --tag registry.mydomain.com/myapp:$VERSION .
194+podman push registry.mydomain.com/myapp:$VERSION
195+
196+# Deploy v1.0.2 with version annotation
197+export VERSION=v1.0.2
198+podman manifest create myapp:latest
199+podman manifest add myapp:latest registry.mydomain.com/myapp:$VERSION
200+podman manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION
201+podman manifest push myapp:latest registry.mydomain.com/myapp:latest
202+
203+# Rollback to v1.0.1 (just change the version)
204+export VERSION=v1.0.1
205+podman manifest create myapp:latest --amend
206+podman manifest add myapp:latest registry.mydomain.com/myapp:$VERSION
207+podman manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION
208+podman manifest push myapp:latest registry.mydomain.com/myapp:latest
209+# Gordon automatically deploys the specified version
210+```
211+
212+**Recommendation**: Use Method 2 for instant deployments without config file edits. Method 1 is best for simple workflows.
213+
214+### Auto-Route Creation
215+```toml
216+[auto_route]
217+enabled = true
218+```
219+
220+```bash
221+# Image name becomes route automatically
222+podman push registry.mydomain.com/staging.example.com:latest
223+# Creates: "staging.example.com" = "staging.example.com:latest"
224+```
225+
226+## Persistent Data with Volumes
227+
228+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.
229+
230+### How It Works
231+
232+```dockerfile
233+# In your Dockerfile
234+FROM node:18-alpine
235+WORKDIR /app
236+COPY package*.json ./
237+RUN npm install
238+COPY . .
239+
240+# Gordon automatically creates volumes for these paths
241+VOLUME ["/app/data", "/app/uploads"]
242+
243+EXPOSE 3000
244+CMD ["npm", "start"]
245+```
246+
247+When you deploy this image, Gordon will:
248+1. **Detect** the `VOLUME ["/app/data", "/app/uploads"]` directive
249+2. **Create** named volumes like `gordon-app-example-com-a1b2c3d4` (one per path)
250+3. **Mount** them to your container automatically
251+4. **Preserve** data across container restarts and updates
252+
253+### Volume Features
254+
255+- **Automatic Detection**: No config needed - Gordon reads VOLUME directives from your images
256+- **Persistent Storage**: Data survives container restarts, updates, and VPS reboots
257+- **Zero Downtime**: Volumes re-attach instantly when containers restart
258+- **Predictable Naming**: `gordon-{domain}-{path-hash}` format for easy identification
259+- **Cross-Platform**: Works with Docker and Podman
260+
261+### Example Workflows
262+
263+#### Database Container
264+```dockerfile
265+FROM postgres:15
266+# Gordon auto-creates persistent volume for PostgreSQL data
267+VOLUME ["/var/lib/postgresql/data"]
268+```
269+
270+#### Web App with File Uploads
271+```dockerfile
272+FROM nginx:alpine
273+COPY dist/ /usr/share/nginx/html/
274+# Gordon auto-creates persistent volume for user uploads
275+VOLUME ["/usr/share/nginx/html/uploads"]
276+```
277+
278+#### Multi-Volume Application
279+```dockerfile
280+FROM myapp:latest
281+# Gordon creates separate volumes for each path
282+VOLUME ["/app/data", "/app/logs", "/app/cache"]
283+```
284+
285+### Volume Configuration (Optional)
286+
287+```toml
288+[volumes]
289+auto_create = true # Default: true (set to false to disable)
290+prefix = "gordon" # Default: "gordon" (volume name prefix)
291+preserve = true # Default: true (keep volumes when removing containers)
292+```
293+
294+**Most users never need to touch this configuration** - the defaults work perfectly for 99% of use cases.
295+
296+## Smart Environment & Volume Management
297+
298+Gordon automatically handles both environment variables and persistent storage with zero configuration:
299+
300+### Environment Variables
301+- **Auto-Detection**: Reads `ENV` directives from your Dockerfile
302+- **Smart Merging**: Your `.env` files override Dockerfile ENV values
303+- **Secret Integration**: Supports `pass` and `SOPS` for sensitive data
304+
305+### Persistent Volumes
306+- **Auto-Detection**: Reads `VOLUME` directives from your Dockerfile
307+- **Zero Configuration**: Creates and manages volumes automatically
308+- **Data Persistence**: Survives container updates and reboots
309+
310+See [examples/](examples/) for detailed configuration examples.
311+
312+## Advanced Configuration
313+
314+### Full Config Structure (with default values)
315+```toml
316+# gordon.toml - Auto-generated on first run
317+[server]
318+port = 8080 # Default: 8080
319+registry_port = 5000 # Default: 5000
320+registry_domain = "registry.mydomain.com" # No default (required)
321+runtime = "auto" # Default: "auto" (auto, docker, podman, podman-rootless)
322+socket_path = "" # Default: "" (auto-detected)
323+data_dir = "~/.local/share/gordon" # Default: ~/.local/share/gordon (or ./data for root)
324+ssl_email = "" # No default (optional)
325+# runtime auto-detects: docker → podman-rootless → podman
326+# Override with env: CONTAINER_HOST=unix:///custom/socket
327+
328+[registry_auth]
329+enabled = true # Default: true
330+username = "admin" # No default (required when enabled)
331+password = "your-secure-password" # No default (required when enabled)
332+
333+[routes]
334+"app.mydomain.com" = "myapp:latest" # Default: {} (empty map)
335+# Routes default to HTTPS unless prefixed with http://
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+
343+[auto_route]
344+enabled = false # Default: false
345+
346+[volumes]
347+auto_create = true # Default: true (automatically create volumes from VOLUME directives)
348+prefix = "gordon" # Default: "gordon" (volume name prefix)
349+preserve = true # Default: true (keep volumes when containers are removed)
350+# Volumes are auto-created from Dockerfile VOLUME directives
351+# Naming: gordon-{domain}-{path-hash} (e.g., gordon-app-example-com-a1b2c3d4)
352+
353+[env]
354+dir = "{data_dir}/env" # Default: {data_dir}/env
355+providers = ["pass", "sops"] # Default: ["pass", "sops"]
356+# Example env file: ./data/env/app_mydomain_com.env
357+# NODE_ENV=production
358+# DATABASE_URL=postgresql://localhost:5432/prod
359+# API_KEY=${pass:company/api-key} # Unix pass integration
360+# JWT_SECRET=${sops:secrets.yaml:jwt} # SOPS integration
361+
362+[logging]
363+enabled = true # Default: true
364+level = "info" # Default: "info" (debug, info, warn, error, fatal, panic)
365+dir = "{data_dir}/logs" # Default: {data_dir}/logs
366+main_log_file = "gordon.log" # Default: "gordon.log"
367+proxy_log_file = "proxy.log" # Default: "proxy.log"
368+container_log_dir = "containers" # Default: "containers"
369+max_size = 100 # Default: 100 (MB before rotation)
370+max_backups = 3 # Default: 3 (old files to keep)
371+max_age = 28 # Default: 28 (days to keep old files)
372+compress = true # Default: true (gzip rotated logs)
373+# Creates: gordon.log, proxy.log, containers/*.log
374+```
375+
376+## FAQ
377+
378+**Q: How is this different from Traefik/Nginx Proxy Manager?**
379+A: Those are just reverse proxies. Gordon is a complete deployment platform with built-in registry, automatic deployment, and container lifecycle management.
380+
381+**Q: Where do builds happen?**
382+A: On YOUR machine. If it runs locally, it runs in production.
383+
384+**Q: How do I rollback?**
385+A: Change the version annotation in your manifest and push, or edit gordon.toml. Takes seconds.
386+
387+**Q: Do I need Cloudflare?**
388+A: Yes, for SSL certificates and DDoS protection.
389+
390+**Q: Resource requirements?**
391+A: Runs on 1GB RAM VPS. Gordon uses <15MB RAM.
392+
393+**Q: How do I check if volumes are working?**
394+A: Use `docker volume ls` or `podman volume ls` to see Gordon-managed volumes (named `gordon-*`).
395+
396+**Q: Can I disable automatic volumes?**
397+A: Yes, set `volumes.auto_create = false` in your gordon.toml config.
398+
399+**Q: What happens to my data when I update containers?**
400+A: Volumes are preserved by default. Your data persists across updates and reboots.
401+
402+**Q: How do I backup volume data?**
403+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 .`
404+
405+**Q: Do Dockerfile ENV directives work automatically?**
406+A: Yes! Gordon reads ENV directives from your images and merges them with your custom .env files.
407+
408+**Q: What happens if I set the same environment variable in both Dockerfile and .env?**
409+A: Your .env file always wins - custom environment variables override Dockerfile ENV directives.
410+
411+**Q: Can I see what environment variables are being applied?**
412+A: Yes, check Gordon's logs - it shows both Dockerfile ENV and final merged environment variables.
413+
414+**Q: Do I need to rebuild my images to change environment variables?**
415+A: No! Just update your .env files and reload Gordon. Dockerfile ENV provides defaults; .env provides overrides.
416+
417+## Philosophy
418+
419+```bash
420+# Traditional CI/CD
421+push code → wait for build → hope it works → debug remotely → repeat
422+
423+# The Gordon Way
424+build locally → test locally → push image → instant deploy
425+```
426+
427+Your machine already works. Why replicate it in CI/CD?
428+
429+## License
430+
431+GPL-3.0 - Use freely, contribute back.
432+
433+---
434+
435+<div align="center">
436+ <p><strong>Stop overcomplicating container deployment.</strong></p>
437+ <p>Star this repo if Gordon saves you money!</p>
438+</div>⏎̸
D cmd/gordon.go +0 -61
@@ -1,61 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/gordon/internal/cli"
8- "github.com/bnema/gordon/internal/cli/cmd"
9- "github.com/bnema/gordon/internal/common"
10- "github.com/bnema/gordon/internal/server"
11- "github.com/spf13/cobra"
12-
13- _ "github.com/joho/godotenv/autoload"
14-)
15-
16-var (
17- rootCmd = &cobra.Command{Use: "gordon"}
18- proxyURL = "https://gordon-proxy.bamen.dev"
19-)
20-
21-func InitializeCommands(client *cli.App, server *server.App) {
22- rootCmd.AddCommand(cmd.NewRootCommand(client))
23- rootCmd.AddCommand(cmd.NewServeCommand(server))
24- rootCmd.AddCommand(cmd.NewPingCommand(client))
25- rootCmd.AddCommand(cmd.NewDeployCommand(client))
26- rootCmd.AddCommand(cmd.NewUpdateCommand(client))
27- rootCmd.AddCommand(cmd.NewPushCommand(client))
28- rootCmd.AddCommand(cmd.NewVersionCommand(client))
29-}
30-
31-func Execute(client *cli.App, server *server.App) {
32- InitializeCommands(client, server)
33- cobra.CheckErr(rootCmd.Execute())
34-}
35-
36-func ExecuteCLI(build, commit, date string) {
37- versionInfo := common.GetVersionInfo(build, commit, date, proxyURL)
38-
39- buildInfo := &common.BuildConfig{
40- BuildVersion: build,
41- BuildCommit: commit,
42- BuildDate: date,
43- ProxyURL: proxyURL,
44- }
45-
46- a, err := cli.NewClientApp(buildInfo)
47- if err != nil {
48- fmt.Println("Error initializing app:", err)
49- }
50- s, err := server.NewServerApp(buildInfo)
51- if err != nil {
52- fmt.Println("Error initializing app:", err)
53- }
54-
55- common.DockerInit(&s.Config.ContainerEngine)
56-
57- // Start periodic version checking in the background
58- go common.CheckVersionPeriodically(versionInfo, 3*time.Hour)
59-
60- Execute(a, s)
61-}
A cmd/reload.go +85 -0
@@ -0,0 +1,85 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "syscall"
8+
9+ "github.com/spf13/cobra"
10+)
11+
12+var reloadCmd = &cobra.Command{
13+ Use: "reload",
14+ Short: "Reload configuration and environment variables into running containers",
15+ Long: `Reload the Gordon configuration and environment variables from .env files into running containers.
16+This command reloads the config file and redeploys all containers with updated environment
17+variables from their respective .env files. Containers are recreated to pick up the new configuration and environment variables.`,
18+ Run: runReload,
19+}
20+
21+func init() {
22+ rootCmd.AddCommand(reloadCmd)
23+}
24+
25+func runReload(cmd *cobra.Command, args []string) {
26+ // Find the Gordon PID file to get the process ID
27+ pidFile := findGordonPidFile()
28+ if pidFile == "" {
29+ fmt.Println("Error: Gordon PID file not found. Is Gordon running?")
30+ fmt.Println("Please start Gordon with 'gordon start' first.")
31+ return
32+ }
33+
34+ // Read the PID from the file
35+ pidBytes, err := os.ReadFile(pidFile)
36+ if err != nil {
37+ fmt.Printf("Error reading Gordon PID file: %v\n", err)
38+ return
39+ }
40+
41+ var pid int
42+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
43+ fmt.Printf("Error parsing Gordon PID: %v\n", err)
44+ return
45+ }
46+
47+ // Check if the process is still running
48+ process, err := os.FindProcess(pid)
49+ if err != nil {
50+ fmt.Printf("Error finding Gordon process: %v\n", err)
51+ return
52+ }
53+
54+ // Send SIGUSR1 signal to trigger reload
55+ if err := process.Signal(syscall.SIGUSR1); err != nil {
56+ fmt.Printf("Error sending reload signal to Gordon: %v\n", err)
57+ fmt.Println("Gordon may not be running or may not have permission to send signals.")
58+ return
59+ }
60+
61+ fmt.Println("Manual reload signal sent to Gordon successfully.")
62+ fmt.Println("Check Gordon logs to see the reload progress.")
63+}
64+
65+func findGordonPidFile() string {
66+ // Look for PID file in common locations
67+ locations := []string{
68+ "/tmp/gordon.pid",
69+ "/var/run/gordon.pid",
70+ filepath.Join(os.TempDir(), "gordon.pid"),
71+ }
72+
73+ // Also check in user's home directory
74+ if homeDir, err := os.UserHomeDir(); err == nil {
75+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
76+ }
77+
78+ for _, location := range locations {
79+ if _, err := os.Stat(location); err == nil {
80+ return location
81+ }
82+ }
83+
84+ return ""
85+}
A cmd/reload_test.go +434 -0
@@ -0,0 +1,434 @@
1+package cmd
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "syscall"
9+ "testing"
10+
11+ "github.com/spf13/cobra"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestReloadCommand(t *testing.T) {
17+ // Test reload command structure
18+ assert.Equal(t, "reload", reloadCmd.Use)
19+ assert.Contains(t, reloadCmd.Short, "Reload")
20+ assert.Contains(t, reloadCmd.Long, "configuration")
21+ assert.Contains(t, reloadCmd.Long, "environment variables")
22+}
23+
24+func TestReloadCmdStructure(t *testing.T) {
25+ // Verify command is properly registered
26+ found := false
27+ for _, cmd := range rootCmd.Commands() {
28+ if cmd.Name() == "reload" {
29+ found = true
30+ break
31+ }
32+ }
33+ assert.True(t, found, "reload command should be registered with root command")
34+}
35+
36+func TestReloadCmdHelp(t *testing.T) {
37+ var output bytes.Buffer
38+
39+ // Create a copy of the command to avoid affecting the global command
40+ cmd := &cobra.Command{
41+ Use: reloadCmd.Use,
42+ Short: reloadCmd.Short,
43+ Long: reloadCmd.Long,
44+ Run: reloadCmd.Run,
45+ }
46+
47+ cmd.SetOut(&output)
48+ cmd.SetErr(&output)
49+ cmd.SetArgs([]string{"--help"})
50+
51+ err := cmd.Execute()
52+ assert.NoError(t, err)
53+
54+ helpOutput := output.String()
55+ assert.Contains(t, helpOutput, "Reload")
56+ assert.Contains(t, helpOutput, "configuration")
57+ assert.Contains(t, helpOutput, "environment variables")
58+ assert.Contains(t, helpOutput, "containers")
59+}
60+
61+func TestFindGordonPidFile(t *testing.T) {
62+ tests := []struct {
63+ name string
64+ setupFunc func(t *testing.T) (string, func()) // returns temp dir and cleanup func
65+ expectedFound bool
66+ }{
67+ {
68+ name: "PID file exists in temp directory",
69+ setupFunc: func(t *testing.T) (string, func()) {
70+ tempDir := t.TempDir()
71+ pidFile := filepath.Join(tempDir, "gordon.pid")
72+ err := os.WriteFile(pidFile, []byte("12345"), 0644)
73+ require.NoError(t, err)
74+
75+ cleanup := func() {
76+ // Cleanup function for consistency
77+ }
78+ return pidFile, cleanup
79+ },
80+ expectedFound: false, // Won't find it because we can't easily mock os.TempDir
81+ },
82+ {
83+ name: "PID file exists in home directory",
84+ setupFunc: func(t *testing.T) (string, func()) {
85+ // Create temp directory to simulate home
86+ tempDir := t.TempDir()
87+ pidFile := filepath.Join(tempDir, ".gordon.pid")
88+ err := os.WriteFile(pidFile, []byte("12345"), 0644)
89+ require.NoError(t, err)
90+
91+ // Mock home directory
92+ originalHome := os.Getenv("HOME")
93+ os.Setenv("HOME", tempDir)
94+
95+ cleanup := func() {
96+ os.Setenv("HOME", originalHome)
97+ }
98+ return pidFile, cleanup
99+ },
100+ expectedFound: true,
101+ },
102+ {
103+ name: "no PID file exists",
104+ setupFunc: func(t *testing.T) (string, func()) {
105+ // Set home to a temp directory with no PID file
106+ tempDir := t.TempDir()
107+ originalHome := os.Getenv("HOME")
108+ os.Setenv("HOME", tempDir)
109+
110+ cleanup := func() {
111+ os.Setenv("HOME", originalHome)
112+ }
113+ return "", cleanup
114+ },
115+ expectedFound: false,
116+ },
117+ }
118+
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ expectedPidFile, cleanup := tt.setupFunc(t)
122+ defer cleanup()
123+
124+ result := findGordonPidFile()
125+
126+ if tt.expectedFound {
127+ assert.NotEmpty(t, result)
128+ assert.Equal(t, expectedPidFile, result)
129+ } else {
130+ assert.Empty(t, result)
131+ }
132+ })
133+ }
134+}
135+
136+func TestRunReload_NoPidFile(t *testing.T) {
137+ // Set up environment with no PID file
138+ tempDir := t.TempDir()
139+ originalHome := os.Getenv("HOME")
140+ os.Setenv("HOME", tempDir)
141+ defer os.Setenv("HOME", originalHome)
142+
143+ // Capture stdout by redirecting it
144+ oldStdout := os.Stdout
145+ r, w, _ := os.Pipe()
146+ os.Stdout = w
147+
148+ done := make(chan string)
149+ go func() {
150+ var buf bytes.Buffer
151+ buf.ReadFrom(r)
152+ done <- buf.String()
153+ }()
154+
155+ // Run reload command
156+ runReload(reloadCmd, []string{})
157+
158+ // Restore stdout and get output
159+ w.Close()
160+ os.Stdout = oldStdout
161+ outputStr := <-done
162+
163+ // Should indicate PID file not found
164+ assert.Contains(t, outputStr, "PID file not found")
165+ assert.Contains(t, outputStr, "Is Gordon running?")
166+}
167+
168+func TestRunReload_WithValidPidFile(t *testing.T) {
169+ // Create a test PID file
170+ tempDir := t.TempDir()
171+ pidFile := filepath.Join(tempDir, ".gordon.pid")
172+
173+ // Write current process PID to file (so it's valid)
174+ pid := os.Getpid()
175+ err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", pid)), 0644)
176+ require.NoError(t, err)
177+
178+ // Mock home directory
179+ originalHome := os.Getenv("HOME")
180+ os.Setenv("HOME", tempDir)
181+ defer os.Setenv("HOME", originalHome)
182+
183+ // Capture stdout
184+ var output bytes.Buffer
185+ reloadCmd.SetOut(&output)
186+
187+ // We can't easily test the actual signal sending to another process,
188+ // but we can test the PID file reading logic
189+
190+ // Test PID file reading
191+ foundPidFile := findGordonPidFile()
192+ assert.Equal(t, pidFile, foundPidFile)
193+
194+ // Test PID parsing
195+ pidBytes, err := os.ReadFile(pidFile)
196+ require.NoError(t, err)
197+
198+ var parsedPid int
199+ _, err = fmt.Sscanf(string(pidBytes), "%d", &parsedPid)
200+ require.NoError(t, err)
201+ assert.Equal(t, pid, parsedPid)
202+}
203+
204+func TestRunReload_InvalidPidFile(t *testing.T) {
205+ // Create a PID file with invalid content
206+ tempDir := t.TempDir()
207+ pidFile := filepath.Join(tempDir, ".gordon.pid")
208+
209+ // Write invalid PID content
210+ err := os.WriteFile(pidFile, []byte("invalid_pid"), 0644)
211+ require.NoError(t, err)
212+
213+ // Mock home directory
214+ originalHome := os.Getenv("HOME")
215+ os.Setenv("HOME", tempDir)
216+ defer os.Setenv("HOME", originalHome)
217+
218+ // Capture stdout by redirecting it
219+ oldStdout := os.Stdout
220+ r, w, _ := os.Pipe()
221+ os.Stdout = w
222+
223+ done := make(chan string)
224+ go func() {
225+ var buf bytes.Buffer
226+ buf.ReadFrom(r)
227+ done <- buf.String()
228+ }()
229+
230+ // Run reload command
231+ runReload(reloadCmd, []string{})
232+
233+ // Restore stdout and get output
234+ w.Close()
235+ os.Stdout = oldStdout
236+ outputStr := <-done
237+
238+ // Should indicate parsing error
239+ assert.Contains(t, outputStr, "Error parsing Gordon PID")
240+}
241+
242+func TestRunReload_UnreadablePidFile(t *testing.T) {
243+ // Create a PID file that can't be read
244+ tempDir := t.TempDir()
245+ pidFile := filepath.Join(tempDir, ".gordon.pid")
246+
247+ // Create file but make it unreadable (if possible)
248+ err := os.WriteFile(pidFile, []byte("12345"), 0000)
249+ require.NoError(t, err)
250+
251+ // Mock home directory
252+ originalHome := os.Getenv("HOME")
253+ os.Setenv("HOME", tempDir)
254+ defer os.Setenv("HOME", originalHome)
255+
256+ // Capture stdout
257+ var output bytes.Buffer
258+ reloadCmd.SetOut(&output)
259+
260+ // Run reload command
261+ runReload(reloadCmd, []string{})
262+
263+ // Should indicate read error (on systems that enforce permissions)
264+ outputStr := output.String()
265+ // Note: This test might not fail on all systems due to permission handling
266+ if len(outputStr) > 0 {
267+ // If we get output, it should contain an error message
268+ assert.True(t,
269+ contains(outputStr, "Error reading") ||
270+ contains(outputStr, "Error parsing") ||
271+ contains(outputStr, "Error finding") ||
272+ contains(outputStr, "Error sending"),
273+ "Should contain some error message, got: %s", outputStr)
274+ }
275+}
276+
277+func TestRunReload_NonExistentProcess(t *testing.T) {
278+ // Create a PID file with a PID that doesn't exist
279+ tempDir := t.TempDir()
280+ pidFile := filepath.Join(tempDir, ".gordon.pid")
281+
282+ // Use a PID that's very unlikely to exist (high number)
283+ nonExistentPid := 999999
284+ err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", nonExistentPid)), 0644)
285+ require.NoError(t, err)
286+
287+ // Mock home directory
288+ originalHome := os.Getenv("HOME")
289+ os.Setenv("HOME", tempDir)
290+ defer os.Setenv("HOME", originalHome)
291+
292+ // Capture stdout by redirecting it
293+ oldStdout := os.Stdout
294+ r, w, _ := os.Pipe()
295+ os.Stdout = w
296+
297+ done := make(chan string)
298+ go func() {
299+ var buf bytes.Buffer
300+ buf.ReadFrom(r)
301+ done <- buf.String()
302+ }()
303+
304+ // Run reload command
305+ runReload(reloadCmd, []string{})
306+
307+ // Restore stdout and get output
308+ w.Close()
309+ os.Stdout = oldStdout
310+ outputStr := <-done
311+
312+ // Should indicate process not found or signal sending error
313+ assert.True(t,
314+ contains(outputStr, "Error finding") ||
315+ contains(outputStr, "Error sending") ||
316+ contains(outputStr, "not be running"),
317+ "Should contain process-related error, got: %s", outputStr)
318+}
319+
320+func TestFindGordonPidFile_AllLocations(t *testing.T) {
321+ // Test the locations that findGordonPidFile checks
322+ locations := []string{
323+ "/tmp/gordon.pid",
324+ "/var/run/gordon.pid",
325+ }
326+
327+ // Add temp dir location
328+ tempDirLocation := filepath.Join(os.TempDir(), "gordon.pid")
329+ locations = append(locations, tempDirLocation)
330+
331+ // Add home directory location
332+ if homeDir, err := os.UserHomeDir(); err == nil {
333+ homeLocation := filepath.Join(homeDir, ".gordon.pid")
334+ locations = append(locations, homeLocation)
335+ }
336+
337+ // Verify that the function checks these locations
338+ // We can't easily test all of them without root access,
339+ // but we can verify the function doesn't panic
340+ assert.NotPanics(t, func() {
341+ result := findGordonPidFile()
342+ // Result might be empty if no PID file exists, which is fine
343+ _ = result
344+ })
345+}
346+
347+func TestSignalSending_Concepts(t *testing.T) {
348+ // Test the signal sending concepts used in runReload
349+
350+ // Test that we can find our own process
351+ process, err := os.FindProcess(os.Getpid())
352+ assert.NoError(t, err)
353+ assert.NotNil(t, process)
354+
355+ // Test signal types
356+ signals := []syscall.Signal{
357+ syscall.SIGUSR1,
358+ syscall.SIGTERM,
359+ syscall.SIGINT,
360+ }
361+
362+ for _, sig := range signals {
363+ assert.NotNil(t, sig)
364+ }
365+}
366+
367+func TestPidFileValidation(t *testing.T) {
368+ tests := []struct {
369+ name string
370+ content string
371+ expectErr bool
372+ }{
373+ {
374+ name: "valid PID",
375+ content: "12345",
376+ expectErr: false,
377+ },
378+ {
379+ name: "invalid PID - letters",
380+ content: "abc",
381+ expectErr: true,
382+ },
383+ {
384+ name: "invalid PID - empty",
385+ content: "",
386+ expectErr: true,
387+ },
388+ {
389+ name: "invalid PID - negative",
390+ content: "-123",
391+ expectErr: false, // Parsing succeeds but PID is invalid
392+ },
393+ {
394+ name: "valid PID with whitespace",
395+ content: " 12345 \n",
396+ expectErr: false,
397+ },
398+ }
399+
400+ for _, tt := range tests {
401+ t.Run(tt.name, func(t *testing.T) {
402+ var pid int
403+ _, err := fmt.Sscanf(tt.content, "%d", &pid)
404+
405+ if tt.expectErr {
406+ assert.Error(t, err)
407+ } else {
408+ assert.NoError(t, err)
409+ if tt.content != "" && tt.content != " 12345 \n" && tt.content != "-123" {
410+ assert.Greater(t, pid, 0)
411+ }
412+ }
413+ })
414+ }
415+}
416+
417+// Helper function to check if string contains substring (case-insensitive)
418+func contains(s, substr string) bool {
419+ return len(s) >= len(substr) &&
420+ (s == substr ||
421+ len(s) > len(substr) &&
422+ (s[:len(substr)] == substr ||
423+ s[len(s)-len(substr):] == substr ||
424+ searchSubstring(s, substr)))
425+}
426+
427+func searchSubstring(s, substr string) bool {
428+ for i := 0; i <= len(s)-len(substr); i++ {
429+ if s[i:i+len(substr)] == substr {
430+ return true
431+ }
432+ }
433+ return false
434+}⏎̸
A cmd/root.go +68 -0
@@ -0,0 +1,68 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/rs/zerolog/log"
8+ "github.com/spf13/cobra"
9+ "github.com/spf13/viper"
10+)
11+
12+var cfgFile string
13+
14+var rootCmd = &cobra.Command{
15+ Use: "gordon",
16+ Short: "Gordon - Container Registry & Deployment System",
17+ Long: `Gordon is a single-binary application that combines a container registry
18+with an intelligent reverse proxy and automated deployment system.`,
19+}
20+
21+func Execute() error {
22+ return rootCmd.Execute()
23+}
24+
25+func init() {
26+ cobra.OnInitialize(initConfig)
27+ rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./gordon.toml)")
28+}
29+
30+func initConfig() {
31+ if cfgFile != "" {
32+ viper.SetConfigFile(cfgFile)
33+ } else {
34+ // Search for config file in standard locations
35+ viper.SetConfigName("gordon")
36+ viper.SetConfigType("toml")
37+
38+ // Current directory (highest priority)
39+ viper.AddConfigPath(".")
40+
41+ // User config directory
42+ if userConfigDir, err := os.UserConfigDir(); err == nil {
43+ viper.AddConfigPath(userConfigDir + "/gordon")
44+ }
45+
46+ // User home directory
47+ if homeDir, err := os.UserHomeDir(); err == nil {
48+ viper.AddConfigPath(homeDir + "/.gordon")
49+ viper.AddConfigPath(homeDir)
50+ }
51+
52+ // System-wide config directories
53+ viper.AddConfigPath("/etc/gordon")
54+ viper.AddConfigPath("/usr/local/etc/gordon")
55+ }
56+
57+ viper.AutomaticEnv()
58+
59+ if err := viper.ReadInConfig(); err == nil {
60+ fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
61+ } else {
62+ if cfgFile != "" {
63+ fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err)
64+ } else {
65+ log.Fatal().Msg("config file not found - please specify with --config flag or ensure gordon.toml exists in current directory")
66+ }
67+ }
68+}
A cmd/root_test.go +269 -0
@@ -0,0 +1,269 @@
1+package cmd
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/spf13/cobra"
10+ "github.com/spf13/viper"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestRootCommand(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ args []string
19+ }{
20+ {
21+ name: "no arguments shows help",
22+ args: []string{},
23+ },
24+ {
25+ name: "help flag",
26+ args: []string{"--help"},
27+ },
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ // Create new command to avoid state pollution
33+ cmd := &cobra.Command{
34+ Use: "gordon",
35+ Short: "Gordon - Container Registry & Deployment System",
36+ Long: `Gordon is a single-binary application that combines a container registry
37+with an intelligent reverse proxy and automated deployment system.`,
38+ }
39+
40+ cmd.SetArgs(tt.args)
41+
42+ var output bytes.Buffer
43+ cmd.SetOut(&output)
44+ cmd.SetErr(&output)
45+
46+ // Should not panic
47+ assert.NotPanics(t, func() {
48+ cmd.Execute()
49+ })
50+ })
51+ }
52+}
53+
54+func TestExecute(t *testing.T) {
55+ // Create a temporary config file to avoid config errors
56+ tempDir := t.TempDir()
57+ configFile := filepath.Join(tempDir, "gordon.toml")
58+
59+ configContent := `
60+[server]
61+port = 8080
62+registry_port = 5000
63+runtime = "docker"
64+
65+[logging]
66+enabled = false
67+
68+[routes]
69+`
70+ err := os.WriteFile(configFile, []byte(configContent), 0644)
71+ require.NoError(t, err)
72+
73+ // Save original values
74+ originalArgs := os.Args
75+ originalCfgFile := cfgFile
76+
77+ // Test with help flag to avoid starting actual server
78+ os.Args = []string{"gordon", "--help"}
79+ cfgFile = configFile
80+
81+ // Restore after test
82+ defer func() {
83+ os.Args = originalArgs
84+ cfgFile = originalCfgFile
85+ viper.Reset()
86+ }()
87+
88+ // Should not panic
89+ assert.NotPanics(t, func() {
90+ Execute()
91+ })
92+}
93+
94+func TestInitConfig(t *testing.T) {
95+ tests := []struct {
96+ name string
97+ setupFunc func(t *testing.T) string // returns temp dir
98+ configFile string
99+ configContent string
100+ expectedError bool
101+ shouldFindFile bool
102+ }{
103+ {
104+ name: "explicit config file",
105+ setupFunc: func(t *testing.T) string {
106+ return t.TempDir()
107+ },
108+ configFile: "test-gordon.toml",
109+ configContent: `
110+[server]
111+port = 8080
112+registry_port = 5000
113+runtime = "docker"
114+
115+[logging]
116+enabled = false
117+
118+[routes]
119+`,
120+ shouldFindFile: true,
121+ },
122+ {
123+ name: "config file in current directory",
124+ setupFunc: func(t *testing.T) string {
125+ tempDir := t.TempDir()
126+ // Change to temp directory
127+ originalDir, _ := os.Getwd()
128+ os.Chdir(tempDir)
129+ t.Cleanup(func() {
130+ os.Chdir(originalDir)
131+ })
132+ return tempDir
133+ },
134+ configFile: "gordon.toml",
135+ configContent: `
136+[server]
137+port = 8080
138+registry_port = 5000
139+runtime = "docker"
140+
141+[logging]
142+enabled = false
143+
144+[routes]
145+`,
146+ shouldFindFile: true,
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ // Reset viper for each test
153+ viper.Reset()
154+
155+ tempDir := tt.setupFunc(t)
156+
157+ // Save original values
158+ originalCfgFile := cfgFile
159+
160+ if tt.configContent != "" {
161+ configPath := tt.configFile
162+ if !filepath.IsAbs(configPath) {
163+ configPath = filepath.Join(tempDir, tt.configFile)
164+ }
165+
166+ err := os.WriteFile(configPath, []byte(tt.configContent), 0644)
167+ require.NoError(t, err)
168+
169+ if tt.name == "explicit config file" {
170+ cfgFile = configPath
171+ }
172+ }
173+
174+ // Restore after test
175+ t.Cleanup(func() {
176+ cfgFile = originalCfgFile
177+ viper.Reset()
178+ })
179+
180+ assert.NotPanics(t, func() {
181+ initConfig()
182+ })
183+
184+ if tt.shouldFindFile {
185+ assert.NotEmpty(t, viper.ConfigFileUsed())
186+ }
187+ })
188+ }
189+}
190+
191+func TestRootCmdStructure(t *testing.T) {
192+ // Test root command basic properties
193+ assert.Equal(t, "gordon", rootCmd.Use)
194+ assert.Contains(t, rootCmd.Short, "Gordon")
195+ assert.Contains(t, rootCmd.Long, "container registry")
196+
197+ // Test that persistent flags are set
198+ flag := rootCmd.PersistentFlags().Lookup("config")
199+ assert.NotNil(t, flag)
200+ assert.Equal(t, "config", flag.Name)
201+}
202+
203+func TestRootCmdSubcommands(t *testing.T) {
204+ // Check that expected subcommands are registered
205+ commands := rootCmd.Commands()
206+
207+ commandNames := make([]string, len(commands))
208+ for i, cmd := range commands {
209+ commandNames[i] = cmd.Name()
210+ }
211+
212+ // Should have start and reload commands
213+ assert.Contains(t, commandNames, "start")
214+ assert.Contains(t, commandNames, "reload")
215+}
216+
217+func TestConfigFileFlag(t *testing.T) {
218+ tempDir := t.TempDir()
219+ configFile := filepath.Join(tempDir, "test-config.toml")
220+
221+ configContent := `
222+[server]
223+port = 9999
224+registry_port = 6000
225+runtime = "docker"
226+
227+[logging]
228+enabled = false
229+
230+[routes]
231+`
232+ err := os.WriteFile(configFile, []byte(configContent), 0644)
233+ require.NoError(t, err)
234+
235+ // Reset viper and cfgFile
236+ viper.Reset()
237+ originalCfgFile := cfgFile
238+
239+ defer func() {
240+ cfgFile = originalCfgFile
241+ viper.Reset()
242+ }()
243+
244+ // Test setting config file via flag
245+ cfgFile = configFile
246+
247+ assert.NotPanics(t, func() {
248+ initConfig()
249+ })
250+
251+ // Should use the specified config file
252+ assert.Equal(t, configFile, viper.ConfigFileUsed())
253+}
254+
255+func TestRootCmdHelp(t *testing.T) {
256+ var output bytes.Buffer
257+ rootCmd.SetOut(&output)
258+ rootCmd.SetArgs([]string{"--help"})
259+
260+ err := rootCmd.Execute()
261+ assert.NoError(t, err)
262+
263+ helpOutput := output.String()
264+ assert.Contains(t, helpOutput, "Gordon")
265+ assert.Contains(t, helpOutput, "container registry")
266+ assert.Contains(t, helpOutput, "Available Commands:")
267+ assert.Contains(t, helpOutput, "start")
268+ assert.Contains(t, helpOutput, "reload")
269+}⏎̸
A cmd/start.go +327 -0
@@ -0,0 +1,327 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/signal"
8+ "path/filepath"
9+ "sync"
10+ "syscall"
11+ "time"
12+
13+ "gordon/internal/config"
14+ "gordon/internal/container"
15+ "gordon/internal/events"
16+ "gordon/internal/logging"
17+ "gordon/internal/proxy"
18+ "gordon/internal/registry"
19+
20+ "github.com/fsnotify/fsnotify"
21+ "github.com/rs/zerolog"
22+ "github.com/rs/zerolog/log"
23+ "github.com/spf13/cobra"
24+ "github.com/spf13/viper"
25+)
26+
27+var startCmd = &cobra.Command{
28+ Use: "start",
29+ Short: "Start the Gordon server",
30+ Long: `Start the container registry and reverse proxy server`,
31+ Run: runStart,
32+}
33+
34+func init() {
35+ rootCmd.AddCommand(startCmd)
36+}
37+
38+func runStart(cmd *cobra.Command, args []string) {
39+ // Load configuration first
40+ cfg, err := config.Load()
41+ if err != nil {
42+ // Use basic console logging for config errors
43+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
44+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
45+ log.Fatal().Err(err).Msg("Failed to load config")
46+ }
47+
48+ // Initialize logging system
49+ if err := logging.Setup(cfg); err != nil {
50+ log.Fatal().Err(err).Msg("Failed to initialize logging")
51+ }
52+ defer logging.Close()
53+
54+ log.Info().Msg("Starting Gordon server...")
55+ log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")
56+ log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")
57+ log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime")
58+
59+ // Create PID file for reload command
60+ pidFile := createPidFile()
61+ if pidFile != "" {
62+ defer removePidFile(pidFile)
63+ }
64+
65+ // Initialize event bus
66+ eventBus := events.NewInMemoryEventBus(100)
67+ if err := eventBus.Start(); err != nil {
68+ log.Fatal().Err(err).Msg("Failed to start event bus")
69+ }
70+
71+ // Initialize container manager
72+ manager, err := container.NewManager(cfg)
73+ if err != nil {
74+ log.Fatal().Err(err).Msg("Failed to initialize container manager")
75+ }
76+
77+ // Create and subscribe container event handler
78+ containerHandler := events.NewContainerEventHandler(manager, cfg)
79+ if err := eventBus.Subscribe(containerHandler); err != nil {
80+ log.Fatal().Err(err).Msg("Failed to subscribe container event handler")
81+ }
82+
83+ // Create and subscribe auto-route event handler
84+ var autoRouteHandler *events.AutoRouteHandler
85+ autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
86+ if err := eventBus.Subscribe(autoRouteHandler); err != nil {
87+ log.Fatal().Err(err).Msg("Failed to subscribe auto-route event handler")
88+ }
89+
90+ // Create and subscribe version event handler
91+ var versionHandler *events.VersionHandler
92+ versionHandler = events.NewVersionHandler(manager, cfg)
93+ if err := eventBus.Subscribe(versionHandler); err != nil {
94+ log.Fatal().Err(err).Msg("Failed to subscribe version event handler")
95+ }
96+
97+ // Start proxy server (needs to be accessible for config reload)
98+ proxyServer := proxy.NewServer(cfg, manager)
99+
100+ // Create and subscribe proxy event handler
101+ var proxyHandler *proxy.ProxyEventHandler
102+ proxyHandler = proxy.NewProxyEventHandler(proxyServer)
103+ if err := eventBus.Subscribe(proxyHandler); err != nil {
104+ log.Fatal().Err(err).Msg("Failed to subscribe proxy event handler")
105+ }
106+
107+ // Create context for the entire server lifecycle
108+ ctx, cancel := context.WithCancel(context.Background())
109+
110+ // Setup config file watching for live reload with debouncing
111+ var reloadTimer *time.Timer
112+ var reloadMutex sync.Mutex
113+
114+ viper.WatchConfig()
115+ viper.OnConfigChange(func(e fsnotify.Event) {
116+ log.Info().
117+ Str("file", e.Name).
118+ Str("op", e.Op.String()).
119+ Msg("Configuration file changed, reloading...")
120+
121+ // Debounce config reloads to prevent multiple events from a single logical change
122+ reloadMutex.Lock()
123+ defer reloadMutex.Unlock()
124+
125+ if reloadTimer != nil {
126+ reloadTimer.Stop()
127+ }
128+
129+ reloadTimer = time.AfterFunc(500*time.Millisecond, func() {
130+ // Reload configuration
131+ newCfg, err := config.Load()
132+ if err != nil {
133+ log.Error().Err(err).Msg("Failed to reload configuration")
134+ return
135+ }
136+
137+ // Update the configuration reference
138+ cfg = newCfg
139+
140+ // Reinitialize logging with new config
141+ if err := logging.Setup(cfg); err != nil {
142+ log.Error().Err(err).Msg("Failed to reinitialize logging after config reload")
143+ }
144+
145+ // Update proxy server config
146+ proxyServer.UpdateConfig(cfg)
147+
148+ // Update container manager config (includes env file creation for new routes)
149+ manager.UpdateConfig(cfg)
150+
151+ // Unsubscribe old handlers before creating new ones
152+ if err := eventBus.Unsubscribe(containerHandler); err != nil {
153+ log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler")
154+ }
155+ if err := eventBus.Unsubscribe(autoRouteHandler); err != nil {
156+ log.Warn().Err(err).Msg("Failed to unsubscribe old auto-route event handler")
157+ }
158+ if err := eventBus.Unsubscribe(versionHandler); err != nil {
159+ log.Warn().Err(err).Msg("Failed to unsubscribe old version event handler")
160+ }
161+ if err := eventBus.Unsubscribe(proxyHandler); err != nil {
162+ log.Warn().Err(err).Msg("Failed to unsubscribe old proxy event handler")
163+ }
164+
165+ // Update container handler config
166+ containerHandler = events.NewContainerEventHandler(manager, cfg)
167+ if err := eventBus.Subscribe(containerHandler); err != nil {
168+ log.Error().Err(err).Msg("Failed to re-subscribe container event handler")
169+ return
170+ }
171+
172+ // Update auto-route handler config
173+ autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
174+ if err := eventBus.Subscribe(autoRouteHandler); err != nil {
175+ log.Error().Err(err).Msg("Failed to re-subscribe auto-route event handler")
176+ return
177+ }
178+
179+ // Update version handler config
180+ versionHandler = events.NewVersionHandler(manager, cfg)
181+ if err := eventBus.Subscribe(versionHandler); err != nil {
182+ log.Error().Err(err).Msg("Failed to re-subscribe version event handler")
183+ return
184+ }
185+
186+ // Re-subscribe proxy handler (config ref updated automatically)
187+ proxyHandler = proxy.NewProxyEventHandler(proxyServer)
188+ if err := eventBus.Subscribe(proxyHandler); err != nil {
189+ log.Error().Err(err).Msg("Failed to re-subscribe proxy event handler")
190+ return
191+ }
192+
193+ // Publish config reload event to handle route changes
194+ if err := eventBus.Publish(events.ConfigReload, nil); err != nil {
195+ log.Error().Err(err).Msg("Failed to publish config reload event")
196+ } else {
197+ log.Info().Msg("Configuration reloaded successfully")
198+ }
199+ })
200+ })
201+
202+ // Sync existing containers
203+ if err := manager.SyncContainers(ctx); err != nil {
204+ log.Warn().Err(err).Msg("Failed to sync existing containers")
205+ }
206+
207+ // Auto-start containers that match config routes
208+ log.Info().Msg("Auto-starting containers for configured routes...")
209+ if err := manager.AutoStartContainers(ctx); err != nil {
210+ log.Warn().Err(err).Msg("Failed to auto-start containers")
211+ }
212+
213+ var wg sync.WaitGroup
214+
215+ // Start registry server
216+ registryServer, err := registry.NewServer(cfg, eventBus)
217+ if err != nil {
218+ log.Fatal().Err(err).Msg("Failed to initialize registry server")
219+ }
220+ wg.Add(1)
221+ go func() {
222+ defer wg.Done()
223+ if err := registryServer.Start(ctx); err != nil {
224+ log.Error().Err(err).Msg("Registry server error")
225+ }
226+ }()
227+
228+ // Start proxy server (already created above)
229+ wg.Add(1)
230+ go func() {
231+ defer wg.Done()
232+ if err := proxyServer.Start(ctx); err != nil {
233+ log.Error().Err(err).Msg("Proxy server error")
234+ }
235+ }()
236+
237+ // Wait for interrupt or reload signals
238+ sigChan := make(chan os.Signal, 1)
239+ reloadChan := make(chan os.Signal, 1)
240+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
241+ signal.Notify(reloadChan, syscall.SIGUSR1)
242+
243+ // Handle signals in a loop
244+ for {
245+ select {
246+ case <-sigChan:
247+ log.Info().Msg("Received shutdown signal")
248+ goto shutdown
249+ case <-reloadChan:
250+ log.Info().Msg("Received manual reload signal (SIGUSR1)")
251+ // Trigger manual reload event
252+ if err := eventBus.Publish(events.ManualReload, nil); err != nil {
253+ log.Error().Err(err).Msg("Failed to publish manual reload event")
254+ } else {
255+ log.Info().Msg("Manual reload event published successfully")
256+ }
257+ }
258+ }
259+
260+shutdown:
261+
262+ log.Info().Msg("Shutting down servers...")
263+ cancel()
264+
265+ // Stop all managed containers with a separate context for shutdown
266+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
267+ defer shutdownCancel()
268+
269+ log.Info().Msg("Shutting down container manager...")
270+ if err := manager.Shutdown(shutdownCtx); err != nil {
271+ log.Error().Err(err).Msg("Failed to shutdown container manager")
272+ }
273+
274+ // Stop event bus
275+ if err := eventBus.Stop(); err != nil {
276+ log.Error().Err(err).Msg("Failed to stop event bus")
277+ }
278+
279+ // Give servers time to shutdown gracefully
280+ done := make(chan struct{})
281+ go func() {
282+ wg.Wait()
283+ close(done)
284+ }()
285+
286+ select {
287+ case <-done:
288+ log.Info().Msg("Servers stopped gracefully")
289+ case <-time.After(10 * time.Second):
290+ log.Warn().Msg("Force shutdown after timeout")
291+ }
292+}
293+
294+// createPidFile creates a PID file for the Gordon process
295+func createPidFile() string {
296+ pid := os.Getpid()
297+
298+ // Try multiple locations for the PID file
299+ locations := []string{
300+ "/tmp/gordon.pid",
301+ filepath.Join(os.TempDir(), "gordon.pid"),
302+ }
303+
304+ // Also try user's home directory
305+ if homeDir, err := os.UserHomeDir(); err == nil {
306+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
307+ }
308+
309+ for _, location := range locations {
310+ if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0644); err == nil {
311+ log.Debug().Str("pid_file", location).Int("pid", pid).Msg("Created PID file")
312+ return location
313+ }
314+ }
315+
316+ log.Warn().Int("pid", pid).Msg("Failed to create PID file in any location")
317+ return ""
318+}
319+
320+// removePidFile removes the PID file
321+func removePidFile(pidFile string) {
322+ if err := os.Remove(pidFile); err != nil {
323+ log.Warn().Err(err).Str("pid_file", pidFile).Msg("Failed to remove PID file")
324+ } else {
325+ log.Debug().Str("pid_file", pidFile).Msg("Removed PID file")
326+ }
327+}
A cmd/start_test.go +333 -0
@@ -0,0 +1,333 @@
1+package cmd
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+ "testing"
11+ "time"
12+
13+ "github.com/spf13/cobra"
14+ "github.com/spf13/viper"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestStartCommand(t *testing.T) {
20+ // Test start command structure
21+ assert.Equal(t, "start", startCmd.Use)
22+ assert.Contains(t, startCmd.Short, "Start")
23+ assert.Contains(t, startCmd.Long, "registry")
24+}
25+
26+func TestRunStart_ConfigError(t *testing.T) {
27+ // Save original values
28+ originalCfgFile := cfgFile
29+ defer func() {
30+ cfgFile = originalCfgFile
31+ viper.Reset()
32+ }()
33+
34+ // Set non-existent config file
35+ cfgFile = "/nonexistent/config.toml"
36+
37+ // Capture stderr to check config error message
38+ var stderr bytes.Buffer
39+ originalStderr := os.Stderr
40+ r, w, _ := os.Pipe()
41+ os.Stderr = w
42+
43+ // Initialize config to trigger error handling
44+ done := make(chan bool)
45+ go func() {
46+ defer func() {
47+ if recover() != nil {
48+ // Recovery from log.Fatal is expected in some cases
49+ }
50+ done <- true
51+ }()
52+
53+ // Call initConfig directly to test config error handling
54+ initConfig()
55+ }()
56+
57+ // Give it a moment to process
58+ select {
59+ case <-done:
60+ // Expected
61+ case <-time.After(1 * time.Second):
62+ t.Fatal("initConfig should have completed quickly")
63+ }
64+
65+ w.Close()
66+ os.Stderr = originalStderr
67+ stderr.ReadFrom(r)
68+
69+ // Check that an error message was printed
70+ stderrContent := stderr.String()
71+ assert.Contains(t, stderrContent, "Error reading config file")
72+}
73+
74+func TestCreatePidFile(t *testing.T) {
75+ // Test PID file creation
76+ pidFile := createPidFile()
77+
78+ if pidFile != "" {
79+ // Verify file exists and contains valid PID
80+ assert.FileExists(t, pidFile)
81+
82+ content, err := os.ReadFile(pidFile)
83+ require.NoError(t, err)
84+
85+ var pid int
86+ _, err = fmt.Sscanf(string(content), "%d", &pid)
87+ assert.NoError(t, err)
88+ assert.Equal(t, os.Getpid(), pid)
89+
90+ // Clean up
91+ removePidFile(pidFile)
92+ assert.NoFileExists(t, pidFile)
93+ }
94+
95+ // Test should pass even if no writable location is found
96+ // (function returns empty string in that case)
97+}
98+
99+func TestRemovePidFile(t *testing.T) {
100+ tests := []struct {
101+ name string
102+ setupFunc func(t *testing.T) string
103+ expectError bool
104+ }{
105+ {
106+ name: "remove existing file",
107+ setupFunc: func(t *testing.T) string {
108+ tempDir := t.TempDir()
109+ pidFile := filepath.Join(tempDir, "test.pid")
110+ err := os.WriteFile(pidFile, []byte("12345"), 0644)
111+ require.NoError(t, err)
112+ return pidFile
113+ },
114+ expectError: false,
115+ },
116+ {
117+ name: "remove non-existent file",
118+ setupFunc: func(t *testing.T) string {
119+ tempDir := t.TempDir()
120+ return filepath.Join(tempDir, "nonexistent.pid")
121+ },
122+ expectError: true, // Warning logged, but no panic
123+ },
124+ }
125+
126+ for _, tt := range tests {
127+ t.Run(tt.name, func(t *testing.T) {
128+ pidFile := tt.setupFunc(t)
129+
130+ // Should not panic regardless of whether file exists
131+ assert.NotPanics(t, func() {
132+ removePidFile(pidFile)
133+ })
134+
135+ // File should not exist after removal attempt
136+ assert.NoFileExists(t, pidFile)
137+ })
138+ }
139+}
140+
141+func TestCreatePidFile_Locations(t *testing.T) {
142+ // Test that createPidFile tries multiple locations
143+ // We can't easily mock os.WriteFile to test all failure scenarios,
144+ // but we can verify the function doesn't panic
145+ assert.NotPanics(t, func() {
146+ pidFile := createPidFile()
147+ if pidFile != "" {
148+ removePidFile(pidFile)
149+ }
150+ })
151+}
152+
153+func TestCreatePidFile_Content(t *testing.T) {
154+ pidFile := createPidFile()
155+ if pidFile == "" {
156+ t.Skip("No writable location found for PID file")
157+ }
158+
159+ defer removePidFile(pidFile)
160+
161+ // Verify PID file contains current process ID
162+ content, err := os.ReadFile(pidFile)
163+ require.NoError(t, err)
164+
165+ var storedPid int
166+ _, err = fmt.Sscanf(string(content), "%d", &storedPid)
167+ require.NoError(t, err)
168+
169+ assert.Equal(t, os.Getpid(), storedPid)
170+}
171+
172+func TestPidFilePermissions(t *testing.T) {
173+ pidFile := createPidFile()
174+ if pidFile == "" {
175+ t.Skip("No writable location found for PID file")
176+ }
177+
178+ defer removePidFile(pidFile)
179+
180+ // Check file permissions
181+ info, err := os.Stat(pidFile)
182+ require.NoError(t, err)
183+
184+ // Should be readable by owner and group
185+ assert.True(t, info.Mode().Perm()&0644 != 0)
186+}
187+
188+func TestPidFileLocations(t *testing.T) {
189+ // Test different potential PID file locations
190+ // This mainly tests that the function handles various scenarios
191+
192+ originalHome := os.Getenv("HOME")
193+ defer os.Setenv("HOME", originalHome)
194+
195+ // Test with invalid home directory
196+ os.Setenv("HOME", "/nonexistent/invalid/path")
197+
198+ assert.NotPanics(t, func() {
199+ pidFile := createPidFile()
200+ if pidFile != "" {
201+ removePidFile(pidFile)
202+ }
203+ })
204+}
205+
206+func TestStartCmdStructure(t *testing.T) {
207+ // Verify command is properly registered
208+ found := false
209+ for _, cmd := range rootCmd.Commands() {
210+ if cmd.Name() == "start" {
211+ found = true
212+ break
213+ }
214+ }
215+ assert.True(t, found, "start command should be registered with root command")
216+}
217+
218+func TestStartCmdHelp(t *testing.T) {
219+ var output bytes.Buffer
220+
221+ // Create a copy of the command to avoid affecting the global command
222+ cmd := &cobra.Command{
223+ Use: startCmd.Use,
224+ Short: startCmd.Short,
225+ Long: startCmd.Long,
226+ Run: startCmd.Run,
227+ }
228+
229+ cmd.SetOut(&output)
230+ cmd.SetErr(&output)
231+ cmd.SetArgs([]string{"--help"})
232+
233+ err := cmd.Execute()
234+ assert.NoError(t, err)
235+
236+ helpOutput := output.String()
237+ assert.Contains(t, helpOutput, "Start")
238+ assert.Contains(t, helpOutput, "container registry")
239+ assert.Contains(t, helpOutput, "reverse proxy")
240+ assert.Contains(t, helpOutput, "server")
241+}
242+
243+// Test helper functions for signal handling scenarios
244+func TestStartCommand_SignalHandling(t *testing.T) {
245+ // This is a simplified test since we can't easily test the full signal handling
246+ // without actually starting the server. We mainly test that the command structure is correct.
247+
248+ // Verify the command can be created and has the right properties
249+ assert.NotNil(t, startCmd.Run)
250+ assert.Equal(t, "start", startCmd.Use)
251+}
252+
253+func TestRunStart_ValidConfig(t *testing.T) {
254+ // Create a valid config file
255+ tempDir := t.TempDir()
256+ configFile := filepath.Join(tempDir, "gordon.toml")
257+
258+ configContent := `
259+[server]
260+port = 8080
261+registry_port = 5000
262+runtime = "docker"
263+
264+[logging]
265+enabled = false
266+dir = "` + filepath.Join(tempDir, "logs") + `"
267+
268+[registry]
269+enabled = false
270+
271+[routes]
272+`
273+
274+ err := os.WriteFile(configFile, []byte(configContent), 0644)
275+ require.NoError(t, err)
276+
277+ // Save original values
278+ originalCfgFile := cfgFile
279+ defer func() {
280+ cfgFile = originalCfgFile
281+ viper.Reset()
282+ }()
283+
284+ // Set config file
285+ cfgFile = configFile
286+ viper.Reset()
287+
288+ // We can't actually run the full start command without Docker,
289+ // but we can test that it doesn't immediately fail with config errors
290+ // by mocking the execution
291+
292+ // This is a limited test since runStart calls log.Fatal on various errors
293+ // and we can't easily test the full server startup without actual dependencies
294+ assert.NotNil(t, startCmd.Run, "Start command should have a run function")
295+}
296+
297+func TestSignalHandling_Concepts(t *testing.T) {
298+ // Test the concepts used in signal handling
299+
300+ // Test that we can create signal channels (used in runStart)
301+ sigChan := make(chan os.Signal, 1)
302+ reloadChan := make(chan os.Signal, 1)
303+
304+ assert.NotNil(t, sigChan)
305+ assert.NotNil(t, reloadChan)
306+
307+ // Test signal types that would be used
308+ signals := []os.Signal{
309+ syscall.SIGINT,
310+ syscall.SIGTERM,
311+ syscall.SIGUSR1,
312+ }
313+
314+ for _, sig := range signals {
315+ assert.NotNil(t, sig)
316+ }
317+}
318+
319+func TestContextCancellation(t *testing.T) {
320+ // Test context cancellation patterns used in runStart
321+ ctx, cancel := context.WithCancel(context.Background())
322+ assert.NotNil(t, ctx)
323+ assert.NotNil(t, cancel)
324+
325+ // Test timeout context
326+ timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Second)
327+ assert.NotNil(t, timeoutCtx)
328+ assert.NotNil(t, timeoutCancel)
329+
330+ // Clean up
331+ cancel()
332+ timeoutCancel()
333+}⏎̸
A examples/README.md +332 -0
@@ -0,0 +1,332 @@
1+# Gordon v2 Configuration Examples
2+
3+This directory contains configuration examples for different use cases and environments.
4+
5+## 📁 Available Examples
6+
7+### 🚀 [`minimal.toml`](minimal.toml)
8+**Perfect for getting started**
9+- Single route configuration
10+- Default settings
11+- No authentication
12+- Ideal for testing and learning
13+
14+### 🏠 [`development.toml`](development.toml)
15+**Local development setup**
16+- Multiple .local domains
17+- No authentication for ease of use
18+- Third-party development tools
19+- localhost registry
20+
21+### 🧪 [`staging.toml`](staging.toml)
22+**Staging and preview environments**
23+- Branch-based deployments
24+- Feature branch testing
25+- PR preview environments
26+- Separate staging registry
27+
28+### 🏭 [`production.toml`](production.toml)
29+**Production-ready configuration**
30+- Pinned image versions
31+- Registry authentication
32+- Multiple production services
33+- Monitoring tools included
34+
35+### 🏢 [`saas-multi-tenant.toml`](saas-multi-tenant.toml)
36+**Multi-tenant SaaS platform**
37+- Customer subdomains
38+- Custom domains
39+- Shared application architecture
40+- Enterprise features
41+
42+### 📊 [`logging.toml`](logging.toml)
43+**Comprehensive logging configuration**
44+- Complete logging setup examples
45+- Different logging levels and configurations
46+- Production and development logging strategies
47+- Log rotation and monitoring examples
48+
49+### 🔄 [`rollback-workflow-example.md`](rollback-workflow-example.md)
50+**Simple version deployment and rollback workflow**
51+- Manifest annotation-based deployments
52+- Simple version changes for deployments and rollbacks
53+- Version tracking and deployment strategies
54+
55+## 🚀 Quick Start
56+
57+1. **Choose an example** that matches your use case
58+2. **Copy the config file** to your Gordon directory:
59+ ```bash
60+ cp examples/minimal.toml gordon.toml
61+ ```
62+3. **Edit the configuration** with your domains and settings
63+4. **Start Gordon**:
64+ ```bash
65+ gordon start
66+ ```
67+
68+## 🔧 Customization Tips
69+
70+### Domain Configuration
71+Update the `[routes]` section with your actual domains:
72+```toml
73+[routes]
74+"your-domain.com" = "your-app:latest"
75+"api.your-domain.com" = "your-api:v1.0.0"
76+```
77+
78+### Registry Setup
79+Configure your registry domain and authentication:
80+```toml
81+[server]
82+registry_domain = "registry.your-domain.com"
83+
84+[registry_auth]
85+enabled = true
86+username = "your-username"
87+password = "your-secure-password"
88+```
89+
90+### Container Runtime Setup
91+Gordon supports Docker and Podman with automatic detection:
92+```toml
93+[server]
94+runtime = "auto" # auto, docker, podman, podman-rootless
95+socket_path = "" # optional custom socket path
96+
97+# Examples:
98+# runtime = "docker" # Force Docker
99+# runtime = "podman" # Force Podman root
100+# runtime = "podman-rootless" # Force Podman rootless
101+# socket_path = "unix:///run/user/1000/podman/podman.sock"
102+```
103+
104+Override with environment variables:
105+```bash
106+# Works for both Docker and Podman
107+export CONTAINER_HOST=unix:///custom/path/container.sock
108+export CONTAINER_HOST=tcp://remote-docker:2376
109+gordon start
110+```
111+
112+### Logging Configuration
113+Enable comprehensive logging to monitor your deployments:
114+```toml
115+[logging]
116+enabled = true # Enable file-based logging
117+level = "info" # Log level: trace, debug, info, warn, error
118+dir = "./logs" # Directory for log files
119+main_log_file = "gordon.log" # Main application logs
120+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
121+container_log_dir = "containers" # Container logs subdirectory
122+
123+# Log rotation settings
124+max_size = 100 # Max log file size in MB
125+max_backups = 5 # Number of old log files to keep
126+max_age = 30 # Max age in days
127+compress = true # Compress old log files
128+```
129+
130+See [`logging.toml`](logging.toml) for comprehensive logging examples.
131+
132+### Environment Variables
133+Gordon intelligently merges environment variables from multiple sources:
134+
135+1. **Dockerfile ENV directives** (automatically detected)
136+2. **Your .env files** (override Dockerfile ENV)
137+3. **Runtime environment** (lowest priority)
138+
139+#### Dockerfile ENV (Automatic Detection)
140+```dockerfile
141+FROM node:18-alpine
142+WORKDIR /app
143+
144+# Gordon automatically reads these ENV directives
145+ENV NODE_ENV=production
146+ENV PORT=3000
147+ENV LOG_LEVEL=info
148+ENV DATABASE_HOST=localhost
149+
150+COPY package*.json ./
151+RUN npm install
152+COPY . .
153+EXPOSE 3000
154+CMD ["npm", "start"]
155+```
156+
157+#### Custom Environment Files
158+Create `.env` files for each route: `./data/env/app_yourdomain_com.env`
159+```bash
160+# Your custom overrides (these take precedence over Dockerfile ENV)
161+DATABASE_HOST=db.mydomain.com
162+DATABASE_USER=myapp
163+DATABASE_PASSWORD=${pass:myapp/db-password}
164+API_KEY=${sops:secrets.yaml:api_key}
165+# NODE_ENV, PORT, LOG_LEVEL from Dockerfile are preserved
166+```
167+
168+#### Final Applied Environment
169+```bash
170+NODE_ENV=production # From Dockerfile ENV
171+PORT=3000 # From Dockerfile ENV
172+LOG_LEVEL=info # From Dockerfile ENV
173+DATABASE_HOST=db.mydomain.com # From .env (overridden)
174+DATABASE_USER=myapp # From .env (new)
175+DATABASE_PASSWORD=secret123 # From .env via pass
176+API_KEY=abc123 # From .env via SOPS
177+```
178+
179+#### Production Environment Variables
180+For sensitive production data:
181+```bash
182+export GORDON_REGISTRY_PASSWORD="your-secure-password"
183+export GORDON_SSL_EMAIL="admin@your-domain.com"
184+```
185+
186+### Volume Management
187+Gordon automatically creates persistent volumes from Dockerfile VOLUME directives:
188+
189+#### Dockerfile VOLUME (Automatic Detection)
190+```dockerfile
191+FROM postgres:15
192+# Gordon automatically creates persistent volumes for these paths
193+VOLUME ["/var/lib/postgresql/data"]
194+
195+# Multi-volume example
196+FROM myapp:latest
197+VOLUME ["/app/data", "/app/logs", "/app/uploads"]
198+```
199+
200+#### Volume Features
201+- **Zero Configuration**: Reads VOLUME directives automatically
202+- **Persistent Storage**: Data survives container restarts and updates
203+- **Predictable Naming**: `gordon-{domain}-{path-hash}` format
204+- **Cross-Platform**: Works with Docker and Podman
205+
206+#### Volume Configuration (Optional)
207+```toml
208+[volumes]
209+auto_create = true # Default: true (automatically handle VOLUME directives)
210+prefix = "gordon" # Default: "gordon" (volume name prefix)
211+preserve = true # Default: true (keep volumes when containers are removed)
212+```
213+
214+#### Complete Dockerfile Example
215+```dockerfile
216+FROM node:18-alpine
217+WORKDIR /app
218+
219+# Environment variables (automatically merged with your .env files)
220+ENV NODE_ENV=production
221+ENV PORT=3000
222+ENV LOG_LEVEL=info
223+
224+# Install dependencies
225+COPY package*.json ./
226+RUN npm install
227+
228+# Copy application code
229+COPY . .
230+
231+# Persistent storage (automatically managed by Gordon)
232+VOLUME ["/app/data", "/app/logs"]
233+
234+# Network configuration
235+EXPOSE 3000
236+
237+# Start command
238+CMD ["npm", "start"]
239+```
240+
241+When you deploy this image:
242+- Gordon reads ENV directives and merges with your .env files
243+- Gordon creates volumes for `/app/data` and `/app/logs`
244+- Your data persists across deployments and reboots
245+- Environment variables are intelligently managed
246+
247+## 🌐 DNS Configuration
248+
249+### Cloudflare Setup (Recommended)
250+For all examples except development, set up these DNS records:
251+
252+```
253+Type Name Content
254+A app.your-domain.com YOUR_SERVER_IP
255+A api.your-domain.com YOUR_SERVER_IP
256+A registry.your-domain.com YOUR_SERVER_IP
257+```
258+
259+Or use a wildcard:
260+```
261+Type Name Content
262+A *.your-domain.com YOUR_SERVER_IP
263+```
264+
265+### Local Development
266+For the development example, add to `/etc/hosts`:
267+```
268+127.0.0.1 app.local
269+127.0.0.1 api.local
270+127.0.0.1 admin.local
271+```
272+
273+## 🔄 Workflow Examples
274+
275+### Simple Deployment
276+```bash
277+# Build your app
278+export VERSION=latest
279+docker build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
280+
281+# Push to deploy
282+docker push registry.your-domain.com/myapp:$VERSION
283+```
284+
285+### Version Management
286+```bash
287+# Build and push specific version
288+export VERSION=v1.2.0
289+docker build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
290+docker push registry.your-domain.com/myapp:$VERSION
291+
292+# Update config
293+# "app.your-domain.com" = "myapp:v1.2.0"
294+```
295+
296+### Simple Version Deployment with Manifest Annotations
297+```bash
298+# Build and push your versions first
299+export VERSION=v1.1.0
300+podman build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
301+podman push registry.your-domain.com/myapp:$VERSION
302+
303+export VERSION=v1.2.0
304+podman build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
305+podman push registry.your-domain.com/myapp:$VERSION
306+
307+# Deploy v1.2.0
308+export VERSION=v1.2.0
309+podman manifest create myapp:latest
310+podman manifest add myapp:latest registry.your-domain.com/myapp:$VERSION
311+podman manifest annotate myapp:latest --annotation version=$VERSION registry.your-domain.com/myapp:$VERSION
312+podman manifest push myapp:latest registry.your-domain.com/myapp:latest
313+
314+# Rollback to v1.1.0 (just change the version)
315+export VERSION=v1.1.0
316+podman manifest create myapp:latest --amend
317+podman manifest add myapp:latest registry.your-domain.com/myapp:$VERSION
318+podman manifest annotate myapp:latest --annotation version=$VERSION registry.your-domain.com/myapp:$VERSION
319+podman manifest push myapp:latest registry.your-domain.com/myapp:latest
320+```
321+
322+See [`rollback-workflow-example.md`](rollback-workflow-example.md) for complete version deployment workflow documentation.
323+
324+## 🤝 Need Help?
325+
326+- 📖 **Read the main [README](../README.md)** for detailed documentation
327+- 🔍 **Check the logs** for deployment issues
328+- 🐛 **Open an issue** if you find problems with these examples
329+- 💡 **Contribute** your own configuration examples!
330+
331+---
332+*These examples are starting points - customize them for your specific needs!*⏎̸
A examples/development.toml +157 -0
@@ -0,0 +1,157 @@
1+# Gordon v2 - Development Configuration Example
2+# For local development and testing
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.yourdomain.com" # Your Gordon registry domain
8+runtime = "auto" # auto-detects Docker/Podman
9+ssl_email = "dev@localhost"
10+data_dir = "./dev-data"
11+
12+[registry_auth]
13+enabled = false # Disable auth for easier local development
14+
15+[auto_route]
16+enabled = true # Enable automatic route creation from image names
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+
24+# Logging configuration for development
25+[logging]
26+enabled = true # Enable file-based logging
27+level = "debug" # Verbose logging for development
28+dir = "./dev-data/logs" # Store logs in dev data directory
29+main_log_file = "gordon.log" # Main application logs
30+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
31+container_log_dir = "containers" # Container logs subdirectory
32+
33+# Log rotation settings - smaller for development
34+max_size = 10 # Max 10MB per log file
35+max_backups = 3 # Keep 3 old files
36+max_age = 7 # Keep logs for 1 week
37+compress = true # Compress old logs
38+
39+# Environment variables configuration
40+[env]
41+# Directory where .env files are stored for each route
42+dir = "./dev-data/env"
43+# Secret providers (optional for development)
44+providers = ["pass"] # For development secrets
45+
46+# Development routes using your domain
47+[routes]
48+"app.yourdomain.com" = "myapp:latest" # Main app - latest build
49+"api.yourdomain.com" = "myapi:latest" # API service
50+"admin.yourdomain.com" = "admin:latest" # Admin interface
51+"docs.yourdomain.com" = "docs:latest" # Documentation
52+
53+# Test different versions
54+"app-v1.yourdomain.com" = "myapp:v1.0.0" # Test older version
55+"app-v2.yourdomain.com" = "myapp:v2.0.0" # Test newer version
56+
57+# Third-party services for development
58+"db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin
59+"mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing
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+
83+# Environment Variables Usage:
84+#
85+# For development, create .env files for your routes:
86+# ./dev-data/env/app_yourdomain_com.env
87+# ./dev-data/env/api_yourdomain_com.env
88+#
89+# Example env file content:
90+# NODE_ENV=development
91+# DEBUG=true
92+# DATABASE_URL=postgresql://localhost:5432/dev
93+# API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets
94+
95+# Development workflow:
96+# 1. Point DNS to your Gordon server:
97+# A *.yourdomain.com → YOUR_GORDON_SERVER_IP
98+# A yourdomain.com → YOUR_GORDON_SERVER_IP
99+#
100+# 2. Build your app: docker build -t myapp:latest .
101+# 3. Create env file: ./dev-data/env/app_yourdomain_com.env
102+# 4. Push to Gordon registry:
103+# docker tag myapp:latest registry.yourdomain.com/myapp:latest
104+# docker push registry.yourdomain.com/myapp:latest
105+# 5. Access at https://app.yourdomain.com
106+#
107+# AUTO-ROUTE FEATURE:
108+# With auto_route.enabled = true, you can also push images with domain names as image names:
109+# docker build -t myapp .
110+# docker tag myapp:latest registry.yourdomain.com/mytestapp.bamen.dev:latest
111+# docker push registry.yourdomain.com/mytestapp.bamen.dev:latest
112+# Gordon will automatically create a route for mytestapp.bamen.dev -> mytestapp.bamen.dev:latest
113+#
114+# VOLUME FEATURE:
115+# Add VOLUME directives to your Dockerfile for automatic persistent storage:
116+# VOLUME ["/app/data", "/app/uploads"]
117+# Gordon automatically creates and mounts persistent volumes - no config needed!
118+#
119+# ENV DIRECTIVE FEATURE:
120+# Add ENV directives to your Dockerfile for default environment variables:
121+# ENV NODE_ENV=production
122+# ENV PORT=3000
123+# ENV LOG_LEVEL=info
124+# Gordon automatically reads these and merges them with your .env files!
125+# Your .env files always override Dockerfile ENV directives.
126+#
127+# Example Dockerfile with both features:
128+# FROM node:18-alpine
129+# WORKDIR /app
130+# ENV NODE_ENV=production
131+# ENV PORT=3000
132+# VOLUME ["/app/data", "/app/logs"]
133+# COPY . .
134+# EXPOSE 3000
135+# CMD ["npm", "start"]
136+#
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⏎̸
A examples/docker-compose.yml +108 -0
@@ -0,0 +1,108 @@
1+# Gordon v2 - Docker Compose Deployment Example
2+# Complete containerized deployment with proper networking
3+
4+version: '3.8'
5+
6+services:
7+ # Main Gordon service
8+ gordon:
9+ image: gordon:latest
10+ container_name: gordon
11+ restart: unless-stopped
12+
13+ # Port mapping for Cloudflare → Gordon
14+ ports:
15+ - "80:8080" # HTTP traffic (Cloudflare terminates SSL)
16+ - "443:8080" # HTTPS traffic (Cloudflare → HTTP to Gordon)
17+
18+ volumes:
19+ # Docker socket access for container management
20+ - /var/run/docker.sock:/var/run/docker.sock
21+
22+ # Data persistence
23+ - ./gordon-data:/data
24+
25+ # Configuration
26+ - ./gordon.toml:/app/gordon.toml:ro
27+
28+ # Optional: Mount Docker registry data
29+ - ./registry-data:/data/registry
30+
31+ environment:
32+ - GORDON_LOG_LEVEL=info
33+ - GORDON_CONFIG=/app/gordon.toml
34+
35+ # Health check
36+ healthcheck:
37+ test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
38+ interval: 30s
39+ timeout: 10s
40+ retries: 3
41+ start_period: 40s
42+
43+ networks:
44+ - gordon-network
45+
46+ # Security
47+ user: "1000:1000" # Run as non-root user
48+
49+ # Resource limits (optional)
50+ deploy:
51+ resources:
52+ limits:
53+ memory: 512M
54+ cpus: '0.5'
55+ reservations:
56+ memory: 256M
57+ cpus: '0.25'
58+
59+ # Optional: Monitoring with Prometheus
60+ prometheus:
61+ image: prom/prometheus:latest
62+ container_name: gordon-prometheus
63+ restart: unless-stopped
64+ ports:
65+ - "9090:9090"
66+ volumes:
67+ - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
68+ - prometheus-data:/prometheus
69+ command:
70+ - '--config.file=/etc/prometheus/prometheus.yml'
71+ - '--storage.tsdb.path=/prometheus'
72+ - '--web.console.libraries=/etc/prometheus/console_libraries'
73+ - '--web.console.templates=/etc/prometheus/consoles'
74+ - '--web.enable-lifecycle'
75+ networks:
76+ - gordon-network
77+
78+ # Optional: Grafana dashboard
79+ grafana:
80+ image: grafana/grafana:latest
81+ container_name: gordon-grafana
82+ restart: unless-stopped
83+ ports:
84+ - "3000:3000"
85+ volumes:
86+ - grafana-data:/var/lib/grafana
87+ environment:
88+ - GF_SECURITY_ADMIN_PASSWORD=admin123
89+ networks:
90+ - gordon-network
91+
92+networks:
93+ gordon-network:
94+ driver: bridge
95+ ipam:
96+ config:
97+ - subnet: 172.20.0.0/16
98+
99+volumes:
100+ prometheus-data:
101+ grafana-data:
102+
103+# Usage:
104+# 1. Create gordon.toml with your configuration
105+# 2. Run: docker-compose up -d
106+# 3. Check health: docker-compose ps
107+# 4. View logs: docker-compose logs -f gordon
108+# 5. Scale if needed: docker-compose up -d --scale gordon=2⏎̸
A examples/logging.toml +180 -0
@@ -0,0 +1,180 @@
1+# Gordon v2 - Comprehensive Logging Configuration Example
2+# Demonstrates all logging features and configurations
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.yourdomain.com"
8+runtime = "auto"
9+data_dir = "./data"
10+
11+[registry_auth]
12+enabled = true
13+username = "admin"
14+password = "secure-password"
15+
16+# ============================================================================
17+# LOGGING CONFIGURATION EXAMPLES
18+# ============================================================================
19+
20+# Basic logging setup - good for most users
21+[logging]
22+enabled = true # Enable file-based logging (default: false)
23+level = "info" # Log level: trace, debug, info, warn, error (default: info)
24+dir = "./logs" # Directory for log files (default: ./logs)
25+main_log_file = "gordon.log" # Main application log file (default: gordon.log)
26+proxy_log_file = "proxy.log" # HTTP proxy traffic log file (default: proxy.log)
27+container_log_dir = "containers" # Container logs subdirectory (default: containers)
28+
29+# Log rotation settings
30+max_size = 100 # Max log file size in MB before rotation (default: 100)
31+max_backups = 5 # Number of old log files to keep (default: 5)
32+max_age = 30 # Max age in days to keep old log files (default: 30)
33+compress = true # Compress old log files with gzip (default: true)
34+
35+# ============================================================================
36+# ALTERNATIVE LOGGING CONFIGURATIONS
37+# ============================================================================
38+
39+# Development logging configuration (uncomment to use)
40+# [logging]
41+# enabled = true
42+# level = "debug" # More verbose for debugging
43+# dir = "./dev-logs"
44+# main_log_file = "gordon-dev.log"
45+# proxy_log_file = "proxy-dev.log"
46+# container_log_dir = "containers"
47+# max_size = 10 # Smaller files for development
48+# max_backups = 3 # Less history needed
49+# max_age = 7 # Keep logs for 1 week
50+# compress = false # No compression for faster access
51+
52+# Production logging configuration (uncomment to use)
53+# [logging]
54+# enabled = true
55+# level = "warn" # Only warnings and errors in production
56+# dir = "/var/log/gordon" # System log directory
57+# main_log_file = "gordon.log"
58+# proxy_log_file = "proxy.log"
59+# container_log_dir = "containers"
60+# max_size = 200 # Larger files for production
61+# max_backups = 15 # More history for analysis
62+# max_age = 90 # Keep logs for 3 months
63+# compress = true # Save disk space
64+
65+# High-traffic logging configuration (uncomment to use)
66+# [logging]
67+# enabled = true
68+# level = "error" # Only errors to reduce log volume
69+# dir = "/var/log/gordon"
70+# main_log_file = "gordon.log"
71+# proxy_log_file = "proxy.log"
72+# container_log_dir = "containers"
73+# max_size = 500 # Very large files for high traffic
74+# max_backups = 20 # Extensive history
75+# max_age = 180 # Keep logs for 6 months
76+# compress = true # Essential for large files
77+
78+# Minimal logging configuration (uncomment to use)
79+# [logging]
80+# enabled = true
81+# level = "error" # Only log errors
82+# dir = "./logs"
83+# max_size = 50 # Small files
84+# max_backups = 2 # Minimal history
85+# max_age = 14 # 2 weeks retention
86+# compress = true
87+
88+# Disable logging (console output only) - uncomment to use
89+# [logging]
90+# enabled = false # Only console output, no files
91+
92+# ============================================================================
93+# LOG STRUCTURE CREATED BY GORDON
94+# ============================================================================
95+#
96+# When logging is enabled, Gordon creates this structure:
97+#
98+# ./logs/ # Main log directory (permissions: 0700)
99+# ├── gordon.log # Main application logs (permissions: 0600)
100+# ├── gordon.log.1.gz # Rotated logs (compressed)
101+# ├── gordon.log.2.gz
102+# ├── proxy.log # HTTP proxy traffic logs (permissions: 0600)
103+# ├── proxy.log.1.gz # Rotated proxy logs
104+# └── containers/ # Container logs directory (permissions: 0700)
105+# ├── abc123def456.log # Container logs by ID (permissions: 0600)
106+# ├── def456ghi789.log
107+# ├── myapp_yourdomain_com.log # Symlink to container ID for easier access
108+# └── api_yourdomain_com.log # Symlink to container ID for easier access
109+#
110+# ============================================================================
111+# WHAT GETS LOGGED
112+# ============================================================================
113+#
114+# Main Application Logs (gordon.log):
115+# - Server startup and shutdown events
116+# - Container lifecycle events (deploy, start, stop, remove)
117+# - Configuration changes and hot reloads
118+# - Registry operations (push/pull events)
119+# - Error conditions and warnings
120+# - Authentication events
121+# - Auto-route creation events
122+#
123+# Proxy Logs (proxy.log):
124+# - All HTTP/HTTPS requests through the reverse proxy
125+# - Request method, URL, response code, response time
126+# - Client IP addresses and user agents
127+# - SSL/TLS connection information
128+# - Upstream container routing decisions
129+#
130+# Container Logs (containers/*.log):
131+# - Real-time stdout/stderr from each container
132+# - Timestamped application output
133+# - Container startup and shutdown messages
134+# - Application errors and debug output
135+# - Custom application logging
136+#
137+# ============================================================================
138+# SECURITY FEATURES
139+# ============================================================================
140+#
141+# - Log directories created with 0700 permissions (owner-only access)
142+# - Log files created with 0600 permissions (owner read/write only)
143+# - Automatic log rotation prevents disk space exhaustion
144+# - Sensitive information (passwords, tokens) is automatically redacted
145+# - Container logs are isolated per container for security analysis
146+#
147+# ============================================================================
148+# LOG MONITORING COMMANDS
149+# ============================================================================
150+#
151+# Real-time monitoring:
152+# tail -f logs/gordon.log # Watch main application logs
153+# tail -f logs/proxy.log # Monitor HTTP traffic
154+# tail -f logs/containers/*.log # Follow all container logs
155+#
156+# Log analysis:
157+# grep -i error logs/gordon.log # Find errors in main logs
158+# grep "deploy" logs/gordon.log # Find deployment events
159+# awk '{print $9}' logs/proxy.log | sort | uniq -c # HTTP status code analysis
160+# grep "container" logs/gordon.log | tail -20 # Recent container events
161+#
162+# ============================================================================
163+
164+[routes]
165+"app.yourdomain.com" = "myapp:latest"
166+"api.yourdomain.com" = "myapi:latest"
167+"logs.yourdomain.com" = "logviewer:latest" # Example log viewing service
168+
169+# Example environment variables configuration
170+[env]
171+dir = "./data/env"
172+providers = ["pass"]
173+
174+# With comprehensive logging enabled, you can:
175+# 1. Monitor all HTTP traffic to your applications
176+# 2. Debug container deployment issues
177+# 3. Analyze application performance
178+# 4. Track security events and access patterns
179+# 5. Maintain audit trails for compliance
180+# 6. Troubleshoot network and routing issues⏎̸
A examples/minimal.toml +47 -0
@@ -0,0 +1,47 @@
1+# Gordon v2 - Minimal Configuration Example
2+# Perfect for getting started
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.local"
8+
9+[registry_auth]
10+enabled = false # Disabled for simplicity
11+
12+# Optional: Enable basic logging
13+# [logging]
14+# enabled = true
15+# level = "info"
16+# dir = "./logs"
17+
18+# Environment variables (optional)
19+[env]
20+dir = "./data/env" # Default location
21+
22+# Simple routes
23+[routes]
24+"app.local" = "myapp:latest"
25+"api.local" = "myapi:latest"
26+
27+# Usage:
28+# 1. Create env files (optional):
29+# ./data/env/app_local.env
30+# ./data/env/api_local.env
31+#
32+# 2. Example env file content:
33+# NODE_ENV=production
34+# PORT=3000
35+# DATABASE_URL=postgresql://localhost:5432/app
36+#
37+# 3. Add to /etc/hosts:
38+# 127.0.0.1 app.local
39+# 127.0.0.1 api.local
40+# 127.0.0.1 registry.local
41+#
42+# 4. Build and push:
43+# docker build -t myapp:latest .
44+# docker tag myapp:latest registry.local/myapp:latest
45+# docker push registry.local/myapp:latest
46+#
47+# 5. Access at http://app.local:8080⏎̸
A examples/production.toml +117 -0
@@ -0,0 +1,117 @@
1+# Gordon v2 - Production Configuration Example
2+# Optimized for production environments with multiple services
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.company.com"
8+runtime = "auto" # auto-detects Docker/Podman
9+# socket_path = "" # optional: override with custom socket path
10+ssl_email = "devops@company.com"
11+data_dir = "/var/lib/gordon"
12+
13+[registry_auth]
14+enabled = true
15+username = "deploy-bot"
16+password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!
17+
18+# Production logging configuration
19+[logging]
20+enabled = true # Enable comprehensive logging
21+level = "info" # Standard production logging level
22+dir = "/var/log/gordon" # System log directory
23+main_log_file = "gordon.log" # Main application logs
24+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
25+container_log_dir = "containers" # Container logs subdirectory
26+
27+# Production log rotation settings
28+max_size = 100 # Max 100MB per log file
29+max_backups = 10 # Keep 10 old files for history
30+max_age = 90 # Keep logs for 3 months
31+compress = true # Compress old logs to save space
32+
33+# Environment variables configuration
34+[env]
35+# Directory where .env files are stored for each route
36+dir = "/var/lib/gordon/env"
37+# Secret providers for secure credential management
38+providers = ["pass", "sops"]
39+
40+# Production service routes
41+[routes]
42+"app.company.com" = "company-app:v2.1.0" # Main app with pinned version
43+"api.company.com" = "company-api:v1.5.2" # API service
44+"admin.company.com" = "admin-panel:v1.0.1" # Admin interface
45+"docs.company.com" = "company-docs:latest" # Documentation site
46+"status.company.com" = "status-page:v0.3.0" # Status page
47+
48+# Monitoring and internal tools
49+"grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard
50+"prometheus.company.com" = "prom/prometheus:latest" # Metrics collection
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+
74+# Environment Variables Usage:
75+#
76+# For each route, create a .env file named after the domain:
77+# /var/lib/gordon/env/app_company_com.env
78+# /var/lib/gordon/env/api_company_com.env
79+#
80+# Example env file content:
81+# NODE_ENV=production
82+# DATABASE_URL=postgresql://user:pass@db:5432/prod
83+# API_KEY=${pass:company/api-key} # From pass
84+# SECRET_KEY=${sops:secrets.yaml:app.secret} # From sops
85+#
86+# Secret syntax:
87+# ${pass:path/to/secret} - Unix password manager
88+# ${sops:file.yaml:key.path} - SOPS encrypted file
89+
90+# Production deployment workflow:
91+# 1. Build and tag: docker build -t company-app:v2.1.1 .
92+# 2. Tag for registry: docker tag company-app:v2.1.1 registry.company.com/company-app:v2.1.1
93+# 3. Update this config: "app.company.com" = "company-app:v2.1.1"
94+# 4. Create/update env file: /var/lib/gordon/env/app_company_com.env
95+# 5. Restart Gordon to reload config
96+# 6. Push to deploy: docker push registry.company.com/company-app:v2.1.1
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"]⏎̸
A examples/rollback-workflow-example.md +192 -0
@@ -0,0 +1,192 @@
1+# Gordon Version Deployment Workflow Example
2+
3+This document demonstrates how to use manifest annotations for simple version deployments and rollbacks in Gordon.
4+
5+## Prerequisites
6+
7+- Gordon server running with the registry and auto-route features enabled
8+- Podman or Docker with OCI manifest support
9+- A container image to deploy
10+
11+## Basic Versioned Deployment
12+
13+### 1. Build and Push Your Versioned Images
14+
15+```bash
16+# Build and push your application images with versions
17+export VERSION=v1.0.1
18+podman build --tag myapp:$VERSION --tag registry.domain.com/myapp:$VERSION .
19+podman push registry.domain.com/myapp:$VERSION
20+
21+export VERSION=v1.0.2
22+podman build --tag myapp:$VERSION --tag registry.domain.com/myapp:$VERSION .
23+podman push registry.domain.com/myapp:$VERSION
24+```
25+
26+### 2. Create Manifest and Deploy
27+
28+```bash
29+# Deploy v1.0.1 with version annotation
30+export VERSION=v1.0.1
31+podman manifest create myapp:latest
32+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
33+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
34+podman manifest push myapp:latest registry.domain.com/myapp:latest
35+```
36+
37+### 3. Gordon Processing
38+
39+When Gordon receives the manifest, it will:
40+
41+1. Parse the manifest annotations from the JSON structure:
42+ ```json
43+ {
44+ "schemaVersion": 2,
45+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
46+ "annotations": {
47+ "version": "v1.0.1"
48+ }
49+ }
50+ ```
51+
52+2. Extract version information and deploy the specified version
53+3. Update routes to use the versioned image
54+
55+## Simple Version Deployment and Rollback
56+
57+### Deploying Different Versions
58+
59+```bash
60+# Deploy v1.0.2
61+export VERSION=v1.0.2
62+podman manifest create myapp:latest --amend
63+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
64+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
65+podman manifest push myapp:latest registry.domain.com/myapp:latest
66+
67+# Rollback to v1.0.1 (just change the version)
68+export VERSION=v1.0.1
69+podman manifest create myapp:latest --amend
70+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
71+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
72+podman manifest push myapp:latest registry.domain.com/myapp:latest
73+
74+# Deploy v1.0.3 (assuming you built and pushed it first)
75+export VERSION=v1.0.3
76+podman manifest create myapp:latest --amend
77+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
78+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
79+podman manifest push myapp:latest registry.domain.com/myapp:latest
80+```
81+
82+### Gordon Version Processing
83+
84+When Gordon detects version annotations, it will:
85+
86+1. **Parse version annotations**: Extract the version from the `version` annotation
87+2. **Execute deployment**: Deploy the specified version to all matching routes
88+3. **Log the deployment**
89+
90+**Example log output**:
91+```
92+INFO Processing versioned deployment image=myapp reference=latest version=v1.0.1
93+INFO Performing version deployment route=myapp.domain.com versioned_image=myapp:v1.0.1
94+INFO Version deployment completed successfully route=myapp.domain.com version=v1.0.1
95+```
96+
97+## Supported Annotation Keys
98+
99+### Version Annotations
100+- `version` - The version to deploy (required)
101+
102+## Real-World Example
103+
104+```bash
105+# Deploy production v1.0.1
106+export VERSION=v1.0.1
107+podman manifest create myapp:latest --amend
108+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
109+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
110+podman manifest push myapp:latest registry.domain.com/myapp:latest
111+
112+# Deploy new version v1.0.2
113+export VERSION=v1.0.2
114+podman manifest create myapp:latest --amend
115+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
116+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
117+podman manifest push myapp:latest registry.domain.com/myapp:latest
118+
119+# Oh no! Bug found - rollback to v1.0.1
120+export VERSION=v1.0.1
121+podman manifest create myapp:latest --amend
122+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
123+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
124+podman manifest push myapp:latest registry.domain.com/myapp:latest
125+
126+# Fix deployed - move forward to v1.0.3
127+export VERSION=v1.0.3
128+podman manifest create myapp:latest --amend
129+podman manifest add myapp:latest registry.domain.com/myapp:$VERSION
130+podman manifest annotate myapp:latest --annotation version=$VERSION registry.domain.com/myapp:$VERSION
131+podman manifest push myapp:latest registry.domain.com/myapp:latest
132+```
133+
134+## Configuration Requirements
135+
136+Ensure your Gordon configuration supports this workflow:
137+
138+```toml
139+[auto_route]
140+enabled = true
141+
142+[registry_auth]
143+enabled = true
144+username = "gordon"
145+password = "your-password"
146+
147+[server]
148+registry_domain = "registry.domain.com"
149+```
150+
151+## Monitoring and Observability
152+
153+Gordon logs provide visibility into the version deployment process:
154+
155+- Manifest annotation parsing
156+- Version detection and tracking
157+- Deployment execution
158+- Success/failure status
159+
160+Check Gordon logs for detailed version deployment information:
161+
162+```bash
163+# View Gordon logs for version operations
164+journalctl -u gordon -f | grep -i version
165+
166+# Or check Docker/Podman logs if running in container
167+docker logs gordon-container | grep -i version
168+```
169+
170+## Troubleshooting
171+
172+### Common Issues
173+
174+1. **Annotations not parsed**: Ensure you're using OCI manifest format, not Docker v2.2
175+2. **Version target not found**: Verify the target version exists in your registry
176+3. **Route not found**: Ensure the image has configured routes in Gordon
177+4. **Permission errors**: Check registry authentication and permissions
178+
179+### Debugging Commands
180+
181+```bash
182+# Inspect manifest annotations
183+podman manifest inspect myapp:latest
184+
185+# Check Gordon configuration
186+gordon config show
187+
188+# Verify image exists
189+podman search registry.domain.com/myapp
190+```
191+
192+This version deployment workflow provides a simple, declarative way to manage deployments and rollbacks using standard container registry operations and OCI manifest annotations.⏎̸
A examples/saas-multi-tenant.toml +51 -0
@@ -0,0 +1,51 @@
1+# Gordon v2 - Multi-Tenant SaaS Configuration Example
2+# For SaaS platforms with customer subdomains
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.saas-platform.com"
8+runtime = "auto" # auto-detects Docker/Podman
9+ssl_email = "ops@saas-platform.com"
10+data_dir = "/var/lib/gordon"
11+
12+[registry_auth]
13+enabled = true
14+username = "deploy"
15+password = "secure-deployment-key"
16+
17+# Multi-tenant customer routes
18+[routes]
19+# Main platform services
20+"app.saas-platform.com" = "saas-frontend:v2.1.0" # Marketing site
21+"admin.saas-platform.com" = "saas-admin:v1.5.0" # Admin dashboard
22+"api.saas-platform.com" = "saas-api:v3.2.1" # Main API
23+
24+# Customer tenant subdomains
25+"acme-corp.saas-platform.com" = "saas-app:v2.1.0" # Customer: Acme Corp
26+"beta-inc.saas-platform.com" = "saas-app:v2.1.0" # Customer: Beta Inc
27+"gamma-llc.saas-platform.com" = "saas-app:v2.1.0" # Customer: Gamma LLC
28+
29+# Enterprise customers with custom domains
30+"portal.acme-corp.com" = "saas-app:v2.1.0" # Acme's custom domain
31+"app.beta-inc.com" = "saas-app:v2.1.0" # Beta's custom domain
32+
33+# Staging/testing tenants
34+"demo.saas-platform.com" = "saas-app:latest" # Demo environment
35+"staging-tenant.saas-platform.com" = "saas-app:staging" # Staging tests
36+
37+# Support tools
38+"status.saas-platform.com" = "status-page:v1.0.0" # Status page
39+"docs.saas-platform.com" = "documentation:v2.0.0" # Customer docs
40+
41+# Multi-tenant deployment strategy:
42+# 1. Single application image serves all tenants
43+# 2. Tenant identification via subdomain/domain
44+# 3. Application handles tenant routing internally
45+# 4. Each tenant gets isolated data/configuration
46+# 5. Rolling updates deploy to all tenants simultaneously
47+#
48+# Scaling strategy:
49+# - Start with shared containers (same image, tenant routing)
50+# - Move to dedicated containers per tenant if needed
51+# - Use Gordon's event system for tenant provisioning automation⏎̸
A examples/staging.toml +55 -0
@@ -0,0 +1,55 @@
1+# Gordon v2 - Staging Configuration Example
2+# For testing and preview environments
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "staging-registry.company.com"
8+runtime = "auto" # auto-detects Docker/Podman
9+ssl_email = "devops@company.com"
10+data_dir = "./data"
11+
12+[registry_auth]
13+enabled = true
14+username = "staging-user"
15+password = "staging-pass-123"
16+
17+# Staging logging configuration
18+[logging]
19+enabled = true # Enable logging for staging debugging
20+level = "debug" # Verbose logging for troubleshooting
21+dir = "./staging-logs" # Staging-specific log directory
22+main_log_file = "gordon.log" # Main application logs
23+proxy_log_file = "proxy.log" # HTTP proxy traffic logs
24+container_log_dir = "containers" # Container logs subdirectory
25+
26+# Staging log rotation settings
27+max_size = 50 # Moderate file sizes for staging
28+max_backups = 5 # Keep reasonable history
29+max_age = 30 # Keep logs for 1 month
30+compress = true # Compress to save space
31+
32+# Staging environment routes - using branch-based deployments
33+[routes]
34+"staging.company.com" = "company-app:staging" # Main staging branch
35+"staging-api.company.com" = "company-api:staging" # API staging
36+
37+# Feature branch testing
38+"feature-auth.company.com" = "company-app:feature-auth" # Feature branch: auth improvements
39+"feature-ui.company.com" = "company-app:feature-ui-redesign" # Feature branch: UI updates
40+"hotfix-123.company.com" = "company-app:hotfix-user-login" # Hotfix branch
41+
42+# PR preview environments
43+"pr-42.company.com" = "company-app:pr-42" # Pull request #42 preview
44+"pr-55.company.com" = "company-app:pr-55" # Pull request #55 preview
45+
46+# Development tools
47+"staging-admin.company.com" = "admin-panel:develop" # Admin panel dev version
48+
49+# Staging workflow:
50+# 1. Developer pushes feature branch
51+# 2. CI builds and tags: company-app:feature-auth
52+# 3. CI pushes: docker push staging-registry.company.com/company-app:feature-auth
53+# 4. Gordon auto-deploys to feature-auth.company.com
54+# 5. Team tests at https://feature-auth.company.com
55+# 6. Merge to staging → auto-deploy to staging.company.com⏎̸
M go.mod +49 -86
@@ -1,104 +1,67 @@
1-module github.com/bnema/gordon
1+module gordon
22
3-go 1.23
3+go 1.24.3
44
55 require (
6- github.com/AlecAivazis/survey/v2 v2.3.7
7- github.com/Masterminds/semver/v3 v3.3.1
8- github.com/PuerkitoBio/goquery v1.9.2
9- github.com/charmbracelet/bubbles v0.20.0
10- github.com/charmbracelet/lipgloss v0.13.0
11- github.com/charmbracelet/log v0.4.0
12- github.com/docker/docker v27.1.2+incompatible
6+ github.com/docker/docker v28.2.2+incompatible
137 github.com/docker/go-connections v0.5.0
14- github.com/fatih/color v1.17.0
15- github.com/gorilla/sessions v1.4.0
16- github.com/joho/godotenv v1.5.1
17- github.com/labstack/echo-contrib v0.17.1
18- github.com/labstack/echo/v4 v4.12.0
19- github.com/microcosm-cc/bluemonday v1.0.27
20- github.com/pocketbase/pocketbase v0.22.19
21- github.com/spf13/cobra v1.8.1
22- gopkg.in/yaml.v3 v3.0.1
23- modernc.org/sqlite v1.32.0
8+ github.com/google/uuid v1.6.0
9+ github.com/rs/zerolog v1.34.0
10+ github.com/spf13/cobra v1.9.1
11+ github.com/spf13/viper v1.20.1
12+ github.com/stretchr/testify v1.10.0
13+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
2414 )
2515
2616 require (
27- github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
28- github.com/charmbracelet/bubbletea v1.1.0 // direct
29- github.com/charmbracelet/x/ansi v0.2.3 // indirect
30- github.com/charmbracelet/x/term v0.2.0 // indirect
17+ github.com/davecgh/go-spew v1.1.1 // indirect
18+ github.com/pmezard/go-difflib v1.0.0 // indirect
19+ github.com/stretchr/objx v0.5.2 // indirect
20+ go.uber.org/mock v0.5.2 // direct
21+ golang.org/x/time v0.12.0 // indirect
22+)
23+
24+require (
25+ github.com/Microsoft/go-winio v0.4.14 // indirect
26+ github.com/containerd/errdefs v1.0.0 // indirect
27+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
3128 github.com/containerd/log v0.1.0 // indirect
32- github.com/creack/pty v1.1.18 // indirect
3329 github.com/distribution/reference v0.6.0 // indirect
34- github.com/dustin/go-humanize v1.0.1 // indirect
35- github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
30+ github.com/docker/go-units v0.5.0 // indirect
3631 github.com/felixge/httpsnoop v1.0.4 // indirect
37- github.com/go-logfmt/logfmt v0.6.0 // indirect
32+ github.com/fsnotify/fsnotify v1.8.0 // direct
3833 github.com/go-logr/logr v1.4.2 // indirect
3934 github.com/go-logr/stdr v1.2.2 // indirect
40- github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
41- github.com/inconshreveable/mousetrap v1.1.0 // indirect
42- github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
43- github.com/kr/pretty v0.3.1 // indirect
44- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
45- github.com/mattn/go-localereader v0.0.1 // indirect
46- github.com/mattn/go-runewidth v0.0.16 // indirect
47- github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
48- github.com/moby/docker-image-spec v1.3.1 // indirect
49- github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
50- github.com/muesli/cancelreader v0.2.2 // indirect
51- github.com/muesli/termenv v0.15.2 // indirect
52- github.com/ncruces/go-strftime v0.1.9 // indirect
53- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
54- github.com/rivo/uniseg v0.4.7 // indirect
55- github.com/spf13/pflag v1.0.5 // indirect
56- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
57- go.opentelemetry.io/otel v1.29.0 // indirect
58- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
59- go.opentelemetry.io/otel/metric v1.29.0 // indirect
60- go.opentelemetry.io/otel/sdk v1.21.0 // indirect
61- go.opentelemetry.io/otel/trace v1.29.0 // indirect
62- golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct
63- golang.org/x/sync v0.8.0 // indirect
64- golang.org/x/term v0.23.0 // indirect
65- google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
66- modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
67- modernc.org/libc v1.59.9 // indirect
68- modernc.org/mathutil v1.6.0 // indirect
69- modernc.org/memory v1.8.0 // indirect
70- modernc.org/strutil v1.2.0 // indirect
71- modernc.org/token v1.1.0 // indirect
72-)
73-
74-require (
75- github.com/Microsoft/go-winio v0.6.2 // indirect
76- github.com/andybalholm/cascadia v1.3.2 // indirect
77- github.com/aymerick/douceur v0.2.0 // indirect
78- github.com/docker/go-units v0.5.0 // indirect
35+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
7936 github.com/gogo/protobuf v1.3.2 // indirect
80- github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
81- github.com/google/uuid v1.6.0 // direct
82- github.com/gorilla/context v1.1.2 // indirect
83- github.com/gorilla/css v1.0.1 // indirect
84- github.com/gorilla/securecookie v1.1.2 // indirect
85- github.com/labstack/gommon v0.4.2 // indirect
37+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
8638 github.com/mattn/go-colorable v0.1.13 // indirect
87- github.com/mattn/go-isatty v0.0.20 // indirect
88- github.com/moby/term v0.5.0 // indirect
39+ github.com/mattn/go-isatty v0.0.19 // indirect
40+ github.com/moby/docker-image-spec v1.3.1 // indirect
41+ github.com/moby/sys/atomicwriter v0.1.0 // indirect
42+ github.com/moby/term v0.5.2 // indirect
8943 github.com/morikuni/aec v1.0.0 // indirect
9044 github.com/opencontainers/go-digest v1.0.0 // indirect
91- github.com/opencontainers/image-spec v1.1.0 // indirect
45+ github.com/opencontainers/image-spec v1.1.1 // indirect
46+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
9247 github.com/pkg/errors v0.9.1 // indirect
93- github.com/rogpeppe/go-internal v1.11.0 // indirect
94- github.com/valyala/bytebufferpool v1.0.0 // indirect
95- github.com/valyala/fasttemplate v1.2.2 // indirect
96- golang.org/x/crypto v0.26.0 // indirect
97- golang.org/x/net v0.28.0 // indirect
98- golang.org/x/sys v0.24.0 // indirect
99- golang.org/x/text v0.17.0 // indirect
100- golang.org/x/time v0.6.0 // indirect
101- golang.org/x/tools v0.24.0 // indirect
102- gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
103- gotest.tools/v3 v3.5.0 // indirect
48+ github.com/sagikazarmark/locafero v0.7.0 // indirect
49+ github.com/sourcegraph/conc v0.3.0 // indirect
50+ github.com/spf13/afero v1.14.0
51+ github.com/spf13/cast v1.7.1 // indirect
52+ github.com/spf13/pflag v1.0.6 // indirect
53+ github.com/subosito/gotenv v1.6.0 // indirect
54+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
55+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
56+ go.opentelemetry.io/otel v1.36.0 // indirect
57+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
58+ go.opentelemetry.io/otel/metric v1.36.0 // indirect
59+ go.opentelemetry.io/otel/sdk v1.36.0 // indirect
60+ go.opentelemetry.io/otel/trace v1.36.0 // indirect
61+ go.uber.org/atomic v1.9.0 // indirect
62+ go.uber.org/multierr v1.9.0 // indirect
63+ golang.org/x/sys v0.33.0 // indirect
64+ golang.org/x/text v0.26.0 // indirect
65+ gopkg.in/yaml.v3 v3.0.1 // indirect
66+ gotest.tools/v3 v3.5.2 // indirect
10467 )
M go.sum +115 -229 generated file: collapsed
D internal/cli/auth/device_flow.go +0 -114
@@ -1,114 +0,0 @@
1-// internal/cli/auth/device_flow.go
2-package auth
3-
4-import (
5- "encoding/json"
6- "fmt"
7- "io"
8- "net/http"
9- "net/url"
10- "time"
11-
12- "github.com/bnema/gordon/internal/cli"
13-)
14-
15-func DeviceFlowAuth(a *cli.App) error {
16- fmt.Println("Starting device flow authentication...")
17-
18- // Request device code
19- deviceCode, err := requestDeviceCode(a)
20- if err != nil {
21- return fmt.Errorf("error requesting device code: %w", err)
22- }
23-
24- fmt.Printf("Please go to %v and enter the code: %v\n", deviceCode["verification_uri"], deviceCode["user_code"])
25-
26- // Poll for access token
27- interval := 5 // Default to 5 seconds if not provided
28- if i, ok := deviceCode["interval"].(float64); ok {
29- interval = int(i)
30- }
31-
32- token, err := pollForAccessToken(a, deviceCode["device_code"].(string), interval)
33- if err != nil {
34- return fmt.Errorf("error polling for access token: %w", err)
35- }
36-
37- // Save the token
38- a.Config.SetToken(token)
39-
40- fmt.Println("Authentication successful!")
41- return nil
42-}
43-
44-func requestDeviceCode(a *cli.App) (map[string]interface{}, error) {
45- resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/code", url.Values{})
46- if err != nil {
47- return nil, fmt.Errorf("error making request: %w", err)
48- }
49- defer resp.Body.Close()
50-
51- body, err := io.ReadAll(resp.Body)
52- if err != nil {
53- return nil, fmt.Errorf("error reading response body: %w", err)
54- }
55-
56- if resp.StatusCode != http.StatusOK {
57- return nil, fmt.Errorf("server returned non-200 status code: %d. Body: %s", resp.StatusCode, string(body))
58- }
59-
60- var deviceCode map[string]interface{}
61- if err := json.Unmarshal(body, &deviceCode); err != nil {
62- return nil, fmt.Errorf("error parsing JSON response: %w", err)
63- }
64-
65- // Check for required fields
66- requiredFields := []string{"verification_uri", "user_code", "device_code", "interval"}
67- for _, field := range requiredFields {
68- if _, ok := deviceCode[field]; !ok {
69- return nil, fmt.Errorf("missing required field in response: %s", field)
70- }
71- }
72-
73- return deviceCode, nil
74-}
75-
76-func pollForAccessToken(a *cli.App, deviceCode string, interval int) (string, error) {
77- maxAttempts := 60 // Maximum number of attempts (5 minutes with 5-second intervals)
78- for attempt := 0; attempt < maxAttempts; attempt++ {
79- time.Sleep(time.Duration(interval) * time.Second)
80-
81- resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/token", url.Values{
82- "device_code": {deviceCode},
83- })
84- if err != nil {
85- continue
86- }
87- defer resp.Body.Close()
88-
89- body, err := io.ReadAll(resp.Body)
90- if err != nil {
91- continue
92- }
93-
94- var tokenResp map[string]interface{}
95- if err := json.Unmarshal(body, &tokenResp); err != nil {
96- continue
97- }
98-
99- if accessToken, ok := tokenResp["access_token"].(string); ok && accessToken != "" {
100- return accessToken, nil
101- }
102-
103- if tokenResp["error"] == "authorization_pending" {
104- // Optionally, you can print a dot to show progress without cluttering the output
105- fmt.Print(".")
106- continue
107- }
108-
109- // If we get here, there's an unexpected response but we can continue to try
110- // fmt.Printf("Unexpected response from server (attempt %d): %v\n", attempt+1, tokenResp)
111- }
112-
113- return "", fmt.Errorf("max attempts reached, failed to obtain access token")
114-}
D internal/cli/auth/token_cache.go +0 -87
@@ -1,87 +0,0 @@
1-package auth
2-
3-import (
4- "sync"
5- "time"
6-
7- "github.com/bnema/gordon/internal/db/queries"
8- "github.com/charmbracelet/log"
9-)
10-
11-type TokenCache struct {
12- cache sync.Map
13-}
14-
15-type cacheEntry struct {
16- token string
17- expiration time.Time
18- githubUser *queries.GithubUserInfo
19-}
20-
21-var (
22- defaultCache *TokenCache
23- once sync.Once
24-)
25-
26-// GetTokenCache returns a singleton instance of TokenCache
27-func GetTokenCache() *TokenCache {
28- once.Do(func() {
29- defaultCache = &TokenCache{}
30- log.Debug("initialized token cache singleton")
31- })
32- return defaultCache
33-}
34-
35-// GetWithUser retrieves a token and GitHub user info from the cache
36-func (tc *TokenCache) GetWithUser(key string) (string, *queries.GithubUserInfo, bool) {
37- if value, ok := tc.cache.Load(key); ok {
38- entry := value.(cacheEntry)
39- if time.Now().Before(entry.expiration) {
40- log.Debug("cache hit", "key", key, "expires_in", time.Until(entry.expiration))
41- return entry.token, entry.githubUser, true
42- }
43- log.Debug("cache entry expired", "key", key)
44- tc.cache.Delete(key)
45- }
46- log.Debug("cache miss", "key", key)
47- return "", nil, false
48-}
49-
50-// Get retrieves just the token validation status
51-func (tc *TokenCache) Get(key string) (bool, bool) {
52- if value, ok := tc.cache.Load(key); ok {
53- entry := value.(cacheEntry)
54- if time.Now().Before(entry.expiration) {
55- log.Debug("cache hit (validation status)", "key", key, "expires_in", time.Until(entry.expiration))
56- return true, true
57- }
58- log.Debug("cache entry expired (validation status)", "key", key)
59- tc.cache.Delete(key)
60- }
61- log.Debug("cache miss (validation status)", "key", key)
62- return false, false
63-}
64-
65-// SetWithUser stores a token and GitHub user info in the cache
66-func (tc *TokenCache) SetWithUser(key string, token string, user *queries.GithubUserInfo, duration time.Duration) {
67- tc.cache.Store(key, cacheEntry{
68- token: token,
69- githubUser: user,
70- expiration: time.Now().Add(duration),
71- })
72- log.Debug("stored token with user info in cache",
73- "key", key,
74- "duration", duration,
75- "github_user", user.Login)
76-}
77-
78-// Set stores just the token validation status
79-func (tc *TokenCache) Set(key string, duration time.Duration) {
80- tc.cache.Store(key, cacheEntry{
81- token: key,
82- expiration: time.Now().Add(duration),
83- })
84- log.Debug("stored token validation status in cache",
85- "key", key,
86- "duration", duration)
87-}
D internal/cli/auth/token_check.go +0 -64
@@ -1,64 +0,0 @@
1-package auth
2-
3-import (
4- "net/http"
5- "strings"
6- "time"
7-
8- "github.com/charmbracelet/log"
9-)
10-
11-// IsValidToken validates a GitHub token
12-func IsValidGitHubToken(token string) bool {
13- // Use the existing token cache
14- tokenCache := GetTokenCache()
15-
16- // Check if token exists in cache
17- if _, _, found := tokenCache.GetWithUser(token); found {
18- log.Debug("token found in cache")
19- return true
20- }
21-
22- // If not in cache, validate with GitHub
23- client := &http.Client{Timeout: 10 * time.Second}
24- req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
25- if err != nil {
26- log.Error("failed to create GitHub API request", "error", err)
27- return false
28- }
29-
30- req.Header.Set("Authorization", "token "+token)
31- req.Header.Set("Accept", "application/vnd.github.v3+json")
32-
33- resp, err := client.Do(req)
34- if err != nil {
35- log.Error("GitHub API request failed", "error", err)
36- return false
37- }
38- defer resp.Body.Close()
39-
40- if resp.StatusCode != http.StatusOK {
41- log.Warn("invalid GitHub token", "status", resp.Status)
42- return false
43- }
44-
45- return true
46-}
47-
48-// ExtractAndValidateToken extracts and validates a token from an Authorization header
49-func ExtractAndValidateToken(authHeader string) (string, bool) {
50- if authHeader == "" {
51- return "", false
52- }
53-
54- token := strings.TrimPrefix(authHeader, "Bearer ")
55- if token == "" {
56- return "", false
57- }
58-
59- if !IsValidGitHubToken(token) {
60- return "", false
61- }
62-
63- return token, true
64-}
D internal/cli/cmd/deploy.go +0 -238
@@ -1,238 +0,0 @@
1-// gordon/internal/cli/cmd/deploy.go
2-
3-package cmd
4-
5-import (
6- "bytes"
7- "context"
8- "encoding/json"
9- "fmt"
10- "io"
11- "net/http"
12- "os"
13- "strings"
14- "time"
15-
16- "github.com/bnema/gordon/internal/cli"
17- "github.com/bnema/gordon/internal/cli/handler"
18- "github.com/bnema/gordon/internal/common"
19- "github.com/charmbracelet/log"
20- "github.com/spf13/cobra"
21-)
22-
23-func init() {
24- log.SetReportTimestamp(true)
25- log.SetTimeFormat("15:04")
26-}
27-
28-func NewDeployCommand(a *cli.App) *cobra.Command {
29- var port string
30- var targetDomain string
31-
32- deployCmd := &cobra.Command{
33- Use: "deploy [image:tag]",
34- Short: "Deploy an image to your remote Gordon instance",
35- Args: cobra.ExactArgs(1),
36- PreRun: func(cmd *cobra.Command, args []string) {
37- if err := handler.FieldCheck(a); err != nil {
38- log.Error("Field check failed", "error", err)
39- os.Exit(1)
40- }
41- },
42- Run: func(cmd *cobra.Command, args []string) {
43- imageName := args[0]
44- log.Info("Pushing image", "image", imageName)
45-
46- if err := validateDeployInputs(imageName, port, targetDomain); err != nil {
47- log.Error("Validation failed", "error", err)
48- return
49- }
50-
51- reader, actualSize, err := handler.ExportDockerImage(imageName)
52- if err != nil {
53- log.Error("Error exporting image", "error", err)
54- return
55- }
56- defer reader.Close()
57-
58- sizeInMB := float64(actualSize) / 1024 / 1024
59-
60- log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))
61-
62- if err := deployImage(a, reader, imageName, port, targetDomain); err != nil {
63- os.Exit(1)
64- }
65- },
66- }
67-
68- deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container and Traefik entry point")
69- deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik")
70-
71- return deployCmd
72-}
73-
74-func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error {
75- // Check authentication early
76- if err := handler.CheckAndRefreshAuth(a); err != nil {
77- log.Error("Authentication check failed", "error", err)
78- return err
79- }
80-
81- log.Info("Deploying image", "image", imageName)
82- // Check for conflicts first
83- resp, err := checkDeployConflict(a, targetDomain, port)
84- if err != nil {
85- return fmt.Errorf("conflict check failed: %w", err)
86- }
87-
88- var shortID string
89- if len(resp.ContainerID) >= 12 {
90- shortID = resp.ContainerID[:12]
91- } else {
92- shortID = resp.ContainerID // Use full ID if less than 12 chars
93- }
94-
95- // If there's a conflict, handle it before proceeding
96- if !resp.Success && resp.ContainerID != "" {
97- log.Warn("Container already exists",
98- "name", resp.ContainerName,
99- "id", shortID,
100- "state", resp.State,
101- "uptime", resp.RunningTime)
102-
103- if err := handler.HandleExistingContainer(a, resp); err != nil {
104- if strings.Contains(err.Error(), "cancelled by user") {
105- return fmt.Errorf("deployment cancelled by user")
106- }
107- return fmt.Errorf("failed to handle existing container: %w", err)
108- }
109-
110- }
111-
112- var buf bytes.Buffer
113- size, err := io.Copy(&buf, reader)
114- if err != nil {
115- return fmt.Errorf("failed to read image data: %w", err)
116- }
117-
118- imageReader := bytes.NewReader(buf.Bytes())
119- sizeInMB := float64(size) / 1024 / 1024
120- log.Info("Attempting to deploy...",
121- "image", imageName,
122- "size", fmt.Sprintf("%.2fMB", sizeInMB),
123- "port", port,
124- "target", targetDomain,
125- )
126-
127- headers := http.Header{
128- "X-Image-Name": {imageName},
129- "X-Ports": {port},
130- "X-Target-Domain": {targetDomain},
131- "Content-Type": {"application/octet-stream"},
132- }
133-
134- chunkedClient := handler.NewChunkedClient(a)
135- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
136- defer cancel()
137-
138- chunkResp, err := chunkedClient.SendFileAsChunks(ctx, "/deploy/chunked", headers, imageReader, size, imageName)
139- if err != nil {
140- return fmt.Errorf("failed to deploy image: %w", err)
141- }
142-
143- if chunkResp == nil {
144- return fmt.Errorf("received nil response from server")
145- }
146-
147- var deployResp common.DeployResponse
148- if err := json.Unmarshal(chunkResp.Body, &deployResp); err != nil {
149- return fmt.Errorf("failed to parse deployment response: %w", err)
150- }
151-
152- return waitForDeployment(deployResp.Domain, deployResp.ContainerID)
153-}
154-
155-func waitForDeployment(domain string, containerID string) error {
156- client := &http.Client{Timeout: 10 * time.Second}
157- maxRetries := 10
158- retryInterval := time.Second
159-
160- var shortContainerID string
161- if containerID != "" {
162- shortContainerID = containerID[:12]
163- }
164-
165- log.Info("Waiting for deployment to be reachable",
166- "domain", domain,
167- "container_id", shortContainerID)
168-
169- for i := 0; i < maxRetries; i++ {
170- resp, err := client.Get(domain)
171- if err == nil {
172- defer resp.Body.Close()
173- if resp.StatusCode == http.StatusOK {
174- log.Info("Deployment successful",
175- "domain", domain,
176- "container_id", shortContainerID)
177- return nil
178- }
179- body, _ := io.ReadAll(resp.Body)
180- if strings.Contains(string(body), "failed to create container:") {
181- return fmt.Errorf("deployment failed: %s", string(body))
182- }
183- }
184- log.Warn("Deployment not ready yet, retrying",
185- "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))
186- time.Sleep(retryInterval)
187- }
188-
189- return fmt.Errorf("deployment not ready after %d attempts", maxRetries)
190-}
191-
192-func validateDeployInputs(imageName, port, targetDomain string) error {
193- if err := handler.ValidateImageName(imageName); err != nil {
194- return err
195- }
196-
197- if err := handler.EnsureImageTag(&imageName); err != nil {
198- return fmt.Errorf("failed to ensure image tag: %w", err)
199- }
200-
201- if port == "" {
202- return fmt.Errorf("port is required")
203- }
204-
205- return handler.ValidateTargetDomain(targetDomain)
206-}
207-
208-func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.DeployResponse, error) {
209-
210- // Create request to check-conflict endpoint with both domain and port parameters
211- reqUrl := fmt.Sprintf("%s/api/deploy/check-conflict?domain=%s&port=%s",
212- a.Config.Http.BackendURL, targetDomain, port)
213-
214- req, err := http.NewRequest("GET", reqUrl, nil)
215- if err != nil {
216- return nil, fmt.Errorf("failed to create conflict check request: %w", err)
217- }
218-
219- // Set auth header
220- req.Header.Set("Authorization", "Bearer "+a.Config.General.Token)
221-
222- // Send request
223- client := &http.Client{
224- Timeout: 10 * time.Second,
225- }
226- resp, err := client.Do(req)
227- if err != nil {
228- return nil, fmt.Errorf("failed to send conflict check request: %w", err)
229- }
230- defer resp.Body.Close()
231-
232- var conflictResp common.DeployResponse
233- if err := json.NewDecoder(resp.Body).Decode(&conflictResp); err != nil {
234- return nil, fmt.Errorf("failed to parse conflict check response: %w", err)
235- }
236-
237- return &conflictResp, nil
238-}
D internal/cli/cmd/ping.go +0 -50
@@ -1,50 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6- "time"
7-
8- "github.com/bnema/gordon/internal/cli"
9- "github.com/bnema/gordon/internal/cli/handler"
10- "github.com/bnema/gordon/pkg/humanize"
11-
12- "github.com/spf13/cobra"
13-)
14-
15-func NewPingCommand(a *cli.App) *cobra.Command {
16- // handler.FieldCheck(a)
17- return &cobra.Command{
18- Use: "ping",
19- Short: "Send a ping request to your remote Gordon instance",
20- PreRun: func(cmd *cobra.Command, args []string) {
21- if err := handler.FieldCheck(a); err != nil {
22- fmt.Println("Field check failed:", err)
23- os.Exit(1)
24- }
25- },
26- Run: func(cmd *cobra.Command, args []string) {
27- pingResp, err := handler.PerformPingRequest(a)
28- if err != nil {
29- fmt.Println("Error:", err)
30- return
31- }
32-
33- // Parse the uptime duration string
34- duration, err := time.ParseDuration(pingResp.Uptime)
35- if err != nil {
36- fmt.Println("Error parsing uptime duration:", err)
37- return
38- }
39-
40- // Calculate the start time of the server
41- startTime := time.Now().Add(-duration)
42-
43- // Use humanize to get a human-readable representation of the start time
44- humanizedUptime := humanize.TimeAgo(startTime)
45-
46- // Print the information
47- fmt.Printf("Pong!\nServer up since: %s\nServer version: %s\n", humanizedUptime, pingResp.Version)
48- },
49- }
50-}
D internal/cli/cmd/push.go +0 -136
@@ -1,136 +0,0 @@
1-package cmd
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/json"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "strings"
12- "time"
13-
14- "github.com/bnema/gordon/internal/cli"
15- "github.com/bnema/gordon/internal/cli/handler"
16- "github.com/bnema/gordon/internal/common"
17- "github.com/charmbracelet/log"
18- "github.com/spf13/cobra"
19-)
20-
21-func init() {
22- log.SetReportTimestamp(true)
23- log.SetTimeFormat("15:04")
24-}
25-
26-func NewPushCommand(a *cli.App) *cobra.Command {
27- var port string
28-
29- pushCmd := &cobra.Command{
30- Use: "push [image:tag]",
31- Short: "Push an image to your remote Gordon instance",
32- Args: cobra.ExactArgs(1),
33- PreRun: func(cmd *cobra.Command, args []string) {
34- if err := handler.FieldCheck(a); err != nil {
35- log.Error("Field check failed", "error", err)
36- os.Exit(1)
37- }
38- },
39- Run: func(cmd *cobra.Command, args []string) {
40- imageName := args[0]
41- log.Info("Pushing image", "image", imageName)
42-
43- if err := validatePushInputs(imageName); err != nil {
44- log.Error("Validation failed", "error", err)
45- return
46- }
47-
48- reader, actualSize, err := handler.ExportDockerImage(imageName)
49- if err != nil {
50- log.Error("Error exporting image", "error", err)
51- return
52- }
53- defer reader.Close()
54-
55- sizeInMB := float64(actualSize) / 1024 / 1024
56-
57- log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))
58-
59- if err := pushImage(a, reader, imageName); err != nil {
60- os.Exit(1)
61- }
62- },
63- }
64-
65- pushCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container (e.g., 8080:80/tcp)")
66-
67- return pushCmd
68-}
69-
70-func pushImage(a *cli.App, reader io.Reader, imageName string) error {
71- // Check authentication early
72- if err := handler.CheckAndRefreshAuth(a); err != nil {
73- log.Error("Authentication check failed", "error", err)
74- return err
75- }
76-
77- var buf bytes.Buffer
78- size, err := io.Copy(&buf, reader)
79- if err != nil {
80- return fmt.Errorf("failed to read image data: %w", err)
81- }
82-
83- imageReader := bytes.NewReader(buf.Bytes())
84- sizeInMB := float64(size) / 1024 / 1024
85- log.Info("Attempting to push...",
86- "image", imageName,
87- "size", fmt.Sprintf("%.2fMB", sizeInMB),
88- )
89-
90- headers := http.Header{
91- "X-Image-Name": {imageName},
92- "Content-Type": {"application/octet-stream"},
93- }
94-
95- chunkedClient := handler.NewChunkedClient(a)
96- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
97- defer cancel()
98-
99- resp, err := chunkedClient.SendFileAsChunks(ctx, "/push/chunked", headers, imageReader, size, imageName)
100- if err != nil {
101- log.Error("Failed to send chunks", "error", err)
102- return fmt.Errorf("failed to send chunks: %w", err)
103- }
104-
105- if resp == nil {
106- return fmt.Errorf("received nil response from server")
107- }
108-
109- var pushResponse common.PushResponse
110- if err := json.Unmarshal(resp.Body, &pushResponse); err != nil {
111- log.Error("Error parsing response", "error", err)
112- return fmt.Errorf("failed to parse response: %w", err)
113- }
114-
115- if !pushResponse.Success {
116- log.Error("Push failed", "resp", pushResponse.Message)
117- return fmt.Errorf(pushResponse.Message)
118- }
119-
120- // Remove the double quotes from the message
121- message := strings.Trim(pushResponse.Message, "\"")
122- log.Info(message)
123-
124- if pushResponse.CreateContainerURL != "" {
125- log.Info("Container creation available", "url", pushResponse.CreateContainerURL)
126- }
127-
128- return nil
129-}
130-
131-func validatePushInputs(imageName string) error {
132- if err := handler.ValidateImageName(imageName); err != nil {
133- return err
134- }
135- return handler.EnsureImageTag(&imageName)
136-}
D internal/cli/cmd/root.go +0 -39
@@ -1,39 +0,0 @@
1-// the root command is the entrypoint for the gordon cli (default: client)
2-package cmd
3-
4-import (
5- "fmt"
6-
7- "github.com/bnema/gordon/internal/cli"
8- "github.com/bnema/gordon/internal/cli/handler"
9- "github.com/fatih/color"
10- "github.com/spf13/cobra"
11-)
12-
13-// NewRootCommand creates a new root command
14-func NewRootCommand(a *cli.App) *cobra.Command {
15- return &cobra.Command{
16- Run: func(cmd *cobra.Command, args []string) {
17-
18- backendVersion := getBackendVersion(a)
19- color.Green("Gordon %s", a.Config.GetVersion())
20- color.Blue("Gordon Backend %s", backendVersion)
21-
22- if backendVersion != a.Config.GetVersion() {
23- color.Yellow("A new version of Gordon is available")
24- }
25-
26- fmt.Println()
27- fmt.Println("Use \"gordon --help\" for more information about a command.")
28- },
29- }
30-}
31-
32-func getBackendVersion(a *cli.App) string {
33- pingResp, err := handler.PerformPingRequest(a)
34- if err != nil {
35- return fmt.Sprintf("Error: %s", err)
36- }
37-
38- return pingResp.Version
39-}
D internal/cli/cmd/serve.go +0 -41
@@ -1,41 +0,0 @@
1-// the serve command is used to start the gordon server
2-// Path: internal/cli/cmd/serve.go
3-package cmd
4-
5-import (
6- "github.com/bnema/gordon/internal/cli/handler"
7- "github.com/bnema/gordon/internal/server"
8- "github.com/bnema/gordon/pkg/docker"
9- "github.com/spf13/cobra"
10-)
11-
12-// NewServeCommand creates a new serve command
13-func NewServeCommand(a *server.App) *cobra.Command {
14- defaultport := "1323"
15- var port string
16-
17- // if the server is running in a docker container, the default port is 80
18- if docker.IsRunningInContainer() {
19- defaultport = "80"
20- }
21-
22- // if no flags -p or --port are specified, the default port is used
23- serveCmd := &cobra.Command{
24- Use: "serve",
25- Short: "Start a new Gordon server instance",
26- Args: cobra.NoArgs,
27- Run: func(cmd *cobra.Command, args []string) {
28- // handler.StartServer will use the value of port, which will be the default
29- // value if no flag is specified
30- a.Config.Http.Port = port
31- err := handler.StartServer(a, port)
32- if err != nil {
33- panic(err)
34- }
35- },
36- }
37-
38- // Attach the flag to serveCmd and store its value in the variable port
39- serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on")
40- return serveCmd
41-}
D internal/cli/cmd/update.go +0 -406
@@ -1,406 +0,0 @@
1-// This upload feature is based on pocketbase's ghupdate command.
2-// Thank you for your work, @Gani Georgiev
3-// Check the original code here: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
4-package cmd
5-
6-import (
7- "context"
8- "encoding/json"
9- "errors"
10- "fmt"
11- "io"
12- "log"
13- "net/http"
14- "os"
15- "path/filepath"
16- "runtime"
17- "strconv"
18- "strings"
19-
20- "github.com/AlecAivazis/survey/v2"
21- "github.com/bnema/gordon/internal/cli"
22- "github.com/bnema/gordon/internal/cli/handler"
23- "github.com/bnema/gordon/pkg/docker"
24- "github.com/fatih/color"
25- "github.com/pocketbase/pocketbase/tools/archive"
26- "github.com/spf13/cobra"
27-)
28-
29-type HttpClient interface {
30- Do(req *http.Request) (*http.Response, error)
31-}
32-
33-type Config struct {
34- Owner string
35- Repo string
36- ArchiveExecutable string
37- Context context.Context
38- HttpClient HttpClient
39-}
40-
41-var pluginInstance *plugin
42-
43-type plugin struct {
44- app *cli.App
45- currentVersion string
46- config Config
47-}
48-
49-func NewUpdateCommand(a *cli.App) *cobra.Command {
50-
51- pluginInstance = &plugin{
52- app: a,
53- currentVersion: a.Config.Build.BuildVersion,
54- config: Config{
55- Owner: "bnema",
56- Repo: "gordon",
57- ArchiveExecutable: "gordon",
58- Context: context.Background(),
59- HttpClient: http.DefaultClient,
60- },
61- }
62-
63- if pluginInstance.app != a {
64- panic("app instance is not the same")
65- }
66-
67- if a == nil {
68- panic("app instance is nil")
69- }
70-
71- command := &cobra.Command{
72- Use: "update",
73- Short: "Update the Gordon executable",
74- SilenceErrors: true,
75- SilenceUsage: true,
76- RunE: func(command *cobra.Command, args []string) error {
77- var needConfirm bool
78- if docker.IsRunningInContainer() {
79- needConfirm = true
80- color.Yellow("It seems that you are in a Docker container.")
81- color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.")
82- }
83-
84- if needConfirm {
85- confirm := false
86- prompt := &survey.Confirm{
87- Message: "Do you want to proceed with the update?",
88- }
89- survey.AskOne(prompt, &confirm)
90- if !confirm {
91- fmt.Println("The command has been cancelled.")
92- return nil
93- }
94- }
95-
96- // Vérifier si l'utilisateur est root
97- if isRoot() {
98- color.Yellow("Running update as root. Skipping configuration check.")
99- return pluginInstance.update()
100- }
101-
102- // Si non root, effectuer la vérification des champs
103- if err := handler.FieldCheck(a); err != nil {
104- return fmt.Errorf("field check failed: %w", err)
105- }
106-
107- return pluginInstance.update()
108- },
109- }
110- return command
111-}
112-
113-func (p *plugin) update() error {
114- color.Yellow("Fetching release information...")
115-
116- latest, err := fetchLatestRelease(
117- p.config.Context,
118- p.config.HttpClient,
119- p.config.Owner,
120- p.config.Repo,
121- )
122- if err != nil {
123- return err
124- }
125-
126- if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
127- color.Green("You already have the latest version %s.", p.currentVersion)
128- return nil
129- }
130-
131- // Prompt user for update
132- confirm := false
133- prompt := &survey.Confirm{
134- Message: fmt.Sprintf("A new version (%s) is available. Do you want to update?", latest.Tag),
135- }
136- survey.AskOne(prompt, &confirm)
137- if !confirm {
138- fmt.Println("Update cancelled by user.")
139- return nil
140- }
141-
142- suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
143- if suffix == "" {
144- return errors.New("unsupported platform")
145- }
146-
147- asset, err := latest.findAssetBySuffix(suffix)
148- if err != nil {
149- return err
150- }
151-
152- // temporary file is under common.Config.General.StorageDir
153- releaseDir := filepath.Join(p.app.Config.General.StorageDir, "update")
154- defer os.RemoveAll(releaseDir)
155-
156- color.Yellow("Downloading %s...", asset.Name)
157-
158- // download the release asset
159- assetZip := filepath.Join(releaseDir, asset.Name)
160- if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
161- return err
162- }
163-
164- color.Yellow("Extracting %s...", asset.Name)
165-
166- extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
167- defer os.RemoveAll(extractDir)
168-
169- if err := archive.Extract(assetZip, extractDir); err != nil {
170- return err
171- }
172-
173- color.Yellow("Replacing the executable...")
174-
175- oldExec, err := os.Executable()
176- if err != nil {
177- return err
178- }
179- renamedOldExec := oldExec + ".old"
180- defer os.Remove(renamedOldExec)
181-
182- // Check if the user has write permissions
183- if err := checkWritePermission(oldExec); err != nil {
184- color.Yellow("Administrator rights are required to perform the update.")
185- color.Yellow("Please run the following command with sudo:")
186- color.HiBlack("sudo gordon update")
187- return nil
188- }
189-
190- newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
191- if _, err := os.Stat(newExec); err != nil {
192- // try again with an .exe extension
193- newExec = newExec + ".exe"
194- if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
195- return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
196- }
197- }
198-
199- // rename the current executable
200- if err := os.Rename(oldExec, renamedOldExec); err != nil {
201- return fmt.Errorf("failed to rename the current executable: %w", err)
202- }
203-
204- tryToRevertExecChanges := func() {
205- if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
206- log.Println(revertErr)
207- }
208- }
209-
210- // replace with the extracted binary
211- if err := os.Rename(newExec, oldExec); err != nil {
212- tryToRevertExecChanges()
213- return fmt.Errorf("failed replacing the executable: %w", err)
214- }
215-
216- color.HiBlack("---")
217- color.Green("Update completed successfully! You can start the executable as usual.")
218-
219- return nil
220-}
221-
222-func (r *release) findAssetBySuffix(suffix string) (*asset, error) {
223- for _, asset := range r.Assets {
224- if strings.HasSuffix(asset.Name, suffix) {
225- return &asset, nil
226- }
227- }
228-
229- return nil, fmt.Errorf("no asset found with suffix %q", suffix)
230-}
231-
232-type release struct {
233- Tag string `json:"tag_name"`
234- Assets []asset `json:"assets"`
235-}
236-
237-type asset struct {
238- Name string `json:"name"`
239- DownloadUrl string `json:"browser_download_url"`
240-}
241-
242-func fetchLatestRelease(
243- ctx context.Context,
244- client HttpClient,
245- owner string,
246- repo string,
247-) (*release, error) {
248- url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
249-
250- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
251- if err != nil {
252- return nil, err
253- }
254-
255- res, err := client.Do(req)
256- if err != nil {
257- return nil, err
258- }
259- defer res.Body.Close()
260-
261- rawBody, err := io.ReadAll(res.Body)
262- if err != nil {
263- return nil, err
264- }
265-
266- // http.Client doesn't treat non 2xx responses as error
267- if res.StatusCode >= 400 {
268- return nil, fmt.Errorf(
269- "(%d) failed to fetch latest releases:\n%s",
270- res.StatusCode,
271- string(rawBody),
272- )
273- }
274-
275- result := &release{}
276- if err := json.Unmarshal(rawBody, result); err != nil {
277- return nil, err
278- }
279-
280- return result, nil
281-}
282-
283-func downloadFile(
284- ctx context.Context,
285- client HttpClient,
286- url string,
287- destPath string,
288-) error {
289- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
290- if err != nil {
291- return err
292- }
293-
294- res, err := client.Do(req)
295- if err != nil {
296- return err
297- }
298- defer res.Body.Close()
299-
300- // http.Client doesn't treat non 2xx responses as error
301- if res.StatusCode >= 400 {
302- return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
303- }
304-
305- // ensure that the dest parent dir(s) exist
306- if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
307- return err
308- }
309-
310- dest, err := os.Create(destPath)
311- if err != nil {
312- return err
313- }
314- defer dest.Close()
315-
316- if _, err := io.Copy(dest, res.Body); err != nil {
317- return err
318- }
319-
320- return nil
321-}
322-
323-// archives:
324-// - format: zip
325-// name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
326-
327-func archiveSuffix(goos, goarch string) string {
328- // Define a map to hold the combinations of goos and goarch.
329- var archMap = map[string]map[string]string{
330- "linux": {
331- "amd64": "_linux_amd64.zip",
332- "arm64": "_linux_arm64.zip",
333- "arm": "_linux_armv7.zip",
334- },
335- "darwin": {
336- "amd64": "_darwin_amd64.zip",
337- "arm64": "_darwin_arm64.zip",
338- },
339- "windows": {
340- "amd64": "_windows_amd64.zip",
341- "arm64": "_windows_arm64.zip",
342- },
343- }
344-
345- // Look up goos then goarch.
346- if arch, ok := archMap[goos]; ok {
347- if suffix, ok := arch[goarch]; ok {
348- return suffix
349- }
350- }
351-
352- // Return a default value if no match is found.
353- return ""
354-}
355-func compareVersions(a, b string) int {
356- aSplit := strings.Split(a, ".")
357- aTotal := len(aSplit)
358-
359- bSplit := strings.Split(b, ".")
360- bTotal := len(bSplit)
361-
362- limit := aTotal
363- if bTotal > aTotal {
364- limit = bTotal
365- }
366-
367- for i := 0; i < limit; i++ {
368- var x, y int
369-
370- if i < aTotal {
371- x, _ = strconv.Atoi(aSplit[i])
372- }
373-
374- if i < bTotal {
375- y, _ = strconv.Atoi(bSplit[i])
376- }
377-
378- if x < y {
379- return 1 // b is newer
380- }
381-
382- if x > y {
383- return -1 // a is newer
384- }
385- }
386-
387- return 0 // equal
388-}
389-
390-// New function to check write permissions
391-func checkWritePermission(path string) error {
392- // Try to create a temporary file in the same directory
393- dir := filepath.Dir(path)
394- tmpFile := filepath.Join(dir, ".gordon_update_test")
395- f, err := os.Create(tmpFile)
396- if err != nil {
397- return err
398- }
399- f.Close()
400- os.Remove(tmpFile)
401- return nil
402-}
403-
404-func isRoot() bool {
405- return os.Geteuid() == 0
406-}
D internal/cli/cmd/version.go +0 -40
@@ -1,40 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/cli"
7- "github.com/bnema/gordon/internal/common"
8- "github.com/spf13/cobra"
9-)
10-
11-func NewVersionCommand(app *cli.App) *cobra.Command {
12- cmd := &cobra.Command{
13- Use: "version",
14- Short: "Print version information",
15- Run: func(cmd *cobra.Command, args []string) {
16- info := common.GetVersionInfo(
17- app.Config.Build.BuildVersion,
18- app.Config.Build.BuildCommit,
19- app.Config.Build.BuildDate,
20- app.Config.Build.ProxyURL,
21- )
22- fmt.Println(info.String())
23-
24- hasUpdate, latestVersion, err := common.CheckForNewVersion(
25- info.Version,
26- info.ProxyURL,
27- )
28- if err != nil {
29- fmt.Printf("Error checking for updates: %v\n", err)
30- return
31- }
32-
33- if hasUpdate {
34- fmt.Printf("\nA new version is available: %s\n", latestVersion)
35- fmt.Println("You can update using: gordon update")
36- }
37- },
38- }
39- return cmd
40-}
D internal/cli/handler/auth.go +0 -77
@@ -1,77 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/gordon/internal/cli"
8- "github.com/bnema/gordon/internal/cli/auth"
9- "github.com/bnema/gordon/internal/common"
10- "github.com/charmbracelet/log"
11-)
12-
13-func CheckAndRefreshAuth(a *cli.App) error {
14- if a.Config.General.Token == "" {
15- log.Info("No authentication token found. Initiating authentication...")
16- return ReAuthenticate(a)
17- }
18-
19- resp, err := SendHTTPRequest(a, &common.RequestPayload{
20- Type: "ping",
21- Payload: common.PingPayload{
22- Message: "ping",
23- },
24- }, "GET", "/ping")
25-
26- if err != nil {
27- log.Debug("Token validation request failed", "error", err)
28- return ReAuthenticate(a)
29- }
30-
31- // If we get a 200 status code, and the resp contains message = pong, we're good
32- if resp.StatusCode != 200 {
33- log.Debug("Token validation failed",
34- "status_code", resp.StatusCode,
35- "response", resp.Body)
36- return ReAuthenticate(a)
37-
38- }
39-
40- return nil
41-}
42-
43-func ReAuthenticate(a *cli.App) error {
44- fmt.Println("Re-authenticating...")
45-
46- // Small delay for visual clarity
47- time.Sleep(100 * time.Millisecond)
48-
49- err := auth.DeviceFlowAuth(a)
50- if err != nil {
51- return fmt.Errorf("device flow authentication failed: %w", err)
52- }
53-
54- // After successful re-authentication, get the new token
55- newToken, err := a.Config.GetToken()
56- if err != nil {
57- return fmt.Errorf("failed to get new token: %w", err)
58- }
59-
60- // Update the token in the app config
61- a.Config.General.Token = newToken
62-
63- // Save the new token to config file
64- err = a.Config.SaveConfig()
65- if err != nil {
66- return fmt.Errorf("failed to save new token to config: %w", err)
67- }
68-
69- // Clear any remaining auth UI elements
70- fmt.Print("\033[2K\r")
71- fmt.Println("Re-authentication successful.")
72-
73- // Add delay before resuming upload display
74- time.Sleep(500 * time.Millisecond)
75-
76- return nil
77-}
D internal/cli/handler/chunked_transfer.go +0 -568
@@ -1,568 +0,0 @@
1-// internal/cli/handler/progress_display.go
2-
3-package handler
4-
5-import (
6- "bytes"
7- "context"
8- "crypto/sha256"
9- "encoding/json"
10- "errors"
11- "fmt"
12- "io"
13- "net"
14- "net/http"
15- "os"
16- "os/signal"
17- "strings"
18- "sync"
19- "syscall"
20- "time"
21-
22- "github.com/bnema/gordon/internal/cli"
23- "github.com/bnema/gordon/internal/common"
24- "github.com/charmbracelet/bubbles/spinner"
25- tea "github.com/charmbracelet/bubbletea"
26- "github.com/charmbracelet/lipgloss"
27- "github.com/charmbracelet/log"
28- "github.com/google/uuid"
29- "golang.org/x/exp/rand"
30-)
31-
32-type DeployResponse = common.DeployResponse
33-
34-// UploadProgress represents the current state of the upload
35-type UploadProgress struct {
36- Completed int
37- Total int
38- Percentage float64
39-}
40-
41-// ProgressDisplay manages the upload progress display
42-type ProgressDisplay struct {
43- mu sync.Mutex
44- model *uploadModel
45- program *tea.Program
46- updateChan chan UploadProgress
47- done chan struct{}
48- paused bool
49- pauseChan chan bool
50-}
51-
52-type uploadModel struct {
53- spinner spinner.Model
54- completed int
55- total int
56- percentage float64
57- done bool
58-}
59-
60-// NewProgressDisplay creates a new progress display
61-func NewProgressDisplay() *ProgressDisplay {
62- return &ProgressDisplay{
63- updateChan: make(chan UploadProgress),
64- done: make(chan struct{}),
65- pauseChan: make(chan bool),
66- }
67-}
68-
69-func (pd *ProgressDisplay) Start() {
70- pd.mu.Lock()
71- defer pd.mu.Unlock()
72-
73- pd.model = &uploadModel{
74- spinner: spinner.New(
75- spinner.WithSpinner(spinner.MiniDot),
76- spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("69"))),
77- ),
78- }
79-
80- pd.program = tea.NewProgram(pd.model)
81-
82- // Run the UI in a goroutine
83- go func() {
84- if _, err := pd.program.Run(); err != nil {
85- fmt.Printf("Error running progress display: %v\n", err)
86- }
87- }()
88-
89- // Handle updates in a separate goroutine
90- go pd.handleUpdates()
91-}
92-
93-func (pd *ProgressDisplay) Pause() {
94- pd.mu.Lock()
95- defer pd.mu.Unlock()
96- pd.paused = true
97- pd.Clear() // Clear the current display
98-}
99-
100-func (pd *ProgressDisplay) Resume() {
101- pd.mu.Lock()
102- defer pd.mu.Unlock()
103- pd.paused = false
104-}
105-
106-func (pd *ProgressDisplay) handleUpdates() {
107- for {
108- select {
109- case progress := <-pd.updateChan:
110- pd.mu.Lock()
111- if !pd.paused {
112- pd.program.Send(progressMsg{
113- completed: progress.Completed,
114- total: progress.Total,
115- percentage: progress.Percentage,
116- })
117- }
118- pd.mu.Unlock()
119- case <-pd.done:
120- pd.program.Send(doneMsg{})
121- return
122- }
123- }
124-}
125-
126-func (pd *ProgressDisplay) Update(completed, total int, percentage float64) {
127- pd.updateChan <- UploadProgress{
128- Completed: completed,
129- Total: total,
130- Percentage: percentage,
131- }
132-}
133-
134-func (pd *ProgressDisplay) Stop() {
135- close(pd.done)
136-}
137-
138-func (pd *ProgressDisplay) Clear() {
139- pd.mu.Lock()
140- defer pd.mu.Unlock()
141-
142- // Send a special clear message
143- pd.program.Send(clearMsg{})
144- // Wait a brief moment for the clear to take effect
145- time.Sleep(50 * time.Millisecond)
146-}
147-
148-type progressMsg struct {
149- completed int
150- total int
151- percentage float64
152-}
153-
154-type doneMsg struct{}
155-
156-type clearMsg struct{}
157-
158-func (m *uploadModel) Init() tea.Cmd {
159- return m.spinner.Tick
160-}
161-
162-func (m *uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163- switch msg := msg.(type) {
164- case tea.KeyMsg:
165- if msg.Type == tea.KeyCtrlC {
166- return m, tea.Quit
167- }
168- case spinner.TickMsg:
169- var cmd tea.Cmd
170- m.spinner, cmd = m.spinner.Update(msg)
171- return m, cmd
172- case progressMsg:
173- m.completed = msg.completed
174- m.total = msg.total
175- m.percentage = msg.percentage
176- return m, m.spinner.Tick
177- case doneMsg:
178- m.done = true
179- return m, tea.Quit
180- case clearMsg:
181- m.done = true
182- return m, tea.Quit
183- }
184- return m, nil
185-}
186-
187-func (m *uploadModel) View() string {
188- if m.done {
189- return ""
190- }
191-
192- return fmt.Sprintf("\r%s Uploading image... %d/%d chunks (%.2f%%)",
193- m.spinner.View(),
194- m.completed,
195- m.total,
196- m.percentage,
197- )
198-}
199-
200-type ChunkMetadata = common.ChunkMetadata
201-
202-type ChunkedClient struct {
203- client *http.Client
204- baseURL string
205- token string
206- chunkSize int64
207- app *cli.App
208- progress *ProgressDisplay
209- cancelFunc context.CancelFunc
210-}
211-
212-type transferStore struct {
213- mu sync.Mutex
214- chunks map[string]map[int][]byte
215- metadata map[string]*ChunkMetadata
216- started map[string]time.Time
217-}
218-
219-var clientChunkStore = &transferStore{
220- chunks: make(map[string]map[int][]byte),
221- metadata: make(map[string]*ChunkMetadata),
222- started: make(map[string]time.Time),
223-}
224-
225-// NewChunkedClient creates a new chunked client
226-func NewChunkedClient(app *cli.App) *ChunkedClient {
227- baseURL := app.Config.Http.BackendURL + "/api"
228- token := app.Config.General.Token
229-
230- return &ChunkedClient{
231- client: &http.Client{
232- Timeout: 30 * time.Minute,
233- },
234- baseURL: baseURL,
235- token: token,
236- chunkSize: 5 * 1024 * 1024,
237- app: app,
238- progress: NewProgressDisplay(),
239- }
240-}
241-
242-// SendFileAsChunks sends a file as chunks to the specified endpoint
243-func (c *ChunkedClient) SendFileAsChunks(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (*Response, error) {
244- ctx, cancel := context.WithCancel(ctx)
245- c.cancelFunc = cancel
246- defer cancel()
247-
248- // Set up signal handling
249- sigChan := make(chan os.Signal, 1)
250- signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
251- defer signal.Stop(sigChan)
252-
253- // Start progress display
254- c.progress.Start()
255- defer c.progress.Stop()
256-
257- // Handle signals in a goroutine
258- go func() {
259- select {
260- case <-sigChan:
261- log.Info("Received interrupt signal, cancelling upload...")
262- cancel()
263- case <-ctx.Done():
264- return
265- }
266- }()
267-
268- transferID := uuid.New().String()
269- totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize)
270- buf := make([]byte, c.chunkSize)
271-
272- // Initialize transfer tracking
273- clientChunkStore.mu.Lock()
274- clientChunkStore.started[transferID] = time.Now()
275- clientChunkStore.mu.Unlock()
276-
277- defer cleanupClientTransfer(transferID)
278-
279- var lastResponse *Response
280- for chunkNum := 0; chunkNum < totalChunks; chunkNum++ {
281- select {
282- case <-ctx.Done():
283- return nil, fmt.Errorf("upload cancelled")
284- default:
285- n, err := io.ReadFull(reader, buf)
286- if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
287- return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err)
288- }
289-
290- if n == 0 {
291- continue
292- }
293-
294- chunk := buf[:n]
295- metadata := common.ChunkMetadata{
296- ChunkNumber: chunkNum,
297- TotalChunks: totalChunks,
298- ChunkSize: int64(n),
299- TotalSize: totalSize,
300- ImageName: imageName,
301- TransferID: transferID,
302- }
303-
304- resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata)
305- if err != nil {
306- return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err)
307- }
308- lastResponse = resp
309-
310- // Update progress
311- c.progress.Update(chunkNum+1, totalChunks, float64(chunkNum+1)/float64(totalChunks)*100)
312- }
313- }
314-
315- if lastResponse != nil {
316- c.progress.Clear()
317- responseType := determineResponseType(endpoint)
318- if err := handleFinalResponse(lastResponse, responseType); err != nil {
319- log.Error("Failed to handle final response", "error", err)
320- }
321- }
322-
323- return lastResponse, nil
324-}
325-
326-// sendChunkWithRetry sends a single chunk to the server and retries on failure
327-func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
328- maxRetries := 3
329- backoff := time.Second
330-
331- isRetryableError := func(err error) bool {
332- if err == nil {
333- return false
334- }
335-
336- if netErr, ok := err.(net.Error); ok {
337- return netErr.Timeout()
338- }
339-
340- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
341- return false
342- }
343-
344- errorStr := err.Error()
345- retryableErrors := []string{
346- "connection reset by peer",
347- "broken pipe",
348- "connection refused",
349- "no such host",
350- "i/o timeout",
351- "unexpected EOF",
352- "connection timed out",
353- "network is unreachable",
354- }
355-
356- for _, retryableErr := range retryableErrors {
357- if strings.Contains(strings.ToLower(errorStr), retryableErr) {
358- return true
359- }
360- }
361-
362- return false
363- }
364-
365- var lastErr error
366- for attempt := 0; attempt < maxRetries; attempt++ {
367- resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata)
368- if err == nil {
369- return resp, nil
370- }
371-
372- lastErr = err
373-
374- if !isRetryableError(err) {
375- return nil, err
376- }
377-
378- if attempt < maxRetries-1 {
379- retryDelay := backoff * time.Duration(attempt+1)
380- jitter := time.Duration(rand.Int63n(int64(retryDelay) / 2))
381-
382- select {
383- case <-ctx.Done():
384- return nil, ctx.Err()
385- case <-time.After(retryDelay + jitter):
386- continue
387- }
388- }
389- }
390-
391- return nil, fmt.Errorf("max retries exceeded after %d attempts: %w", maxRetries, lastErr)
392-}
393-
394-func (c *ChunkedClient) Cancel() {
395- if c.cancelFunc != nil {
396- c.cancelFunc()
397- }
398-}
399-
400-// Response handling functions
401-func determineResponseType(endpoint string) string {
402- switch {
403- case strings.Contains(endpoint, "/deploy"):
404- return "deploy"
405- case strings.Contains(endpoint, "/push"):
406- return "push"
407- default:
408- return "unknown"
409- }
410-}
411-
412-// handleFinalResponse determines the response type and calls the appropriate handler
413-func handleFinalResponse(resp *Response, responseType string) error {
414- if resp == nil || len(resp.Body) == 0 {
415- return fmt.Errorf("empty response received")
416- }
417-
418- switch responseType {
419- case "deploy":
420- return handleDeployResponse(resp)
421- case "push":
422- return handlePushResponse(resp)
423- default:
424- return fmt.Errorf("unknown response type: %s", responseType)
425- }
426-}
427-
428-// handleDeployResponse handles the response from a deploy operation
429-func handleDeployResponse(resp *Response) error {
430- finalResp := DeployResponse{}
431- if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
432- return fmt.Errorf("failed to unmarshal deploy response: %w", err)
433- }
434-
435- // Always log the success message with available information
436- logFields := []interface{}{}
437-
438- if finalResp.Domain != "" {
439- logFields = append(logFields, "domain", finalResp.Domain)
440- }
441- if finalResp.ContainerID != "" {
442- shortContainerID := finalResp.ContainerID
443- if len(shortContainerID) > 8 {
444- shortContainerID = shortContainerID[:8]
445- }
446- logFields = append(logFields, "containerID", shortContainerID)
447- }
448- if finalResp.ContainerName != "" {
449- logFields = append(logFields, "containerName", finalResp.ContainerName)
450- }
451-
452- // If there's a message, use it; otherwise, use a default success message
453- message := finalResp.Message
454- if message == "" {
455- message = "Deployment completed successfully"
456- }
457-
458- // Ensure we log the result even if there are no additional fields
459- if len(logFields) > 0 {
460- log.Info(message, logFields...)
461- } else {
462- log.Info(message)
463- }
464-
465- return nil
466-}
467-
468-// handlePushResponse handles the response from a push operation
469-func handlePushResponse(resp *Response) error {
470- var finalResp common.PushResponse
471- if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
472- return fmt.Errorf("failed to unmarshal push response: %w", err)
473- }
474- return nil
475-}
476-
477-// sendChunk sends a single chunk to the server
478-func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
479- if err := verifyChunkIntegrity(chunk, metadata); err != nil {
480- return nil, fmt.Errorf("chunk integrity check failed: %w", err)
481- }
482- for {
483- select {
484- case <-ctx.Done():
485- return nil, ctx.Err()
486- default:
487- resp, err := c.attemptChunkSend(ctx, endpoint, headers, chunk, metadata)
488- if err != nil {
489- return nil, err
490- }
491- return resp, nil
492- }
493- }
494-}
495-
496-func (c *ChunkedClient) attemptChunkSend(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
497- metadataJSON, err := json.Marshal(metadata)
498- if err != nil {
499- return nil, fmt.Errorf("failed to marshal metadata: %w", err)
500- }
501-
502- url := c.baseURL + endpoint
503- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk))
504- if err != nil {
505- return nil, fmt.Errorf("failed to create request: %w", err)
506- }
507-
508- // Set headers
509- req.Header.Set("Authorization", "Bearer "+c.token)
510- req.Header.Set("Content-Type", "application/octet-stream")
511- req.Header.Set("X-Chunk-Metadata", string(metadataJSON))
512- req.Header.Set("X-Chunk-Hash", fmt.Sprintf("%x", sha256.Sum256(chunk)))
513-
514- for k, v := range headers {
515- req.Header[k] = v
516- }
517-
518- resp, err := c.client.Do(req)
519- if err != nil {
520- return nil, err
521- }
522- defer resp.Body.Close()
523-
524- body, err := io.ReadAll(resp.Body)
525- if err != nil {
526- return nil, fmt.Errorf("failed to read response: %w", err)
527- }
528-
529- if resp.StatusCode != http.StatusOK {
530- return &Response{
531- Http: resp,
532- Body: body,
533- StatusCode: resp.StatusCode,
534- Header: resp.Header,
535- }, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
536- }
537-
538- return &Response{
539- Http: resp,
540- Body: body,
541- StatusCode: resp.StatusCode,
542- Header: resp.Header,
543- }, nil
544-}
545-
546-// verifyChunkIntegrity verifies the integrity of a chunk
547-func verifyChunkIntegrity(chunk []byte, metadata common.ChunkMetadata) error {
548- if len(chunk) == 0 {
549- return fmt.Errorf("empty chunk received")
550- }
551-
552- if int64(len(chunk)) != metadata.ChunkSize {
553- return fmt.Errorf("chunk size mismatch: expected %d, got %d",
554- metadata.ChunkSize, len(chunk))
555- }
556-
557- return nil
558-}
559-
560-// cleanupClientTransfer cleans up a client transfer
561-func cleanupClientTransfer(transferID string) {
562- clientChunkStore.mu.Lock()
563- defer clientChunkStore.mu.Unlock()
564-
565- delete(clientChunkStore.chunks, transferID)
566- delete(clientChunkStore.metadata, transferID)
567- delete(clientChunkStore.started, transferID)
568-}
D internal/cli/handler/deploy_survey.go +0 -116
@@ -1,116 +0,0 @@
1-// gordon/internal/cli/handler/deploy_survey.go
2-
3-package handler
4-
5-import (
6- "encoding/json"
7- "fmt"
8- "time"
9-
10- "github.com/AlecAivazis/survey/v2"
11- "github.com/bnema/gordon/internal/cli"
12- "github.com/bnema/gordon/internal/common"
13- "github.com/charmbracelet/log"
14-)
15-
16-func HandleExistingContainer(app *cli.App, conflictResp *common.DeployResponse) error {
17- // Add a small delay to ensure terminal is clear
18- time.Sleep(100 * time.Millisecond)
19-
20- // Clear the current line
21- fmt.Print("\033[2K\r")
22-
23- containerName := conflictResp.ContainerName
24- shortID := conflictResp.ContainerID
25- if len(shortID) > 12 {
26- shortID = shortID[:12]
27- }
28-
29- var proceed bool
30- prompt := &survey.Confirm{
31- Message: fmt.Sprintf("Container '%s' (ID: %s) is already running for %s. Stop and remove it?",
32- containerName,
33- shortID,
34- conflictResp.RunningTime,
35- ),
36- Default: true,
37- }
38-
39- if err := survey.AskOne(prompt, &proceed); err != nil {
40- return fmt.Errorf("survey failed: %w", err)
41- }
42-
43- if !proceed {
44- return fmt.Errorf("operation cancelled by user")
45- }
46-
47- log.Info("Stopping container...", "name", containerName, "id", shortID)
48-
49- // Stop the container
50- if err := stopContainer(app, conflictResp.ContainerID); err != nil {
51- return fmt.Errorf("failed to stop container '%s': %w", containerName, err)
52- }
53-
54- log.Info("Removing container...", "name", containerName, "id", shortID)
55-
56- // Remove the container
57- if err := removeContainer(app, conflictResp.ContainerID); err != nil {
58- return fmt.Errorf("failed to remove container '%s': %w", containerName, err)
59- }
60-
61- log.Info("Container stopped and removed successfully",
62- "name", containerName,
63- "id", shortID,
64- )
65- return nil
66-}
67-
68-func removeContainer(a *cli.App, containerID string) error {
69- reqPayload := common.RequestPayload{
70- Type: "remove",
71- Payload: common.RemovePayload{
72- ContainerID: containerID,
73- },
74- }
75-
76- resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/remove")
77- if err != nil {
78- return err
79- }
80-
81- var removeResponse common.RemoveResponse
82- if err := json.Unmarshal(resp.Body, &removeResponse); err != nil {
83- return fmt.Errorf("failed to parse remove response: %w", err)
84- }
85-
86- if !removeResponse.Success {
87- return fmt.Errorf("failed to remove container: %s", removeResponse.Message)
88- }
89-
90- return nil
91-}
92-
93-func stopContainer(a *cli.App, containerID string) error {
94- reqPayload := common.RequestPayload{
95- Type: "stop",
96- Payload: common.StopPayload{
97- ContainerID: containerID,
98- },
99- }
100-
101- resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/stop")
102- if err != nil {
103- return err
104- }
105-
106- var stopResponse common.StopResponse
107- if err := json.Unmarshal(resp.Body, &stopResponse); err != nil {
108- return fmt.Errorf("failed to parse stop response: %w", err)
109- }
110-
111- if !stopResponse.Success {
112- return fmt.Errorf("failed to stop container: %s", stopResponse.Message)
113- }
114-
115- return nil
116-}
D internal/cli/handler/docker.go +0 -51
@@ -1,51 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5- "io"
6-
7- "github.com/bnema/gordon/pkg/docker"
8-)
9-
10-func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) {
11- // Initialize Docker client
12- if err := docker.CheckIfInitialized(); err != nil {
13- return nil, 0, fmt.Errorf("failed to initialize Docker client: %w", err)
14- }
15-
16- // Get the image ID first
17- imageID, err := docker.GetImageIDByName(imageName)
18- if err != nil {
19- return nil, 0, fmt.Errorf("failed to get image ID: %w", err)
20- }
21-
22- // Create a pipe for streaming
23- pr, pw := io.Pipe()
24-
25- go func() {
26- defer pw.Close()
27-
28- // Export the image using save
29- reader, err := docker.ExportImageFromEngine(imageID)
30- if err != nil {
31- pw.CloseWithError(fmt.Errorf("failed to export image: %w", err))
32- return
33- }
34- defer reader.Close()
35-
36- // Copy the data to the pipe
37- _, err = io.Copy(pw, reader)
38- if err != nil {
39- pw.CloseWithError(fmt.Errorf("failed to copy image data: %w", err))
40- return
41- }
42- }()
43-
44- // Get the actual size
45- size, err := docker.GetImageSize(imageID)
46- if err != nil {
47- return nil, 0, fmt.Errorf("failed to get image size: %w", err)
48- }
49-
50- return pr, size, nil
51-}
D internal/cli/handler/field_check.go +0 -53
@@ -1,53 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/cli"
7- "github.com/bnema/gordon/internal/cli/auth"
8- "github.com/bnema/gordon/internal/common"
9-)
10-
11-// config.Http.BackendURL = readUserInput("Enter the backend URL (e.g. https://gordon.mydomain.com):")
12-// config.General.Token = readUserInput("Enter the token (check your backend config.yml):")
13-// check in the config.yml if BackendURL and Token are set
14-// if not, prompt the user to enter them
15-
16-// Helper function to check, prompt and set a value
17-func checkAndSetField(value *string, promptMessage, errorMessage string) (bool, error) {
18- wasEmpty := false
19- if *value == "" {
20- wasEmpty = true
21- fmt.Println(errorMessage)
22- *value = common.ReadUserInput(promptMessage)
23- }
24- return wasEmpty, nil
25-}
26-
27-func FieldCheck(a *cli.App) error {
28- wasBackendURLEmpty, err := checkAndSetField(&a.Config.Http.BackendURL,
29- "Enter the backend URL (e.g. https://gordon.mydomain.com):",
30- "BackendURL is not set in config.yml")
31- if err != nil {
32- return err
33- }
34-
35- if a.Config.General.Token == "" {
36- fmt.Println("Token is not set. Starting OAuth device flow...")
37- err := auth.DeviceFlowAuth(a)
38- if err != nil {
39- fmt.Printf("Device flow authentication error: %v\n", err)
40- return fmt.Errorf("failed to complete device flow authentication: %w", err)
41- }
42- }
43-
44- // Save config if BackendURL was empty or if a new token was set
45- if wasBackendURLEmpty || a.Config.General.Token != "" {
46- err = a.Config.SaveConfig()
47- if err != nil {
48- return err
49- }
50- }
51-
52- return nil
53-}
D internal/cli/handler/http.go +0 -145
@@ -1,145 +0,0 @@
1-package handler
2-
3-import (
4- "bytes"
5- "encoding/json"
6- "fmt"
7- "io"
8- "net/http"
9-
10- "github.com/bnema/gordon/internal/cli"
11- "github.com/bnema/gordon/internal/common"
12-)
13-
14-// PrepareJSONPayload marshals the payload into JSON
15-func PrepareJSONPayload(payload common.Payload) ([]byte, error) {
16- return json.Marshal(payload)
17-}
18-
19-// CreateNewRequest creates a new HTTP request
20-func CreateNewRequest(method, url string, body []byte) (*http.Request, error) {
21-
22- return http.NewRequest(method, url, bytes.NewBuffer(body))
23-}
24-
25-// SetRequestHeaders sets the headers for the request
26-func setAuthRequestHeaders(req *http.Request, token string) {
27- req.Header.Set("Authorization", "Bearer "+token)
28-}
29-
30-// SendRequest sends the HTTP request and returns the response
31-func SendRequest(req *http.Request) (*http.Response, error) {
32- client := &http.Client{}
33- return client.Do(req)
34-}
35-
36-type Response struct {
37- Http *http.Response
38- Header http.Header
39- Body []byte
40- StatusCode int
41-}
42-
43-// SendHTTPRequest sends the HTTP request
44-func SendHTTPRequest(a *cli.App, rp *common.RequestPayload, method string, endpoint string) (*Response, error) {
45- apiUrl := a.Config.Http.BackendURL + "/api"
46- client := &http.Client{}
47-
48- reauthenticated := false
49-
50- for {
51- req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.Token)
52- if err != nil {
53- return nil, fmt.Errorf("failed to create request: %w", err)
54- }
55-
56- resp, err := client.Do(req)
57- if err != nil {
58- return nil, fmt.Errorf("failed to send request: %w", err)
59- }
60- defer resp.Body.Close()
61-
62- body, err := io.ReadAll(resp.Body)
63- if err != nil {
64- return nil, fmt.Errorf("failed to read response body: %w", err)
65- }
66-
67- if resp.StatusCode != http.StatusOK {
68- var errorResp struct {
69- Success bool `json:"success"`
70- Message string `json:"message"`
71- }
72- if err := json.Unmarshal(body, &errorResp); err == nil {
73- if resp.StatusCode == http.StatusUnauthorized && !reauthenticated {
74- fmt.Println("Token is invalid or expired. Initiating re-authentication...")
75- err := ReAuthenticate(a)
76- if err != nil {
77- return nil, fmt.Errorf("re-authentication failed: %w", err)
78- }
79-
80- reauthenticated = true
81- continue
82- }
83- return nil, fmt.Errorf("failed to send request: %s", errorResp.Message)
84- }
85- return nil, fmt.Errorf("failed to send request: %s", string(body))
86- }
87-
88- return &Response{Http: resp, Body: body, StatusCode: resp.StatusCode}, nil
89- }
90-}
91-
92-func createRequest(apiUrl, endpoint, method string, rp *common.RequestPayload, token string) (*http.Request, error) {
93- var req *http.Request
94- var err error
95-
96- switch rp.Type {
97- case "deploy":
98- deployPayload, ok := rp.Payload.(common.DeployPayload)
99- if !ok {
100- return nil, fmt.Errorf("invalid payload type for deploy")
101- }
102-
103- req, err = http.NewRequest(method, apiUrl+endpoint, deployPayload.Data)
104- if err != nil {
105- return nil, fmt.Errorf("failed to create new streaming request: %w", err)
106- }
107-
108- req.Header.Set("Content-Type", "application/octet-stream")
109- req.Header.Set("X-Ports", deployPayload.Port)
110- req.Header.Set("X-Target-Domain", deployPayload.TargetDomain)
111- req.Header.Set("X-Image-Name", deployPayload.ImageName)
112-
113- case "push":
114- pushPayload, ok := rp.Payload.(common.PushPayload)
115- if !ok {
116- return nil, fmt.Errorf("invalid payload type for push")
117- }
118-
119- req, err = http.NewRequest(method, apiUrl+endpoint, pushPayload.Data)
120- if err != nil {
121- return nil, fmt.Errorf("failed to create new streaming request: %w", err)
122- }
123-
124- req.Header.Set("Content-Type", "application/octet-stream")
125- req.Header.Set("X-Image-Name", pushPayload.ImageName)
126-
127- default:
128- jsonPayload, err := json.Marshal(rp)
129- if err != nil {
130- return nil, fmt.Errorf("failed to marshal payload: %w", err)
131- }
132-
133- req, err = http.NewRequest(method, apiUrl+endpoint, bytes.NewBuffer(jsonPayload))
134- if err != nil {
135- return nil, fmt.Errorf("failed to create new JSON request: %w", err)
136- }
137-
138- req.Header.Set("Content-Type", "application/json")
139- }
140-
141- // Set the authorization header for all request types
142- setAuthRequestHeaders(req, token)
143-
144- return req, nil
145-}
D internal/cli/handler/ping_request.go +0 -46
@@ -1,46 +0,0 @@
1-package handler
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "net/http"
7-
8- "github.com/bnema/gordon/internal/cli"
9- "github.com/bnema/gordon/internal/common"
10-)
11-
12-// Define a struct to match the JSON response structure
13-type PingResponse struct {
14- Uptime string `json:"uptime"`
15- Version string `json:"version"`
16-}
17-
18-func PerformPingRequest(a *cli.App) (PingResponse, error) {
19- var pingResp PingResponse
20-
21- pingPayload := common.PingPayload{Message: "ping"}
22- reqPayload := common.RequestPayload{
23- Type: "ping",
24- Payload: pingPayload,
25- }
26-
27- resp, err := SendHTTPRequest(a, &reqPayload, "GET", "/ping")
28- if err != nil {
29- return pingResp, fmt.Errorf("failed to reach server: %w", err)
30- }
31-
32- defer resp.Http.Body.Close()
33-
34- if resp.StatusCode != http.StatusOK {
35- // Return the error message from the server
36- return PingResponse{}, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(resp.Body))
37- }
38-
39- // Unmarshal the JSON response into the PingResponse struct
40- err = json.Unmarshal(resp.Body, &pingResp)
41- if err != nil {
42- return pingResp, err
43- }
44-
45- return pingResp, nil
46-}
D internal/cli/handler/regex_check.go +0 -40
@@ -1,40 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5- "regexp"
6- "strings"
7-)
8-
9-func ValidateImageName(imageName string) error {
10- match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName)
11- if !match {
12- return fmt.Errorf("You must specify a valid image name in the form (registry/)image:tag, check your container engine image list")
13- }
14- return nil
15-}
16-
17-func EnsureImageTag(imageName *string) error {
18- // if not tags returns an error
19- if !strings.Contains(*imageName, ":") {
20- return fmt.Errorf("You must specify a tag for the image")
21- }
22-
23- return nil
24-}
25-
26-func ValidatePortMapping(port string) error {
27- match, _ := regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port)
28- if !match {
29- return fmt.Errorf("You must specify a port mapping in the form port:port/proto, if no protocol is specified, TCP is used")
30- }
31- return nil
32-}
33-
34-func ValidateTargetDomain(targetDomain string) error {
35- match, _ := regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain)
36- if !match {
37- return fmt.Errorf("invalid target domain format")
38- }
39- return nil
40-}
D internal/cli/handler/start_server.go +0 -55
@@ -1,55 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5- "os"
6- "os/signal"
7- "syscall"
8- "time"
9-
10- "github.com/bnema/gordon/internal/httpserve"
11- "github.com/bnema/gordon/internal/server"
12- "github.com/charmbracelet/log"
13- "github.com/labstack/echo/v4"
14-)
15-
16-// execute cmd/srv/main.go main function
17-
18-func StartServer(a *server.App, port string) error {
19- _, err := server.InitializeDB(a)
20- if err != nil {
21- log.Fatal("Failed to initialize database:", err)
22- }
23-
24- // Start the session cleaner cron job
25- a.StartSessionCleaner()
26-
27- _, err = server.HandleNewTokenInitialization(a)
28- if err != nil {
29- log.Print(err)
30- }
31-
32- // Setup a channel to capture termination signals
33- sigs := make(chan os.Signal, 1)
34- signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
35-
36- go func() {
37- sig := <-sigs
38- log.Info(fmt.Println("Received signal:", sig))
39- os.Exit(0)
40- }()
41-
42- e := echo.New()
43- e.Server.ReadTimeout = 10 * time.Minute
44- e.Server.WriteTimeout = 10 * time.Minute
45- e.HideBanner = true
46- e.HidePort = true
47- e = httpserve.RegisterRoutes(e, a)
48-
49- log.Info(fmt.Sprintf("Starting server on port %s", port))
50- if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
51- log.Fatal(err)
52- }
53-
54- return nil
55-}
D internal/cli/new_client.go +0 -31
@@ -1,31 +0,0 @@
1-package cli
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/common"
7-)
8-
9-type App struct {
10- Config common.Config
11-}
12-
13-// NewClientApp initializes a new App with configuration.
14-func NewClientApp(buildConfig *common.BuildConfig) (*App, error) {
15- // Initialize AppConfig
16- config := common.Config{
17- Build: *buildConfig,
18- }
19-
20- _, err := config.LoadConfig()
21- if err != nil {
22- return nil, fmt.Errorf("failed to load configuration: %w", err)
23- }
24-
25- // Initialize App
26- a := &App{
27- Config: config,
28- }
29-
30- return a, nil
31-}
D internal/common/init_config.go +0 -264
@@ -1,264 +0,0 @@
1-package common
2-
3-import (
4- "errors"
5- "fmt"
6- "io/fs"
7- "os"
8- "path/filepath"
9- "runtime"
10- "strings"
11-
12- "github.com/bnema/gordon/pkg/docker"
13- "github.com/bnema/gordon/pkg/parser"
14-)
15-
16-type Config struct {
17- General GeneralConfig `yaml:"General"`
18- Http HttpConfig `yaml:"Http"`
19- Admin AdminConfig `yaml:"Admin"`
20- ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"`
21- Traefik TraefikConfig `yaml:"Traefik"`
22- Build BuildConfig `yaml:"-"`
23-}
24-
25-type BuildConfig struct {
26- RunEnv string `yaml:"-"` // come from env
27- BuildVersion string `yaml:"-"` // come from build ldflags
28- BuildCommit string `yaml:"-"` // come from build ldflags
29- BuildDate string `yaml:"-"` // come from build ldflags
30- ProxyURL string `yaml:"-"`
31-}
32-
33-type AdminConfig struct {
34- Path string `yaml:"path"`
35-}
36-
37-type GeneralConfig struct {
38- StorageDir string `yaml:"storageDir"`
39- Token string `yaml:"token"`
40-}
41-type HttpConfig struct {
42- Port string `yaml:"port"`
43- Domain string `yaml:"domain"`
44- SubDomain string `yaml:"subDomain"`
45- BackendURL string `yaml:"backendURL"`
46- Https bool `yaml:"https"`
47-}
48-
49-type ContainerEngineConfig struct {
50- Sock string `yaml:"dockersock"`
51- PodmanSock string `yaml:"podmansock"`
52- Podman bool `yaml:"podman"`
53- Network string `yaml:"network"`
54-}
55-
56-type TraefikConfig struct {
57- EntryPoint string `yaml:"entryPoint"`
58- SecureEntryPoint string `yaml:"secureEntryPoint"`
59- Resolver string `yaml:"resolver"`
60-}
61-
62-// Default values
63-var (
64- sock = "/var/run/docker.sock"
65- podmansock = "/run/user/1000/podman/podman.sock"
66- entryPoint = "web"
67- entryPointSecure = "websecure"
68- resolver = "myresolver"
69-)
70-
71-func getConfigDir() (string, error) {
72- var configDir string
73-
74- if isWSL() {
75- // Use XDG_CONFIG_HOME for WSL
76- if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
77- configDir = filepath.Join(xdgConfigHome, "Gordon")
78- } else {
79- // If XDG_CONFIG_HOME is not set, fall back to default locations
80- homeDir, err := os.UserHomeDir()
81- if err != nil {
82- // If we can't get the home directory, use a fallback
83- homeDir = os.TempDir() // Use the system's temp directory as a fallback
84- fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir)
85- }
86-
87- configDir = filepath.Join(homeDir, ".config", "Gordon")
88-
89- }
90-
91- return configDir, nil
92- }
93-
94- if docker.IsRunningInContainer() {
95- return "/.", nil
96- }
97-
98- // Check for XDG_CONFIG_HOME first
99- if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
100- configDir = filepath.Join(xdgConfigHome, "Gordon")
101- } else {
102- // If XDG_CONFIG_HOME is not set, fall back to default locations
103- homeDir, err := os.UserHomeDir()
104- if err != nil {
105- // If we can't get the home directory, use a fallback
106- homeDir = os.TempDir() // Use the system's temp directory as a fallback
107- fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir)
108- }
109-
110- // Select the configuration directory based on the OS
111- if runtime.GOOS == "windows" {
112- configDir = filepath.Join(homeDir, "AppData", "Roaming", "Gordon")
113- } else {
114- configDir = filepath.Join(homeDir, ".config", "Gordon")
115- }
116- }
117-
118- return configDir, nil
119-}
120-
121-func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error {
122- err := parser.ParseYAMLFile(fs, filePath, config)
123- if err != nil {
124- return fmt.Errorf("error reading and unmarshaling configuration file: %w", err)
125- }
126- return nil
127-}
128-
129-func (config *Config) LoadConfig() (*Config, error) {
130- configDir, err := getConfigDir()
131- if err != nil {
132- return nil, fmt.Errorf("error getting configuration directory: %w", err)
133- }
134-
135- configFilePath := filepath.Join(configDir, "config.yml")
136-
137- _, err = os.Stat(configFilePath)
138- if errors.Is(err, fs.ErrNotExist) {
139- fmt.Printf("Config file not found, creating it at %s\n", configFilePath)
140-
141- // Create config dir if it doesn't exist
142- err = os.MkdirAll(configDir, 0755)
143- if err != nil {
144- return nil, fmt.Errorf("error creating configuration directory: %w", err)
145- }
146-
147- // Create config file with the default values of ContainerEngineConfig
148- config = &Config{
149- ContainerEngine: ContainerEngineConfig{
150- Sock: sock,
151- PodmanSock: podmansock,
152- Podman: ReadUserInput("Are you using podman ? (y/n)") == "y",
153- },
154- Traefik: TraefikConfig{
155- EntryPoint: entryPoint,
156- SecureEntryPoint: entryPointSecure,
157- Resolver: resolver,
158- },
159- }
160-
161- err = config.SaveConfig()
162- if err != nil {
163- return nil, fmt.Errorf("error saving new configuration: %w", err)
164- }
165- return config, nil
166- }
167-
168- if err != nil {
169- return nil, fmt.Errorf("error checking configuration file: %w", err)
170- }
171-
172- err = readAndUnmarshalConfig(os.DirFS(configDir), "config.yml", config)
173- if err != nil {
174- return nil, err
175- }
176-
177- // Load run env
178- config.Build.RunEnv = os.Getenv("RUN_ENV")
179-
180- if config.Build.RunEnv == "" {
181- config.Build.RunEnv = "prod"
182- }
183-
184- return config, nil
185-}
186-
187-func (config *Config) SaveConfig() error {
188- configDir, err := getConfigDir()
189- if err != nil {
190- return fmt.Errorf("error getting configuration directory: %w", err)
191- }
192- configFilePath := filepath.Join(configDir, "config.yml")
193-
194- err = parser.WriteYAMLFile(configFilePath, config)
195- if err != nil {
196- return fmt.Errorf("error writing configuration file: %w", err)
197- }
198-
199- return nil
200-}
201-
202-func (config *Config) GetToken() (string, error) {
203- token := config.General.Token
204- if token == "" {
205- return "", fmt.Errorf("no token found in config.yml")
206- }
207-
208- return token, nil
209-
210-}
211-
212-// GetRunEnv returns the run environment
213-func (config *BuildConfig) GetRunEnv() string {
214- return config.RunEnv
215-}
216-
217-// get storage dir
218-func (config *Config) GetStorageDir() string {
219- return config.General.StorageDir
220-}
221-
222-func (c *Config) GetBackendURL() string {
223- return c.Http.BackendURL
224-}
225-
226-func (c *Config) SetToken(token string) {
227- c.General.Token = token
228-}
229-
230-func isWSL() bool {
231- // Check for /proc/version file
232- if _, err := os.Stat("/proc/version"); err == nil {
233- content, err := os.ReadFile("/proc/version")
234- if err == nil && strings.Contains(strings.ToLower(string(content)), "microsoft") {
235- return true
236- }
237- }
238-
239- // Check for WSL-specific environment variable
240- if os.Getenv("WSL_DISTRO_NAME") != "" {
241- return true
242- }
243-
244- return false
245-}
246-
247-func (c *HttpConfig) Protocol() string {
248- if c.Https {
249- return "https"
250- }
251- return "http"
252-}
253-
254-func (c *HttpConfig) FullDomain() string {
255- if c.SubDomain != "" {
256- return fmt.Sprintf("%s.%s", c.SubDomain, c.Domain)
257- }
258- return c.Domain
259-}
260-
261-// GetVersion
262-func (c *Config) GetVersion() string {
263- return c.Build.BuildVersion
264-}
D internal/common/init_dockerclient.go +0 -28
@@ -1,28 +0,0 @@
1-package common
2-
3-import (
4- "log"
5-
6- "github.com/bnema/gordon/pkg/docker"
7-)
8-
9-func DockerInit(cc *ContainerEngineConfig) {
10- err := docker.InitializeClient(NewDockerConfig(cc))
11- if err != nil {
12- log.Printf("Error initializing Docker client: %s", err)
13- }
14-}
15-
16-// NewDockerConfig creates and returns a new Docker client configuration
17-func NewDockerConfig(cc *ContainerEngineConfig) *docker.Config {
18- if cc.Podman {
19- return &docker.Config{
20- Sock: cc.PodmanSock,
21- PodmanEnable: true,
22- }
23- }
24- return &docker.Config{
25- Sock: cc.Sock,
26- PodmanEnable: false,
27- }
28-}
D internal/common/inputs.go +0 -72
@@ -1,72 +0,0 @@
1-package common
2-
3-import (
4- "bufio"
5- "fmt"
6- "log"
7- "os"
8- "strings"
9-)
10-
11-// ReadUserInput reads a string input from the user with a prompt.
12-// It handles multi-word inputs and properly checks for errors.
13-func ReadUserInput(prompt string) string {
14- fmt.Print(prompt + " ")
15-
16- // Use bufio.NewReader for better input handling
17- reader := bufio.NewReader(os.Stdin)
18-
19- // ReadString reads until newline, handling spaces in input
20- input, err := reader.ReadString('\n')
21- if err != nil {
22- log.Printf("Error reading input: %v", err)
23- return ""
24- }
25-
26- // Trim spaces and newline characters
27- return strings.TrimSpace(input)
28-}
29-
30-// ReadUserInputWithValidation reads user input and validates it using a provided function.
31-// It keeps prompting until valid input is received.
32-func ReadUserInputWithValidation(prompt string, validate func(string) bool) string {
33- for {
34- input := ReadUserInput(prompt)
35- if validate(input) {
36- return input
37- }
38- fmt.Println("Invalid input. Please try again.")
39- }
40-}
41-
42-// ReadUserInputWithDefault reads user input with a default value if input is empty.
43-func ReadUserInputWithDefault(prompt string, defaultValue string) string {
44- fmt.Printf("%s (default: %s) ", prompt, defaultValue)
45-
46- reader := bufio.NewReader(os.Stdin)
47- input, err := reader.ReadString('\n')
48- if err != nil {
49- log.Printf("Error reading input: %v", err)
50- return defaultValue
51- }
52-
53- input = strings.TrimSpace(input)
54- if input == "" {
55- return defaultValue
56- }
57- return input
58-}
59-
60-// ReadConfirmation reads a yes/no confirmation from the user.
61-func ReadConfirmation(prompt string) bool {
62- input := ReadUserInputWithValidation(
63- prompt+" (y/n)",
64- func(s string) bool {
65- s = strings.ToLower(s)
66- return s == "y" || s == "n" || s == "yes" || s == "no"
67- },
68- )
69-
70- input = strings.ToLower(input)
71- return input == "y" || input == "yes"
72-}
D internal/common/payload.go +0 -207
@@ -1,207 +0,0 @@
1-package common
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "io"
7-)
8-
9-// RequestPayload represents the structure of incoming requests
10-type RequestPayload struct {
11- Type string `json:"type"`
12- Payload Payload `json:"payload"`
13-}
14-
15-// Payload is an interface for different types of payloads
16-type Payload interface {
17- GetType() string
18-}
19-
20-// DeployResponse represents the response structure for deployment requests
21-type DeployResponse struct {
22- Success bool `json:"success"`
23- Message string `json:"message"`
24- StatusCode int `json:"status_code,omitempty"`
25- Domain string `json:"domain,omitempty"`
26- ContainerID string `json:"container_id,omitempty"`
27- ContainerName string `json:"container_name,omitempty"`
28- State string `json:"state,omitempty"`
29- RunningTime string `json:"running_time,omitempty"`
30- Ports string `json:"ports,omitempty"`
31-}
32-
33-// PushResponse represents the response structure for push requests
34-type PushResponse struct {
35- Success bool `json:"success"`
36- Message string `json:"message"`
37- CreateContainerURL string `json:"create_container_url,omitempty"`
38- ImageID string `json:"image_id,omitempty"`
39-}
40-
41-type StopResponse struct {
42- Success bool `json:"success"`
43- Message string `json:"message"`
44-}
45-
46-type RemoveResponse struct {
47- Success bool `json:"success"`
48- Message string `json:"message"`
49-}
50-
51-type StartResponse struct {
52- Success bool `json:"success"`
53- Message string `json:"message"`
54-}
55-
56-// DeployPayload represents the payload for deployment requests
57-type DeployPayload struct {
58- Port string `json:"port"`
59- TargetDomain string `json:"targetdomain"`
60- ImageName string `json:"imagename"`
61- ImageID string `json:"imageid"`
62- Data io.ReadCloser
63-}
64-
65-// PushPayload represents the payload for push requests
66-type PushPayload struct {
67- ImageName string `json:"imagename"`
68- ImageID string `json:"imageid"`
69- Data io.Reader
70-}
71-
72-type ChunkMetadata struct {
73- ChunkNumber int `json:"chunk_number"`
74- TotalChunks int `json:"total_chunks"`
75- ChunkSize int64 `json:"chunk_size"`
76- TotalSize int64 `json:"total_size"`
77- ImageName string `json:"image_name"`
78- TransferID string `json:"transfer_id"`
79-}
80-
81-type ConflictAction struct {
82- Action string `json:"action"` // "stop", "remove", or "cancel"
83- Force bool `json:"force"`
84-}
85-
86-type DeviceFlowResponse struct {
87- Code string `json:"code"`
88- Message string `json:"message"`
89-}
90-
91-// UnmarshalJSON custom unmarshaler for RequestPayload
92-func (p *RequestPayload) UnmarshalJSON(data []byte) error {
93- var raw map[string]json.RawMessage
94- err := json.Unmarshal(data, &raw)
95- if err != nil {
96- return err
97- }
98-
99- if err := json.Unmarshal(raw["type"], &p.Type); err != nil {
100- return err
101- }
102-
103- // Unmarshal the payload based on the type
104- switch p.Type {
105- case "ping":
106- var payload PingPayload
107- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
108- return err
109- }
110- p.Payload = payload
111- case "deploy":
112- var payload DeployPayload
113- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
114- return err
115- }
116- p.Payload = payload
117- case "push":
118- var payload PushPayload
119- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
120- return err
121- }
122- p.Payload = payload
123- case "stop":
124- var payload StopPayload
125- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
126- return err
127- }
128- p.Payload = payload
129- case "start":
130- var payload StartPayload
131- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
132- return err
133- }
134- p.Payload = payload
135- case "remove":
136- var payload RemovePayload
137- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
138- return err
139- }
140- p.Payload = payload
141- default:
142- return fmt.Errorf("invalid type: %s", p.Type)
143- }
144-
145- return nil
146-}
147-
148-// PingPayload represents the payload for ping requests
149-type PingPayload struct {
150- Message string `json:"message"`
151-}
152-
153-// GetType returns the type of the PingPayload
154-func (p PingPayload) GetType() string {
155- return "ping"
156-}
157-
158-// GetType returns the type of the DeployPayload
159-func (p DeployPayload) GetType() string {
160- return "deploy"
161-}
162-
163-// GetType returns the type of the PushPayload
164-func (p PushPayload) GetType() string {
165- return "push"
166-}
167-
168-// NewPingPayload creates a new PingPayload from a map of data
169-func NewPingPayload(data map[string]interface{}) (PingPayload, error) {
170- message, ok := data["message"].(string)
171- if !ok {
172- return PingPayload{}, fmt.Errorf("invalid data for message")
173- }
174- return PingPayload{Message: message}, nil
175-}
176-
177-// StopPayload represents the payload for stop requests
178-type StopPayload struct {
179- ContainerID string `json:"container_id"`
180- ContainerName string `json:"container_name"`
181-}
182-
183-// GetType returns the type of the StopPayload
184-func (p StopPayload) GetType() string {
185- return "stop"
186-}
187-
188-// StartPayload represents the payload for start requests
189-type StartPayload struct {
190- ContainerID string `json:"container_id"`
191- ContainerName string `json:"container_name"`
192-}
193-
194-// GetType returns the type of the StartPayload
195-func (p StartPayload) GetType() string {
196- return "start"
197-}
198-
199-// RemovePayload represents the payload for remove requests
200-type RemovePayload struct {
201- ContainerID string `json:"container_id"`
202-}
203-
204-// GetType returns the type of the RemovePayload
205-func (p RemovePayload) GetType() string {
206- return "remove"
207-}
D internal/common/version.go +0 -189
@@ -1,189 +0,0 @@
1-// internal/common/version.go
2-
3-package common
4-
5-import (
6- "encoding/json"
7- "fmt"
8- "io"
9- "net/http"
10- "runtime/debug"
11- "strings"
12- "time"
13-
14- "github.com/Masterminds/semver/v3"
15- "github.com/charmbracelet/log"
16-)
17-
18-// Info represents version information
19-type Info struct {
20- Version string
21- Commit string
22- Date string
23- ProxyURL string
24-}
25-
26-type ProxyVersion struct {
27- Version string `json:"version"`
28-}
29-
30-// GetVersionInfo returns the current version information based on BuildConfig
31-func GetVersionInfo(buildVersion, buildCommit, buildDate, proxyURL string) Info {
32- // If version was set in BuildConfig, use that
33- if buildVersion != "" && buildVersion != "dev" {
34- return Info{
35- Version: buildVersion,
36- Commit: buildCommit,
37- Date: buildDate,
38- ProxyURL: proxyURL,
39- }
40- }
41-
42- // Try to get version from runtime build info (for go install)
43- if buildInfo, ok := debug.ReadBuildInfo(); ok {
44- return Info{
45- Version: buildInfo.Main.Version,
46- Commit: "unknown",
47- Date: "unknown",
48- ProxyURL: proxyURL,
49- }
50- }
51-
52- // Fallback for development builds
53- return Info{
54- Version: "dev",
55- Commit: "unknown",
56- Date: "unknown",
57- ProxyURL: proxyURL,
58- }
59-}
60-
61-// CheckForNewVersion checks if a newer version is available via proxy
62-func CheckForNewVersion(currentVersion, proxyURL string) (bool, string, error) {
63- // Don't check for new versions in development builds or dirty builds
64- if currentVersion == "dev" || strings.Contains(currentVersion, "-dirty") {
65- return false, "", nil
66- }
67-
68- // If currentVersion is empty, treat it as v0.0.0
69- if currentVersion == "" {
70- currentVersion = "v0.0.0"
71- }
72-
73- client := &http.Client{
74- Timeout: 10 * time.Second,
75- }
76-
77- resp, err := client.Get(proxyURL + "/version")
78- if err != nil {
79- return false, "", fmt.Errorf("error making request: %w", err)
80- }
81- defer resp.Body.Close()
82-
83- if resp.StatusCode != http.StatusOK {
84- return false, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
85- }
86-
87- body, err := io.ReadAll(resp.Body)
88- if err != nil {
89- return false, "", fmt.Errorf("error reading response: %w", err)
90- }
91-
92- var proxyVer ProxyVersion
93- if err := json.Unmarshal(body, &proxyVer); err != nil {
94- return false, "", fmt.Errorf("error parsing JSON: %w", err)
95- }
96-
97- // Ensure versions start with 'v'
98- if !strings.HasPrefix(currentVersion, "v") {
99- currentVersion = "v" + currentVersion
100- }
101- if !strings.HasPrefix(proxyVer.Version, "v") {
102- proxyVer.Version = "v" + proxyVer.Version
103- }
104-
105- // Clean up version strings
106- latestVersion := strings.TrimPrefix(proxyVer.Version, "v")
107- currentBase := extractBaseVersion(currentVersion)
108-
109- // Handle development versions
110- if currentBase == "" || currentBase == "dev" {
111- currentBase = "0.0.0"
112- }
113-
114- // Parse versions using semver
115- current, err := semver.NewVersion(currentBase)
116- if err != nil {
117- return false, "", fmt.Errorf("error parsing current version (%s): %w", currentBase, err)
118- }
119-
120- latest, err := semver.NewVersion(latestVersion)
121- if err != nil {
122- return false, "", fmt.Errorf("error parsing latest version (%s): %w", latestVersion, err)
123- }
124-
125- if latest.GreaterThan(current) {
126- return true, proxyVer.Version, nil
127- }
128-
129- return false, "", nil
130-}
131-
132-// CheckVersionPeriodically checks for new versions periodically
133-func CheckVersionPeriodically(info Info, checkInterval time.Duration) {
134- ticker := time.NewTicker(checkInterval)
135- defer ticker.Stop()
136-
137- // Initial check
138- checkVersion(info)
139-
140- // Periodic checks
141- for range ticker.C {
142- checkVersion(info)
143- }
144-}
145-
146-func checkVersion(info Info) {
147- hasUpdate, latestVersion, err := CheckForNewVersion(info.Version, info.ProxyURL)
148- if err != nil {
149- log.Error("Failed to check for updates", "error", err)
150- return
151- }
152-
153- if hasUpdate {
154- log.Info("New version available!",
155- "current", info.Version,
156- "latest", latestVersion,
157- "update_url", "https://github.com/bnema/gordon/releases/latest")
158- }
159-}
160-
161-func extractBaseVersion(version string) string {
162- // Remove 'v' prefix if present
163- version = strings.TrimPrefix(version, "v")
164-
165- // Handle empty or invalid versions
166- if version == "" || version == "dev" {
167- return "0.0.0"
168- }
169-
170- // Split on first hyphen to remove dirty/commit info
171- parts := strings.SplitN(version, "-", 2)
172- return parts[0]
173-}
174-
175-// String returns a formatted version string
176-func (i Info) String() string {
177- if i.Version == "dev" {
178- return "Gordon version dev (development build)"
179- }
180-
181- // Check if it's a dirty version
182- if strings.Contains(i.Version, "-dirty") {
183- return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)",
184- i.Version, i.Commit, i.Date)
185- }
186-
187- return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)",
188- i.Version, i.Commit, i.Date)
189-}
A internal/config/config.go +529 -0
@@ -0,0 +1,529 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
9+
10+ "github.com/rs/zerolog/log"
11+ "github.com/spf13/viper"
12+)
13+
14+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"`
22+ Attachments map[string][]string `mapstructure:"attachments"`
23+ NetworkGroups map[string][]string `mapstructure:"network_groups"`
24+ NetworkIsolation NetworkIsolationConfig `mapstructure:"network_isolation"`
25+}
26+
27+type ServerConfig struct {
28+ Port int `mapstructure:"port"`
29+ RegistryPort int `mapstructure:"registry_port"`
30+ RegistryDomain string `mapstructure:"registry_domain"`
31+ Runtime string `mapstructure:"runtime"`
32+ SocketPath string `mapstructure:"socket_path"`
33+ SSLEmail string `mapstructure:"ssl_email"`
34+ DataDir string `mapstructure:"data_dir"`
35+}
36+
37+type RegistryAuthConfig struct {
38+ Enabled bool `mapstructure:"enabled"`
39+ Username string `mapstructure:"username"`
40+ Password string `mapstructure:"password"`
41+}
42+
43+type AutoRouteConfig struct {
44+ Enabled bool `mapstructure:"enabled"`
45+}
46+
47+type EnvConfig struct {
48+ Dir string `mapstructure:"dir"`
49+ Providers []string `mapstructure:"providers"`
50+}
51+
52+type LoggingConfig struct {
53+ Enabled bool `mapstructure:"enabled"`
54+ Level string `mapstructure:"level"`
55+ Dir string `mapstructure:"dir"`
56+ MainLogFile string `mapstructure:"main_log_file"`
57+ ProxyLogFile string `mapstructure:"proxy_log_file"`
58+ ContainerLogDir string `mapstructure:"container_log_dir"`
59+ MaxSize int `mapstructure:"max_size"`
60+ MaxBackups int `mapstructure:"max_backups"`
61+ MaxAge int `mapstructure:"max_age"`
62+ Compress bool `mapstructure:"compress"`
63+}
64+
65+type VolumeConfig struct {
66+ AutoCreate bool `mapstructure:"auto_create"`
67+ Prefix string `mapstructure:"prefix"`
68+ Preserve bool `mapstructure:"preserve"`
69+}
70+
71+type NetworkIsolationConfig struct {
72+ Enabled bool `mapstructure:"enabled"`
73+ NetworkPrefix string `mapstructure:"network_prefix"`
74+ DNSSuffix string `mapstructure:"dns_suffix"`
75+}
76+
77+type SecretProvider struct {
78+ Type string `mapstructure:"type"`
79+ Config map[string]string `mapstructure:"config"`
80+}
81+
82+type Route struct {
83+ Domain string
84+ Image string
85+ HTTPS bool
86+}
87+
88+func Load() (*Config, error) {
89+ var cfg Config
90+
91+ // Set defaults
92+ viper.SetDefault("server.port", 8080)
93+ viper.SetDefault("server.registry_port", 5000)
94+ viper.SetDefault("server.runtime", "auto")
95+ viper.SetDefault("server.socket_path", "")
96+
97+ // Set data_dir default based on environment
98+ defaultDataDir := getDefaultDataDir()
99+ viper.SetDefault("server.data_dir", defaultDataDir)
100+ viper.SetDefault("registry_auth.enabled", true)
101+ viper.SetDefault("auto_route.enabled", false)
102+ viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))
103+ viper.SetDefault("env.providers", []string{"pass", "sops"})
104+
105+ // Logging defaults
106+ viper.SetDefault("logging.enabled", true)
107+ viper.SetDefault("logging.level", "info")
108+ viper.SetDefault("logging.dir", filepath.Join(defaultDataDir, "logs"))
109+ viper.SetDefault("logging.main_log_file", "gordon.log")
110+ viper.SetDefault("logging.proxy_log_file", "proxy.log")
111+ viper.SetDefault("logging.container_log_dir", "containers")
112+ viper.SetDefault("logging.max_size", 100) // 100MB
113+ viper.SetDefault("logging.max_backups", 3) // Keep 3 old files
114+ viper.SetDefault("logging.max_age", 28) // 28 days
115+ viper.SetDefault("logging.compress", true)
116+
117+ // Volume defaults
118+ viper.SetDefault("volumes.auto_create", true)
119+ viper.SetDefault("volumes.prefix", "gordon")
120+ viper.SetDefault("volumes.preserve", true)
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+
127+ // Handle the routes manually since Viper struggles with domain names
128+ cfg.Routes = make(map[string]string)
129+ cfg.Attachments = make(map[string][]string)
130+ cfg.NetworkGroups = make(map[string][]string)
131+
132+ // Get server config first
133+ if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
134+ return nil, fmt.Errorf("unable to decode server config: %v", err)
135+ }
136+
137+ // If data_dir is empty after loading config, use the default
138+ if cfg.Server.DataDir == "" {
139+ cfg.Server.DataDir = defaultDataDir
140+ log.Debug().Str("data_dir", cfg.Server.DataDir).Msg("Config had empty data_dir, using default")
141+ }
142+
143+ // If registry_port is 0 after loading config, use the default
144+ if cfg.Server.RegistryPort == 0 {
145+ cfg.Server.RegistryPort = 5000
146+ log.Debug().Int("registry_port", cfg.Server.RegistryPort).Msg("Config had empty registry_port, using default")
147+ }
148+
149+ // Get registry auth config
150+ if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {
151+ return nil, fmt.Errorf("unable to decode registry auth config: %v", err)
152+ }
153+
154+ // Get auto route config
155+ if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {
156+ return nil, fmt.Errorf("unable to decode auto route config: %v", err)
157+ }
158+
159+ // Get env config
160+ if err := viper.UnmarshalKey("env", &cfg.Env); err != nil {
161+ return nil, fmt.Errorf("unable to decode env config: %v", err)
162+ }
163+
164+ // If env.dir is empty after loading config, use the default
165+ if cfg.Env.Dir == "" {
166+ cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")
167+ log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")
168+ }
169+
170+ // Get logging config
171+ if err := viper.UnmarshalKey("logging", &cfg.Logging); err != nil {
172+ return nil, fmt.Errorf("unable to decode logging config: %v", err)
173+ }
174+
175+ // If logging.dir is empty after loading config, use the default
176+ if cfg.Logging.Dir == "" {
177+ cfg.Logging.Dir = filepath.Join(cfg.Server.DataDir, "logs")
178+ log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default")
179+ }
180+
181+ // Get volumes config
182+ if err := viper.UnmarshalKey("volumes", &cfg.Volumes); err != nil {
183+ return nil, fmt.Errorf("unable to decode volumes config: %v", err)
184+ }
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+
191+ // Get routes manually from the raw config
192+ routesRaw := viper.Get("routes")
193+ if routesRaw != nil {
194+ if routes, ok := routesRaw.(map[string]interface{}); ok {
195+ for domain, image := range routes {
196+ if imageStr, ok := image.(string); ok {
197+ cfg.Routes[domain] = imageStr
198+ }
199+ }
200+ }
201+ }
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+
239+ // Note: ssl_email is optional and only needed for future Let's Encrypt integration
240+ // Currently using Cloudflare for HTTPS
241+
242+ validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}
243+ isValid := false
244+ for _, valid := range validRuntimes {
245+ if cfg.Server.Runtime == valid {
246+ isValid = true
247+ break
248+ }
249+ }
250+ if !isValid {
251+ return nil, fmt.Errorf("server.runtime must be one of: %s", strings.Join(validRuntimes, ", "))
252+ }
253+
254+ // Validate registry auth config
255+ if cfg.RegistryAuth.Enabled {
256+ if cfg.RegistryAuth.Username == "" || cfg.RegistryAuth.Password == "" {
257+ return nil, fmt.Errorf("Registry auth enabled but username/password not provided")
258+ }
259+ }
260+
261+ // Validate registry domain if provided
262+ if cfg.Server.RegistryDomain != "" {
263+ // Basic domain validation - should not contain protocol or paths
264+ if strings.Contains(cfg.Server.RegistryDomain, "://") || strings.Contains(cfg.Server.RegistryDomain, "/") {
265+ return nil, fmt.Errorf("registry_domain should be just the domain name (e.g. 'registry.example.com')")
266+ }
267+ }
268+
269+ // Validate env config
270+ if err := validateEnvConfig(&cfg.Env); err != nil {
271+ return nil, fmt.Errorf("invalid env config: %w", err)
272+ }
273+
274+ return &cfg, nil
275+}
276+
277+func (c *Config) GetRoutes() []Route {
278+ var routes []Route
279+
280+ for domain, image := range c.Routes {
281+ route := Route{
282+ Image: image,
283+ HTTPS: true, // Default to HTTPS
284+ }
285+
286+ // Check if domain has http:// prefix
287+ if strings.HasPrefix(domain, "http://") {
288+ route.Domain = strings.TrimPrefix(domain, "http://")
289+ route.HTTPS = false
290+ } else {
291+ route.Domain = domain
292+ }
293+
294+ routes = append(routes, route)
295+ }
296+
297+ return routes
298+}
299+
300+// getDefaultDataDir returns a platform-appropriate default data directory
301+func getDefaultDataDir() string {
302+ uid := os.Getuid()
303+ log.Debug().Int("uid", uid).Msg("Detecting default data directory")
304+
305+ // Check if we're running in a rootless environment
306+ if uid != 0 {
307+ // For rootless environments, use user's home directory or current directory
308+ if homeDir, err := os.UserHomeDir(); err == nil {
309+ dataDir := filepath.Join(homeDir, ".local/share/gordon")
310+ log.Debug().Str("data_dir", dataDir).Msg("Using user data directory for rootless environment")
311+ return dataDir
312+ }
313+ log.Debug().Msg("Failed to get user home directory, falling back to ./data")
314+ } else {
315+ log.Debug().Msg("Running as root, using ./data")
316+ }
317+
318+ // For root or when home directory is not available, use relative path
319+ return "./data"
320+}
321+
322+// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest"
323+func ExtractDomainFromImageName(imageName string) (string, bool) {
324+ // Split by colon to separate image name from tag
325+ parts := strings.Split(imageName, ":")
326+ imageNamePart := parts[0]
327+
328+ // Domain pattern: should contain at least one dot and valid domain characters
329+ domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
330+
331+ if domainPattern.MatchString(imageNamePart) {
332+ return imageNamePart, true
333+ }
334+
335+ return "", false
336+}
337+
338+// AddRoute adds a new route to the configuration and saves it
339+func (c *Config) AddRoute(domain, image string) error {
340+ if c.Routes == nil {
341+ c.Routes = make(map[string]string)
342+ }
343+
344+ c.Routes[domain] = image
345+
346+ // Read the current config file to preserve formatting
347+ configFile := viper.ConfigFileUsed()
348+ if configFile == "" {
349+ return fmt.Errorf("no config file path available")
350+ }
351+
352+ // Read existing content
353+ content, err := os.ReadFile(configFile)
354+ if err != nil {
355+ return fmt.Errorf("failed to read config file: %w", err)
356+ }
357+
358+ // Parse and update only the routes section while preserving other formatting
359+ lines := strings.Split(string(content), "\n")
360+ var newLines []string
361+ inRoutesSection := false
362+ routeAdded := false
363+
364+ for _, line := range lines {
365+ trimmed := strings.TrimSpace(line)
366+
367+ // Check if we're entering or leaving the routes section
368+ if trimmed == "[routes]" {
369+ inRoutesSection = true
370+ newLines = append(newLines, line)
371+ continue
372+ }
373+
374+ // Check if we're entering a new section
375+ if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
376+ // If we were in routes section and haven't added the route yet, add it
377+ if inRoutesSection && !routeAdded {
378+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
379+ routeAdded = true
380+ }
381+ inRoutesSection = false
382+ }
383+
384+ // If we're in routes section, check if this route already exists (check both quote styles)
385+ if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
386+ // Update existing route using double quotes for consistency
387+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
388+ routeAdded = true
389+ } else {
390+ newLines = append(newLines, line)
391+ }
392+ }
393+
394+ // If we were in routes section at the end and haven't added the route yet, add it
395+ if inRoutesSection && !routeAdded {
396+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
397+ }
398+
399+ // Write the updated content back
400+ newContent := strings.Join(newLines, "\n")
401+ if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
402+ return fmt.Errorf("failed to write config file: %w", err)
403+ }
404+
405+ log.Info().
406+ Str("domain", domain).
407+ Str("image", image).
408+ Msg("Auto-added route to configuration")
409+
410+ // Create env file for the new route (if env loader is available)
411+ // This will be called from the auto-route handler which has access to the env loader
412+
413+ return nil
414+}
415+
416+// UpdateRoute updates an existing route configuration
417+func (c *Config) UpdateRoute(domain, image string) error {
418+ if c.Routes == nil {
419+ c.Routes = make(map[string]string)
420+ }
421+
422+ // Check if route exists
423+ if _, exists := c.Routes[domain]; !exists {
424+ return fmt.Errorf("route %s does not exist", domain)
425+ }
426+
427+ c.Routes[domain] = image
428+
429+ // Read the current config file to preserve formatting
430+ configFile := viper.ConfigFileUsed()
431+ if configFile == "" {
432+ return fmt.Errorf("no config file path available")
433+ }
434+
435+ // Read existing content
436+ content, err := os.ReadFile(configFile)
437+ if err != nil {
438+ return fmt.Errorf("failed to read config file: %w", err)
439+ }
440+
441+ // Parse and update only the routes section while preserving other formatting
442+ lines := strings.Split(string(content), "\n")
443+ var newLines []string
444+ inRoutesSection := false
445+ routeUpdated := false
446+
447+ for _, line := range lines {
448+ trimmed := strings.TrimSpace(line)
449+
450+ // Check if we're entering or leaving the routes section
451+ if trimmed == "[routes]" {
452+ inRoutesSection = true
453+ newLines = append(newLines, line)
454+ continue
455+ }
456+
457+ // Check if we're entering a new section
458+ if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
459+ inRoutesSection = false
460+ }
461+
462+ // If we're in routes section, check if this route matches (check both quote styles)
463+ if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
464+ // Update existing route using double quotes for consistency
465+ newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
466+ routeUpdated = true
467+ } else {
468+ newLines = append(newLines, line)
469+ }
470+ }
471+
472+ if !routeUpdated {
473+ return fmt.Errorf("route %s not found in config file", domain)
474+ }
475+
476+ // Write the updated content back
477+ newContent := strings.Join(newLines, "\n")
478+ if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
479+ return fmt.Errorf("failed to write config file: %w", err)
480+ }
481+
482+ log.Info().
483+ Str("domain", domain).
484+ Str("image", image).
485+ Msg("Updated route configuration")
486+
487+ return nil
488+}
489+
490+// validateEnvConfig validates the environment configuration
491+func validateEnvConfig(envCfg *EnvConfig) error {
492+ // Validate env directory path
493+ if envCfg.Dir != "" {
494+ if filepath.IsAbs(envCfg.Dir) {
495+ // For absolute paths, check if the directory is accessible
496+ if info, err := os.Stat(envCfg.Dir); err != nil {
497+ if !os.IsNotExist(err) {
498+ return fmt.Errorf("env.dir path is not accessible: %w", err)
499+ }
500+ // Directory doesn't exist - that's okay, it will be created
501+ } else if !info.IsDir() {
502+ return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir)
503+ }
504+ }
505+ // For relative paths, we can't easily validate without changing working directory
506+ }
507+
508+ // Validate secret providers configuration
509+ validProviders := []string{"pass", "sops"}
510+ for _, providerName := range envCfg.Providers {
511+ if providerName == "" {
512+ return fmt.Errorf("empty provider name in env.providers")
513+ }
514+
515+ isValid := false
516+ for _, validType := range validProviders {
517+ if providerName == validType {
518+ isValid = true
519+ break
520+ }
521+ }
522+ if !isValid {
523+ return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
524+ providerName, strings.Join(validProviders, ", "))
525+ }
526+ }
527+
528+ return nil
529+}
A internal/config/config_test.go +650 -0
@@ -0,0 +1,650 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/spf13/viper"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "gordon/internal/testutils"
13+)
14+
15+func TestConfig_Load_ValidBasicConfig(t *testing.T) {
16+ // Create temporary config file
17+ configContent := testutils.LoadFixtureConfig(t, "basic.toml")
18+
19+ // Create a temporary directory for the config file
20+ tempDir := t.TempDir()
21+ configFile := filepath.Join(tempDir, "gordon.toml")
22+
23+ // Write config to temporary file
24+ err := os.WriteFile(configFile, []byte(configContent), 0644)
25+ require.NoError(t, err)
26+
27+ // Set viper to use this config file
28+ viper.Reset()
29+ viper.SetConfigFile(configFile)
30+ err = viper.ReadInConfig()
31+ require.NoError(t, err)
32+
33+ // Load config
34+ cfg, err := Load()
35+ require.NoError(t, err)
36+ require.NotNil(t, cfg)
37+
38+ // Verify server config
39+ assert.Equal(t, 8080, cfg.Server.Port)
40+ assert.Equal(t, 5000, cfg.Server.RegistryPort)
41+ assert.Equal(t, "registry.example.com", cfg.Server.RegistryDomain)
42+ assert.Equal(t, "docker", cfg.Server.Runtime)
43+
44+ // Verify registry auth config
45+ assert.True(t, cfg.RegistryAuth.Enabled)
46+ assert.Equal(t, "admin", cfg.RegistryAuth.Username)
47+ assert.Equal(t, "password123", cfg.RegistryAuth.Password)
48+
49+ // Verify routes
50+ assert.Len(t, cfg.Routes, 2)
51+ assert.Equal(t, "nginx:latest", cfg.Routes["app.example.com"])
52+ assert.Equal(t, "myapi:v1", cfg.Routes["api.example.com"])
53+
54+ // Verify volumes config
55+ assert.True(t, cfg.Volumes.AutoCreate)
56+ assert.Equal(t, "gordon", cfg.Volumes.Prefix)
57+ assert.True(t, cfg.Volumes.Preserve)
58+
59+ // Verify env config
60+ assert.Equal(t, "/tmp/env", cfg.Env.Dir)
61+ assert.Contains(t, cfg.Env.Providers, "pass")
62+ assert.Contains(t, cfg.Env.Providers, "sops")
63+
64+ // Verify logging config
65+ assert.True(t, cfg.Logging.Enabled)
66+ assert.Equal(t, "info", cfg.Logging.Level)
67+}
68+
69+func TestConfig_Load_MinimalConfig(t *testing.T) {
70+ configContent := testutils.LoadFixtureConfig(t, "minimal.toml")
71+
72+ tempDir := t.TempDir()
73+ configFile := filepath.Join(tempDir, "gordon.toml")
74+
75+ err := os.WriteFile(configFile, []byte(configContent), 0644)
76+ require.NoError(t, err)
77+
78+ viper.Reset()
79+ viper.SetConfigFile(configFile)
80+ err = viper.ReadInConfig()
81+ require.NoError(t, err)
82+
83+ cfg, err := Load()
84+ require.NoError(t, err)
85+ require.NotNil(t, cfg)
86+
87+ // Should have defaults applied where not specified, but explicit values where set
88+ // Note: viper defaults may not apply if config structure is partially loaded
89+ assert.Equal(t, "registry.example.com", cfg.Server.RegistryDomain)
90+ assert.Equal(t, "docker", cfg.Server.Runtime) // explicitly set in minimal config
91+ assert.False(t, cfg.RegistryAuth.Enabled) // explicitly set in minimal config
92+ assert.True(t, cfg.Volumes.AutoCreate) // default
93+ assert.Equal(t, "gordon", cfg.Volumes.Prefix) // default
94+}
95+
96+func TestConfig_Load_InvalidTOMLSyntax(t *testing.T) {
97+ configContent := testutils.LoadFixtureConfig(t, "invalid.toml")
98+
99+ tempDir := t.TempDir()
100+ configFile := filepath.Join(tempDir, "gordon.toml")
101+
102+ err := os.WriteFile(configFile, []byte(configContent), 0644)
103+ require.NoError(t, err)
104+
105+ viper.Reset()
106+ viper.SetConfigFile(configFile)
107+ err = viper.ReadInConfig()
108+ require.Error(t, err)
109+ assert.Contains(t, err.Error(), "toml")
110+}
111+
112+func TestConfig_Load_InvalidRuntime(t *testing.T) {
113+ configContent := `[server]
114+registry_domain = "registry.example.com"
115+runtime = "invalid-runtime"`
116+
117+ tempDir := t.TempDir()
118+ configFile := filepath.Join(tempDir, "gordon.toml")
119+
120+ err := os.WriteFile(configFile, []byte(configContent), 0644)
121+ require.NoError(t, err)
122+
123+ viper.Reset()
124+ viper.SetConfigFile(configFile)
125+ err = viper.ReadInConfig()
126+ require.NoError(t, err)
127+
128+ _, err = Load()
129+ require.Error(t, err)
130+ assert.Contains(t, err.Error(), "server.runtime must be one of")
131+}
132+
133+func TestConfig_Load_InvalidRegistryDomain(t *testing.T) {
134+ testCases := []struct {
135+ name string
136+ domain string
137+ }{
138+ {"with protocol", "https://registry.example.com"},
139+ {"with path", "registry.example.com/path"},
140+ {"with protocol and path", "https://registry.example.com/path"},
141+ }
142+
143+ for _, tc := range testCases {
144+ t.Run(tc.name, func(t *testing.T) {
145+ configContent := `[server]
146+registry_domain = "` + tc.domain + `"
147+runtime = "docker"
148+
149+[registry_auth]
150+enabled = false`
151+
152+ tempDir := t.TempDir()
153+ configFile := filepath.Join(tempDir, "gordon.toml")
154+
155+ err := os.WriteFile(configFile, []byte(configContent), 0644)
156+ require.NoError(t, err)
157+
158+ viper.Reset()
159+ viper.SetConfigFile(configFile)
160+ err = viper.ReadInConfig()
161+ require.NoError(t, err)
162+
163+ _, err = Load()
164+ require.Error(t, err)
165+ assert.Contains(t, err.Error(), "registry_domain should be just the domain name")
166+ })
167+ }
168+}
169+
170+func TestConfig_Load_RegistryAuthValidation(t *testing.T) {
171+ configContent := `[server]
172+registry_domain = "registry.example.com"
173+runtime = "docker"
174+
175+[registry_auth]
176+enabled = true
177+username = ""
178+password = ""`
179+
180+ tempDir := t.TempDir()
181+ configFile := filepath.Join(tempDir, "gordon.toml")
182+
183+ err := os.WriteFile(configFile, []byte(configContent), 0644)
184+ require.NoError(t, err)
185+
186+ viper.Reset()
187+ viper.SetConfigFile(configFile)
188+ err = viper.ReadInConfig()
189+ require.NoError(t, err)
190+
191+ _, err = Load()
192+ require.Error(t, err)
193+ assert.Contains(t, err.Error(), "Registry auth enabled but username/password not provided")
194+}
195+
196+func TestConfig_GetRoutes(t *testing.T) {
197+ cfg := &Config{
198+ Routes: map[string]string{
199+ "app.example.com": "nginx:latest",
200+ "http://api.example.com": "api:v1",
201+ "secure.example.com": "secure:latest",
202+ },
203+ }
204+
205+ routes := cfg.GetRoutes()
206+
207+ require.Len(t, routes, 3)
208+
209+ // Find routes by domain
210+ var appRoute, apiRoute, secureRoute Route
211+ for _, route := range routes {
212+ switch route.Domain {
213+ case "app.example.com":
214+ appRoute = route
215+ case "api.example.com":
216+ apiRoute = route
217+ case "secure.example.com":
218+ secureRoute = route
219+ }
220+ }
221+
222+ // Verify HTTPS defaults
223+ assert.Equal(t, "nginx:latest", appRoute.Image)
224+ assert.True(t, appRoute.HTTPS)
225+
226+ // Verify HTTP override
227+ assert.Equal(t, "api:v1", apiRoute.Image)
228+ assert.False(t, apiRoute.HTTPS)
229+
230+ // Verify HTTPS default
231+ assert.Equal(t, "secure:latest", secureRoute.Image)
232+ assert.True(t, secureRoute.HTTPS)
233+}
234+
235+func TestExtractDomainFromImageName(t *testing.T) {
236+ testCases := []struct {
237+ name string
238+ imageName string
239+ expectedDomain string
240+ shouldMatch bool
241+ }{
242+ {"valid domain with tag", "app.example.com:latest", "app.example.com", true},
243+ {"valid domain without tag", "api.example.com", "api.example.com", true},
244+ {"subdomain", "staging.api.example.com:v1", "staging.api.example.com", true},
245+ {"simple name", "nginx", "", false},
246+ {"dockerhub format", "user/repo", "", false},
247+ {"localhost", "localhost:5000", "", false},
248+ {"ip address", "192.168.1.1:8080", "", false},
249+ {"invalid domain", "app-.example.com", "", false},
250+ }
251+
252+ for _, tc := range testCases {
253+ t.Run(tc.name, func(t *testing.T) {
254+ domain, match := ExtractDomainFromImageName(tc.imageName)
255+ assert.Equal(t, tc.shouldMatch, match)
256+ assert.Equal(t, tc.expectedDomain, domain)
257+ })
258+ }
259+}
260+
261+func TestConfig_AddRoute(t *testing.T) {
262+ // Create a config with existing routes
263+ configContent := `[server]
264+registry_domain = "registry.example.com"
265+runtime = "docker"
266+
267+[registry_auth]
268+enabled = false
269+
270+[routes]
271+"existing.example.com" = "existing:latest"`
272+
273+ tempDir := t.TempDir()
274+ configFile := filepath.Join(tempDir, "gordon.toml")
275+
276+ err := os.WriteFile(configFile, []byte(configContent), 0644)
277+ require.NoError(t, err)
278+
279+ viper.Reset()
280+ viper.SetConfigFile(configFile)
281+ err = viper.ReadInConfig()
282+ require.NoError(t, err)
283+
284+ cfg, err := Load()
285+ require.NoError(t, err)
286+
287+ // Add a new route
288+ err = cfg.AddRoute("new.example.com", "newapp:v1")
289+ require.NoError(t, err)
290+
291+ // Verify route was added to memory
292+ assert.Equal(t, "newapp:v1", cfg.Routes["new.example.com"])
293+
294+ // Verify config file was updated
295+ updatedContent, err := os.ReadFile(configFile)
296+ require.NoError(t, err)
297+ assert.Contains(t, string(updatedContent), `"new.example.com" = "newapp:v1"`)
298+ assert.Contains(t, string(updatedContent), `"existing.example.com" = "existing:latest"`)
299+}
300+
301+func TestConfig_UpdateRoute(t *testing.T) {
302+ configContent := `[server]
303+registry_domain = "registry.example.com"
304+runtime = "docker"
305+
306+[registry_auth]
307+enabled = false
308+
309+[routes]
310+"app.example.com" = "oldapp:v1"`
311+
312+ tempDir := t.TempDir()
313+ configFile := filepath.Join(tempDir, "gordon.toml")
314+
315+ err := os.WriteFile(configFile, []byte(configContent), 0644)
316+ require.NoError(t, err)
317+
318+ viper.Reset()
319+ viper.SetConfigFile(configFile)
320+ err = viper.ReadInConfig()
321+ require.NoError(t, err)
322+
323+ cfg, err := Load()
324+ require.NoError(t, err)
325+
326+ // Update existing route
327+ err = cfg.UpdateRoute("app.example.com", "newapp:v2")
328+ require.NoError(t, err)
329+
330+ // Verify route was updated in memory
331+ assert.Equal(t, "newapp:v2", cfg.Routes["app.example.com"])
332+
333+ // Verify config file was updated
334+ updatedContent, err := os.ReadFile(configFile)
335+ require.NoError(t, err)
336+ assert.Contains(t, string(updatedContent), `"app.example.com" = "newapp:v2"`)
337+ assert.NotContains(t, string(updatedContent), "oldapp:v1")
338+}
339+
340+func TestConfig_UpdateRoute_NonExistent(t *testing.T) {
341+ configContent := `[server]
342+registry_domain = "registry.example.com"
343+runtime = "docker"
344+
345+[registry_auth]
346+enabled = false
347+
348+[routes]`
349+
350+ tempDir := t.TempDir()
351+ configFile := filepath.Join(tempDir, "gordon.toml")
352+
353+ err := os.WriteFile(configFile, []byte(configContent), 0644)
354+ require.NoError(t, err)
355+
356+ viper.Reset()
357+ viper.SetConfigFile(configFile)
358+ err = viper.ReadInConfig()
359+ require.NoError(t, err)
360+
361+ cfg, err := Load()
362+ require.NoError(t, err)
363+
364+ // Try to update non-existent route
365+ err = cfg.UpdateRoute("nonexistent.example.com", "app:v1")
366+ require.Error(t, err)
367+ assert.Contains(t, err.Error(), "route nonexistent.example.com does not exist")
368+}
369+
370+func TestValidateEnvConfig(t *testing.T) {
371+ testCases := []struct {
372+ name string
373+ envConfig EnvConfig
374+ expectError bool
375+ errorText string
376+ }{
377+ {
378+ name: "valid config",
379+ envConfig: EnvConfig{
380+ Dir: "/tmp/env",
381+ Providers: []string{"pass", "sops"},
382+ },
383+ expectError: false,
384+ },
385+ {
386+ name: "empty provider",
387+ envConfig: EnvConfig{
388+ Dir: "/tmp/env",
389+ Providers: []string{"pass", "", "sops"},
390+ },
391+ expectError: true,
392+ errorText: "empty provider name",
393+ },
394+ {
395+ name: "invalid provider",
396+ envConfig: EnvConfig{
397+ Dir: "/tmp/env",
398+ Providers: []string{"pass", "invalid", "sops"},
399+ },
400+ expectError: true,
401+ errorText: "unsupported provider 'invalid'",
402+ },
403+ {
404+ name: "non-existent directory file conflict",
405+ envConfig: EnvConfig{
406+ Dir: "/dev/null", // This exists but is not a directory
407+ Providers: []string{"pass"},
408+ },
409+ expectError: true,
410+ errorText: "not a directory",
411+ },
412+ }
413+
414+ for _, tc := range testCases {
415+ t.Run(tc.name, func(t *testing.T) {
416+ err := validateEnvConfig(&tc.envConfig)
417+ if tc.expectError {
418+ require.Error(t, err)
419+ assert.Contains(t, err.Error(), tc.errorText)
420+ } else {
421+ require.NoError(t, err)
422+ }
423+ })
424+ }
425+}
426+
427+func TestGetDefaultDataDir(t *testing.T) {
428+ // Test should not fail regardless of environment
429+ dataDir := getDefaultDataDir()
430+ assert.NotEmpty(t, dataDir)
431+
432+ // Should be either absolute path (for user home) or relative "./data"
433+ assert.True(t, filepath.IsAbs(dataDir) || dataDir == "./data")
434+}
435+
436+func TestConfig_Load_NetworkIsolation(t *testing.T) {
437+ configContent := `[server]
438+registry_domain = "registry.example.com"
439+runtime = "docker"
440+
441+[registry_auth]
442+enabled = false
443+
444+[network_isolation]
445+enabled = true
446+network_prefix = "custom"
447+dns_suffix = ".local"`
448+
449+ tempDir := t.TempDir()
450+ configFile := filepath.Join(tempDir, "gordon.toml")
451+
452+ err := os.WriteFile(configFile, []byte(configContent), 0644)
453+ require.NoError(t, err)
454+
455+ viper.Reset()
456+ viper.SetConfigFile(configFile)
457+ err = viper.ReadInConfig()
458+ require.NoError(t, err)
459+
460+ cfg, err := Load()
461+ require.NoError(t, err)
462+
463+ assert.True(t, cfg.NetworkIsolation.Enabled)
464+ assert.Equal(t, "custom", cfg.NetworkIsolation.NetworkPrefix)
465+ assert.Equal(t, ".local", cfg.NetworkIsolation.DNSSuffix)
466+}
467+
468+func TestConfig_Load_AttachmentsAndNetworkGroups(t *testing.T) {
469+ configContent := `[server]
470+registry_domain = "registry.example.com"
471+runtime = "docker"
472+
473+[registry_auth]
474+enabled = false
475+
476+[attachments]
477+"app.example.com" = ["postgres:14", "redis:6"]
478+"backend" = ["postgres:14"]
479+
480+[network_groups]
481+"backend" = ["api.example.com", "worker.example.com"]`
482+
483+ tempDir := t.TempDir()
484+ configFile := filepath.Join(tempDir, "gordon.toml")
485+
486+ err := os.WriteFile(configFile, []byte(configContent), 0644)
487+ require.NoError(t, err)
488+
489+ viper.Reset()
490+ viper.SetConfigFile(configFile)
491+ err = viper.ReadInConfig()
492+ require.NoError(t, err)
493+
494+ cfg, err := Load()
495+ require.NoError(t, err)
496+
497+ // Verify attachments
498+ assert.Len(t, cfg.Attachments, 2)
499+ assert.Equal(t, []string{"postgres:14", "redis:6"}, cfg.Attachments["app.example.com"])
500+ assert.Equal(t, []string{"postgres:14"}, cfg.Attachments["backend"])
501+
502+ // Verify network groups
503+ assert.Len(t, cfg.NetworkGroups, 1)
504+ assert.Equal(t, []string{"api.example.com", "worker.example.com"}, cfg.NetworkGroups["backend"])
505+}
506+
507+func TestConfig_Load_EmptyConfiguration(t *testing.T) {
508+ // Test loading with completely empty config file but needs minimal valid config
509+ configContent := `[registry_auth]
510+enabled = false`
511+
512+ tempDir := t.TempDir()
513+ configFile := filepath.Join(tempDir, "gordon.toml")
514+
515+ err := os.WriteFile(configFile, []byte(configContent), 0644)
516+ require.NoError(t, err)
517+
518+ viper.Reset()
519+ viper.SetConfigFile(configFile)
520+ err = viper.ReadInConfig()
521+ require.NoError(t, err)
522+
523+ cfg, err := Load()
524+ require.NoError(t, err)
525+
526+ // Should have all defaults except what we explicitly set
527+ assert.Equal(t, 8080, cfg.Server.Port)
528+ assert.Equal(t, 5000, cfg.Server.RegistryPort)
529+ assert.Equal(t, "auto", cfg.Server.Runtime)
530+ assert.False(t, cfg.RegistryAuth.Enabled) // explicitly disabled
531+ assert.True(t, cfg.Volumes.AutoCreate)
532+ assert.Equal(t, "gordon", cfg.Volumes.Prefix)
533+ assert.True(t, cfg.Volumes.Preserve)
534+ assert.True(t, cfg.NetworkIsolation.Enabled)
535+ assert.Equal(t, "gordon", cfg.NetworkIsolation.NetworkPrefix)
536+ assert.Equal(t, ".internal", cfg.NetworkIsolation.DNSSuffix)
537+}
538+
539+func TestConfig_AddRoute_NoConfigFile(t *testing.T) {
540+ // Clear viper state to ensure no config file is set
541+ viper.Reset()
542+
543+ cfg := &Config{
544+ Routes: make(map[string]string),
545+ }
546+
547+ // Try to add route when no config file is available
548+ err := cfg.AddRoute("test.example.com", "test:latest")
549+ require.Error(t, err)
550+ assert.Contains(t, err.Error(), "no config file path available")
551+}
552+
553+func TestConfig_AddRoute_FileReadError(t *testing.T) {
554+ // Create a config file in a non-existent directory
555+ configFile := "/nonexistent/directory/gordon.toml"
556+
557+ viper.Reset()
558+ viper.SetConfigFile(configFile)
559+
560+ cfg := &Config{
561+ Routes: make(map[string]string),
562+ }
563+
564+ err := cfg.AddRoute("test.example.com", "test:latest")
565+ require.Error(t, err)
566+ assert.Contains(t, err.Error(), "failed to read config file")
567+}
568+
569+func TestConfig_UpdateRoute_FileReadError(t *testing.T) {
570+ // Create a config file in a non-existent directory
571+ configFile := "/nonexistent/directory/gordon.toml"
572+
573+ viper.Reset()
574+ viper.SetConfigFile(configFile)
575+
576+ cfg := &Config{
577+ Routes: map[string]string{
578+ "test.example.com": "test:v1",
579+ },
580+ }
581+
582+ err := cfg.UpdateRoute("test.example.com", "test:v2")
583+ require.Error(t, err)
584+ assert.Contains(t, err.Error(), "failed to read config file")
585+}
586+
587+func TestConfig_AddRoute_RouteAtEndOfFile(t *testing.T) {
588+ configContent := `[server]
589+registry_domain = "registry.example.com"
590+runtime = "docker"
591+
592+[registry_auth]
593+enabled = false
594+
595+[routes]
596+"existing.example.com" = "existing:latest"`
597+
598+ tempDir := t.TempDir()
599+ configFile := filepath.Join(tempDir, "gordon.toml")
600+
601+ err := os.WriteFile(configFile, []byte(configContent), 0644)
602+ require.NoError(t, err)
603+
604+ viper.Reset()
605+ viper.SetConfigFile(configFile)
606+ err = viper.ReadInConfig()
607+ require.NoError(t, err)
608+
609+ cfg, err := Load()
610+ require.NoError(t, err)
611+
612+ // Add a new route at the end of the routes section
613+ err = cfg.AddRoute("end.example.com", "endapp:v1")
614+ require.NoError(t, err)
615+
616+ // Verify route was added to memory
617+ assert.Equal(t, "endapp:v1", cfg.Routes["end.example.com"])
618+
619+ // Verify config file was updated
620+ updatedContent, err := os.ReadFile(configFile)
621+ require.NoError(t, err)
622+ assert.Contains(t, string(updatedContent), `"end.example.com" = "endapp:v1"`)
623+}
624+
625+func TestValidateEnvConfig_RelativePath(t *testing.T) {
626+ envConfig := EnvConfig{
627+ Dir: "./relative/path", // relative path should not cause error
628+ Providers: []string{"pass"},
629+ }
630+
631+ err := validateEnvConfig(&envConfig)
632+ require.NoError(t, err)
633+}
634+
635+func TestValidateEnvConfig_EmptyDir(t *testing.T) {
636+ envConfig := EnvConfig{
637+ Dir: "", // empty dir should not cause error
638+ Providers: []string{"pass"},
639+ }
640+
641+ err := validateEnvConfig(&envConfig)
642+ require.NoError(t, err)
643+}
644+
645+func TestGetDefaultDataDir_Coverage(t *testing.T) {
646+ // This test mainly exists to ensure coverage of getDefaultDataDir branches
647+ // We can't easily test root vs non-root scenarios, but we can test the function
648+ dataDir := getDefaultDataDir()
649+ assert.NotEmpty(t, dataDir)
650+}⏎̸
A internal/container/docker.go +681 -0
@@ -0,0 +1,681 @@
1+package container
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "encoding/json"
7+ "fmt"
8+ "gordon/pkg/runtime"
9+ "io"
10+ "strconv"
11+ "strings"
12+
13+ "github.com/docker/docker/api/types/container"
14+ "github.com/docker/docker/api/types/image"
15+ "github.com/docker/docker/api/types/network"
16+ "github.com/docker/docker/api/types/registry"
17+ "github.com/docker/docker/api/types/volume"
18+ "github.com/docker/docker/client"
19+ "github.com/docker/docker/errdefs"
20+ "github.com/docker/go-connections/nat"
21+ "github.com/rs/zerolog/log"
22+)
23+
24+// DockerRuntime implements the Runtime interface using Docker API
25+type DockerRuntime struct {
26+ client *client.Client
27+}
28+
29+// NewDockerRuntime creates a new Docker runtime instance
30+func NewDockerRuntime() (*DockerRuntime, error) {
31+ cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
32+ if err != nil {
33+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
34+ }
35+
36+ return &DockerRuntime{
37+ client: cli,
38+ }, nil
39+}
40+
41+// NewDockerRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing)
42+func NewDockerRuntimeWithClient(cli *client.Client) *DockerRuntime {
43+ return &DockerRuntime{
44+ client: cli,
45+ }
46+}
47+
48+// CreateContainer creates a new container
49+func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
50+ // Convert ports to Docker format
51+ exposedPorts := make(nat.PortSet)
52+ portBindings := make(nat.PortMap)
53+
54+ for _, port := range config.Ports {
55+ containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
56+ exposedPorts[containerPort] = struct{}{}
57+
58+ // Bind to random available port on host
59+ portBindings[containerPort] = []nat.PortBinding{
60+ {
61+ HostIP: "0.0.0.0",
62+ HostPort: "0", // Docker will assign a random available port
63+ },
64+ }
65+ }
66+
67+ // Convert volumes to Docker format
68+ var binds []string
69+ if config.Volumes != nil {
70+ for containerPath, volumeName := range config.Volumes {
71+ bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
72+ binds = append(binds, bind)
73+ log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("Adding volume mount")
74+ }
75+ }
76+
77+ // Create container configuration
78+ containerConfig := &container.Config{
79+ Image: config.Image,
80+ Env: config.Env,
81+ ExposedPorts: exposedPorts,
82+ WorkingDir: config.WorkingDir,
83+ Cmd: config.Cmd,
84+ Labels: config.Labels,
85+ }
86+
87+ hostConfig := &container.HostConfig{
88+ PortBindings: portBindings,
89+ AutoRemove: config.AutoRemove,
90+ Binds: binds,
91+ NetworkMode: container.NetworkMode(config.NetworkMode),
92+ }
93+
94+ // Create network configuration for container
95+ var networkConfig *network.NetworkingConfig
96+ if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
97+ networkConfig = &network.NetworkingConfig{
98+ EndpointsConfig: map[string]*network.EndpointSettings{
99+ config.NetworkMode: {
100+ Aliases: config.Aliases,
101+ },
102+ },
103+ }
104+ if config.Hostname != "" {
105+ networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
106+ }
107+ }
108+
109+ // Create the container
110+ resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
111+ if err != nil {
112+ return nil, fmt.Errorf("failed to create container: %w", err)
113+ }
114+
115+ log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created")
116+
117+ // Inspect the created container to get full details
118+ return d.InspectContainer(ctx, resp.ID)
119+}
120+
121+// StartContainer starts a container
122+func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error {
123+ err := d.client.ContainerStart(ctx, containerID, container.StartOptions{})
124+ if err != nil {
125+ return fmt.Errorf("failed to start container %s: %w", containerID, err)
126+ }
127+
128+ log.Info().Str("id", containerID).Msg("Container started")
129+ return nil
130+}
131+
132+// StopContainer stops a container
133+func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error {
134+ timeout := 30 // 30 seconds timeout
135+ err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
136+ if err != nil {
137+ return fmt.Errorf("failed to stop container %s: %w", containerID, err)
138+ }
139+
140+ log.Info().Str("id", containerID).Msg("Container stopped")
141+ return nil
142+}
143+
144+// RestartContainer restarts a container
145+func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error {
146+ timeout := 30 // 30 seconds timeout
147+ err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
148+ if err != nil {
149+ return fmt.Errorf("failed to restart container %s: %w", containerID, err)
150+ }
151+
152+ log.Info().Str("id", containerID).Msg("Container restarted")
153+ return nil
154+}
155+
156+// RemoveContainer removes a container
157+func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
158+ err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
159+ if err != nil {
160+ return fmt.Errorf("failed to remove container %s: %w", containerID, err)
161+ }
162+
163+ log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed")
164+ return nil
165+}
166+
167+// ListContainers lists containers
168+func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
169+ containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all})
170+ if err != nil {
171+ return nil, fmt.Errorf("failed to list containers: %w", err)
172+ }
173+
174+ var result []*runtime.Container
175+ for _, c := range containers {
176+ // Extract ports
177+ var ports []int
178+ for _, port := range c.Ports {
179+ if port.PublicPort > 0 {
180+ ports = append(ports, int(port.PublicPort))
181+ }
182+ }
183+
184+ // Get the primary name (remove leading slash)
185+ name := ""
186+ if len(c.Names) > 0 {
187+ name = strings.TrimPrefix(c.Names[0], "/")
188+ }
189+
190+ result = append(result, &runtime.Container{
191+ ID: c.ID,
192+ Image: c.Image,
193+ Name: name,
194+ Status: c.Status,
195+ Ports: ports,
196+ Labels: c.Labels,
197+ })
198+ }
199+
200+ return result, nil
201+}
202+
203+// InspectContainer inspects a container
204+func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
205+ resp, err := d.client.ContainerInspect(ctx, containerID)
206+ if err != nil {
207+ return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
208+ }
209+
210+ // Extract published ports
211+ var ports []int
212+ if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
213+ for _, bindings := range resp.NetworkSettings.Ports {
214+ for _, binding := range bindings {
215+ if binding.HostPort != "" {
216+ if port, err := strconv.Atoi(binding.HostPort); err == nil {
217+ ports = append(ports, port)
218+ }
219+ }
220+ }
221+ }
222+ }
223+
224+ // Get container name (remove leading slash)
225+ name := strings.TrimPrefix(resp.Name, "/")
226+
227+ return &runtime.Container{
228+ ID: resp.ID,
229+ Image: resp.Config.Image,
230+ Name: name,
231+ Status: resp.State.Status,
232+ Ports: ports,
233+ Labels: resp.Config.Labels,
234+ }, nil
235+}
236+
237+// GetContainerLogs gets container logs
238+func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
239+ logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{
240+ ShowStdout: true,
241+ ShowStderr: true,
242+ Follow: follow,
243+ Timestamps: true,
244+ })
245+ if err != nil {
246+ return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
247+ }
248+
249+ return logs, nil
250+}
251+
252+// PullImage pulls an image
253+func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
254+ log.Info().Str("image", imageRef).Msg("Pulling image")
255+
256+ reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{})
257+ if err != nil {
258+ return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
259+ }
260+ defer reader.Close()
261+
262+ // Read the response to completion (this is required for the pull to complete)
263+ _, err = io.Copy(io.Discard, reader)
264+ if err != nil {
265+ return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
266+ }
267+
268+ log.Info().Str("image", imageRef).Msg("Image pulled successfully")
269+ return nil
270+}
271+
272+// PullImageWithAuth pulls an image with authentication
273+func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
274+ log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication")
275+
276+ // Create authentication configuration
277+ authConfig := registry.AuthConfig{
278+ Username: username,
279+ Password: password,
280+ }
281+
282+ // Encode authentication to base64 JSON
283+ authConfigBytes, err := json.Marshal(authConfig)
284+ if err != nil {
285+ return fmt.Errorf("failed to marshal auth config: %w", err)
286+ }
287+ authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
288+
289+ // Pull with authentication
290+ reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{
291+ RegistryAuth: authStr,
292+ })
293+ if err != nil {
294+ return fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
295+ }
296+ defer reader.Close()
297+
298+ // Read the response to completion (this is required for the pull to complete)
299+ _, err = io.Copy(io.Discard, reader)
300+ if err != nil {
301+ return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
302+ }
303+
304+ log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
305+ return nil
306+}
307+
308+// RemoveImage removes an image
309+func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
310+ _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
311+ if err != nil {
312+ return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
313+ }
314+
315+ log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed")
316+ return nil
317+}
318+
319+// ListImages lists images
320+func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) {
321+ images, err := d.client.ImageList(ctx, image.ListOptions{})
322+ if err != nil {
323+ return nil, fmt.Errorf("failed to list images: %w", err)
324+ }
325+
326+ var result []string
327+ for _, img := range images {
328+ for _, tag := range img.RepoTags {
329+ if tag != "<none>:<none>" {
330+ result = append(result, tag)
331+ }
332+ }
333+ }
334+
335+ return result, nil
336+}
337+
338+// Ping checks if Docker is responsive
339+func (d *DockerRuntime) Ping(ctx context.Context) error {
340+ _, err := d.client.Ping(ctx)
341+ if err != nil {
342+ return fmt.Errorf("Docker ping failed: %w", err)
343+ }
344+ return nil
345+}
346+
347+// Version returns Docker version
348+func (d *DockerRuntime) Version(ctx context.Context) (string, error) {
349+ version, err := d.client.ServerVersion(ctx)
350+ if err != nil {
351+ return "", fmt.Errorf("failed to get Docker version: %w", err)
352+ }
353+ return version.Version, nil
354+}
355+
356+// IsContainerRunning checks if a container is running
357+func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
358+ container, err := d.InspectContainer(ctx, containerID)
359+ if err != nil {
360+ return false, err
361+ }
362+ return container.Status == "running", nil
363+}
364+
365+// GetContainerPort gets the host port for a container's internal port
366+func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
367+ resp, err := d.client.ContainerInspect(ctx, containerID)
368+ if err != nil {
369+ return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
370+ }
371+
372+ if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
373+ return 0, fmt.Errorf("no port mappings found for container %s", containerID)
374+ }
375+
376+ containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
377+ bindings, exists := resp.NetworkSettings.Ports[containerPort]
378+ if !exists || len(bindings) == 0 {
379+ return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
380+ }
381+
382+ hostPort, err := strconv.Atoi(bindings[0].HostPort)
383+ if err != nil {
384+ return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
385+ }
386+
387+ return hostPort, nil
388+}
389+
390+// GetImageExposedPorts gets the exposed ports from an image
391+func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
392+ imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
393+ if err != nil {
394+ return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
395+ }
396+
397+ var ports []int
398+ if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
399+ for portSpec := range imageInspect.Config.ExposedPorts {
400+ // Parse port from format like "80/tcp"
401+ portStr := strings.Split(string(portSpec), "/")[0]
402+ if port, err := strconv.Atoi(portStr); err == nil {
403+ ports = append(ports, port)
404+ }
405+ }
406+ }
407+
408+ if len(ports) == 0 {
409+ return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
410+ }
411+
412+ log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
413+ return ports, nil
414+}
415+
416+// GetContainerExposedPorts gets all exposed ports from a running container
417+func (d *DockerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
418+ resp, err := d.client.ContainerInspect(ctx, containerID)
419+ if err != nil {
420+ return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
421+ }
422+
423+ var ports []int
424+ if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
425+ for portSpec := range resp.NetworkSettings.Ports {
426+ // Parse port from format like "80/tcp"
427+ portStr := strings.Split(string(portSpec), "/")[0]
428+ if port, err := strconv.Atoi(portStr); err == nil {
429+ ports = append(ports, port)
430+ }
431+ }
432+ }
433+
434+ return ports, nil
435+}
436+
437+// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use
438+func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
439+ resp, err := d.client.ContainerInspect(ctx, containerID)
440+ if err != nil {
441+ return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
442+ }
443+
444+ // Get container's internal IP address
445+ var containerIP string
446+ if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
447+ // Use the first available network (usually bridge or custom network)
448+ for _, network := range resp.NetworkSettings.Networks {
449+ if network.IPAddress != "" {
450+ containerIP = network.IPAddress
451+ break
452+ }
453+ }
454+ }
455+
456+ if containerIP == "" {
457+ return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
458+ }
459+
460+ // Get exposed ports from container
461+ var exposedPorts []int
462+ if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
463+ for portSpec := range resp.NetworkSettings.Ports {
464+ // Parse port from format like "80/tcp"
465+ portStr := strings.Split(string(portSpec), "/")[0]
466+ if port, err := strconv.Atoi(portStr); err == nil {
467+ exposedPorts = append(exposedPorts, port)
468+ }
469+ }
470+ }
471+
472+ if len(exposedPorts) == 0 {
473+ return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
474+ }
475+
476+ // Use the first exposed port
477+ selectedPort := exposedPorts[0]
478+
479+ log.Info().
480+ Str("container_id", containerID).
481+ Str("ip", containerIP).
482+ Int("selected_port", selectedPort).
483+ Ints("exposed_ports", exposedPorts).
484+ Msg("Container network info detected")
485+
486+ return containerIP, selectedPort, nil
487+}
488+
489+// InspectImageVolumes gets the volume mount points declared in the image
490+func (d *DockerRuntime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
491+ imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
492+ if err != nil {
493+ return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
494+ }
495+
496+ var volumes []string
497+ if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
498+ for volumePath := range imageInspect.Config.Volumes {
499+ volumes = append(volumes, volumePath)
500+ }
501+ }
502+
503+ if len(volumes) > 0 {
504+ log.Info().Str("image", imageRef).Strs("volumes", volumes).Msg("Found VOLUME directives in image")
505+ } else {
506+ log.Debug().Str("image", imageRef).Msg("No VOLUME directives found in image")
507+ }
508+
509+ return volumes, nil
510+}
511+
512+// VolumeExists checks if a Docker volume exists
513+func (d *DockerRuntime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
514+ _, err := d.client.VolumeInspect(ctx, volumeName)
515+ if err != nil {
516+ if errdefs.IsNotFound(err) {
517+ return false, nil
518+ }
519+ return false, fmt.Errorf("failed to inspect volume %s: %w", volumeName, err)
520+ }
521+ return true, nil
522+}
523+
524+// CreateVolume creates a new Docker volume
525+func (d *DockerRuntime) CreateVolume(ctx context.Context, volumeName string) error {
526+ _, err := d.client.VolumeCreate(ctx, volume.CreateOptions{
527+ Name: volumeName,
528+ Labels: map[string]string{
529+ "gordon.managed": "true",
530+ "gordon.created": "auto",
531+ },
532+ })
533+ if err != nil {
534+ return fmt.Errorf("failed to create volume %s: %w", volumeName, err)
535+ }
536+
537+ log.Info().Str("volume", volumeName).Msg("Volume created")
538+ return nil
539+}
540+
541+// RemoveVolume removes a Docker volume
542+func (d *DockerRuntime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
543+ err := d.client.VolumeRemove(ctx, volumeName, force)
544+ if err != nil {
545+ if errdefs.IsNotFound(err) {
546+ log.Debug().Str("volume", volumeName).Msg("Volume not found, already removed")
547+ return nil
548+ }
549+ return fmt.Errorf("failed to remove volume %s: %w", volumeName, err)
550+ }
551+
552+ log.Info().Str("volume", volumeName).Msg("Volume removed")
553+ return nil
554+}
555+
556+// InspectImageEnv gets the environment variables declared in the image
557+func (d *DockerRuntime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
558+ imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
559+ if err != nil {
560+ return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
561+ }
562+
563+ var envVars []string
564+ if imageInspect.Config != nil && imageInspect.Config.Env != nil {
565+ envVars = imageInspect.Config.Env
566+ }
567+
568+ if len(envVars) > 0 {
569+ log.Debug().Str("image", imageRef).Strs("env_vars", envVars).Msg("Found ENV directives in image")
570+ } else {
571+ log.Debug().Str("image", imageRef).Msg("No ENV directives found in image")
572+ }
573+
574+ return envVars, nil
575+}
576+
577+// CreateNetwork creates a new Docker network
578+func (d *DockerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
579+ // Set default driver to bridge if not specified
580+ driver := "bridge"
581+ if driverOption, exists := options["driver"]; exists {
582+ driver = driverOption
583+ }
584+
585+ createOptions := network.CreateOptions{
586+ Driver: driver,
587+ Labels: map[string]string{
588+ "gordon.managed": "true",
589+ },
590+ }
591+
592+ // Add any additional options to labels
593+ for key, value := range options {
594+ if key != "driver" {
595+ createOptions.Labels["gordon."+key] = value
596+ }
597+ }
598+
599+ _, err := d.client.NetworkCreate(ctx, name, createOptions)
600+ if err != nil {
601+ return fmt.Errorf("failed to create network %s: %w", name, err)
602+ }
603+
604+ log.Info().Str("network", name).Str("driver", driver).Msg("Network created")
605+ return nil
606+}
607+
608+// RemoveNetwork removes a Docker network
609+func (d *DockerRuntime) RemoveNetwork(ctx context.Context, name string) error {
610+ err := d.client.NetworkRemove(ctx, name)
611+ if err != nil {
612+ if errdefs.IsNotFound(err) {
613+ log.Debug().Str("network", name).Msg("Network not found, already removed")
614+ return nil
615+ }
616+ return fmt.Errorf("failed to remove network %s: %w", name, err)
617+ }
618+
619+ log.Info().Str("network", name).Msg("Network removed")
620+ return nil
621+}
622+
623+// ListNetworks lists all Docker networks
624+func (d *DockerRuntime) ListNetworks(ctx context.Context) ([]*runtime.NetworkInfo, error) {
625+ networks, err := d.client.NetworkList(ctx, network.ListOptions{})
626+ if err != nil {
627+ return nil, fmt.Errorf("failed to list networks: %w", err)
628+ }
629+
630+ var result []*runtime.NetworkInfo
631+ for _, net := range networks {
632+ var containers []string
633+ for containerID := range net.Containers {
634+ containers = append(containers, containerID)
635+ }
636+
637+ result = append(result, &runtime.NetworkInfo{
638+ ID: net.ID,
639+ Name: net.Name,
640+ Driver: net.Driver,
641+ Containers: containers,
642+ Labels: net.Labels,
643+ })
644+ }
645+
646+ return result, nil
647+}
648+
649+// NetworkExists checks if a Docker network exists
650+func (d *DockerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
651+ _, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{})
652+ if err != nil {
653+ if errdefs.IsNotFound(err) {
654+ return false, nil
655+ }
656+ return false, fmt.Errorf("failed to inspect network %s: %w", name, err)
657+ }
658+ return true, nil
659+}
660+
661+// ConnectContainerToNetwork connects a container to a network
662+func (d *DockerRuntime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
663+ err := d.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
664+ if err != nil {
665+ return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
666+ }
667+
668+ log.Info().Str("container", containerName).Str("network", networkName).Msg("Container connected to network")
669+ return nil
670+}
671+
672+// DisconnectContainerFromNetwork disconnects a container from a network
673+func (d *DockerRuntime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
674+ err := d.client.NetworkDisconnect(ctx, networkName, containerName, false)
675+ if err != nil {
676+ return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
677+ }
678+
679+ log.Info().Str("container", containerName).Str("network", networkName).Msg("Container disconnected from network")
680+ return nil
681+}
A internal/container/factory.go +263 -0
@@ -0,0 +1,263 @@
1+package container
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/user"
8+ "strings"
9+
10+ "github.com/docker/docker/client"
11+ "github.com/rs/zerolog/log"
12+
13+ "gordon/internal/config"
14+ "gordon/pkg/runtime"
15+)
16+
17+// RuntimeType represents the container runtime type
18+type RuntimeType string
19+
20+const (
21+ RuntimeAuto RuntimeType = "auto"
22+ RuntimeDocker RuntimeType = "docker"
23+ RuntimePodman RuntimeType = "podman"
24+ RuntimePodmanRootless RuntimeType = "podman-rootless"
25+)
26+
27+// RuntimeInfo contains information about a detected runtime
28+type RuntimeInfo struct {
29+ Type RuntimeType
30+ SocketPath string
31+ Version string
32+}
33+
34+// CreateRuntime creates a container runtime based on configuration
35+func CreateRuntime(ctx context.Context, cfg *config.Config) (runtime.Runtime, error) {
36+ runtimeType := RuntimeType(cfg.Server.Runtime)
37+
38+ // Get socket path from config or environment
39+ socketPath := getSocketPath(cfg.Server.SocketPath)
40+
41+ switch runtimeType {
42+ case RuntimeAuto:
43+ return createAutoRuntime(ctx, socketPath)
44+ case RuntimeDocker:
45+ return createDockerRuntime(ctx, socketPath)
46+ case RuntimePodman, RuntimePodmanRootless:
47+ return createPodmanRuntime(ctx, socketPath, runtimeType == RuntimePodmanRootless)
48+ default:
49+ return nil, fmt.Errorf("unsupported runtime: %s", runtimeType)
50+ }
51+}
52+
53+// getSocketPath determines the socket path to use
54+func getSocketPath(configPath string) string {
55+ // 1. Use config if provided
56+ if configPath != "" {
57+ log.Debug().Str("path", configPath).Msg("Using socket path from config")
58+ return configPath
59+ }
60+
61+ // 2. Check CONTAINER_HOST environment variable
62+ if envPath := os.Getenv("CONTAINER_HOST"); envPath != "" {
63+ log.Debug().Str("path", envPath).Msg("Using socket path from CONTAINER_HOST")
64+ return envPath
65+ }
66+
67+ // 3. Return empty string for auto-detection
68+ return ""
69+}
70+
71+// createAutoRuntime automatically detects and creates the best available runtime
72+func createAutoRuntime(ctx context.Context, customSocketPath string) (runtime.Runtime, error) {
73+ var candidatePaths []string
74+
75+ if customSocketPath != "" {
76+ // If custom path provided, try it first
77+ candidatePaths = []string{customSocketPath}
78+ } else {
79+ // Auto-detect socket paths
80+ candidatePaths = getDefaultSocketPaths()
81+ }
82+
83+ // Try each socket path
84+ for _, socketPath := range candidatePaths {
85+ if rt, info, err := tryCreateRuntime(ctx, socketPath); err == nil {
86+ log.Info().
87+ Str("runtime", string(info.Type)).
88+ Str("socket", info.SocketPath).
89+ Str("version", info.Version).
90+ Msg("Container runtime auto-detected")
91+ return rt, nil
92+ }
93+ }
94+
95+ return nil, fmt.Errorf("no container runtime found. Tried paths: %v", candidatePaths)
96+}
97+
98+// createDockerRuntime creates a Docker runtime with optional custom socket
99+func createDockerRuntime(ctx context.Context, socketPath string) (runtime.Runtime, error) {
100+ var opts []client.Opt
101+ opts = append(opts, client.FromEnv, client.WithAPIVersionNegotiation())
102+
103+ if socketPath != "" {
104+ opts = append(opts, client.WithHost(socketPath))
105+ }
106+
107+ cli, err := client.NewClientWithOpts(opts...)
108+ if err != nil {
109+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
110+ }
111+
112+ // Test connectivity
113+ if _, err := cli.Ping(ctx); err != nil {
114+ return nil, fmt.Errorf("Docker ping failed: %w", err)
115+ }
116+
117+ return &DockerRuntime{client: cli}, nil
118+}
119+
120+// createPodmanRuntime creates a Podman runtime with optional custom socket
121+func createPodmanRuntime(ctx context.Context, socketPath string, rootless bool) (runtime.Runtime, error) {
122+ if socketPath == "" {
123+ socketPath = getDefaultPodmanSocket(rootless)
124+ }
125+
126+ return createPodmanRuntimeWithSocket(ctx, socketPath)
127+}
128+
129+// tryCreateRuntime attempts to create a runtime for a given socket path
130+func tryCreateRuntime(ctx context.Context, socketPath string) (runtime.Runtime, *RuntimeInfo, error) {
131+ // Try to create client
132+ opts := []client.Opt{
133+ client.WithHost(socketPath),
134+ client.WithAPIVersionNegotiation(),
135+ }
136+
137+ cli, err := client.NewClientWithOpts(opts...)
138+ if err != nil {
139+ return nil, nil, fmt.Errorf("failed to create client: %w", err)
140+ }
141+
142+ // Test connectivity and get version
143+ ping, err := cli.Ping(ctx)
144+ if err != nil {
145+ return nil, nil, fmt.Errorf("ping failed: %w", err)
146+ }
147+
148+ // Determine runtime type based on socket path and server info
149+ runtimeType := detectRuntimeType(socketPath)
150+
151+ // Since Podman uses Docker-compatible API, we can use DockerRuntime for both
152+ rt := &DockerRuntime{client: cli}
153+
154+ info := &RuntimeInfo{
155+ Type: runtimeType,
156+ SocketPath: socketPath,
157+ Version: ping.APIVersion,
158+ }
159+
160+ return rt, info, nil
161+}
162+
163+// detectRuntimeType determines if the socket is Docker or Podman
164+func detectRuntimeType(socketPath string) RuntimeType {
165+ // Check socket path patterns
166+ if strings.Contains(socketPath, "podman") {
167+ if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") {
168+ return RuntimePodmanRootless
169+ }
170+ return RuntimePodman
171+ }
172+
173+ if strings.Contains(socketPath, "docker") {
174+ return RuntimeDocker
175+ }
176+
177+ // Default socket paths
178+ switch socketPath {
179+ case "/var/run/docker.sock", "unix:///var/run/docker.sock":
180+ return RuntimeDocker
181+ case "/run/podman/podman.sock", "unix:///run/podman/podman.sock":
182+ return RuntimePodman
183+ }
184+
185+ // Check if it's in user runtime directory (rootless)
186+ if strings.Contains(socketPath, "/run/user/") {
187+ return RuntimePodmanRootless
188+ }
189+
190+ // Default to Docker for unknown paths
191+ return RuntimeDocker
192+}
193+
194+// getDefaultSocketPaths returns the default socket paths to try
195+func getDefaultSocketPaths() []string {
196+ paths := []string{
197+ "unix:///var/run/docker.sock", // Docker
198+ }
199+
200+ // Add Podman rootless socket first (preferred)
201+ if rootlessSocket := getDefaultPodmanSocket(true); rootlessSocket != "" {
202+ paths = append(paths, rootlessSocket)
203+ }
204+
205+ // Add Podman root socket last
206+ paths = append(paths, "unix:///run/podman/podman.sock")
207+
208+ return paths
209+}
210+
211+// getDefaultPodmanSocket returns the default Podman socket path
212+func getDefaultPodmanSocket(rootless bool) string {
213+ if !rootless {
214+ return "unix:///run/podman/podman.sock"
215+ }
216+
217+ // Get user runtime directory for rootless Podman
218+ if xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR"); xdgRuntimeDir != "" {
219+ return fmt.Sprintf("unix://%s/podman/podman.sock", xdgRuntimeDir)
220+ }
221+
222+ // Fallback: construct from user ID
223+ if currentUser, err := user.Current(); err == nil {
224+ return fmt.Sprintf("unix:///run/user/%s/podman/podman.sock", currentUser.Uid)
225+ }
226+
227+ log.Warn().Msg("Could not determine rootless Podman socket path")
228+ return ""
229+}
230+
231+// createPodmanRuntimeWithSocket creates a Podman runtime for a specific socket
232+func createPodmanRuntimeWithSocket(ctx context.Context, socketPath string) (runtime.Runtime, error) {
233+ opts := []client.Opt{
234+ client.WithHost(socketPath),
235+ client.WithAPIVersionNegotiation(),
236+ }
237+
238+ cli, err := client.NewClientWithOpts(opts...)
239+ if err != nil {
240+ return nil, fmt.Errorf("failed to create Podman client: %w", err)
241+ }
242+
243+ // Test connectivity
244+ if _, err := cli.Ping(ctx); err != nil {
245+ return nil, fmt.Errorf("Podman ping failed: %w", err)
246+ }
247+
248+ return &DockerRuntime{client: cli}, nil
249+}
250+
251+// GetAvailableRuntimes returns information about all available runtimes
252+func GetAvailableRuntimes() []RuntimeInfo {
253+ var runtimes []RuntimeInfo
254+ ctx := context.Background()
255+
256+ for _, socketPath := range getDefaultSocketPaths() {
257+ if _, info, err := tryCreateRuntime(ctx, socketPath); err == nil {
258+ runtimes = append(runtimes, *info)
259+ }
260+ }
261+
262+ return runtimes
263+}
A internal/container/factory_test.go +337 -0
@@ -0,0 +1,337 @@
1+package container
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/config"
14+)
15+
16+func TestCreateRuntime(t *testing.T) {
17+ ctx := context.Background()
18+
19+ tests := []struct {
20+ name string
21+ config *config.Config
22+ expectError bool
23+ expectedError string
24+ }{
25+ {
26+ name: "docker runtime",
27+ config: &config.Config{
28+ Server: config.ServerConfig{
29+ Runtime: "docker",
30+ SocketPath: "/var/run/docker.sock",
31+ },
32+ },
33+ expectError: true, // Will fail because Docker isn't running in test
34+ },
35+ {
36+ name: "podman runtime",
37+ config: &config.Config{
38+ Server: config.ServerConfig{
39+ Runtime: "podman",
40+ SocketPath: "/run/user/1000/podman/podman.sock",
41+ },
42+ },
43+ expectError: true, // Will fail because Podman isn't running in test
44+ },
45+ {
46+ name: "auto runtime",
47+ config: &config.Config{
48+ Server: config.ServerConfig{
49+ Runtime: "auto",
50+ },
51+ },
52+ expectError: false, // May succeed if runtime is available
53+ },
54+ {
55+ name: "invalid runtime",
56+ config: &config.Config{
57+ Server: config.ServerConfig{
58+ Runtime: "invalid",
59+ },
60+ },
61+ expectError: true,
62+ expectedError: "unsupported runtime",
63+ },
64+ }
65+
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ runtime, err := CreateRuntime(ctx, tt.config)
69+
70+ if tt.name == "auto runtime" {
71+ // Auto runtime may succeed or fail depending on environment
72+ if err != nil {
73+ assert.Nil(t, runtime)
74+ } else {
75+ assert.NotNil(t, runtime)
76+ }
77+ } else if tt.expectError {
78+ assert.Error(t, err)
79+ if tt.expectedError != "" {
80+ assert.Contains(t, err.Error(), tt.expectedError)
81+ }
82+ assert.Nil(t, runtime)
83+ } else {
84+ assert.NoError(t, err)
85+ assert.NotNil(t, runtime)
86+ }
87+ })
88+ }
89+}
90+
91+func TestGetSocketPath(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ configPath string
95+ setupEnv func()
96+ cleanupEnv func()
97+ expected string
98+ }{
99+ {
100+ name: "with provided config path",
101+ configPath: "/custom/docker.sock",
102+ setupEnv: func() {},
103+ cleanupEnv: func() {},
104+ expected: "/custom/docker.sock",
105+ },
106+ {
107+ name: "with CONTAINER_HOST env var",
108+ configPath: "",
109+ setupEnv: func() {
110+ os.Setenv("CONTAINER_HOST", "/env/container.sock")
111+ },
112+ cleanupEnv: func() {
113+ os.Unsetenv("CONTAINER_HOST")
114+ },
115+ expected: "/env/container.sock",
116+ },
117+ {
118+ name: "no config or env - returns empty",
119+ configPath: "",
120+ setupEnv: func() {
121+ os.Unsetenv("CONTAINER_HOST")
122+ },
123+ cleanupEnv: func() {},
124+ expected: "",
125+ },
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ tt.setupEnv()
131+ defer tt.cleanupEnv()
132+
133+ result := getSocketPath(tt.configPath)
134+ assert.Equal(t, tt.expected, result)
135+ })
136+ }
137+}
138+
139+func TestCreateAutoRuntime(t *testing.T) {
140+ ctx := context.Background()
141+
142+ // Test that createAutoRuntime tries different runtimes
143+ runtime, err := createAutoRuntime(ctx, "")
144+ // In test environment, this may succeed or fail depending on available runtimes
145+ if err != nil {
146+ // If it fails, should be because no runtime is available
147+ assert.Nil(t, runtime)
148+ assert.Contains(t, err.Error(), "no container runtime available")
149+ } else {
150+ // If it succeeds, should return a runtime
151+ assert.NotNil(t, runtime)
152+ }
153+}
154+
155+func TestCreateDockerRuntime(t *testing.T) {
156+ ctx := context.Background()
157+
158+ tests := []struct {
159+ name string
160+ socketPath string
161+ expectError bool
162+ }{
163+ {
164+ name: "default docker socket",
165+ socketPath: "/var/run/docker.sock",
166+ expectError: true, // Will fail because Docker isn't running
167+ },
168+ {
169+ name: "custom socket path",
170+ socketPath: "/custom/docker.sock",
171+ expectError: true, // Will fail because socket doesn't exist
172+ },
173+ }
174+
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ runtime, err := createDockerRuntime(ctx, tt.socketPath)
178+
179+ if tt.expectError {
180+ assert.Error(t, err)
181+ assert.Nil(t, runtime)
182+ } else {
183+ assert.NoError(t, err)
184+ assert.NotNil(t, runtime)
185+ }
186+ })
187+ }
188+}
189+
190+func TestCreatePodmanRuntime(t *testing.T) {
191+ ctx := context.Background()
192+
193+ tests := []struct {
194+ name string
195+ socketPath string
196+ rootless bool
197+ expectError bool
198+ }{
199+ {
200+ name: "custom podman socket",
201+ socketPath: "/custom/podman.sock",
202+ rootless: false,
203+ expectError: true, // Will fail because socket doesn't exist
204+ },
205+ {
206+ name: "rootless podman socket",
207+ socketPath: "/custom/podman.sock",
208+ rootless: true,
209+ expectError: true, // Will fail because socket doesn't exist
210+ },
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.name, func(t *testing.T) {
215+ runtime, err := createPodmanRuntime(ctx, tt.socketPath, tt.rootless)
216+
217+ if tt.expectError {
218+ assert.Error(t, err)
219+ assert.Nil(t, runtime)
220+ } else {
221+ assert.NoError(t, err)
222+ assert.NotNil(t, runtime)
223+ }
224+ })
225+ }
226+}
227+
228+func TestDetectRuntimeType(t *testing.T) {
229+ tests := []struct {
230+ name string
231+ socketPath string
232+ expected RuntimeType
233+ }{
234+ {
235+ name: "docker socket",
236+ socketPath: "/var/run/docker.sock",
237+ expected: RuntimeDocker,
238+ },
239+ {
240+ name: "docker unix socket",
241+ socketPath: "unix:///var/run/docker.sock",
242+ expected: RuntimeDocker,
243+ },
244+ {
245+ name: "podman socket",
246+ socketPath: "/run/podman/podman.sock",
247+ expected: RuntimePodman,
248+ },
249+ {
250+ name: "rootless podman socket",
251+ socketPath: "/run/user/1000/podman/podman.sock",
252+ expected: RuntimePodmanRootless,
253+ },
254+ {
255+ name: "unknown socket defaults to docker",
256+ socketPath: "/unknown/socket.sock",
257+ expected: RuntimeDocker,
258+ },
259+ }
260+
261+ for _, tt := range tests {
262+ t.Run(tt.name, func(t *testing.T) {
263+ result := detectRuntimeType(tt.socketPath)
264+ assert.Equal(t, tt.expected, result)
265+ })
266+ }
267+}
268+
269+func TestGetDefaultSocketPaths(t *testing.T) {
270+ paths := getDefaultSocketPaths()
271+
272+ // Should return common socket paths
273+ assert.True(t, len(paths) > 0)
274+
275+ // Should contain at least one Docker socket path
276+ found := false
277+ for _, path := range paths {
278+ if strings.Contains(path, "docker") {
279+ found = true
280+ break
281+ }
282+ }
283+ assert.True(t, found, "Should contain a Docker socket path")
284+}
285+
286+func TestGetDefaultPodmanSocket(t *testing.T) {
287+ socket := getDefaultPodmanSocket(false) // Regular mode
288+
289+ // Should return a valid path
290+ assert.NotEmpty(t, socket)
291+
292+ // Should contain "podman" in the path
293+ assert.Contains(t, socket, "podman")
294+
295+ // Test rootless mode
296+ rootlessSocket := getDefaultPodmanSocket(true)
297+ assert.NotEmpty(t, rootlessSocket)
298+ assert.Contains(t, rootlessSocket, "podman")
299+}
300+
301+func TestGetAvailableRuntimes(t *testing.T) {
302+ runtimes := GetAvailableRuntimes()
303+
304+ // Should return a slice of runtime info
305+ assert.IsType(t, []RuntimeInfo{}, runtimes)
306+
307+ // Should not be empty
308+ assert.True(t, len(runtimes) > 0)
309+
310+ // Each runtime should have basic info
311+ for _, rt := range runtimes {
312+ assert.NotEmpty(t, rt.Type)
313+ assert.NotEmpty(t, rt.SocketPath)
314+ // Version may be empty if runtime not actually available
315+ }
316+}
317+
318+// Test socket existence checking (private function behavior via public interface)
319+func TestSocketExistenceChecking(t *testing.T) {
320+ ctx := context.Background()
321+
322+ // Create a temporary socket file
323+ tmpDir := t.TempDir()
324+ socketPath := filepath.Join(tmpDir, "test.sock")
325+
326+ // Create an empty file to simulate a socket
327+ file, err := os.Create(socketPath)
328+ require.NoError(t, err)
329+ file.Close()
330+
331+ // Test with existing file (won't be a real socket, but tests file existence)
332+ _, err = createDockerRuntime(ctx, socketPath)
333+ // This will still fail because it's not a real Docker socket, but for a different reason
334+ assert.Error(t, err)
335+ // Error should not be about file not existing
336+ assert.NotContains(t, err.Error(), "no such file")
337+}⏎̸
A internal/container/helpers.go +87 -0
@@ -0,0 +1,87 @@
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+}
57+
58+// mergeEnvironmentVariables merges Dockerfile ENV directives with user-provided env vars
59+// User-provided env vars take precedence over Dockerfile ENV directives
60+func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
61+ // Create a map to store environment variables by key
62+ envMap := make(map[string]string)
63+
64+ // First, add Dockerfile ENV directives
65+ for _, env := range dockerfileEnv {
66+ parts := strings.SplitN(env, "=", 2)
67+ if len(parts) == 2 {
68+ envMap[parts[0]] = parts[1]
69+ }
70+ }
71+
72+ // Then, add user-provided env vars (these override Dockerfile ENV)
73+ for _, env := range userEnv {
74+ parts := strings.SplitN(env, "=", 2)
75+ if len(parts) == 2 {
76+ envMap[parts[0]] = parts[1]
77+ }
78+ }
79+
80+ // Convert back to slice format
81+ var result []string
82+ for key, value := range envMap {
83+ result = append(result, fmt.Sprintf("%s=%s", key, value))
84+ }
85+
86+ return result
87+}
A internal/container/helpers_test.go +198 -0
@@ -0,0 +1,198 @@
1+package container
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestNormalizeImageRef(t *testing.T) {
11+ testCases := []struct {
12+ name string
13+ input string
14+ expected string
15+ }{
16+ {
17+ name: "simple image with tag",
18+ input: "nginx:latest",
19+ expected: "docker.io/library/nginx:latest",
20+ },
21+ {
22+ name: "image without tag",
23+ input: "nginx",
24+ expected: "docker.io/library/nginx:latest",
25+ },
26+ {
27+ name: "registry with image and tag",
28+ input: "registry.example.com/nginx:v1.0",
29+ expected: "registry.example.com/nginx:v1.0",
30+ },
31+ {
32+ name: "dockerhub image with user",
33+ input: "user/repo:tag",
34+ expected: "docker.io/user/repo:tag",
35+ },
36+ {
37+ name: "official library image without tag",
38+ input: "postgres",
39+ expected: "docker.io/library/postgres:latest",
40+ },
41+ }
42+
43+ for _, tc := range testCases {
44+ t.Run(tc.name, func(t *testing.T) {
45+ result := normalizeImageRef(tc.input)
46+ assert.Equal(t, tc.expected, result)
47+ })
48+ }
49+}
50+
51+func TestGenerateVolumeName(t *testing.T) {
52+ testCases := []struct {
53+ name string
54+ prefix string
55+ domain string
56+ path string
57+ }{
58+ {
59+ name: "basic volume name",
60+ prefix: "gordon",
61+ domain: "app.example.com",
62+ path: "/data",
63+ },
64+ {
65+ name: "complex path",
66+ prefix: "test",
67+ domain: "my-app.domain.com",
68+ path: "/var/www/html/uploads",
69+ },
70+ }
71+
72+ for _, tc := range testCases {
73+ t.Run(tc.name, func(t *testing.T) {
74+ result := generateVolumeName(tc.prefix, tc.domain, tc.path)
75+
76+ // Check that it starts with prefix
77+ assert.True(t, strings.HasPrefix(result, tc.prefix+"-"))
78+
79+ // Check that it contains sanitized domain
80+ sanitizedDomain := strings.ReplaceAll(strings.ToLower(tc.domain), ".", "-")
81+ assert.Contains(t, result, sanitizedDomain)
82+
83+ // Volume names should be consistent for same inputs
84+ result2 := generateVolumeName(tc.prefix, tc.domain, tc.path)
85+ assert.Equal(t, result, result2)
86+
87+ // Volume names should be different for different paths
88+ result3 := generateVolumeName(tc.prefix, tc.domain, tc.path+"/different")
89+ assert.NotEqual(t, result, result3)
90+ })
91+ }
92+}
93+
94+func TestMergeEnvironmentVariables(t *testing.T) {
95+ testCases := []struct {
96+ name string
97+ dockerfileEnv []string
98+ userEnv []string
99+ expected []string
100+ }{
101+ {
102+ name: "user env overrides dockerfile env",
103+ dockerfileEnv: []string{"NODE_ENV=development", "PORT=3000"},
104+ userEnv: []string{"NODE_ENV=production", "API_KEY=secret"},
105+ expected: []string{"NODE_ENV=production", "PORT=3000", "API_KEY=secret"},
106+ },
107+ {
108+ name: "only dockerfile env",
109+ dockerfileEnv: []string{"NODE_ENV=development", "PORT=3000"},
110+ userEnv: []string{},
111+ expected: []string{"NODE_ENV=development", "PORT=3000"},
112+ },
113+ {
114+ name: "only user env",
115+ dockerfileEnv: []string{},
116+ userEnv: []string{"API_KEY=secret", "DEBUG=true"},
117+ expected: []string{"API_KEY=secret", "DEBUG=true"},
118+ },
119+ {
120+ name: "empty environment",
121+ dockerfileEnv: []string{},
122+ userEnv: []string{},
123+ expected: []string{},
124+ },
125+ }
126+
127+ for _, tc := range testCases {
128+ t.Run(tc.name, func(t *testing.T) {
129+ result := mergeEnvironmentVariables(tc.dockerfileEnv, tc.userEnv)
130+ assert.Equal(t, len(tc.expected), len(result))
131+
132+ // Convert to map for easier comparison
133+ resultMap := make(map[string]string)
134+ for _, env := range result {
135+ parts := splitEnvVar(env)
136+ if len(parts) == 2 {
137+ resultMap[parts[0]] = parts[1]
138+ }
139+ }
140+
141+ expectedMap := make(map[string]string)
142+ for _, env := range tc.expected {
143+ parts := splitEnvVar(env)
144+ if len(parts) == 2 {
145+ expectedMap[parts[0]] = parts[1]
146+ }
147+ }
148+
149+ assert.Equal(t, expectedMap, resultMap)
150+ })
151+ }
152+}
153+
154+// Helper function to split environment variable string
155+func splitEnvVar(env string) []string {
156+ for i, c := range env {
157+ if c == '=' {
158+ return []string{env[:i], env[i+1:]}
159+ }
160+ }
161+ return []string{env}
162+}
163+
164+func TestSanitizeDomainForVolume(t *testing.T) {
165+ testCases := []struct {
166+ name string
167+ input string
168+ expected string
169+ }{
170+ {
171+ name: "basic domain",
172+ input: "app.example.com",
173+ expected: "app-example-com",
174+ },
175+ {
176+ name: "domain with port",
177+ input: "localhost:3000",
178+ expected: "localhost-3000",
179+ },
180+ {
181+ name: "domain with special chars",
182+ input: "my_app@example.com",
183+ expected: "my-app-example-com",
184+ },
185+ {
186+ name: "uppercase domain",
187+ input: "API.EXAMPLE.COM",
188+ expected: "api-example-com",
189+ },
190+ }
191+
192+ for _, tc := range testCases {
193+ t.Run(tc.name, func(t *testing.T) {
194+ result := sanitizeDomainForVolume(tc.input)
195+ assert.Equal(t, tc.expected, result)
196+ })
197+ }
198+}⏎̸
A internal/container/interfaces.go +29 -0
@@ -0,0 +1,29 @@
1+package container
2+
3+import (
4+ "context"
5+
6+ "gordon/internal/config"
7+ "gordon/pkg/runtime"
8+)
9+
10+// ManagerInterface defines the interface for container management operations
11+// This interface is used to enable testing with mocks
12+type ManagerInterface interface {
13+ DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error)
14+ StopContainer(ctx context.Context, containerID string) error
15+ RemoveContainer(ctx context.Context, containerID string, force bool) error
16+ ListContainers() map[string]*runtime.Container
17+ GetContainer(domain string) (*runtime.Container, bool)
18+ StopContainerByDomain(ctx context.Context, domain string) error
19+ Runtime() runtime.Runtime
20+ HealthCheck(ctx context.Context) map[string]bool
21+ SyncContainers(ctx context.Context) error
22+ GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error)
23+ GetNetworkForApp(domain string) string
24+ AutoStartContainers(ctx context.Context) error
25+ StopAllManagedContainers(ctx context.Context) error
26+}
27+
28+// Ensure Manager implements the interface
29+var _ ManagerInterface = (*Manager)(nil)⏎̸
A internal/container/manager.go +1106 -0
@@ -0,0 +1,1106 @@
1+package container
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+
9+ "github.com/rs/zerolog/log"
10+
11+ "gordon/internal/config"
12+ "gordon/internal/env"
13+ "gordon/internal/logging"
14+ "gordon/pkg/runtime"
15+)
16+
17+// Manager handles container lifecycle and management
18+type Manager struct {
19+ runtime runtime.Runtime
20+ config *config.Config
21+ containers map[string]*runtime.Container // map[domain] -> container
22+ envLoader *env.Loader
23+ logManager *logging.LogManager
24+ mu sync.RWMutex
25+}
26+
27+// NewManager creates a new container manager
28+func NewManager(cfg *config.Config) (*Manager, error) {
29+ // Test runtime connectivity with background context
30+ ctx := context.Background()
31+
32+ // Create runtime using the factory
33+ rt, err := CreateRuntime(ctx, cfg)
34+ if err != nil {
35+ return nil, fmt.Errorf("failed to create container runtime: %w", err)
36+ }
37+
38+ // Test runtime connectivity
39+ if err := rt.Ping(ctx); err != nil {
40+ return nil, fmt.Errorf("runtime not available: %w", err)
41+ }
42+
43+ version, err := rt.Version(ctx)
44+ if err != nil {
45+ log.Warn().Err(err).Msg("Could not get runtime version")
46+ } else {
47+ log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected")
48+ }
49+
50+ // Create environment loader
51+ envLoader := env.NewLoader(cfg)
52+
53+ // Register secret providers based on config
54+ for _, providerName := range cfg.Env.Providers {
55+ switch providerName {
56+ case "pass":
57+ envLoader.RegisterSecretProvider("pass", env.NewPassProvider())
58+ case "sops":
59+ envLoader.RegisterSecretProvider("sops", env.NewSopsProvider())
60+ }
61+ }
62+
63+ // Ensure env directory exists
64+ if err := envLoader.EnsureEnvDir(); err != nil {
65+ log.Warn().Err(err).Msg("Failed to ensure env directory exists")
66+ }
67+
68+ // Create empty env files for all configured routes (if they don't exist)
69+ if err := envLoader.CreateEnvFilesForRoutes(); err != nil {
70+ log.Warn().Err(err).Msg("Failed to create env files for routes")
71+ }
72+
73+ // Create log manager
74+ logManager := logging.NewLogManager(cfg, rt)
75+
76+ return &Manager{
77+ runtime: rt,
78+ config: cfg,
79+ containers: make(map[string]*runtime.Container),
80+ envLoader: envLoader,
81+ logManager: logManager,
82+ }, nil
83+}
84+
85+// DeployContainer deploys a container for a specific route
86+func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) {
87+ m.mu.Lock()
88+ defer m.mu.Unlock()
89+
90+ // Check if container already exists for this domain
91+ if existing, exists := m.containers[route.Domain]; exists {
92+ log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting")
93+
94+ // Stop and remove existing container
95+ if err := m.runtime.StopContainer(ctx, existing.ID); err != nil {
96+ log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container")
97+ }
98+
99+ if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
100+ log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container")
101+ }
102+
103+ delete(m.containers, route.Domain)
104+ }
105+
106+ // Also check for orphaned containers with the same name in the runtime
107+ expectedContainerName := fmt.Sprintf("gordon-%s", route.Domain)
108+ allContainers, err := m.runtime.ListContainers(ctx, true) // include stopped containers
109+ if err != nil {
110+ log.Warn().Err(err).Msg("Failed to list all containers, proceeding with deployment")
111+ } else {
112+ for _, container := range allContainers {
113+ if container.Name == expectedContainerName {
114+ log.Info().
115+ Str("domain", route.Domain).
116+ Str("container", container.ID).
117+ Str("status", container.Status).
118+ Msg("Found orphaned container with same name, removing")
119+
120+ // Stop if running
121+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
122+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop orphaned container")
123+ }
124+
125+ // Force remove
126+ if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
127+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove orphaned container")
128+ } else {
129+ log.Info().Str("container", container.ID).Msg("Successfully removed orphaned container")
130+ }
131+ }
132+ }
133+ }
134+
135+ // Construct the full image reference
136+ imageRef := route.Image
137+
138+ // If registry auth is enabled and image doesn't already start with our registry domain, prepend it
139+ if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
140+ repoPart := strings.Split(imageRef, ":")[0]
141+ // Don't prepend if:
142+ // 1. Image already starts with our registry domain
143+ // 2. Image looks like an external registry (contains both dots and slashes, indicating registry.com/repo format)
144+ if !strings.HasPrefix(repoPart, m.config.Server.RegistryDomain+"/") &&
145+ !(strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/")) {
146+ imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
147+ }
148+ }
149+
150+ // Check if image exists locally first
151+ imageAvailable := false
152+ localImages, err := m.runtime.ListImages(ctx)
153+ if err != nil {
154+ log.Warn().Err(err).Msg("Failed to list local images, will attempt to pull from registry")
155+ } else {
156+ // Normalize image references for comparison
157+ normalizedImageRef := normalizeImageRef(imageRef)
158+
159+ for _, localImage := range localImages {
160+ if normalizeImageRef(localImage) == normalizedImageRef {
161+ imageAvailable = true
162+ log.Info().
163+ Str("image", imageRef).
164+ Msg("Image found locally, skipping pull")
165+ break
166+ }
167+ }
168+ }
169+
170+ // Pull the image only if not available locally
171+ if !imageAvailable {
172+ log.Info().Str("image", imageRef).Msg("Image not found locally, pulling from registry")
173+
174+ // Use authenticated pull if registry auth is configured
175+ if m.config.RegistryAuth.Enabled {
176+ log.Info().
177+ Str("image", imageRef).
178+ Str("username", m.config.RegistryAuth.Username).
179+ Msg("Pulling image with authentication")
180+
181+ err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
182+ if err != nil {
183+ // Provide better error message with context
184+ availableImages := make([]string, 0, len(localImages))
185+ for _, img := range localImages {
186+ availableImages = append(availableImages, img)
187+ }
188+
189+ return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+
190+ "Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+
191+ "Available local images: %v. Error: %w",
192+ imageRef, m.config.Server.RegistryDomain, availableImages, err)
193+ }
194+ log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
195+ } else {
196+ log.Info().Str("image", imageRef).Msg("Pulling image without authentication")
197+
198+ err := m.runtime.PullImage(ctx, imageRef)
199+ if err != nil {
200+ // Provide better error message with context
201+ availableImages := make([]string, 0, len(localImages))
202+ for _, img := range localImages {
203+ availableImages = append(availableImages, img)
204+ }
205+
206+ return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+
207+ "Please check: 1) Image name spelling, 2) Image exists in registry. "+
208+ "Available local images: %v. Error: %w",
209+ imageRef, availableImages, err)
210+ }
211+ log.Info().Str("image", imageRef).Msg("Image pulled successfully")
212+ }
213+ }
214+
215+ // Get exposed ports from the image
216+ exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, imageRef)
217+ if err != nil {
218+ log.Warn().Err(err).Str("image", imageRef).Msg("Failed to get exposed ports from image, using defaults")
219+ exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports
220+ }
221+
222+ // Load environment variables for this route
223+ userEnvVars, err := m.envLoader.LoadEnvForRoute(route.Domain)
224+ if err != nil {
225+ log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to load environment variables for route")
226+ return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err)
227+ }
228+
229+ // Get environment variables from image ENV directives
230+ dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, imageRef)
231+ if err != nil {
232+ log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image environment variables, proceeding without them")
233+ dockerfileEnvVars = []string{}
234+ } else if len(dockerfileEnvVars) > 0 {
235+ log.Info().Str("image", imageRef).Strs("dockerfile_env", dockerfileEnvVars).Msg("Found ENV directives in image")
236+ }
237+
238+ // Merge Dockerfile ENV with user-provided env vars (user env takes precedence)
239+ envVars := mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars)
240+
241+ // Handle volume auto-creation if enabled
242+ volumes := make(map[string]string)
243+ if m.config.Volumes.AutoCreate {
244+ // Get volume paths from image VOLUME directives
245+ volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef)
246+ if err != nil {
247+ log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes, proceeding without volumes")
248+ } else if len(volumePaths) > 0 {
249+ log.Info().Str("image", imageRef).Strs("volume_paths", volumePaths).Msg("Found VOLUME directives in image")
250+
251+ for _, volumePath := range volumePaths {
252+ volumeName := generateVolumeName(m.config.Volumes.Prefix, route.Domain, volumePath)
253+
254+ // Check if volume exists, create if it doesn't
255+ exists, err := m.runtime.VolumeExists(ctx, volumeName)
256+ if err != nil {
257+ log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists")
258+ continue
259+ }
260+
261+ if !exists {
262+ if err := m.runtime.CreateVolume(ctx, volumeName); err != nil {
263+ log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create volume")
264+ continue
265+ }
266+ log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for container")
267+ } else {
268+ log.Debug().Str("volume", volumeName).Str("path", volumePath).Msg("Volume already exists, reusing")
269+ }
270+
271+ volumes[volumePath] = volumeName
272+ }
273+ }
274+ }
275+
276+ // Determine which network to use for this app
277+ networkName := m.GetNetworkForApp(route.Domain)
278+
279+ // Create network if it doesn't exist
280+ if err := m.CreateNetworkIfNeeded(ctx, networkName); err != nil {
281+ return nil, fmt.Errorf("failed to create network for %s: %w", route.Domain, err)
282+ }
283+
284+ // Create container configuration
285+ containerConfig := &runtime.ContainerConfig{
286+ Image: imageRef,
287+ Name: fmt.Sprintf("gordon-%s", route.Domain),
288+ Ports: exposedPorts,
289+ Env: envVars,
290+ Volumes: volumes,
291+ NetworkMode: networkName,
292+ Hostname: route.Domain,
293+ Labels: map[string]string{
294+ "gordon.domain": route.Domain,
295+ "gordon.image": route.Image,
296+ "gordon.managed": "true",
297+ "gordon.route": route.Domain,
298+ },
299+ AutoRemove: false, // Keep containers for inspection
300+ }
301+
302+ // Create the container
303+ container, err := m.runtime.CreateContainer(ctx, containerConfig)
304+ if err != nil {
305+ return nil, fmt.Errorf("failed to create container for %s: %w", route.Domain, err)
306+ }
307+
308+ // Start the container
309+ if err := m.runtime.StartContainer(ctx, container.ID); err != nil {
310+ // Clean up the created container
311+ m.runtime.RemoveContainer(ctx, container.ID, true)
312+ return nil, fmt.Errorf("failed to start container for %s: %w", route.Domain, err)
313+ }
314+
315+ // Re-inspect to get updated port information
316+ container, err = m.runtime.InspectContainer(ctx, container.ID)
317+ if err != nil {
318+ return nil, fmt.Errorf("failed to inspect started container: %w", err)
319+ }
320+
321+ // Store the container mapping
322+ m.containers[route.Domain] = container
323+
324+ // Start log collection for the container
325+ containerName := container.Name
326+ if containerName == "" {
327+ containerName = route.Domain
328+ }
329+ if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
330+ log.Warn().
331+ Err(err).
332+ Str("container", container.ID).
333+ Str("domain", route.Domain).
334+ Msg("Failed to start log collection for container")
335+ }
336+
337+ log.Info().
338+ Str("domain", route.Domain).
339+ Str("image", route.Image).
340+ Str("container", container.ID).
341+ Ints("ports", container.Ports).
342+ Str("network", networkName).
343+ Msg("Container deployed successfully")
344+
345+ // Deploy attachments for this specific app
346+ if attachments, ok := m.config.Attachments[route.Domain]; ok {
347+ for _, serviceImage := range attachments {
348+ if err := m.DeployAttachedService(ctx, route.Domain, serviceImage); err != nil {
349+ log.Error().Err(err).Str("service", serviceImage).Str("domain", route.Domain).Msg("Failed to deploy attachment")
350+ // Don't fail the main deployment if attachment fails
351+ }
352+ }
353+ }
354+
355+ // Check if app is part of a network group with attachments
356+ for groupName, domains := range m.config.NetworkGroups {
357+ for _, d := range domains {
358+ if d == route.Domain {
359+ // Deploy group attachments if not already deployed
360+ if attachments, ok := m.config.Attachments[groupName]; ok {
361+ for _, serviceImage := range attachments {
362+ if err := m.DeployAttachedService(ctx, groupName, serviceImage); err != nil {
363+ log.Error().Err(err).Str("service", serviceImage).Str("group", groupName).Msg("Failed to deploy group attachment")
364+ // Don't fail the main deployment if attachment fails
365+ }
366+ }
367+ }
368+ break
369+ }
370+ }
371+ }
372+
373+ return container, nil
374+}
375+
376+// GetContainer returns the container for a domain
377+func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) {
378+ m.mu.RLock()
379+ defer m.mu.RUnlock()
380+
381+ container, exists := m.containers[domain]
382+ return container, exists
383+}
384+
385+// GetContainerPort returns the host port for a domain's container
386+func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) {
387+ m.mu.RLock()
388+ container, exists := m.containers[domain]
389+ m.mu.RUnlock()
390+
391+ if !exists {
392+ return 0, fmt.Errorf("no container found for domain %s", domain)
393+ }
394+
395+ return m.runtime.GetContainerPort(ctx, container.ID, internalPort)
396+}
397+
398+// ListContainers returns all managed containers
399+func (m *Manager) ListContainers() map[string]*runtime.Container {
400+ m.mu.RLock()
401+ defer m.mu.RUnlock()
402+
403+ // Return a copy of the map to avoid race conditions
404+ result := make(map[string]*runtime.Container)
405+ for domain, container := range m.containers {
406+ result[domain] = container
407+ }
408+ return result
409+}
410+
411+// FindContainerByDomain returns the container ID for a domain
412+func (m *Manager) FindContainerByDomain(domain string) (string, bool) {
413+ m.mu.RLock()
414+ defer m.mu.RUnlock()
415+
416+ if container, exists := m.containers[domain]; exists {
417+ return container.ID, true
418+ }
419+ return "", false
420+}
421+
422+// FindDomainByContainerID returns the domain for a container ID
423+func (m *Manager) FindDomainByContainerID(containerID string) (string, bool) {
424+ m.mu.RLock()
425+ defer m.mu.RUnlock()
426+
427+ for domain, container := range m.containers {
428+ if container.ID == containerID {
429+ return domain, true
430+ }
431+ }
432+ return "", false
433+}
434+
435+// StopContainer stops a container by ID
436+func (m *Manager) StopContainer(ctx context.Context, containerID string) error {
437+ if err := m.runtime.StopContainer(ctx, containerID); err != nil {
438+ return fmt.Errorf("failed to stop container %s: %w", containerID, err)
439+ }
440+
441+ // Stop log collection for the container
442+ m.logManager.StopCollection(containerID)
443+
444+ log.Info().Str("container", containerID).Msg("Container stopped")
445+ return nil
446+}
447+
448+// StopContainerByDomain stops a container for a domain
449+func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) error {
450+ m.mu.RLock()
451+ container, exists := m.containers[domain]
452+ m.mu.RUnlock()
453+
454+ if !exists {
455+ return fmt.Errorf("no container found for domain %s", domain)
456+ }
457+
458+ return m.StopContainer(ctx, container.ID)
459+}
460+
461+// RemoveContainer removes a container by ID
462+func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force bool) error {
463+ // Find the domain for this container before removal for volume cleanup
464+ var containerDomain string
465+ m.mu.RLock()
466+ for domain, container := range m.containers {
467+ if container.ID == containerID {
468+ containerDomain = domain
469+ break
470+ }
471+ }
472+ m.mu.RUnlock()
473+
474+ if err := m.runtime.RemoveContainer(ctx, containerID, force); err != nil {
475+ return fmt.Errorf("failed to remove container %s: %w", containerID, err)
476+ }
477+
478+ // Stop log collection for the container
479+ m.logManager.StopCollection(containerID)
480+
481+ // Clean up volumes if preserve is disabled and we found the domain
482+ if containerDomain != "" && !m.config.Volumes.Preserve {
483+ if err := m.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
484+ log.Warn().Err(err).Str("domain", containerDomain).Msg("Failed to cleanup volumes during container removal")
485+ }
486+ }
487+
488+ // Remove from our tracking map and cleanup network if needed
489+ m.mu.Lock()
490+ var removedDomain string
491+ for domain, container := range m.containers {
492+ if container.ID == containerID {
493+ delete(m.containers, domain)
494+ removedDomain = domain
495+ log.Info().Str("domain", domain).Str("container", containerID).Msg("Container removed")
496+ break
497+ }
498+ }
499+ m.mu.Unlock()
500+
501+ // If we removed an app container, check if we need to cleanup its network
502+ if removedDomain != "" && m.config.NetworkIsolation.Enabled {
503+ networkName := m.GetNetworkForApp(removedDomain)
504+ if err := m.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
505+ log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup network after container removal")
506+ }
507+ }
508+
509+ return nil
510+}
511+
512+// RemoveContainerByDomain removes a container for a domain
513+func (m *Manager) RemoveContainerByDomain(ctx context.Context, domain string, force bool) error {
514+ m.mu.RLock()
515+ container, exists := m.containers[domain]
516+ m.mu.RUnlock()
517+
518+ if !exists {
519+ return fmt.Errorf("no container found for domain %s", domain)
520+ }
521+
522+ return m.RemoveContainer(ctx, container.ID, force)
523+}
524+
525+// SyncContainers synchronizes the internal state with the actual running containers
526+func (m *Manager) SyncContainers(ctx context.Context) error {
527+ m.mu.Lock()
528+ defer m.mu.Unlock()
529+
530+ // Get all containers from the runtime
531+ allContainers, err := m.runtime.ListContainers(ctx, false) // only running containers
532+ if err != nil {
533+ return fmt.Errorf("failed to list containers: %w", err)
534+ }
535+
536+ // Find containers managed by Gordon
537+ managedContainers := make(map[string]*runtime.Container)
538+ for _, container := range allContainers {
539+ if container.Labels != nil {
540+ if domain, exists := container.Labels["gordon.domain"]; exists && container.Labels["gordon.managed"] == "true" {
541+ managedContainers[domain] = container
542+ }
543+ }
544+ }
545+
546+ // Update our internal state
547+ m.containers = managedContainers
548+
549+ log.Info().Int("count", len(managedContainers)).Msg("Container state synchronized")
550+ return nil
551+}
552+
553+// AutoStartContainers automatically starts containers that match configured routes
554+func (m *Manager) AutoStartContainers(ctx context.Context) error {
555+ routes := m.config.GetRoutes()
556+ if len(routes) == 0 {
557+ log.Info().Msg("No routes configured, skipping auto-start")
558+ return nil
559+ }
560+
561+ // Get all containers (including stopped ones) to check for matches
562+ allContainers, err := m.runtime.ListContainers(ctx, true)
563+ if err != nil {
564+ return fmt.Errorf("failed to list all containers: %w", err)
565+ }
566+
567+ // Create maps for quick lookup
568+ gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container
569+ containersByImage := make(map[string][]*runtime.Container) // image -> containers
570+
571+ for _, container := range allContainers {
572+ // Track Gordon-managed containers by domain
573+ if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
574+ if domain, exists := container.Labels["gordon.domain"]; exists {
575+ gordonManagedContainers[domain] = container
576+ }
577+ }
578+
579+ // Track all containers by image (normalize image name)
580+ normalizedImage := m.normalizeImageName(container.Image)
581+ containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container)
582+ }
583+
584+ startedCount := 0
585+ for _, route := range routes {
586+ // Build the expected image reference
587+ expectedImage := route.Image
588+ if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
589+ if !strings.Contains(strings.Split(expectedImage, ":")[0], ".") {
590+ expectedImage = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
591+ }
592+ }
593+
594+ // First, check if we already have a Gordon-managed container for this domain
595+ if existingContainer, exists := gordonManagedContainers[route.Domain]; exists {
596+ isRunning, err := m.runtime.IsContainerRunning(ctx, existingContainer.ID)
597+ if err != nil {
598+ log.Warn().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to check container status")
599+ continue
600+ }
601+
602+ if isRunning {
603+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Gordon-managed container already running")
604+ m.mu.Lock()
605+ m.containers[route.Domain] = existingContainer
606+ m.mu.Unlock()
607+ continue
608+ }
609+
610+ // Container exists but is stopped, start it
611+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Starting existing Gordon-managed container")
612+ if err := m.runtime.StartContainer(ctx, existingContainer.ID); err != nil {
613+ log.Error().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to start existing container")
614+ continue
615+ }
616+
617+ // Re-inspect to get updated information
618+ container, err := m.runtime.InspectContainer(ctx, existingContainer.ID)
619+ if err != nil {
620+ log.Error().Err(err).Str("container", existingContainer.ID).Msg("Failed to inspect started container")
621+ continue
622+ }
623+
624+ m.mu.Lock()
625+ m.containers[route.Domain] = container
626+ m.mu.Unlock()
627+ startedCount++
628+
629+ // Start log collection for the container
630+ containerName := container.Name
631+ if containerName == "" {
632+ containerName = route.Domain
633+ }
634+ if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
635+ log.Warn().
636+ Err(err).
637+ Str("container", container.ID).
638+ Str("domain", route.Domain).
639+ Msg("Failed to start log collection for auto-started container")
640+ }
641+
642+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")
643+ continue
644+ }
645+
646+ // Second, check if there are existing containers with the same image and clean them up
647+ normalizedExpectedImage := m.normalizeImageName(expectedImage)
648+ if containers, exists := containersByImage[normalizedExpectedImage]; exists {
649+ // Stop and remove existing containers with the same image to avoid conflicts
650+ for _, container := range containers {
651+ // Skip Gordon-managed containers (already handled above)
652+ if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
653+ continue
654+ }
655+
656+ log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts")
657+
658+ // Stop the container if it's running
659+ isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID)
660+ if err != nil {
661+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to check container status")
662+ continue
663+ }
664+
665+ if isRunning {
666+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
667+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop existing container")
668+ continue
669+ }
670+ }
671+
672+ // Remove the container to clean up
673+ if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
674+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove existing container")
675+ } else {
676+ log.Info().Str("container", container.ID).Msg("Removed existing container with same image")
677+ }
678+ }
679+ }
680+
681+ // No existing container found, deploy a new one
682+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route")
683+
684+ _, err := m.DeployContainer(ctx, route)
685+ if err != nil {
686+ log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route")
687+ continue
688+ }
689+ startedCount++
690+ }
691+
692+ log.Info().Int("started", startedCount).Int("total_routes", len(routes)).Msg("Auto-start completed")
693+ return nil
694+}
695+
696+// normalizeImageName normalizes image names for comparison
697+func (m *Manager) normalizeImageName(image string) string {
698+ // Remove tag if present, keep only repository name
699+ parts := strings.Split(image, ":")
700+ repo := parts[0]
701+
702+ // If no registry domain and it's a simple name, it's from Docker Hub
703+ if !strings.Contains(repo, "/") {
704+ return "docker.io/library/" + repo
705+ }
706+
707+ // If it has one slash and no domain, it's a user repo on Docker Hub
708+ if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") {
709+ return "docker.io/" + repo
710+ }
711+
712+ return repo
713+}
714+
715+// StopAllManagedContainers stops all containers managed by Gordon
716+func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
717+ m.mu.RLock()
718+ containers := make(map[string]*runtime.Container)
719+ for domain, container := range m.containers {
720+ containers[domain] = container
721+ }
722+ m.mu.RUnlock()
723+
724+ if len(containers) == 0 {
725+ log.Info().Msg("No managed containers to stop")
726+ return nil
727+ }
728+
729+ log.Info().Int("count", len(containers)).Msg("Stopping all managed containers...")
730+
731+ var errors []error
732+ for domain, container := range containers {
733+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container")
734+
735+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
736+ log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container")
737+ errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err))
738+ continue
739+ }
740+
741+ // Stop log collection for the container
742+ m.logManager.StopCollection(container.ID)
743+
744+ // Remove from tracking
745+ m.mu.Lock()
746+ delete(m.containers, domain)
747+ m.mu.Unlock()
748+
749+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Managed container stopped successfully")
750+ }
751+
752+ if len(errors) > 0 {
753+ return fmt.Errorf("failed to stop %d containers: %v", len(errors), errors)
754+ }
755+
756+ log.Info().Msg("All managed containers stopped successfully")
757+ return nil
758+}
759+
760+// HealthCheck checks the health of all managed containers
761+func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
762+ m.mu.RLock()
763+ defer m.mu.RUnlock()
764+
765+ health := make(map[string]bool)
766+ for domain, container := range m.containers {
767+ running, err := m.runtime.IsContainerRunning(ctx, container.ID)
768+ if err != nil {
769+ log.Warn().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to check container health")
770+ health[domain] = false
771+ } else {
772+ health[domain] = running
773+ }
774+ }
775+
776+ return health
777+}
778+
779+// Runtime returns the underlying runtime interface
780+func (m *Manager) Runtime() runtime.Runtime {
781+ return m.runtime
782+}
783+
784+// UpdateConfig updates the manager's configuration and creates env files for new routes
785+func (m *Manager) UpdateConfig(cfg *config.Config) {
786+ m.config = cfg
787+
788+ // Update env loader config
789+ m.envLoader.UpdateConfig(cfg)
790+
791+ // Create env files for any new routes
792+ if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil {
793+ log.Warn().Err(err).Msg("Failed to create env files for new routes during config update")
794+ }
795+}
796+
797+// Shutdown gracefully shuts down the container manager
798+func (m *Manager) Shutdown(ctx context.Context) error {
799+ log.Info().Msg("Shutting down container manager...")
800+
801+ // Stop all managed containers (this also stops log collection for each)
802+ if err := m.StopAllManagedContainers(ctx); err != nil {
803+ log.Error().Err(err).Msg("Failed to stop all managed containers during shutdown")
804+ }
805+
806+ // Stop all remaining log collection
807+ m.logManager.StopAll()
808+
809+ log.Info().Msg("Container manager shutdown complete")
810+ return nil
811+}
812+
813+// cleanupVolumesForDomain removes all volumes associated with a domain
814+func (m *Manager) cleanupVolumesForDomain(ctx context.Context, domain string) error {
815+ log.Info().Str("domain", domain).Msg("Cleaning up volumes for domain")
816+
817+ // We need to get the route to know what image was used to determine volume paths
818+ var route *config.Route
819+ for _, r := range m.config.GetRoutes() {
820+ if r.Domain == domain {
821+ route = &r
822+ break
823+ }
824+ }
825+
826+ if route == nil {
827+ log.Debug().Str("domain", domain).Msg("No route found for domain, skipping volume cleanup")
828+ return nil
829+ }
830+
831+ // Construct the full image reference like we do in DeployContainer
832+ imageRef := route.Image
833+ if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
834+ if !strings.Contains(strings.Split(imageRef, ":")[0], ".") {
835+ imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
836+ }
837+ }
838+
839+ // Get volume paths from image VOLUME directives
840+ volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef)
841+ if err != nil {
842+ log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes during cleanup")
843+ return err
844+ }
845+
846+ if len(volumePaths) == 0 {
847+ log.Debug().Str("domain", domain).Msg("No volumes found for cleanup")
848+ return nil
849+ }
850+
851+ var errors []error
852+ for _, volumePath := range volumePaths {
853+ volumeName := generateVolumeName(m.config.Volumes.Prefix, domain, volumePath)
854+
855+ // Check if volume exists before trying to remove
856+ exists, err := m.runtime.VolumeExists(ctx, volumeName)
857+ if err != nil {
858+ log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists during cleanup")
859+ continue
860+ }
861+
862+ if exists {
863+ if err := m.runtime.RemoveVolume(ctx, volumeName, true); err != nil {
864+ log.Error().Err(err).Str("volume", volumeName).Str("domain", domain).Msg("Failed to remove volume during cleanup")
865+ errors = append(errors, err)
866+ } else {
867+ log.Info().Str("volume", volumeName).Str("domain", domain).Msg("Volume cleaned up successfully")
868+ }
869+ }
870+ }
871+
872+ if len(errors) > 0 {
873+ return fmt.Errorf("failed to cleanup %d volumes for domain %s", len(errors), domain)
874+ }
875+
876+ return nil
877+}
878+
879+// cleanupNetworkIfEmpty removes a network if it has no containers
880+func (m *Manager) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
881+ if networkName == "bridge" || networkName == "default" {
882+ return nil // Don't try to remove default networks
883+ }
884+
885+ networks, err := m.runtime.ListNetworks(ctx)
886+ if err != nil {
887+ return fmt.Errorf("failed to list networks: %w", err)
888+ }
889+
890+ for _, network := range networks {
891+ if network.Name == networkName {
892+ // Check if network has any containers
893+ if len(network.Containers) == 0 {
894+ // Network is empty, remove it
895+ if err := m.runtime.RemoveNetwork(ctx, networkName); err != nil {
896+ log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup empty network")
897+ return err
898+ }
899+ log.Info().Str("network", networkName).Msg("Cleaned up empty network")
900+ }
901+ break
902+ }
903+ }
904+
905+ return nil
906+}
907+
908+// DeployAttachedService deploys a service attached to an app or network group
909+func (m *Manager) DeployAttachedService(ctx context.Context, identifier, serviceImage string) error {
910+ networkName := m.generateNetworkName(identifier)
911+
912+ // Extract service name from image for container naming
913+ serviceName := strings.Split(serviceImage, ":")[0]
914+ if strings.Contains(serviceName, "/") {
915+ parts := strings.Split(serviceName, "/")
916+ serviceName = parts[len(parts)-1]
917+ }
918+
919+ // For network groups, we need a different container naming scheme
920+ apps := m.GetAppsForNetwork(identifier)
921+ var containerName string
922+
923+ if len(apps) > 1 {
924+ // Shared service for network group
925+ containerName = fmt.Sprintf("%s-shared-%s", m.config.NetworkIsolation.NetworkPrefix, serviceName)
926+ } else {
927+ // App-specific service
928+ containerName = fmt.Sprintf("%s-%s", strings.ReplaceAll(identifier, ".", "-"), serviceName)
929+ }
930+
931+ // Check if service is already running
932+ containers, err := m.runtime.ListContainers(ctx, false)
933+ if err != nil {
934+ return fmt.Errorf("failed to list containers: %w", err)
935+ }
936+
937+ for _, container := range containers {
938+ if container.Name == containerName {
939+ log.Info().Str("service", containerName).Msg("Service already running")
940+ return nil
941+ }
942+ }
943+
944+ // Get exposed ports from the service image
945+ exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, serviceImage)
946+ if err != nil {
947+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to get exposed ports from service image, using empty ports")
948+ exposedPorts = []int{} // Services don't need external ports by default
949+ }
950+
951+ // Load environment for service (inherit from first app in group or specific app)
952+ var envVars []string
953+ if len(apps) > 0 {
954+ userEnvVars, err := m.envLoader.LoadEnvForRoute(apps[0])
955+ if err != nil {
956+ log.Warn().Err(err).Str("domain", apps[0]).Msg("Failed to load environment for service, using empty env")
957+ userEnvVars = []string{}
958+ }
959+
960+ // Get environment variables from image ENV directives
961+ dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, serviceImage)
962+ if err != nil {
963+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image environment variables")
964+ dockerfileEnvVars = []string{}
965+ }
966+
967+ // Merge environments
968+ envVars = mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars)
969+ }
970+
971+ // Handle volumes for the service
972+ volumes := make(map[string]string)
973+ if m.config.Volumes.AutoCreate {
974+ volumePaths, err := m.runtime.InspectImageVolumes(ctx, serviceImage)
975+ if err != nil {
976+ log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image volumes")
977+ } else if len(volumePaths) > 0 {
978+ for _, volumePath := range volumePaths {
979+ volumeName := fmt.Sprintf("%s-%s-%s",
980+ m.config.Volumes.Prefix,
981+ strings.ReplaceAll(identifier, ".", "-"),
982+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
983+
984+ // Create volume if it doesn't exist
985+ exists, err := m.runtime.VolumeExists(ctx, volumeName)
986+ if err != nil {
987+ log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check service volume")
988+ continue
989+ }
990+
991+ if !exists {
992+ if err := m.runtime.CreateVolume(ctx, volumeName); err != nil {
993+ log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create service volume")
994+ continue
995+ }
996+ log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for service")
997+ }
998+
999+ volumes[volumePath] = volumeName
1000+ }
1001+ }
1002+ }
1003+
1004+ // Create service container configuration
1005+ serviceConfig := &runtime.ContainerConfig{
1006+ Image: serviceImage,
1007+ Name: containerName,
1008+ Ports: exposedPorts,
1009+ Env: envVars,
1010+ Volumes: volumes,
1011+ NetworkMode: networkName,
1012+ Hostname: serviceName,
1013+ Labels: map[string]string{
1014+ "gordon.managed": "true",
1015+ "gordon.service": "true",
1016+ "gordon.attached": identifier,
1017+ "gordon.image": serviceImage,
1018+ },
1019+ AutoRemove: false,
1020+ }
1021+
1022+ // Create and start the service container
1023+ container, err := m.runtime.CreateContainer(ctx, serviceConfig)
1024+ if err != nil {
1025+ return fmt.Errorf("failed to create service container %s: %w", containerName, err)
1026+ }
1027+
1028+ if err := m.runtime.StartContainer(ctx, container.ID); err != nil {
1029+ m.runtime.RemoveContainer(ctx, container.ID, true)
1030+ return fmt.Errorf("failed to start service container %s: %w", containerName, err)
1031+ }
1032+
1033+ log.Info().
1034+ Str("service", containerName).
1035+ Str("image", serviceImage).
1036+ Str("network", networkName).
1037+ Str("attached_to", identifier).
1038+ Msg("Service deployed successfully")
1039+
1040+ return nil
1041+}
1042+
1043+// GetNetworkForApp determines which network an app should use
1044+func (m *Manager) GetNetworkForApp(domain string) string {
1045+ if !m.config.NetworkIsolation.Enabled {
1046+ return "bridge" // Use default Docker bridge network
1047+ }
1048+
1049+ // Check if domain is part of a network group
1050+ for groupName, domains := range m.config.NetworkGroups {
1051+ for _, d := range domains {
1052+ if d == domain {
1053+ return m.generateNetworkName(groupName)
1054+ }
1055+ }
1056+ }
1057+
1058+ // Default: app gets its own network
1059+ return m.generateNetworkName(domain)
1060+}
1061+
1062+// generateNetworkName creates a network name from an identifier
1063+func (m *Manager) generateNetworkName(identifier string) string {
1064+ // "myapp.example.com" -> "gordon-myapp-example-com"
1065+ // "backend" -> "gordon-backend"
1066+ return fmt.Sprintf("%s-%s",
1067+ m.config.NetworkIsolation.NetworkPrefix,
1068+ strings.ReplaceAll(identifier, ".", "-"))
1069+}
1070+
1071+// GetAppsForNetwork returns all apps that should have access to a network
1072+func (m *Manager) GetAppsForNetwork(identifier string) []string {
1073+ // Check if it's a network group
1074+ if apps, ok := m.config.NetworkGroups[identifier]; ok {
1075+ return apps
1076+ }
1077+
1078+ // Single app network
1079+ return []string{identifier}
1080+}
1081+
1082+// CreateNetworkIfNeeded creates a network if it doesn't exist
1083+func (m *Manager) CreateNetworkIfNeeded(ctx context.Context, networkName string) error {
1084+ if networkName == "bridge" || networkName == "default" {
1085+ return nil // Don't try to create default networks
1086+ }
1087+
1088+ exists, err := m.runtime.NetworkExists(ctx, networkName)
1089+ if err != nil {
1090+ return fmt.Errorf("failed to check if network exists: %w", err)
1091+ }
1092+
1093+ if !exists {
1094+ options := map[string]string{
1095+ "driver": "bridge",
1096+ }
1097+
1098+ if err := m.runtime.CreateNetwork(ctx, networkName, options); err != nil {
1099+ return fmt.Errorf("failed to create network %s: %w", networkName, err)
1100+ }
1101+
1102+ log.Info().Str("network", networkName).Msg("Created network for app isolation")
1103+ }
1104+
1105+ return nil
1106+}
A internal/container/manager_autostart_test.go +253 -0
@@ -0,0 +1,253 @@
1+package container
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+ "go.uber.org/mock/gomock"
9+
10+ "gordon/internal/config"
11+ "gordon/internal/testutils"
12+ "gordon/internal/testutils/mocks"
13+ "gordon/pkg/runtime"
14+)
15+
16+func TestManager_AutoStartContainers(t *testing.T) {
17+ ctrl := gomock.NewController(t)
18+ defer ctrl.Finish()
19+
20+ ctx := testutils.TestContext(t)
21+ mockRuntime := mocks.NewMockRuntime(ctrl)
22+
23+ cfg := &config.Config{
24+ Server: config.ServerConfig{
25+ Runtime: "docker",
26+ },
27+ Routes: map[string]string{
28+ "app1.example.com": "nginx:latest",
29+ "app2.example.com": "apache:latest",
30+ },
31+ Volumes: config.VolumeConfig{
32+ AutoCreate: false,
33+ },
34+ NetworkIsolation: config.NetworkIsolationConfig{
35+ Enabled: false,
36+ },
37+ }
38+
39+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
40+ require.NoError(t, err)
41+
42+ // Mock existing containers - one Gordon-managed running, one stopped
43+ existingContainers := []*runtime.Container{
44+ {
45+ ID: "running-container",
46+ Name: "gordon-app1.example.com",
47+ Image: "nginx:latest",
48+ Labels: map[string]string{
49+ "gordon.managed": "true",
50+ "gordon.domain": "app1.example.com",
51+ },
52+ },
53+ {
54+ ID: "stopped-container",
55+ Name: "gordon-app2.example.com",
56+ Image: "apache:latest",
57+ Labels: map[string]string{
58+ "gordon.managed": "true",
59+ "gordon.domain": "app2.example.com",
60+ },
61+ },
62+ }
63+
64+ mockRuntime.EXPECT().ListContainers(ctx, true).Return(existingContainers, nil)
65+
66+ // First container is already running
67+ mockRuntime.EXPECT().IsContainerRunning(ctx, "running-container").Return(true, nil)
68+
69+ // Second container is stopped, should be started
70+ mockRuntime.EXPECT().IsContainerRunning(ctx, "stopped-container").Return(false, nil)
71+ mockRuntime.EXPECT().StartContainer(ctx, "stopped-container").Return(nil)
72+
73+ // Mock inspection of restarted container
74+ startedContainer := &runtime.Container{
75+ ID: "stopped-container",
76+ Name: "gordon-app2.example.com",
77+ Image: "apache:latest",
78+ Ports: []int{80},
79+ }
80+ mockRuntime.EXPECT().InspectContainer(ctx, "stopped-container").Return(startedContainer, nil)
81+
82+ err = manager.AutoStartContainers(ctx)
83+
84+ assert.NoError(t, err)
85+ assert.Len(t, manager.containers, 2)
86+ assert.Contains(t, manager.containers, "app1.example.com")
87+ assert.Contains(t, manager.containers, "app2.example.com")
88+}
89+
90+func TestManager_AutoStartContainers_DeployNew(t *testing.T) {
91+ ctrl := gomock.NewController(t)
92+ defer ctrl.Finish()
93+
94+ ctx := testutils.TestContext(t)
95+ mockRuntime := mocks.NewMockRuntime(ctrl)
96+
97+ cfg := &config.Config{
98+ Server: config.ServerConfig{
99+ Runtime: "docker",
100+ },
101+ Routes: map[string]string{
102+ "new-app.example.com": "redis:latest",
103+ },
104+ Volumes: config.VolumeConfig{
105+ AutoCreate: false,
106+ },
107+ NetworkIsolation: config.NetworkIsolationConfig{
108+ Enabled: false,
109+ },
110+ }
111+
112+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
113+ require.NoError(t, err)
114+
115+ // No existing containers
116+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
117+
118+ // Should deploy new container
119+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
120+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"redis:latest"}, nil)
121+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil)
122+ mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil)
123+
124+ newContainer := &runtime.Container{
125+ ID: "new-container",
126+ Name: "gordon-new-app.example.com",
127+ Image: "redis:latest",
128+ Ports: []int{6379},
129+ }
130+
131+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(newContainer, nil)
132+ mockRuntime.EXPECT().StartContainer(ctx, "new-container").Return(nil)
133+ mockRuntime.EXPECT().InspectContainer(ctx, "new-container").Return(newContainer, nil)
134+
135+ err = manager.AutoStartContainers(ctx)
136+
137+ assert.NoError(t, err)
138+ assert.Len(t, manager.containers, 1)
139+ assert.Contains(t, manager.containers, "new-app.example.com")
140+}
141+
142+func TestManager_AutoStartContainers_CleanupConflicts(t *testing.T) {
143+ ctrl := gomock.NewController(t)
144+ defer ctrl.Finish()
145+
146+ ctx := testutils.TestContext(t)
147+ mockRuntime := mocks.NewMockRuntime(ctrl)
148+
149+ cfg := &config.Config{
150+ Server: config.ServerConfig{
151+ Runtime: "docker",
152+ },
153+ Routes: map[string]string{
154+ "app.example.com": "nginx:latest",
155+ },
156+ Volumes: config.VolumeConfig{
157+ AutoCreate: false,
158+ },
159+ NetworkIsolation: config.NetworkIsolationConfig{
160+ Enabled: false,
161+ },
162+ }
163+
164+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
165+ require.NoError(t, err)
166+
167+ // Existing containers include a non-Gordon container with same image
168+ existingContainers := []*runtime.Container{
169+ {
170+ ID: "unmanaged-container",
171+ Name: "some-nginx-container",
172+ Image: "nginx:latest",
173+ // No Gordon labels - not managed
174+ },
175+ }
176+
177+ mockRuntime.EXPECT().ListContainers(ctx, true).Return(existingContainers, nil)
178+
179+ // Should stop and remove the conflicting container
180+ mockRuntime.EXPECT().IsContainerRunning(ctx, "unmanaged-container").Return(true, nil)
181+ mockRuntime.EXPECT().StopContainer(ctx, "unmanaged-container").Return(nil)
182+ mockRuntime.EXPECT().RemoveContainer(ctx, "unmanaged-container", true).Return(nil)
183+
184+ // Then deploy new Gordon-managed container
185+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
186+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"nginx:latest"}, nil)
187+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "nginx:latest").Return([]int{80}, nil)
188+ mockRuntime.EXPECT().InspectImageEnv(ctx, "nginx:latest").Return([]string{}, nil)
189+
190+ newContainer := &runtime.Container{
191+ ID: "gordon-container",
192+ Name: "gordon-app.example.com",
193+ Image: "nginx:latest",
194+ Ports: []int{80},
195+ }
196+
197+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(newContainer, nil)
198+ mockRuntime.EXPECT().StartContainer(ctx, "gordon-container").Return(nil)
199+ mockRuntime.EXPECT().InspectContainer(ctx, "gordon-container").Return(newContainer, nil)
200+
201+ err = manager.AutoStartContainers(ctx)
202+
203+ assert.NoError(t, err)
204+ assert.Len(t, manager.containers, 1)
205+ assert.Contains(t, manager.containers, "app.example.com")
206+}
207+
208+func TestManager_AutoStartContainers_NoRoutes(t *testing.T) {
209+ ctrl := gomock.NewController(t)
210+ defer ctrl.Finish()
211+
212+ ctx := testutils.TestContext(t)
213+ mockRuntime := mocks.NewMockRuntime(ctrl)
214+
215+ cfg := &config.Config{
216+ Server: config.ServerConfig{
217+ Runtime: "docker",
218+ },
219+ // No routes configured
220+ Routes: map[string]string{},
221+ }
222+
223+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
224+ require.NoError(t, err)
225+
226+ err = manager.AutoStartContainers(ctx)
227+
228+ assert.NoError(t, err)
229+ assert.Empty(t, manager.containers)
230+}
231+
232+func TestManager_normalizeImageName(t *testing.T) {
233+ manager := &Manager{}
234+
235+ tests := []struct {
236+ input string
237+ expected string
238+ }{
239+ {"nginx", "docker.io/library/nginx"},
240+ {"nginx:latest", "docker.io/library/nginx"},
241+ {"user/repo", "docker.io/user/repo"},
242+ {"user/repo:tag", "docker.io/user/repo"},
243+ {"registry.com/user/repo", "registry.com/user/repo"},
244+ {"registry.com/user/repo:tag", "registry.com/user/repo"},
245+ }
246+
247+ for _, tt := range tests {
248+ t.Run(tt.input, func(t *testing.T) {
249+ result := manager.normalizeImageName(tt.input)
250+ assert.Equal(t, tt.expected, result)
251+ })
252+ }
253+}⏎̸
A internal/container/manager_deploy_test.go +394 -0
@@ -0,0 +1,394 @@
1+package container
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "go.uber.org/mock/gomock"
10+
11+ "gordon/internal/config"
12+ "gordon/internal/testutils"
13+ "gordon/internal/testutils/mocks"
14+ "gordon/pkg/runtime"
15+)
16+
17+func TestManager_DeployContainer_ImagePull(t *testing.T) {
18+ ctrl := gomock.NewController(t)
19+ defer ctrl.Finish()
20+
21+ ctx := testutils.TestContext(t)
22+ mockRuntime := mocks.NewMockRuntime(ctrl)
23+
24+ cfg := &config.Config{
25+ Server: config.ServerConfig{
26+ Runtime: "docker",
27+ },
28+ Volumes: config.VolumeConfig{
29+ AutoCreate: false,
30+ },
31+ NetworkIsolation: config.NetworkIsolationConfig{
32+ Enabled: false,
33+ },
34+ }
35+
36+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
37+ require.NoError(t, err)
38+
39+ route := config.Route{
40+ Domain: "test.example.com",
41+ Image: "alpine:latest",
42+ }
43+
44+ // Image not found locally, should pull
45+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
46+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{}, nil)
47+ mockRuntime.EXPECT().PullImage(ctx, "alpine:latest").Return(nil)
48+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "alpine:latest").Return([]int{}, nil)
49+ mockRuntime.EXPECT().InspectImageEnv(ctx, "alpine:latest").Return([]string{}, nil)
50+
51+ expectedContainer := &runtime.Container{
52+ ID: "container456",
53+ Name: "gordon-test.example.com",
54+ Image: "alpine:latest",
55+ Status: "running",
56+ Ports: []int{},
57+ }
58+
59+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
60+ mockRuntime.EXPECT().StartContainer(ctx, "container456").Return(nil)
61+ mockRuntime.EXPECT().InspectContainer(ctx, "container456").Return(expectedContainer, nil)
62+
63+ container, err := manager.DeployContainer(ctx, route)
64+
65+ assert.NoError(t, err)
66+ assert.Equal(t, expectedContainer, container)
67+}
68+
69+func TestManager_DeployContainer_WithAuth(t *testing.T) {
70+ ctrl := gomock.NewController(t)
71+ defer ctrl.Finish()
72+
73+ ctx := testutils.TestContext(t)
74+ mockRuntime := mocks.NewMockRuntime(ctrl)
75+
76+ cfg := &config.Config{
77+ Server: config.ServerConfig{
78+ Runtime: "docker",
79+ RegistryDomain: "registry.example.com",
80+ },
81+ RegistryAuth: config.RegistryAuthConfig{
82+ Enabled: true,
83+ Username: "testuser",
84+ Password: "testpass",
85+ },
86+ Volumes: config.VolumeConfig{
87+ AutoCreate: false,
88+ },
89+ NetworkIsolation: config.NetworkIsolationConfig{
90+ Enabled: false,
91+ },
92+ }
93+
94+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
95+ require.NoError(t, err)
96+
97+ route := config.Route{
98+ Domain: "test.example.com",
99+ Image: "myapp:latest",
100+ }
101+
102+ // Should use authenticated pull with registry domain prefix
103+ expectedImageRef := "registry.example.com/myapp:latest"
104+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
105+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{}, nil)
106+ mockRuntime.EXPECT().PullImageWithAuth(ctx, expectedImageRef, "testuser", "testpass").Return(nil)
107+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, expectedImageRef).Return([]int{8080}, nil)
108+ mockRuntime.EXPECT().InspectImageEnv(ctx, expectedImageRef).Return([]string{}, nil)
109+
110+ expectedContainer := &runtime.Container{
111+ ID: "container789",
112+ Name: "gordon-test.example.com",
113+ Image: expectedImageRef,
114+ Status: "running",
115+ Ports: []int{8080},
116+ }
117+
118+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
119+ mockRuntime.EXPECT().StartContainer(ctx, "container789").Return(nil)
120+ mockRuntime.EXPECT().InspectContainer(ctx, "container789").Return(expectedContainer, nil)
121+
122+ container, err := manager.DeployContainer(ctx, route)
123+
124+ assert.NoError(t, err)
125+ assert.Equal(t, expectedContainer, container)
126+}
127+
128+func TestManager_DeployContainer_ReplaceExisting(t *testing.T) {
129+ ctrl := gomock.NewController(t)
130+ defer ctrl.Finish()
131+
132+ ctx := testutils.TestContext(t)
133+ mockRuntime := mocks.NewMockRuntime(ctrl)
134+
135+ cfg := &config.Config{
136+ Server: config.ServerConfig{
137+ Runtime: "docker",
138+ },
139+ Volumes: config.VolumeConfig{
140+ AutoCreate: false,
141+ },
142+ NetworkIsolation: config.NetworkIsolationConfig{
143+ Enabled: false,
144+ },
145+ }
146+
147+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
148+ require.NoError(t, err)
149+
150+ // Add existing container
151+ existingContainer := &runtime.Container{
152+ ID: "old-container",
153+ Name: "gordon-test.example.com",
154+ Status: "running",
155+ }
156+ manager.containers["test.example.com"] = existingContainer
157+
158+ route := config.Route{
159+ Domain: "test.example.com",
160+ Image: "nginx:latest",
161+ }
162+
163+ // Should stop and remove existing container
164+ mockRuntime.EXPECT().StopContainer(ctx, "old-container").Return(nil)
165+ mockRuntime.EXPECT().RemoveContainer(ctx, "old-container", true).Return(nil)
166+
167+ // Check for orphaned containers
168+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
169+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"nginx:latest"}, nil)
170+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "nginx:latest").Return([]int{80}, nil)
171+ mockRuntime.EXPECT().InspectImageEnv(ctx, "nginx:latest").Return([]string{}, nil)
172+
173+ newContainer := &runtime.Container{
174+ ID: "new-container",
175+ Name: "gordon-test.example.com",
176+ Image: "nginx:latest",
177+ Status: "running",
178+ Ports: []int{80},
179+ }
180+
181+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(newContainer, nil)
182+ mockRuntime.EXPECT().StartContainer(ctx, "new-container").Return(nil)
183+ mockRuntime.EXPECT().InspectContainer(ctx, "new-container").Return(newContainer, nil)
184+
185+ container, err := manager.DeployContainer(ctx, route)
186+
187+ assert.NoError(t, err)
188+ assert.Equal(t, newContainer, container)
189+ assert.Equal(t, newContainer, manager.containers["test.example.com"])
190+}
191+
192+func TestManager_DeployContainer_Errors(t *testing.T) {
193+ tests := []struct {
194+ name string
195+ setupMocks func(*mocks.MockRuntime)
196+ expectedError string
197+ }{
198+ {
199+ name: "pull image error",
200+ setupMocks: func(m *mocks.MockRuntime) {
201+ m.EXPECT().ListContainers(gomock.Any(), true).Return([]*runtime.Container{}, nil)
202+ m.EXPECT().ListImages(gomock.Any()).Return([]string{}, nil)
203+ m.EXPECT().PullImage(gomock.Any(), "nginx:latest").Return(errors.New("pull failed"))
204+ },
205+ expectedError: "failed to pull image",
206+ },
207+ {
208+ name: "create container error",
209+ setupMocks: func(m *mocks.MockRuntime) {
210+ m.EXPECT().ListContainers(gomock.Any(), true).Return([]*runtime.Container{}, nil)
211+ m.EXPECT().ListImages(gomock.Any()).Return([]string{"nginx:latest"}, nil)
212+ m.EXPECT().GetImageExposedPorts(gomock.Any(), "nginx:latest").Return([]int{80}, nil)
213+ m.EXPECT().InspectImageEnv(gomock.Any(), "nginx:latest").Return([]string{}, nil)
214+ m.EXPECT().CreateContainer(gomock.Any(), gomock.Any()).Return(nil, errors.New("create failed"))
215+ },
216+ expectedError: "failed to create container",
217+ },
218+ {
219+ name: "start container error",
220+ setupMocks: func(m *mocks.MockRuntime) {
221+ m.EXPECT().ListContainers(gomock.Any(), true).Return([]*runtime.Container{}, nil)
222+ m.EXPECT().ListImages(gomock.Any()).Return([]string{"nginx:latest"}, nil)
223+ m.EXPECT().GetImageExposedPorts(gomock.Any(), "nginx:latest").Return([]int{80}, nil)
224+ m.EXPECT().InspectImageEnv(gomock.Any(), "nginx:latest").Return([]string{}, nil)
225+ container := &runtime.Container{ID: "test123"}
226+ m.EXPECT().CreateContainer(gomock.Any(), gomock.Any()).Return(container, nil)
227+ m.EXPECT().StartContainer(gomock.Any(), "test123").Return(errors.New("start failed"))
228+ m.EXPECT().RemoveContainer(gomock.Any(), "test123", true).Return(nil)
229+ },
230+ expectedError: "failed to start container",
231+ },
232+ }
233+
234+ for _, tt := range tests {
235+ t.Run(tt.name, func(t *testing.T) {
236+ ctrl := gomock.NewController(t)
237+ defer ctrl.Finish()
238+
239+ ctx := testutils.TestContext(t)
240+ mockRuntime := mocks.NewMockRuntime(ctrl)
241+
242+ cfg := &config.Config{
243+ Server: config.ServerConfig{
244+ Runtime: "docker",
245+ },
246+ Volumes: config.VolumeConfig{
247+ AutoCreate: false,
248+ },
249+ NetworkIsolation: config.NetworkIsolationConfig{
250+ Enabled: false,
251+ },
252+ }
253+
254+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
255+ require.NoError(t, err)
256+
257+ route := config.Route{
258+ Domain: "test.example.com",
259+ Image: "nginx:latest",
260+ }
261+
262+ tt.setupMocks(mockRuntime)
263+
264+ _, err = manager.DeployContainer(ctx, route)
265+ assert.Error(t, err)
266+ assert.Contains(t, err.Error(), tt.expectedError)
267+ })
268+ }
269+}
270+
271+func TestManager_DeployContainer_WithNetworkAttachments(t *testing.T) {
272+ ctrl := gomock.NewController(t)
273+ defer ctrl.Finish()
274+
275+ ctx := testutils.TestContext(t)
276+ mockRuntime := mocks.NewMockRuntime(ctrl)
277+
278+ cfg := &config.Config{
279+ Server: config.ServerConfig{
280+ Runtime: "docker",
281+ },
282+ Volumes: config.VolumeConfig{
283+ AutoCreate: false,
284+ },
285+ NetworkIsolation: config.NetworkIsolationConfig{
286+ Enabled: true,
287+ NetworkPrefix: "gordon",
288+ },
289+ NetworkGroups: map[string][]string{
290+ "backend": {"api.example.com", "worker.example.com"},
291+ },
292+ Attachments: map[string][]string{
293+ "api.example.com": {"redis:latest"},
294+ "backend": {"postgres:latest"},
295+ },
296+ }
297+
298+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
299+ require.NoError(t, err)
300+
301+ route := config.Route{
302+ Domain: "api.example.com",
303+ Image: "myapi:latest",
304+ }
305+
306+ // Main container deployment expectations
307+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
308+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"myapi:latest"}, nil)
309+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "myapi:latest").Return([]int{8080}, nil)
310+ mockRuntime.EXPECT().InspectImageEnv(ctx, "myapi:latest").Return([]string{}, nil)
311+ mockRuntime.EXPECT().NetworkExists(ctx, "gordon-backend").Return(true, nil)
312+
313+ expectedContainer := &runtime.Container{
314+ ID: "main-container",
315+ Name: "gordon-api.example.com",
316+ Image: "myapi:latest",
317+ Status: "running",
318+ Ports: []int{8080},
319+ }
320+
321+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
322+ mockRuntime.EXPECT().StartContainer(ctx, "main-container").Return(nil)
323+ mockRuntime.EXPECT().InspectContainer(ctx, "main-container").Return(expectedContainer, nil)
324+
325+ // Attachment deployment expectations for app-specific service
326+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil)
327+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil)
328+ mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil)
329+
330+ redisContainer := &runtime.Container{
331+ ID: "redis-container",
332+ Name: "api-example-com-redis",
333+ }
334+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(redisContainer, nil)
335+ mockRuntime.EXPECT().StartContainer(ctx, "redis-container").Return(nil)
336+
337+ // Attachment deployment expectations for network group service
338+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{redisContainer}, nil)
339+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil)
340+ mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil)
341+
342+ pgContainer := &runtime.Container{
343+ ID: "pg-container",
344+ Name: "gordon-shared-postgres",
345+ }
346+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(pgContainer, nil)
347+ mockRuntime.EXPECT().StartContainer(ctx, "pg-container").Return(nil)
348+
349+ container, err := manager.DeployContainer(ctx, route)
350+
351+ assert.NoError(t, err)
352+ assert.Equal(t, expectedContainer, container)
353+}
354+
355+func TestManager_DeployAttachedService(t *testing.T) {
356+ ctrl := gomock.NewController(t)
357+ defer ctrl.Finish()
358+
359+ ctx := testutils.TestContext(t)
360+ mockRuntime := mocks.NewMockRuntime(ctrl)
361+
362+ cfg := &config.Config{
363+ Volumes: config.VolumeConfig{
364+ AutoCreate: true,
365+ Prefix: "gordon",
366+ },
367+ NetworkIsolation: config.NetworkIsolationConfig{
368+ NetworkPrefix: "gordon",
369+ },
370+ }
371+
372+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
373+ require.NoError(t, err)
374+
375+ // Test deploying redis service for an app
376+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil)
377+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil)
378+ mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{"REDIS_VERSION=7.0"}, nil)
379+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "redis:latest").Return([]string{"/data"}, nil)
380+ mockRuntime.EXPECT().VolumeExists(ctx, "gordon-test-example-com-data").Return(false, nil)
381+ mockRuntime.EXPECT().CreateVolume(ctx, "gordon-test-example-com-data").Return(nil)
382+
383+ expectedContainer := &runtime.Container{
384+ ID: "redis-service",
385+ Name: "test-example-com-redis",
386+ }
387+
388+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
389+ mockRuntime.EXPECT().StartContainer(ctx, "redis-service").Return(nil)
390+
391+ err = manager.DeployAttachedService(ctx, "test.example.com", "redis:latest")
392+
393+ assert.NoError(t, err)
394+}⏎̸
A internal/container/manager_network_test.go +421 -0
@@ -0,0 +1,421 @@
1+package container
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "go.uber.org/mock/gomock"
10+
11+ "gordon/internal/config"
12+ "gordon/internal/testutils"
13+ "gordon/internal/testutils/mocks"
14+ "gordon/pkg/runtime"
15+)
16+
17+func TestManager_GetNetworkForApp_WithGroups(t *testing.T) {
18+ manager := &Manager{
19+ config: &config.Config{
20+ NetworkIsolation: config.NetworkIsolationConfig{
21+ Enabled: true,
22+ NetworkPrefix: "gordon",
23+ },
24+ NetworkGroups: map[string][]string{
25+ "backend": {"api.example.com", "worker.example.com"},
26+ "frontend": {"web.example.com", "cdn.example.com"},
27+ },
28+ },
29+ }
30+
31+ // Test app in backend group
32+ network := manager.GetNetworkForApp("api.example.com")
33+ assert.Equal(t, "gordon-backend", network)
34+
35+ // Test app in frontend group
36+ network = manager.GetNetworkForApp("web.example.com")
37+ assert.Equal(t, "gordon-frontend", network)
38+
39+ // Test standalone app
40+ network = manager.GetNetworkForApp("standalone.example.com")
41+ assert.Equal(t, "gordon-standalone-example-com", network)
42+
43+ // Test disabled network isolation
44+ manager.config.NetworkIsolation.Enabled = false
45+ network = manager.GetNetworkForApp("test.example.com")
46+ assert.Equal(t, "bridge", network)
47+}
48+
49+func TestManager_generateNetworkName(t *testing.T) {
50+ manager := &Manager{
51+ config: &config.Config{
52+ NetworkIsolation: config.NetworkIsolationConfig{
53+ NetworkPrefix: "gordon",
54+ },
55+ },
56+ }
57+
58+ networkName := manager.generateNetworkName("test.example.com")
59+ assert.Equal(t, "gordon-test-example-com", networkName)
60+
61+ networkName = manager.generateNetworkName("backend")
62+ assert.Equal(t, "gordon-backend", networkName)
63+
64+ networkName = manager.generateNetworkName("complex-app.subdomain.example.com")
65+ assert.Equal(t, "gordon-complex-app-subdomain-example-com", networkName)
66+}
67+
68+func TestManager_GetAppsForNetwork(t *testing.T) {
69+ manager := &Manager{
70+ config: &config.Config{
71+ NetworkGroups: map[string][]string{
72+ "backend": {"api.example.com", "worker.example.com"},
73+ "frontend": {"web.example.com"},
74+ },
75+ },
76+ }
77+
78+ // Test network group
79+ apps := manager.GetAppsForNetwork("backend")
80+ assert.Equal(t, []string{"api.example.com", "worker.example.com"}, apps)
81+
82+ // Test single app group
83+ apps = manager.GetAppsForNetwork("frontend")
84+ assert.Equal(t, []string{"web.example.com"}, apps)
85+
86+ // Test single app (not in any group)
87+ apps = manager.GetAppsForNetwork("standalone.example.com")
88+ assert.Equal(t, []string{"standalone.example.com"}, apps)
89+}
90+
91+func TestManager_CreateNetworkIfNeeded(t *testing.T) {
92+ ctrl := gomock.NewController(t)
93+ defer ctrl.Finish()
94+
95+ ctx := testutils.TestContext(t)
96+ mockRuntime := mocks.NewMockRuntime(ctrl)
97+
98+ cfg := &config.Config{}
99+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
100+ require.NoError(t, err)
101+
102+ // Test bridge network (should not try to create)
103+ err = manager.CreateNetworkIfNeeded(ctx, "bridge")
104+ assert.NoError(t, err)
105+
106+ // Test default network (should not try to create)
107+ err = manager.CreateNetworkIfNeeded(ctx, "default")
108+ assert.NoError(t, err)
109+
110+ // Test custom network that doesn't exist
111+ mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test").Return(false, nil)
112+ mockRuntime.EXPECT().CreateNetwork(ctx, "gordon-test", map[string]string{"driver": "bridge"}).Return(nil)
113+
114+ err = manager.CreateNetworkIfNeeded(ctx, "gordon-test")
115+ assert.NoError(t, err)
116+
117+ // Test custom network that already exists
118+ mockRuntime.EXPECT().NetworkExists(ctx, "gordon-existing").Return(true, nil)
119+
120+ err = manager.CreateNetworkIfNeeded(ctx, "gordon-existing")
121+ assert.NoError(t, err)
122+}
123+
124+func TestManager_CreateNetworkIfNeeded_Errors(t *testing.T) {
125+ ctrl := gomock.NewController(t)
126+ defer ctrl.Finish()
127+
128+ ctx := testutils.TestContext(t)
129+ mockRuntime := mocks.NewMockRuntime(ctrl)
130+
131+ cfg := &config.Config{}
132+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
133+ require.NoError(t, err)
134+
135+ // Test error checking if network exists
136+ mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test").Return(false, errors.New("check failed"))
137+
138+ err = manager.CreateNetworkIfNeeded(ctx, "gordon-test")
139+ assert.Error(t, err)
140+ assert.Contains(t, err.Error(), "failed to check if network exists")
141+
142+ // Test error creating network
143+ mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test").Return(false, nil)
144+ mockRuntime.EXPECT().CreateNetwork(ctx, "gordon-test", map[string]string{"driver": "bridge"}).Return(errors.New("create failed"))
145+
146+ err = manager.CreateNetworkIfNeeded(ctx, "gordon-test")
147+ assert.Error(t, err)
148+ assert.Contains(t, err.Error(), "failed to create network")
149+}
150+
151+func TestManager_cleanupNetworkIfEmpty(t *testing.T) {
152+ ctrl := gomock.NewController(t)
153+ defer ctrl.Finish()
154+
155+ ctx := testutils.TestContext(t)
156+ mockRuntime := mocks.NewMockRuntime(ctrl)
157+
158+ cfg := &config.Config{}
159+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
160+ require.NoError(t, err)
161+
162+ // Test cleanup of empty network
163+ networks := []*runtime.NetworkInfo{
164+ {
165+ Name: "gordon-test",
166+ Containers: []string{}, // Empty network
167+ },
168+ {
169+ Name: "gordon-used",
170+ Containers: []string{"container1"}, // Has containers
171+ },
172+ }
173+
174+ mockRuntime.EXPECT().ListNetworks(ctx).Return(networks, nil)
175+ mockRuntime.EXPECT().RemoveNetwork(ctx, "gordon-test").Return(nil)
176+
177+ err = manager.cleanupNetworkIfEmpty(ctx, "gordon-test")
178+ assert.NoError(t, err)
179+
180+ // Test network with containers (should not be removed)
181+ mockRuntime.EXPECT().ListNetworks(ctx).Return(networks, nil)
182+
183+ err = manager.cleanupNetworkIfEmpty(ctx, "gordon-used")
184+ assert.NoError(t, err)
185+
186+ // Test bridge network (should not try to remove)
187+ err = manager.cleanupNetworkIfEmpty(ctx, "bridge")
188+ assert.NoError(t, err)
189+
190+ // Test default network (should not try to remove)
191+ err = manager.cleanupNetworkIfEmpty(ctx, "default")
192+ assert.NoError(t, err)
193+}
194+
195+func TestManager_cleanupNetworkIfEmpty_Errors(t *testing.T) {
196+ ctrl := gomock.NewController(t)
197+ defer ctrl.Finish()
198+
199+ ctx := testutils.TestContext(t)
200+ mockRuntime := mocks.NewMockRuntime(ctrl)
201+
202+ cfg := &config.Config{}
203+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
204+ require.NoError(t, err)
205+
206+ // Test error listing networks
207+ mockRuntime.EXPECT().ListNetworks(ctx).Return(nil, errors.New("list failed"))
208+
209+ err = manager.cleanupNetworkIfEmpty(ctx, "gordon-test")
210+ assert.Error(t, err)
211+ assert.Contains(t, err.Error(), "failed to list networks")
212+
213+ // Test error removing network
214+ networks := []*runtime.NetworkInfo{
215+ {
216+ Name: "gordon-test",
217+ Containers: []string{},
218+ },
219+ }
220+
221+ mockRuntime.EXPECT().ListNetworks(ctx).Return(networks, nil)
222+ mockRuntime.EXPECT().RemoveNetwork(ctx, "gordon-test").Return(errors.New("remove failed"))
223+
224+ err = manager.cleanupNetworkIfEmpty(ctx, "gordon-test")
225+ assert.Error(t, err)
226+}
227+
228+func TestManager_DeployAttachedService_SharedService(t *testing.T) {
229+ ctrl := gomock.NewController(t)
230+ defer ctrl.Finish()
231+
232+ ctx := testutils.TestContext(t)
233+ mockRuntime := mocks.NewMockRuntime(ctrl)
234+
235+ cfg := &config.Config{
236+ Volumes: config.VolumeConfig{
237+ AutoCreate: false,
238+ },
239+ NetworkIsolation: config.NetworkIsolationConfig{
240+ NetworkPrefix: "gordon",
241+ },
242+ NetworkGroups: map[string][]string{
243+ "backend": {"api.example.com", "worker.example.com"},
244+ },
245+ }
246+
247+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
248+ require.NoError(t, err)
249+
250+ // Test deploying shared service for network group
251+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil)
252+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil)
253+ mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{"POSTGRES_VERSION=15"}, nil)
254+
255+ expectedContainer := &runtime.Container{
256+ ID: "postgres-service",
257+ Name: "gordon-shared-postgres",
258+ }
259+
260+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
261+ mockRuntime.EXPECT().StartContainer(ctx, "postgres-service").Return(nil)
262+
263+ err = manager.DeployAttachedService(ctx, "backend", "postgres:latest")
264+
265+ assert.NoError(t, err)
266+}
267+
268+func TestManager_DeployAttachedService_AppSpecific(t *testing.T) {
269+ ctrl := gomock.NewController(t)
270+ defer ctrl.Finish()
271+
272+ ctx := testutils.TestContext(t)
273+ mockRuntime := mocks.NewMockRuntime(ctrl)
274+
275+ cfg := &config.Config{
276+ Volumes: config.VolumeConfig{
277+ AutoCreate: false,
278+ },
279+ NetworkIsolation: config.NetworkIsolationConfig{
280+ NetworkPrefix: "gordon",
281+ },
282+ }
283+
284+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
285+ require.NoError(t, err)
286+
287+ // Test deploying app-specific service
288+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil)
289+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil)
290+ mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil)
291+
292+ expectedContainer := &runtime.Container{
293+ ID: "redis-service",
294+ Name: "api-example-com-redis",
295+ }
296+
297+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
298+ mockRuntime.EXPECT().StartContainer(ctx, "redis-service").Return(nil)
299+
300+ err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest")
301+
302+ assert.NoError(t, err)
303+}
304+
305+func TestManager_DeployAttachedService_AlreadyRunning(t *testing.T) {
306+ ctrl := gomock.NewController(t)
307+ defer ctrl.Finish()
308+
309+ ctx := testutils.TestContext(t)
310+ mockRuntime := mocks.NewMockRuntime(ctrl)
311+
312+ cfg := &config.Config{
313+ NetworkIsolation: config.NetworkIsolationConfig{
314+ NetworkPrefix: "gordon",
315+ },
316+ }
317+
318+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
319+ require.NoError(t, err)
320+
321+ // Mock existing container with same name
322+ existingContainers := []*runtime.Container{
323+ {
324+ ID: "existing-redis",
325+ Name: "api-example-com-redis",
326+ },
327+ }
328+
329+ mockRuntime.EXPECT().ListContainers(ctx, false).Return(existingContainers, nil)
330+
331+ err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest")
332+
333+ assert.NoError(t, err)
334+}
335+
336+func TestManager_DeployAttachedService_WithVolumes(t *testing.T) {
337+ ctrl := gomock.NewController(t)
338+ defer ctrl.Finish()
339+
340+ ctx := testutils.TestContext(t)
341+ mockRuntime := mocks.NewMockRuntime(ctrl)
342+
343+ cfg := &config.Config{
344+ Volumes: config.VolumeConfig{
345+ AutoCreate: true,
346+ Prefix: "gordon",
347+ },
348+ NetworkIsolation: config.NetworkIsolationConfig{
349+ NetworkPrefix: "gordon",
350+ },
351+ }
352+
353+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
354+ require.NoError(t, err)
355+
356+ // Test service with volumes
357+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil)
358+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil)
359+ mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil)
360+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "postgres:latest").Return([]string{"/var/lib/postgresql/data"}, nil)
361+
362+ // Volume creation expectations
363+ mockRuntime.EXPECT().VolumeExists(ctx, "gordon-api-example-com-var-lib-postgresql-data").Return(false, nil)
364+ mockRuntime.EXPECT().CreateVolume(ctx, "gordon-api-example-com-var-lib-postgresql-data").Return(nil)
365+
366+ expectedContainer := &runtime.Container{
367+ ID: "postgres-service",
368+ Name: "api-example-com-postgres",
369+ }
370+
371+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
372+ mockRuntime.EXPECT().StartContainer(ctx, "postgres-service").Return(nil)
373+
374+ err = manager.DeployAttachedService(ctx, "api.example.com", "postgres:latest")
375+
376+ assert.NoError(t, err)
377+}
378+
379+func TestManager_DeployAttachedService_Errors(t *testing.T) {
380+ ctrl := gomock.NewController(t)
381+ defer ctrl.Finish()
382+
383+ ctx := testutils.TestContext(t)
384+ mockRuntime := mocks.NewMockRuntime(ctrl)
385+
386+ cfg := &config.Config{
387+ Volumes: config.VolumeConfig{
388+ AutoCreate: false,
389+ },
390+ NetworkIsolation: config.NetworkIsolationConfig{
391+ NetworkPrefix: "gordon",
392+ },
393+ }
394+
395+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
396+ require.NoError(t, err)
397+
398+ // Test create container error
399+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil)
400+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil)
401+ mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil)
402+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(nil, errors.New("create failed"))
403+
404+ err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest")
405+ assert.Error(t, err)
406+ assert.Contains(t, err.Error(), "failed to create service container")
407+
408+ // Test start container error
409+ mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil)
410+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil)
411+ mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil)
412+
413+ container := &runtime.Container{ID: "service123", Name: "api-example-com-redis"}
414+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(container, nil)
415+ mockRuntime.EXPECT().StartContainer(ctx, "service123").Return(errors.New("start failed"))
416+ mockRuntime.EXPECT().RemoveContainer(ctx, "service123", true).Return(nil)
417+
418+ err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest")
419+ assert.Error(t, err)
420+ assert.Contains(t, err.Error(), "failed to start service container")
421+}⏎̸
A internal/container/manager_operations_test.go +517 -0
@@ -0,0 +1,517 @@
1+package container
2+
3+import (
4+ "errors"
5+ "os"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+ "go.uber.org/mock/gomock"
11+
12+ "gordon/internal/config"
13+ "gordon/internal/testutils"
14+ "gordon/internal/testutils/mocks"
15+ "gordon/pkg/runtime"
16+)
17+
18+func TestManager_SyncContainers(t *testing.T) {
19+ ctrl := gomock.NewController(t)
20+ defer ctrl.Finish()
21+
22+ ctx := testutils.TestContext(t)
23+ mockRuntime := mocks.NewMockRuntime(ctrl)
24+
25+ cfg := &config.Config{}
26+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
27+ require.NoError(t, err)
28+
29+ // Add some containers to internal state that might be stale
30+ manager.containers["old.example.com"] = &runtime.Container{ID: "old-container"}
31+
32+ // Mock runtime returning current containers
33+ currentContainers := []*runtime.Container{
34+ {
35+ ID: "active-container-1",
36+ Name: "gordon-app1.example.com",
37+ Labels: map[string]string{
38+ "gordon.managed": "true",
39+ "gordon.domain": "app1.example.com",
40+ },
41+ },
42+ {
43+ ID: "active-container-2",
44+ Name: "gordon-app2.example.com",
45+ Labels: map[string]string{
46+ "gordon.managed": "true",
47+ "gordon.domain": "app2.example.com",
48+ },
49+ },
50+ {
51+ ID: "unmanaged-container",
52+ Name: "some-other-container",
53+ Labels: map[string]string{
54+ "other.label": "value",
55+ },
56+ },
57+ }
58+
59+ mockRuntime.EXPECT().ListContainers(ctx, false).Return(currentContainers, nil)
60+
61+ err = manager.SyncContainers(ctx)
62+
63+ assert.NoError(t, err)
64+ assert.Len(t, manager.containers, 2)
65+ assert.Contains(t, manager.containers, "app1.example.com")
66+ assert.Contains(t, manager.containers, "app2.example.com")
67+ assert.NotContains(t, manager.containers, "old.example.com")
68+ assert.Equal(t, "active-container-1", manager.containers["app1.example.com"].ID)
69+ assert.Equal(t, "active-container-2", manager.containers["app2.example.com"].ID)
70+}
71+
72+func TestManager_SyncContainers_Error(t *testing.T) {
73+ ctrl := gomock.NewController(t)
74+ defer ctrl.Finish()
75+
76+ ctx := testutils.TestContext(t)
77+ mockRuntime := mocks.NewMockRuntime(ctrl)
78+
79+ cfg := &config.Config{}
80+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
81+ require.NoError(t, err)
82+
83+ mockRuntime.EXPECT().ListContainers(ctx, false).Return(nil, errors.New("runtime error"))
84+
85+ err = manager.SyncContainers(ctx)
86+
87+ assert.Error(t, err)
88+ assert.Contains(t, err.Error(), "failed to list containers")
89+}
90+
91+func TestManager_StopContainer(t *testing.T) {
92+ ctrl := gomock.NewController(t)
93+ defer ctrl.Finish()
94+
95+ ctx := testutils.TestContext(t)
96+ mockRuntime := mocks.NewMockRuntime(ctrl)
97+
98+ cfg := &config.Config{}
99+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
100+ require.NoError(t, err)
101+
102+ mockRuntime.EXPECT().StopContainer(ctx, "container123").Return(nil)
103+
104+ err = manager.StopContainer(ctx, "container123")
105+
106+ assert.NoError(t, err)
107+}
108+
109+func TestManager_StopContainer_Error(t *testing.T) {
110+ ctrl := gomock.NewController(t)
111+ defer ctrl.Finish()
112+
113+ ctx := testutils.TestContext(t)
114+ mockRuntime := mocks.NewMockRuntime(ctrl)
115+
116+ cfg := &config.Config{}
117+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
118+ require.NoError(t, err)
119+
120+ mockRuntime.EXPECT().StopContainer(ctx, "container123").Return(errors.New("stop failed"))
121+
122+ err = manager.StopContainer(ctx, "container123")
123+
124+ assert.Error(t, err)
125+ assert.Contains(t, err.Error(), "failed to stop container")
126+}
127+
128+func TestManager_StopContainerByDomain(t *testing.T) {
129+ ctrl := gomock.NewController(t)
130+ defer ctrl.Finish()
131+
132+ ctx := testutils.TestContext(t)
133+ mockRuntime := mocks.NewMockRuntime(ctrl)
134+
135+ cfg := &config.Config{}
136+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
137+ require.NoError(t, err)
138+
139+ container := &runtime.Container{
140+ ID: "container123",
141+ }
142+ manager.containers["test.example.com"] = container
143+
144+ mockRuntime.EXPECT().StopContainer(ctx, "container123").Return(nil)
145+
146+ err = manager.StopContainerByDomain(ctx, "test.example.com")
147+
148+ assert.NoError(t, err)
149+}
150+
151+func TestManager_StopContainerByDomain_NotFound(t *testing.T) {
152+ cfg := &config.Config{}
153+ manager := &Manager{
154+ config: cfg,
155+ containers: make(map[string]*runtime.Container),
156+ }
157+
158+ ctx := testutils.TestContext(t)
159+ err := manager.StopContainerByDomain(ctx, "nonexistent.example.com")
160+
161+ assert.Error(t, err)
162+ assert.Contains(t, err.Error(), "no container found for domain")
163+}
164+
165+func TestManager_RemoveContainer(t *testing.T) {
166+ ctrl := gomock.NewController(t)
167+ defer ctrl.Finish()
168+
169+ ctx := testutils.TestContext(t)
170+ mockRuntime := mocks.NewMockRuntime(ctrl)
171+
172+ cfg := &config.Config{
173+ Volumes: config.VolumeConfig{
174+ Preserve: true, // Don't cleanup volumes
175+ },
176+ NetworkIsolation: config.NetworkIsolationConfig{
177+ Enabled: false,
178+ },
179+ }
180+
181+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
182+ require.NoError(t, err)
183+
184+ container := &runtime.Container{
185+ ID: "container123",
186+ }
187+ manager.containers["test.example.com"] = container
188+
189+ mockRuntime.EXPECT().RemoveContainer(ctx, "container123", true).Return(nil)
190+
191+ err = manager.RemoveContainer(ctx, "container123", true)
192+
193+ assert.NoError(t, err)
194+ assert.NotContains(t, manager.containers, "test.example.com")
195+}
196+
197+func TestManager_RemoveContainer_WithVolumeCleanup(t *testing.T) {
198+ ctrl := gomock.NewController(t)
199+ defer ctrl.Finish()
200+
201+ ctx := testutils.TestContext(t)
202+ mockRuntime := mocks.NewMockRuntime(ctrl)
203+
204+ cfg := &config.Config{
205+ Volumes: config.VolumeConfig{
206+ Preserve: false, // Should cleanup volumes
207+ Prefix: "gordon",
208+ },
209+ NetworkIsolation: config.NetworkIsolationConfig{
210+ Enabled: false,
211+ },
212+ Routes: map[string]string{
213+ "test.example.com": "nginx:latest",
214+ },
215+ }
216+
217+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
218+ require.NoError(t, err)
219+
220+ container := &runtime.Container{
221+ ID: "container123",
222+ }
223+ manager.containers["test.example.com"] = container
224+
225+ mockRuntime.EXPECT().RemoveContainer(ctx, "container123", true).Return(nil)
226+
227+ // Expect volume cleanup
228+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil)
229+ varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx")
230+ mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(true, nil)
231+ mockRuntime.EXPECT().RemoveVolume(ctx, varLogNginxVolume, true).Return(nil)
232+
233+ err = manager.RemoveContainer(ctx, "container123", true)
234+
235+ assert.NoError(t, err)
236+ assert.NotContains(t, manager.containers, "test.example.com")
237+}
238+
239+func TestManager_RemoveContainer_WithNetworkCleanup(t *testing.T) {
240+ ctrl := gomock.NewController(t)
241+ defer ctrl.Finish()
242+
243+ ctx := testutils.TestContext(t)
244+ mockRuntime := mocks.NewMockRuntime(ctrl)
245+
246+ cfg := &config.Config{
247+ Volumes: config.VolumeConfig{
248+ Preserve: true,
249+ },
250+ NetworkIsolation: config.NetworkIsolationConfig{
251+ Enabled: true,
252+ NetworkPrefix: "gordon",
253+ },
254+ }
255+
256+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
257+ require.NoError(t, err)
258+
259+ container := &runtime.Container{
260+ ID: "container123",
261+ }
262+ manager.containers["test.example.com"] = container
263+
264+ mockRuntime.EXPECT().RemoveContainer(ctx, "container123", true).Return(nil)
265+
266+ // Expect network cleanup check
267+ mockRuntime.EXPECT().ListNetworks(ctx).Return([]*runtime.NetworkInfo{
268+ {
269+ Name: "gordon-test-example-com",
270+ Containers: []string{}, // Empty network
271+ },
272+ }, nil)
273+ mockRuntime.EXPECT().RemoveNetwork(ctx, "gordon-test-example-com").Return(nil)
274+
275+ err = manager.RemoveContainer(ctx, "container123", true)
276+
277+ assert.NoError(t, err)
278+ assert.NotContains(t, manager.containers, "test.example.com")
279+}
280+
281+func TestManager_RemoveContainerByDomain(t *testing.T) {
282+ ctrl := gomock.NewController(t)
283+ defer ctrl.Finish()
284+
285+ ctx := testutils.TestContext(t)
286+ mockRuntime := mocks.NewMockRuntime(ctrl)
287+
288+ cfg := &config.Config{
289+ Volumes: config.VolumeConfig{
290+ Preserve: true,
291+ },
292+ NetworkIsolation: config.NetworkIsolationConfig{
293+ Enabled: false,
294+ },
295+ }
296+
297+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
298+ require.NoError(t, err)
299+
300+ container := &runtime.Container{
301+ ID: "container123",
302+ }
303+ manager.containers["test.example.com"] = container
304+
305+ mockRuntime.EXPECT().RemoveContainer(ctx, "container123", false).Return(nil)
306+
307+ err = manager.RemoveContainerByDomain(ctx, "test.example.com", false)
308+
309+ assert.NoError(t, err)
310+ assert.NotContains(t, manager.containers, "test.example.com")
311+}
312+
313+func TestManager_RemoveContainerByDomain_NotFound(t *testing.T) {
314+ cfg := &config.Config{}
315+ manager := &Manager{
316+ config: cfg,
317+ containers: make(map[string]*runtime.Container),
318+ }
319+
320+ ctx := testutils.TestContext(t)
321+ err := manager.RemoveContainerByDomain(ctx, "nonexistent.example.com", false)
322+
323+ assert.Error(t, err)
324+ assert.Contains(t, err.Error(), "no container found for domain")
325+}
326+
327+func TestManager_GetContainerPort(t *testing.T) {
328+ ctrl := gomock.NewController(t)
329+ defer ctrl.Finish()
330+
331+ ctx := testutils.TestContext(t)
332+ mockRuntime := mocks.NewMockRuntime(ctrl)
333+
334+ cfg := &config.Config{}
335+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
336+ require.NoError(t, err)
337+
338+ container := &runtime.Container{
339+ ID: "container123",
340+ }
341+ manager.containers["test.example.com"] = container
342+
343+ mockRuntime.EXPECT().GetContainerPort(ctx, "container123", 80).Return(8080, nil)
344+
345+ port, err := manager.GetContainerPort(ctx, "test.example.com", 80)
346+
347+ assert.NoError(t, err)
348+ assert.Equal(t, 8080, port)
349+}
350+
351+func TestManager_GetContainerPort_NotFound(t *testing.T) {
352+ cfg := &config.Config{}
353+ manager := &Manager{
354+ config: cfg,
355+ containers: make(map[string]*runtime.Container),
356+ }
357+
358+ ctx := testutils.TestContext(t)
359+ _, err := manager.GetContainerPort(ctx, "nonexistent.example.com", 80)
360+
361+ assert.Error(t, err)
362+ assert.Contains(t, err.Error(), "no container found for domain")
363+}
364+
365+func TestManager_FindContainerByDomain(t *testing.T) {
366+ manager := &Manager{
367+ containers: map[string]*runtime.Container{
368+ "test.example.com": {ID: "container123"},
369+ },
370+ }
371+
372+ id, exists := manager.FindContainerByDomain("test.example.com")
373+ assert.True(t, exists)
374+ assert.Equal(t, "container123", id)
375+
376+ id, exists = manager.FindContainerByDomain("nonexistent.example.com")
377+ assert.False(t, exists)
378+ assert.Empty(t, id)
379+}
380+
381+func TestManager_FindDomainByContainerID(t *testing.T) {
382+ manager := &Manager{
383+ containers: map[string]*runtime.Container{
384+ "test.example.com": {ID: "container123"},
385+ "app.example.com": {ID: "container456"},
386+ },
387+ }
388+
389+ domain, exists := manager.FindDomainByContainerID("container123")
390+ assert.True(t, exists)
391+ assert.Equal(t, "test.example.com", domain)
392+
393+ domain, exists = manager.FindDomainByContainerID("nonexistent")
394+ assert.False(t, exists)
395+ assert.Empty(t, domain)
396+}
397+
398+func TestManager_StopAllManagedContainers(t *testing.T) {
399+ ctrl := gomock.NewController(t)
400+ defer ctrl.Finish()
401+
402+ ctx := testutils.TestContext(t)
403+ mockRuntime := mocks.NewMockRuntime(ctrl)
404+
405+ cfg := &config.Config{}
406+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
407+ require.NoError(t, err)
408+
409+ manager.containers["app1.example.com"] = &runtime.Container{ID: "container1"}
410+ manager.containers["app2.example.com"] = &runtime.Container{ID: "container2"}
411+
412+ mockRuntime.EXPECT().StopContainer(ctx, "container1").Return(nil)
413+ mockRuntime.EXPECT().StopContainer(ctx, "container2").Return(nil)
414+
415+ err = manager.StopAllManagedContainers(ctx)
416+
417+ assert.NoError(t, err)
418+ assert.Empty(t, manager.containers)
419+}
420+
421+func TestManager_StopAllManagedContainers_WithErrors(t *testing.T) {
422+ ctrl := gomock.NewController(t)
423+ defer ctrl.Finish()
424+
425+ ctx := testutils.TestContext(t)
426+ mockRuntime := mocks.NewMockRuntime(ctrl)
427+
428+ cfg := &config.Config{}
429+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
430+ require.NoError(t, err)
431+
432+ manager.containers["app1.example.com"] = &runtime.Container{ID: "container1"}
433+ manager.containers["app2.example.com"] = &runtime.Container{ID: "container2"}
434+
435+ mockRuntime.EXPECT().StopContainer(ctx, "container1").Return(nil)
436+ mockRuntime.EXPECT().StopContainer(ctx, "container2").Return(errors.New("stop failed"))
437+
438+ err = manager.StopAllManagedContainers(ctx)
439+
440+ assert.Error(t, err)
441+ assert.Contains(t, err.Error(), "failed to stop 1 containers")
442+}
443+
444+func TestManager_StopAllManagedContainers_Empty(t *testing.T) {
445+ ctrl := gomock.NewController(t)
446+ defer ctrl.Finish()
447+
448+ ctx := testutils.TestContext(t)
449+ mockRuntime := mocks.NewMockRuntime(ctrl)
450+
451+ cfg := &config.Config{}
452+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
453+ require.NoError(t, err)
454+
455+ err = manager.StopAllManagedContainers(ctx)
456+
457+ assert.NoError(t, err)
458+ assert.Empty(t, manager.containers)
459+}
460+
461+func TestManager_UpdateConfig(t *testing.T) {
462+ ctrl := gomock.NewController(t)
463+ defer ctrl.Finish()
464+
465+ mockRuntime := mocks.NewMockRuntime(ctrl)
466+
467+ originalCfg := &config.Config{
468+ Server: config.ServerConfig{
469+ Runtime: "docker",
470+ },
471+ }
472+
473+ manager, err := NewManagerWithRuntime(originalCfg, mockRuntime)
474+ require.NoError(t, err)
475+
476+ newCfg := &config.Config{
477+ Server: config.ServerConfig{
478+ Runtime: "podman",
479+ },
480+ Routes: map[string]string{
481+ "new-app.example.com": "nginx:latest",
482+ },
483+ }
484+
485+ manager.UpdateConfig(newCfg)
486+
487+ // Cleanup the env file created by UpdateConfig
488+ defer func() {
489+ if err := os.Remove("new-app_example_com.env"); err != nil && !os.IsNotExist(err) {
490+ t.Logf("Failed to cleanup env file: %v", err)
491+ }
492+ }()
493+
494+ assert.Equal(t, newCfg, manager.config)
495+ assert.Equal(t, "podman", manager.config.Server.Runtime)
496+}
497+
498+func TestManager_Shutdown(t *testing.T) {
499+ ctrl := gomock.NewController(t)
500+ defer ctrl.Finish()
501+
502+ ctx := testutils.TestContext(t)
503+ mockRuntime := mocks.NewMockRuntime(ctrl)
504+
505+ cfg := &config.Config{}
506+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
507+ require.NoError(t, err)
508+
509+ manager.containers["app1.example.com"] = &runtime.Container{ID: "container1"}
510+
511+ mockRuntime.EXPECT().StopContainer(ctx, "container1").Return(nil)
512+
513+ err = manager.Shutdown(ctx)
514+
515+ assert.NoError(t, err)
516+ assert.Empty(t, manager.containers)
517+}⏎̸
A internal/container/manager_test.go +183 -0
@@ -0,0 +1,183 @@
1+package container
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+ "go.uber.org/mock/gomock"
9+
10+ "gordon/internal/config"
11+ "gordon/internal/testutils"
12+ "gordon/internal/testutils/mocks"
13+ "gordon/pkg/runtime"
14+)
15+
16+func TestManager_GetContainer(t *testing.T) {
17+ container := &runtime.Container{
18+ ID: "test123",
19+ Name: "test-container",
20+ }
21+
22+ manager := &Manager{
23+ containers: map[string]*runtime.Container{
24+ "test.example.com": container,
25+ },
26+ }
27+
28+ // Test existing container
29+ found, exists := manager.GetContainer("test.example.com")
30+ assert.True(t, exists)
31+ assert.Equal(t, container, found)
32+
33+ // Test non-existent container
34+ notFound, exists := manager.GetContainer("nonexistent.example.com")
35+ assert.False(t, exists)
36+ assert.Nil(t, notFound)
37+}
38+
39+func TestManager_ListContainers(t *testing.T) {
40+ container1 := &runtime.Container{ID: "test1", Name: "container1"}
41+ container2 := &runtime.Container{ID: "test2", Name: "container2"}
42+
43+ manager := &Manager{
44+ containers: map[string]*runtime.Container{
45+ "app1.example.com": container1,
46+ "app2.example.com": container2,
47+ },
48+ }
49+
50+ containers := manager.ListContainers()
51+
52+ assert.Len(t, containers, 2)
53+ assert.Contains(t, containers, "app1.example.com")
54+ assert.Contains(t, containers, "app2.example.com")
55+ assert.Equal(t, container1, containers["app1.example.com"])
56+ assert.Equal(t, container2, containers["app2.example.com"])
57+}
58+
59+func TestManager_HealthCheck(t *testing.T) {
60+ ctrl := gomock.NewController(t)
61+ defer ctrl.Finish()
62+
63+ ctx := testutils.TestContext(t)
64+
65+ mockRuntime := mocks.NewMockRuntime(ctrl)
66+ mockRuntime.EXPECT().IsContainerRunning(ctx, "container1").Return(true, nil)
67+ mockRuntime.EXPECT().IsContainerRunning(ctx, "container2").Return(false, nil)
68+
69+ manager := &Manager{
70+ runtime: mockRuntime,
71+ containers: map[string]*runtime.Container{
72+ "app1.example.com": {ID: "container1"},
73+ "app2.example.com": {ID: "container2"},
74+ },
75+ }
76+
77+ health := manager.HealthCheck(ctx)
78+
79+ assert.Len(t, health, 2)
80+ assert.True(t, health["app1.example.com"])
81+ assert.False(t, health["app2.example.com"])
82+}
83+
84+func TestManager_GetNetworkForApp(t *testing.T) {
85+ manager := &Manager{
86+ config: &config.Config{
87+ NetworkIsolation: config.NetworkIsolationConfig{
88+ Enabled: true,
89+ NetworkPrefix: "gordon",
90+ },
91+ NetworkGroups: map[string][]string{
92+ "backend": {"api.example.com", "worker.example.com"},
93+ },
94+ },
95+ }
96+
97+ // Test app in network group
98+ network := manager.GetNetworkForApp("api.example.com")
99+ assert.Equal(t, "gordon-backend", network)
100+
101+ // Test standalone app
102+ network = manager.GetNetworkForApp("frontend.example.com")
103+ assert.Equal(t, "gordon-frontend-example-com", network)
104+
105+ // Test disabled network isolation
106+ manager.config.NetworkIsolation.Enabled = false
107+ network = manager.GetNetworkForApp("test.example.com")
108+ assert.Equal(t, "bridge", network)
109+}
110+
111+func TestManager_Runtime(t *testing.T) {
112+ ctrl := gomock.NewController(t)
113+ defer ctrl.Finish()
114+
115+ mockRuntime := mocks.NewMockRuntime(ctrl)
116+ manager := &Manager{
117+ runtime: mockRuntime,
118+ }
119+
120+ result := manager.Runtime()
121+ assert.Equal(t, mockRuntime, result)
122+}
123+
124+func TestManager_DeployContainer(t *testing.T) {
125+ ctrl := gomock.NewController(t)
126+ defer ctrl.Finish()
127+
128+ ctx := testutils.TestContext(t)
129+ mockRuntime := mocks.NewMockRuntime(ctrl)
130+
131+ cfg := &config.Config{
132+ Server: config.ServerConfig{
133+ Runtime: "docker",
134+ },
135+ Volumes: config.VolumeConfig{
136+ AutoCreate: true,
137+ Prefix: "gordon",
138+ Preserve: false,
139+ },
140+ NetworkIsolation: config.NetworkIsolationConfig{
141+ Enabled: true,
142+ NetworkPrefix: "gordon",
143+ },
144+ }
145+
146+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
147+ require.NoError(t, err)
148+
149+ route := config.Route{
150+ Domain: "test.example.com",
151+ Image: "nginx:latest",
152+ }
153+
154+ // Set up expectations for successful deployment
155+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
156+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"nginx:latest"}, nil)
157+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "nginx:latest").Return([]int{80}, nil)
158+ mockRuntime.EXPECT().InspectImageEnv(ctx, "nginx:latest").Return([]string{"PATH=/usr/local/sbin:/usr/local/bin"}, nil)
159+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil)
160+ varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx")
161+ mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(false, nil)
162+ mockRuntime.EXPECT().CreateVolume(ctx, varLogNginxVolume).Return(nil)
163+ mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test-example-com").Return(false, nil)
164+ mockRuntime.EXPECT().CreateNetwork(ctx, "gordon-test-example-com", gomock.Any()).Return(nil)
165+
166+ expectedContainer := &runtime.Container{
167+ ID: "container123",
168+ Name: "gordon-test.example.com",
169+ Image: "nginx:latest",
170+ Status: "running",
171+ Ports: []int{80},
172+ }
173+
174+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
175+ mockRuntime.EXPECT().StartContainer(ctx, "container123").Return(nil)
176+ mockRuntime.EXPECT().InspectContainer(ctx, "container123").Return(expectedContainer, nil)
177+
178+ container, err := manager.DeployContainer(ctx, route)
179+
180+ assert.NoError(t, err)
181+ assert.Equal(t, expectedContainer, container)
182+ assert.Equal(t, expectedContainer, manager.containers["test.example.com"])
183+}
A internal/container/manager_volume_test.go +388 -0
@@ -0,0 +1,388 @@
1+package container
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "go.uber.org/mock/gomock"
10+
11+ "gordon/internal/config"
12+ "gordon/internal/testutils"
13+ "gordon/internal/testutils/mocks"
14+ "gordon/pkg/runtime"
15+)
16+
17+func TestManager_cleanupVolumesForDomain(t *testing.T) {
18+ ctrl := gomock.NewController(t)
19+ defer ctrl.Finish()
20+
21+ ctx := testutils.TestContext(t)
22+ mockRuntime := mocks.NewMockRuntime(ctrl)
23+
24+ cfg := &config.Config{
25+ Volumes: config.VolumeConfig{
26+ Prefix: "gordon",
27+ },
28+ Routes: map[string]string{
29+ "test.example.com": "nginx:latest",
30+ },
31+ }
32+
33+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
34+ require.NoError(t, err)
35+
36+ // Mock image inspection for volume paths
37+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx", "/etc/nginx"}, nil)
38+
39+ // Mock volume existence checks and removal
40+ varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx")
41+ etcNginxVolume := generateVolumeName("gordon", "test.example.com", "/etc/nginx")
42+
43+ mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(true, nil)
44+ mockRuntime.EXPECT().RemoveVolume(ctx, varLogNginxVolume, true).Return(nil)
45+
46+ mockRuntime.EXPECT().VolumeExists(ctx, etcNginxVolume).Return(true, nil)
47+ mockRuntime.EXPECT().RemoveVolume(ctx, etcNginxVolume, true).Return(nil)
48+
49+ err = manager.cleanupVolumesForDomain(ctx, "test.example.com")
50+
51+ assert.NoError(t, err)
52+}
53+
54+func TestManager_cleanupVolumesForDomain_NoRoute(t *testing.T) {
55+ ctrl := gomock.NewController(t)
56+ defer ctrl.Finish()
57+
58+ ctx := testutils.TestContext(t)
59+ mockRuntime := mocks.NewMockRuntime(ctrl)
60+
61+ cfg := &config.Config{
62+ Volumes: config.VolumeConfig{
63+ Prefix: "gordon",
64+ },
65+ Routes: map[string]string{},
66+ }
67+
68+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
69+ require.NoError(t, err)
70+
71+ // Should return nil without doing anything
72+ err = manager.cleanupVolumesForDomain(ctx, "nonexistent.example.com")
73+
74+ assert.NoError(t, err)
75+}
76+
77+func TestManager_cleanupVolumesForDomain_WithRegistryAuth(t *testing.T) {
78+ ctrl := gomock.NewController(t)
79+ defer ctrl.Finish()
80+
81+ ctx := testutils.TestContext(t)
82+ mockRuntime := mocks.NewMockRuntime(ctrl)
83+
84+ cfg := &config.Config{
85+ Server: config.ServerConfig{
86+ RegistryDomain: "registry.example.com",
87+ },
88+ RegistryAuth: config.RegistryAuthConfig{
89+ Enabled: true,
90+ },
91+ Volumes: config.VolumeConfig{
92+ Prefix: "gordon",
93+ },
94+ Routes: map[string]string{
95+ "test.example.com": "myapp:latest",
96+ },
97+ }
98+
99+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
100+ require.NoError(t, err)
101+
102+ // Should use prefixed image reference
103+ expectedImageRef := "registry.example.com/myapp:latest"
104+ mockRuntime.EXPECT().InspectImageVolumes(ctx, expectedImageRef).Return([]string{"/data"}, nil)
105+
106+ dataVolume := generateVolumeName("gordon", "test.example.com", "/data")
107+ mockRuntime.EXPECT().VolumeExists(ctx, dataVolume).Return(true, nil)
108+ mockRuntime.EXPECT().RemoveVolume(ctx, dataVolume, true).Return(nil)
109+
110+ err = manager.cleanupVolumesForDomain(ctx, "test.example.com")
111+
112+ assert.NoError(t, err)
113+}
114+
115+func TestManager_cleanupVolumesForDomain_NoVolumes(t *testing.T) {
116+ ctrl := gomock.NewController(t)
117+ defer ctrl.Finish()
118+
119+ ctx := testutils.TestContext(t)
120+ mockRuntime := mocks.NewMockRuntime(ctrl)
121+
122+ cfg := &config.Config{
123+ Volumes: config.VolumeConfig{
124+ Prefix: "gordon",
125+ },
126+ Routes: map[string]string{
127+ "test.example.com": "alpine:latest",
128+ },
129+ }
130+
131+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
132+ require.NoError(t, err)
133+
134+ // Image has no volumes
135+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "alpine:latest").Return([]string{}, nil)
136+
137+ err = manager.cleanupVolumesForDomain(ctx, "test.example.com")
138+
139+ assert.NoError(t, err)
140+}
141+
142+func TestManager_cleanupVolumesForDomain_VolumeNotExists(t *testing.T) {
143+ ctrl := gomock.NewController(t)
144+ defer ctrl.Finish()
145+
146+ ctx := testutils.TestContext(t)
147+ mockRuntime := mocks.NewMockRuntime(ctrl)
148+
149+ cfg := &config.Config{
150+ Volumes: config.VolumeConfig{
151+ Prefix: "gordon",
152+ },
153+ Routes: map[string]string{
154+ "test.example.com": "nginx:latest",
155+ },
156+ }
157+
158+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
159+ require.NoError(t, err)
160+
161+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil)
162+
163+ // Volume doesn't exist, should skip removal
164+ varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx")
165+ mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(false, nil)
166+
167+ err = manager.cleanupVolumesForDomain(ctx, "test.example.com")
168+
169+ assert.NoError(t, err)
170+}
171+
172+func TestManager_cleanupVolumesForDomain_Errors(t *testing.T) {
173+ ctrl := gomock.NewController(t)
174+ defer ctrl.Finish()
175+
176+ ctx := testutils.TestContext(t)
177+ mockRuntime := mocks.NewMockRuntime(ctrl)
178+
179+ cfg := &config.Config{
180+ Volumes: config.VolumeConfig{
181+ Prefix: "gordon",
182+ },
183+ Routes: map[string]string{
184+ "test.example.com": "nginx:latest",
185+ },
186+ }
187+
188+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
189+ require.NoError(t, err)
190+
191+ // Test error inspecting image volumes
192+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return(nil, errors.New("inspect failed"))
193+
194+ err = manager.cleanupVolumesForDomain(ctx, "test.example.com")
195+ assert.Error(t, err)
196+
197+ // Test error checking volume existence
198+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil)
199+ varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx")
200+ mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(false, errors.New("check failed"))
201+
202+ err = manager.cleanupVolumesForDomain(ctx, "test.example.com")
203+ assert.NoError(t, err) // Should continue despite check error
204+
205+ // Test error removing volume
206+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil)
207+ mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(true, nil)
208+ mockRuntime.EXPECT().RemoveVolume(ctx, varLogNginxVolume, true).Return(errors.New("remove failed"))
209+
210+ err = manager.cleanupVolumesForDomain(ctx, "test.example.com")
211+ assert.Error(t, err)
212+ assert.Contains(t, err.Error(), "failed to cleanup 1 volumes")
213+}
214+
215+func TestManager_DeployContainer_VolumeAutoCreation(t *testing.T) {
216+ ctrl := gomock.NewController(t)
217+ defer ctrl.Finish()
218+
219+ ctx := testutils.TestContext(t)
220+ mockRuntime := mocks.NewMockRuntime(ctrl)
221+
222+ cfg := &config.Config{
223+ Server: config.ServerConfig{
224+ Runtime: "docker",
225+ },
226+ Volumes: config.VolumeConfig{
227+ AutoCreate: true,
228+ Prefix: "gordon",
229+ Preserve: false,
230+ },
231+ NetworkIsolation: config.NetworkIsolationConfig{
232+ Enabled: false,
233+ },
234+ }
235+
236+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
237+ require.NoError(t, err)
238+
239+ route := config.Route{
240+ Domain: "test.example.com",
241+ Image: "postgres:latest",
242+ }
243+
244+ // Set up expectations for deployment with volume creation
245+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
246+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"postgres:latest"}, nil)
247+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil)
248+ mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil)
249+
250+ // Mock volume discovery and creation
251+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "postgres:latest").Return([]string{"/var/lib/postgresql/data", "/etc/postgresql"}, nil)
252+
253+ // First volume
254+ dataVolume := generateVolumeName("gordon", "test.example.com", "/var/lib/postgresql/data")
255+ mockRuntime.EXPECT().VolumeExists(ctx, dataVolume).Return(false, nil)
256+ mockRuntime.EXPECT().CreateVolume(ctx, dataVolume).Return(nil)
257+
258+ // Second volume already exists
259+ etcVolume := generateVolumeName("gordon", "test.example.com", "/etc/postgresql")
260+ mockRuntime.EXPECT().VolumeExists(ctx, etcVolume).Return(true, nil)
261+
262+ expectedContainer := &runtime.Container{
263+ ID: "postgres-container",
264+ Name: "gordon-test.example.com",
265+ Image: "postgres:latest",
266+ Status: "running",
267+ Ports: []int{5432},
268+ }
269+
270+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
271+ mockRuntime.EXPECT().StartContainer(ctx, "postgres-container").Return(nil)
272+ mockRuntime.EXPECT().InspectContainer(ctx, "postgres-container").Return(expectedContainer, nil)
273+
274+ container, err := manager.DeployContainer(ctx, route)
275+
276+ assert.NoError(t, err)
277+ assert.Equal(t, expectedContainer, container)
278+}
279+
280+func TestManager_DeployContainer_VolumeCreationError(t *testing.T) {
281+ ctrl := gomock.NewController(t)
282+ defer ctrl.Finish()
283+
284+ ctx := testutils.TestContext(t)
285+ mockRuntime := mocks.NewMockRuntime(ctrl)
286+
287+ cfg := &config.Config{
288+ Server: config.ServerConfig{
289+ Runtime: "docker",
290+ },
291+ Volumes: config.VolumeConfig{
292+ AutoCreate: true,
293+ Prefix: "gordon",
294+ },
295+ NetworkIsolation: config.NetworkIsolationConfig{
296+ Enabled: false,
297+ },
298+ }
299+
300+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
301+ require.NoError(t, err)
302+
303+ route := config.Route{
304+ Domain: "test.example.com",
305+ Image: "postgres:latest",
306+ }
307+
308+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
309+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"postgres:latest"}, nil)
310+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil)
311+ mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil)
312+
313+ // Mock volume creation failure
314+ mockRuntime.EXPECT().InspectImageVolumes(ctx, "postgres:latest").Return([]string{"/var/lib/postgresql/data"}, nil)
315+ dataVolume := generateVolumeName("gordon", "test.example.com", "/var/lib/postgresql/data")
316+ mockRuntime.EXPECT().VolumeExists(ctx, dataVolume).Return(false, nil)
317+ mockRuntime.EXPECT().CreateVolume(ctx, dataVolume).Return(errors.New("create failed"))
318+
319+ // Should still continue with container creation (volume creation is best-effort)
320+ expectedContainer := &runtime.Container{
321+ ID: "postgres-container",
322+ Name: "gordon-test.example.com",
323+ Image: "postgres:latest",
324+ Status: "running",
325+ Ports: []int{5432},
326+ }
327+
328+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
329+ mockRuntime.EXPECT().StartContainer(ctx, "postgres-container").Return(nil)
330+ mockRuntime.EXPECT().InspectContainer(ctx, "postgres-container").Return(expectedContainer, nil)
331+
332+ container, err := manager.DeployContainer(ctx, route)
333+
334+ assert.NoError(t, err)
335+ assert.Equal(t, expectedContainer, container)
336+}
337+
338+func TestManager_DeployContainer_VolumeAutoCreationDisabled(t *testing.T) {
339+ ctrl := gomock.NewController(t)
340+ defer ctrl.Finish()
341+
342+ ctx := testutils.TestContext(t)
343+ mockRuntime := mocks.NewMockRuntime(ctrl)
344+
345+ cfg := &config.Config{
346+ Server: config.ServerConfig{
347+ Runtime: "docker",
348+ },
349+ Volumes: config.VolumeConfig{
350+ AutoCreate: false, // Disabled
351+ },
352+ NetworkIsolation: config.NetworkIsolationConfig{
353+ Enabled: false,
354+ },
355+ }
356+
357+ manager, err := NewManagerWithRuntime(cfg, mockRuntime)
358+ require.NoError(t, err)
359+
360+ route := config.Route{
361+ Domain: "test.example.com",
362+ Image: "postgres:latest",
363+ }
364+
365+ mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil)
366+ mockRuntime.EXPECT().ListImages(ctx).Return([]string{"postgres:latest"}, nil)
367+ mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil)
368+ mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil)
369+
370+ // Should NOT inspect image volumes when auto-creation is disabled
371+
372+ expectedContainer := &runtime.Container{
373+ ID: "postgres-container",
374+ Name: "gordon-test.example.com",
375+ Image: "postgres:latest",
376+ Status: "running",
377+ Ports: []int{5432},
378+ }
379+
380+ mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil)
381+ mockRuntime.EXPECT().StartContainer(ctx, "postgres-container").Return(nil)
382+ mockRuntime.EXPECT().InspectContainer(ctx, "postgres-container").Return(expectedContainer, nil)
383+
384+ container, err := manager.DeployContainer(ctx, route)
385+
386+ assert.NoError(t, err)
387+ assert.Equal(t, expectedContainer, container)
388+}⏎̸
A internal/container/test_manager.go +35 -0
@@ -0,0 +1,35 @@
1+package container
2+
3+import (
4+ "gordon/internal/config"
5+ "gordon/internal/env"
6+ "gordon/internal/logging"
7+ "gordon/pkg/runtime"
8+)
9+
10+// NewManagerWithRuntime creates a manager with an injected runtime for testing
11+func NewManagerWithRuntime(cfg *config.Config, rt runtime.Runtime) (*Manager, error) {
12+ // Create environment loader
13+ envLoader := env.NewLoader(cfg)
14+
15+ // Register secret providers based on config
16+ for _, providerName := range cfg.Env.Providers {
17+ switch providerName {
18+ case "pass":
19+ envLoader.RegisterSecretProvider("pass", env.NewPassProvider())
20+ case "sops":
21+ envLoader.RegisterSecretProvider("sops", env.NewSopsProvider())
22+ }
23+ }
24+
25+ // Create log manager
26+ logManager := logging.NewLogManager(cfg, rt)
27+
28+ return &Manager{
29+ runtime: rt,
30+ config: cfg,
31+ containers: make(map[string]*runtime.Container),
32+ envLoader: envLoader,
33+ logManager: logManager,
34+ }, nil
35+}⏎̸
D internal/db/db_struct.go +0 -44
@@ -1,44 +0,0 @@
1-package db
2-
3-type User struct {
4- ID string `sql:"id, primary_key"`
5- Name string `sql:"name"`
6- Email string `sql:"email"`
7- Account Account
8-}
9-
10-type Account struct {
11- ID string `sql:"id, primary_key"`
12- UserID string `sql:"user_id, foreign_key=user.id"`
13- Sessions []Sessions
14- Providers []Provider
15- Clients []Clients
16-}
17-
18-type Sessions struct {
19- ID string `sql:"id, primary_key"`
20- AccountID string `sql:"account_id, foreign_key=account.id"`
21- BrowserInfo string `sql:"browser_info"`
22- AccessToken string `sql:"access_token"`
23- Expires string `sql:"expires"`
24- IsOnline bool `sql:"is_online"`
25-}
26-
27-type Provider struct {
28- ID string `sql:"id, primary_key"`
29- AccountID string `sql:"account_id, foreign_key=account.id"`
30- Name string `sql:"name"`
31- Login string `sql:"login"`
32- AvatarURL string `sql:"avatar_url"`
33- ProfileURL string `sql:"profile_url"`
34- Email string `sql:"email"`
35-}
36-
37-type Clients struct {
38- ID string `sql:"id, primary_key"`
39- AccountID string `sql:"account_id, foreign_key=account.id"`
40- OS string `sql:"os"`
41- IP string `sql:"ip"`
42- Hostname string `sql:"hostname"`
43- Expires string `sql:"expires"`
44-}
D internal/db/queries/account.go +0 -47
@@ -1,47 +0,0 @@
1-package queries
2-
3-import (
4- "github.com/bnema/gordon/internal/db"
5- "github.com/bnema/gordon/internal/server"
6-)
7-
8-func createDBAccount(a *server.App) (*db.Account, error) {
9- account := &db.Account{
10- ID: generateUUID(),
11- }
12-
13- _, err := a.DB.Exec(
14- "INSERT INTO account (id, user_id) VALUES (?, ?)",
15- account.ID, a.DBTables.User.ID,
16- )
17- return account, err
18-}
19-
20-func CheckDBAccountExists(a *server.App, accountID string) (bool, error) {
21- var exists bool
22- err := a.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)", accountID).Scan(&exists)
23- if err != nil {
24- return false, err
25- }
26- return exists, nil
27-}
28-
29-func PopulateAccountFromDB(a *server.App) error {
30- rows, err := a.DB.Query("SELECT id, user_id FROM account")
31- if err != nil {
32- return err
33- }
34- defer rows.Close()
35-
36- for rows.Next() {
37- account := db.Account{}
38- err := rows.Scan(&account.ID, &account.UserID)
39- if err != nil {
40- return err
41- }
42-
43- a.DBTables.Account = account
44- }
45-
46- return rows.Err()
47-}
D internal/db/queries/clients.go +0 -1
@@ -1,1 +0,0 @@
1-package queries
D internal/db/queries/inspectdb.go +0 -75
@@ -1,75 +0,0 @@
1-package queries
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/server"
7-)
8-
9-// InspectInMemoryDB inspects the in-memory database. (for debug purpose)
10-func InspectInMemoryDB(a *server.App) error {
11- memDb := a.DB
12- // Query the sqlite_master table to get a list of all tables
13- rows, err := memDb.Query("SELECT name FROM sqlite_master WHERE type='table'")
14- fmt.Println("rows", rows)
15- if err != nil {
16- return err
17- }
18- defer rows.Close()
19-
20- var tables []string
21- for rows.Next() {
22- var tableName string
23- if err := rows.Scan(&tableName); err != nil {
24- return err
25- }
26- tables = append(tables, tableName)
27- }
28-
29- if err := rows.Err(); err != nil {
30- return err
31- }
32-
33- // Print the tables
34- fmt.Println("Tables in the in-memory database:")
35- for _, table := range tables {
36- fmt.Println(table)
37- }
38- // Print the content of the users table
39- userRows, err := memDb.Query("SELECT * FROM user")
40- if err != nil {
41- return err
42- }
43- defer userRows.Close()
44-
45- fmt.Println("\nContent of the 'user' table:")
46- for userRows.Next() {
47- // Assuming users table has columns: id, name
48- var id int
49- var name string
50- if err := userRows.Scan(&id, &name); err != nil {
51- return err
52- }
53- fmt.Printf("ID: %d, Name: %s\n", id, name)
54- }
55-
56- // Print the content of accounts table
57- accountRows, err := memDb.Query("SELECT * FROM account")
58- if err != nil {
59- return err
60- }
61-
62- fmt.Println("\nContent of the 'account' table:")
63- for accountRows.Next() {
64- // Assuming accounts table has columns: id, name, user_id
65- var id int
66- var name string
67- var userID int
68- if err := accountRows.Scan(&id, &name, &userID); err != nil {
69- return err
70- }
71- fmt.Printf("ID: %d, Name: %s, UserID: %d\n", id, name, userID)
72- }
73-
74- return nil
75-}
D internal/db/queries/provider.go +0 -41
@@ -1,41 +0,0 @@
1-package queries
2-
3-import (
4- "github.com/bnema/gordon/internal/db"
5- "github.com/bnema/gordon/internal/server"
6-)
7-
8-func createDBGitHubProvider(a *server.App, userInfo *GithubUserInfo) error {
9- provider := &db.Provider{
10- ID: generateUUID(),
11- Email: userInfo.Emails[0],
12- Login: userInfo.Login,
13- AvatarURL: userInfo.AvatarURL,
14- ProfileURL: userInfo.ProfileURL,
15- Name: "GitHub",
16- }
17-
18- query := "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)"
19- _, err := a.DB.Exec(query, provider.ID, a.DBTables.Account.ID, provider.Name, provider.Login, provider.AvatarURL, provider.ProfileURL, provider.Email)
20- return err
21-}
22-
23-func PopulateProviderFromDB(a *server.App) error {
24- rows, err := a.DB.Query("SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider")
25- if err != nil {
26- return err
27- }
28- defer rows.Close()
29-
30- for rows.Next() {
31- provider := db.Provider{}
32- err := rows.Scan(&provider.ID, &provider.AccountID, &provider.Name, &provider.Login, &provider.AvatarURL, &provider.ProfileURL, &provider.Email)
33- if err != nil {
34- return err
35- }
36-
37- a.DBTables.Provider = provider
38- }
39-
40- return rows.Err()
41-}
D internal/db/queries/sessions.go +0 -143
@@ -1,143 +0,0 @@
1-package queries
2-
3-import (
4- "database/sql"
5- "fmt"
6- "time"
7-
8- "github.com/bnema/gordon/internal/db"
9- "github.com/bnema/gordon/internal/server"
10-)
11-
12-// createSession creates a session for the user.
13-func createDBSession(a *server.App, browserInfo string, accessToken string, accountID string) error {
14- expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
15- sessions := &db.Sessions{
16- ID: generateUUID(),
17- AccessToken: accessToken,
18- }
19-
20- query := "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)"
21- _, err := a.DB.Exec(query, sessions.ID, accountID, accessToken, browserInfo, expireTime, true)
22- if err != nil {
23- return err
24- }
25-
26- // Update global state here if really necessary
27- a.DBTables.Sessions.ID = sessions.ID
28- a.DBTables.Sessions.AccessToken = accessToken
29- a.DBTables.Sessions.Expires = expireTime
30-
31- return nil
32-}
33-
34-// GetDBUserSession gets the user session from the database based on the access token and browser info.
35-func GetDBUserSession(a *server.App, accessToken string, browserInfo string) (*db.Sessions, error) {
36- query := "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?"
37- err := a.DB.QueryRow(query, accessToken, browserInfo).Scan(&a.DBTables.Sessions.ID, &a.DBTables.Sessions.AccountID, &a.DBTables.Sessions.AccessToken, &a.DBTables.Sessions.BrowserInfo, &a.DBTables.Sessions.Expires, &a.DBTables.Sessions.IsOnline)
38- if err != nil {
39- if err == sql.ErrNoRows {
40- return nil, nil
41- }
42- return nil, fmt.Errorf("error getting session: %w", err)
43- }
44-
45- return &a.DBTables.Sessions, nil
46-}
47-
48-func updateDBSession(a *server.App, accessToken string, browserInfo string) error {
49- expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
50- query := "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?"
51- _, err := a.DB.Exec(query, accessToken, expireTime, a.DBTables.Sessions.ID)
52- if err != nil {
53- return err
54- }
55-
56- return nil
57-}
58-
59-func CreateOrUpdateDBSession(a *server.App, accessToken string, browserInfo string) error {
60- // Check if the session exists using the access token and browser info
61- session, err := GetDBUserSession(a, accessToken, browserInfo)
62- if err != nil && err != sql.ErrNoRows {
63- return fmt.Errorf("error checking for session: %w", err)
64- }
65-
66- // If the session exists, update the session
67- if session != nil {
68- err := updateDBSession(a, accessToken, browserInfo)
69- if err != nil {
70- return fmt.Errorf("error updating session: %w", err)
71- }
72- }
73-
74- // If the session does not exist, create the session
75- if session == nil {
76- err := createDBSession(a, browserInfo, accessToken, a.DBTables.Account.ID)
77- if err != nil {
78- return fmt.Errorf("error creating session: %w", err)
79- }
80- }
81-
82- return nil
83-}
84-
85-// DeleteSession deletes the session from the database.
86-func DeleteDBSession(a *server.App, accountID string, sessionID string) error {
87- query := "DELETE FROM sessions WHERE account_id = ? AND id = ?"
88- _, err := a.DB.Exec(query, accountID, sessionID)
89- if err != nil {
90- return err
91- }
92-
93- return nil
94-}
95-
96-func GetSessionExpiration(a *server.App, accountID string, sessionID string, currentTime time.Time) (time.Time, error) {
97- var expirationTime string
98- // with userID and sessionID we can get the expiration time
99- query := "SELECT expires FROM sessions WHERE account_id = ? AND id = ?"
100- err := a.DB.QueryRow(query, accountID, sessionID).Scan(&expirationTime)
101- if err != nil {
102- return currentTime, err
103- }
104-
105- // convert the expiration time to a time.Time
106- expirationTimeParsed, err := time.Parse(time.RFC3339, expirationTime)
107- if err != nil {
108- return currentTime, err
109- }
110-
111- return expirationTimeParsed, nil
112-}
113-
114-func ExtendSessionExpiration(a *server.App, accountID string, sessionID string, newExpirationTime time.Time) error {
115- query := "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?"
116- _, err := a.DB.Exec(query, newExpirationTime.Format(time.RFC3339), accountID, sessionID)
117- if err != nil {
118- return err
119- }
120-
121- return nil
122-}
123-
124-func CheckDBSessionExists(a *server.App, sessionID string) (bool, error) {
125- var sessionExists bool
126- query := "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)"
127- err := a.DB.QueryRow(query, sessionID).Scan(&sessionExists)
128- if err != nil {
129- return false, err
130- }
131-
132- return sessionExists, nil
133-}
134-
135-func InvalidateDBSession(a *server.App, sessionID string) error {
136- query := "UPDATE sessions SET is_online = ? WHERE id = ?"
137- _, err := a.DB.Exec(query, false, sessionID)
138- if err != nil {
139- return err
140- }
141-
142- return nil
143-}
D internal/db/queries/user.go +0 -138
@@ -1,138 +0,0 @@
1-package queries
2-
3-import (
4- "database/sql"
5- "fmt"
6-
7- "github.com/bnema/gordon/internal/db"
8- "github.com/bnema/gordon/internal/server"
9- "github.com/google/uuid"
10-)
11-
12-func generateUUID() string {
13- return uuid.New().String()
14-}
15-
16-// UserInfo holds the essential information for a Github user.
17-type GithubUserInfo struct {
18- Login string `json:"login"`
19- AvatarURL string `json:"avatar_url"`
20- ProfileURL string `json:"html_url"`
21- Emails []string
22-}
23-
24-// CreateUser creates a new user along with the associated account, provider, and session.
25-func CreateUser(a *server.App, accessToken string, browserInfo string, userInfo *GithubUserInfo) error {
26- // Check if a user already exists. If so, return an error.
27- if exists, err := CheckDBUserExists(a); err != nil || exists {
28- return fmt.Errorf("error checking user or user already exists: %v", err)
29- }
30-
31- user, err := createDBUser(a, userInfo)
32- if err != nil {
33- return err
34- }
35-
36- // update the global state
37- a.DBTables.User.ID = user.ID
38- a.DBTables.User.Name = user.Name
39- a.DBTables.User.Email = user.Email
40-
41- account, err := createDBAccount(a)
42- if err != nil {
43- return err
44- }
45-
46- // update the global state
47- a.DBTables.Account.ID = account.ID
48- a.DBTables.Account.UserID = account.UserID
49-
50- err = createDBGitHubProvider(a, userInfo)
51- if err != nil {
52- return err
53- }
54-
55- return createDBSession(a, browserInfo, accessToken, account.ID)
56-}
57-
58-func createDBUser(a *server.App, userInfo *GithubUserInfo) (*db.User, error) {
59- user := &db.User{
60- ID: generateUUID(),
61- Name: userInfo.Login,
62- Email: userInfo.Emails[0],
63- }
64-
65- _, err := a.DB.Exec(
66- "INSERT INTO user (id, name, email) VALUES (?, ?, ?)",
67- user.ID, user.Name, user.Email,
68- )
69- return user, err
70-}
71-
72-func CheckDBUserExists(a *server.App) (bool, error) {
73- var userID string
74- err := a.DB.QueryRow("SELECT id FROM user").Scan(&userID)
75- if err != nil {
76- if err == sql.ErrNoRows {
77- return false, nil
78- }
79- return false, err
80- }
81-
82- if userID != "" {
83- return true, nil
84- }
85-
86- return false, nil
87-}
88-
89-// If there is already a user, we compare the login and email to see if it is the same user
90-func CheckDBUserIsGood(a *server.App, userInfo *GithubUserInfo) (bool, error) {
91- login := userInfo.Login
92- email := userInfo.Emails[0]
93-
94- // Check if the user exists based on github login and email
95- query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?"
96- err := a.DB.QueryRow(query, login, email).Scan(&a.DBTables.User.ID, &a.DBTables.User.Name, &a.DBTables.User.Email)
97- if err != nil {
98- if err == sql.ErrNoRows {
99- return false, nil
100- }
101- return false, err
102- }
103-
104- if a.DBTables.User.ID != "" {
105- return true, nil
106- }
107-
108- return false, nil
109-}
110-
111-func UpdateUser(a *server.App, accessToken string, browserInfo string, userInfo *GithubUserInfo) (*db.User, error) {
112-
113- err := CreateOrUpdateDBSession(a, accessToken, browserInfo)
114- if err != nil {
115- return nil, fmt.Errorf("could not create or update session: %w", err)
116- }
117-
118- user := a.DBTables.User
119-
120- query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?"
121- err = a.DB.QueryRow(query, userInfo.Login, userInfo.Emails[0]).Scan(&user.ID, &user.Name, &user.Email)
122- if err != nil {
123- return nil, err
124- }
125-
126- return &user, nil
127-
128-}
129-
130-func GetAccountCount(a *server.App) (int, error) {
131- var count int
132- err := a.DB.QueryRow("SELECT COUNT(*) FROM account").Scan(&count)
133- if err != nil {
134- return 0, err
135- }
136-
137- return count, nil
138-}
A internal/env/loader.go +294 -0
@@ -0,0 +1,294 @@
1+package env
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "gordon/internal/config"
11+
12+ "github.com/rs/zerolog/log"
13+)
14+
15+type Loader struct {
16+ cfg *config.Config
17+ secretProviders map[string]SecretProvider
18+}
19+
20+func NewLoader(cfg *config.Config) *Loader {
21+ return &Loader{
22+ cfg: cfg,
23+ secretProviders: make(map[string]SecretProvider),
24+ }
25+}
26+
27+func (l *Loader) RegisterSecretProvider(name string, provider SecretProvider) {
28+ l.secretProviders[name] = provider
29+}
30+
31+func (l *Loader) LoadEnvForRoute(domain string) ([]string, error) {
32+ envVars := []string{}
33+
34+ // Create domain-safe filename (replace dots and other chars with underscores)
35+ safeDomain := strings.ReplaceAll(domain, ".", "_")
36+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
37+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
38+
39+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
40+
41+ // Check if env file exists
42+ if _, err := os.Stat(envFile); os.IsNotExist(err) {
43+ log.Debug().
44+ Str("domain", domain).
45+ Str("env_file", envFile).
46+ Msg("No env file found for route")
47+ return envVars, nil
48+ }
49+
50+ log.Debug().
51+ Str("domain", domain).
52+ Str("env_file", envFile).
53+ Msg("Loading env file for route")
54+
55+ file, err := os.Open(envFile)
56+ if err != nil {
57+ return nil, fmt.Errorf("failed to open env file %s: %w", envFile, err)
58+ }
59+ defer file.Close()
60+
61+ scanner := bufio.NewScanner(file)
62+ lineNum := 0
63+
64+ for scanner.Scan() {
65+ lineNum++
66+ line := strings.TrimSpace(scanner.Text())
67+
68+ // Skip empty lines and comments
69+ if line == "" || strings.HasPrefix(line, "#") {
70+ continue
71+ }
72+
73+ // Parse KEY=VALUE format
74+ parts := strings.SplitN(line, "=", 2)
75+ if len(parts) != 2 {
76+ log.Warn().
77+ Str("file", envFile).
78+ Int("line", lineNum).
79+ Str("content", line).
80+ Msg("Invalid env file line format, skipping")
81+ continue
82+ }
83+
84+ key := strings.TrimSpace(parts[0])
85+ value := strings.TrimSpace(parts[1])
86+
87+ // Remove quotes if present
88+ if len(value) >= 2 {
89+ if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
90+ (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
91+ value = value[1 : len(value)-1]
92+ }
93+ }
94+
95+ // Resolve secrets if value contains secret syntax
96+ resolvedValue, err := l.resolveSecrets(value)
97+ if err != nil {
98+ log.Error().
99+ Err(err).
100+ Str("key", key).
101+ Str("file", envFile).
102+ Int("line", lineNum).
103+ Msg("Failed to resolve secret")
104+ return nil, fmt.Errorf("failed to resolve secret for %s in %s line %d: %w", key, envFile, lineNum, err)
105+ }
106+
107+ envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
108+ }
109+
110+ if err := scanner.Err(); err != nil {
111+ return nil, fmt.Errorf("error reading env file %s: %w", envFile, err)
112+ }
113+
114+ log.Info().
115+ Str("domain", domain).
116+ Int("count", len(envVars)).
117+ Msg("Loaded environment variables for route")
118+
119+ return envVars, nil
120+}
121+
122+func (l *Loader) resolveSecrets(value string) (string, error) {
123+ // Check if value contains secret syntax: ${provider:path}
124+ if !strings.Contains(value, "${") {
125+ return value, nil
126+ }
127+
128+ result := value
129+ for {
130+ start := strings.Index(result, "${")
131+ if start == -1 {
132+ break
133+ }
134+
135+ end := strings.Index(result[start:], "}")
136+ if end == -1 {
137+ return "", fmt.Errorf("unclosed secret syntax in value: %s", value)
138+ }
139+ end += start
140+
141+ secretRef := result[start+2 : end]
142+ parts := strings.SplitN(secretRef, ":", 2)
143+ if len(parts) != 2 {
144+ return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)
145+ }
146+
147+ providerName := parts[0]
148+ secretPath := parts[1]
149+
150+ provider, exists := l.secretProviders[providerName]
151+ if !exists {
152+ return "", fmt.Errorf("unknown secret provider: %s", providerName)
153+ }
154+
155+ secretValue, err := provider.GetSecret(secretPath)
156+ if err != nil {
157+ return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)
158+ }
159+
160+ // Replace the secret reference with the actual value
161+ result = result[:start] + secretValue + result[end+1:]
162+ }
163+
164+ return result, nil
165+}
166+
167+func (l *Loader) EnsureEnvDir() error {
168+ if err := os.MkdirAll(l.cfg.Env.Dir, 0700); err != nil {
169+ return fmt.Errorf("failed to create env directory %s: %w", l.cfg.Env.Dir, err)
170+ }
171+
172+ log.Debug().
173+ Str("env_dir", l.cfg.Env.Dir).
174+ Msg("Ensured env directory exists")
175+
176+ return nil
177+}
178+
179+func (l *Loader) CreateEnvFilesForRoutes() error {
180+ routes := l.cfg.GetRoutes()
181+ if len(routes) == 0 {
182+ log.Debug().Msg("No routes configured, skipping env file creation")
183+ return nil
184+ }
185+
186+ createdCount := 0
187+ for _, route := range routes {
188+ // Create domain-safe filename (same logic as LoadEnvForRoute)
189+ safeDomain := strings.ReplaceAll(route.Domain, ".", "_")
190+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
191+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
192+
193+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
194+
195+ // Check if file already exists
196+ if _, err := os.Stat(envFile); err == nil {
197+ log.Debug().
198+ Str("domain", route.Domain).
199+ Str("env_file", envFile).
200+ Msg("Env file already exists, skipping creation")
201+ continue
202+ } else if !os.IsNotExist(err) {
203+ log.Warn().
204+ Err(err).
205+ Str("env_file", envFile).
206+ Msg("Error checking env file, skipping creation")
207+ continue
208+ }
209+
210+ // Create empty env file with helpful comments
211+ content := fmt.Sprintf(`# Environment variables for route: %s
212+# Image: %s
213+
214+`, route.Domain, route.Image)
215+
216+ if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
217+ log.Error().
218+ Err(err).
219+ Str("domain", route.Domain).
220+ Str("env_file", envFile).
221+ Msg("Failed to create env file")
222+ continue
223+ }
224+
225+ createdCount++
226+ log.Info().
227+ Str("domain", route.Domain).
228+ Str("env_file", envFile).
229+ Msg("Created empty env file for route")
230+ }
231+
232+ if createdCount > 0 {
233+ log.Info().
234+ Int("created", createdCount).
235+ Int("total_routes", len(routes)).
236+ Str("env_dir", l.cfg.Env.Dir).
237+ Msg("Created env files for routes")
238+ }
239+
240+ return nil
241+}
242+
243+func (l *Loader) CreateEnvFileForRoute(domain, image string) error {
244+ // Create domain-safe filename (same logic as LoadEnvForRoute)
245+ safeDomain := strings.ReplaceAll(domain, ".", "_")
246+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
247+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
248+
249+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
250+
251+ // Check if file already exists
252+ if _, err := os.Stat(envFile); err == nil {
253+ log.Debug().
254+ Str("domain", domain).
255+ Str("env_file", envFile).
256+ Msg("Env file already exists, skipping creation")
257+ return nil
258+ } else if !os.IsNotExist(err) {
259+ return fmt.Errorf("error checking env file %s: %w", envFile, err)
260+ }
261+
262+ // Ensure directory exists
263+ if err := l.EnsureEnvDir(); err != nil {
264+ return err
265+ }
266+
267+ // Create empty env file with helpful comments
268+ content := fmt.Sprintf(`# Environment variables for route: %s
269+# Image: %s
270+
271+`, domain, image)
272+
273+ if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
274+ return fmt.Errorf("failed to create env file %s: %w", envFile, err)
275+ }
276+
277+ log.Info().
278+ Str("domain", domain).
279+ Str("env_file", envFile).
280+ Msg("Created empty env file for new route")
281+
282+ return nil
283+}
284+
285+func (l *Loader) UpdateConfig(cfg *config.Config) {
286+ l.cfg = cfg
287+}
288+
289+func (l *Loader) GetEnvFilePath(domain string) string {
290+ safeDomain := strings.ReplaceAll(domain, ".", "_")
291+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
292+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
293+ return filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
294+}⏎̸
A internal/env/loader_test.go +488 -0
@@ -0,0 +1,488 @@
1+package env
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "gordon/internal/config"
12+)
13+
14+func TestNewLoader(t *testing.T) {
15+ cfg := &config.Config{
16+ Env: config.EnvConfig{
17+ Dir: "/tmp/env",
18+ },
19+ }
20+
21+ loader := NewLoader(cfg)
22+
23+ assert.NotNil(t, loader)
24+ assert.Equal(t, cfg, loader.cfg)
25+ assert.NotNil(t, loader.secretProviders)
26+ assert.Empty(t, loader.secretProviders)
27+}
28+
29+func TestLoader_RegisterSecretProvider(t *testing.T) {
30+ cfg := &config.Config{}
31+ loader := NewLoader(cfg)
32+
33+ mockProvider := &MockSecretProvider{name: "test"}
34+ loader.RegisterSecretProvider("test", mockProvider)
35+
36+ assert.Contains(t, loader.secretProviders, "test")
37+ assert.Equal(t, mockProvider, loader.secretProviders["test"])
38+}
39+
40+func TestLoader_LoadEnvForRoute(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ domain string
44+ fileContent string
45+ expectedVars []string
46+ expectedError bool
47+ setupProvider bool
48+ providerSecret string
49+ }{
50+ {
51+ name: "no env file exists",
52+ domain: "app.example.com",
53+ expectedVars: []string{},
54+ },
55+ {
56+ name: "basic env file",
57+ domain: "app.example.com",
58+ fileContent: "KEY1=value1\nKEY2=value2\n",
59+ expectedVars: []string{
60+ "KEY1=value1",
61+ "KEY2=value2",
62+ },
63+ },
64+ {
65+ name: "env file with comments and empty lines",
66+ domain: "app.example.com",
67+ fileContent: `# This is a comment
68+KEY1=value1
69+
70+# Another comment
71+KEY2=value2
72+`,
73+ expectedVars: []string{
74+ "KEY1=value1",
75+ "KEY2=value2",
76+ },
77+ },
78+ {
79+ name: "env file with quoted values",
80+ domain: "app.example.com",
81+ fileContent: `KEY1="quoted value"` + "\n" + `KEY2='single quoted'` + "\n",
82+ expectedVars: []string{
83+ "KEY1=quoted value",
84+ "KEY2=single quoted",
85+ },
86+ },
87+ {
88+ name: "env file with secret resolution",
89+ domain: "app.example.com",
90+ fileContent: "SECRET_KEY=${test:secret/path}\nNORMAL_KEY=value\n",
91+ setupProvider: true,
92+ providerSecret: "secret-value",
93+ expectedVars: []string{
94+ "SECRET_KEY=secret-value",
95+ "NORMAL_KEY=value",
96+ },
97+ },
98+ {
99+ name: "env file with invalid format",
100+ domain: "app.example.com",
101+ fileContent: "INVALID_LINE_NO_EQUALS\nVALID_KEY=value\n",
102+ expectedVars: []string{
103+ "VALID_KEY=value",
104+ },
105+ },
106+ {
107+ name: "env file with unknown secret provider",
108+ domain: "app.example.com",
109+ fileContent: "SECRET_KEY=${unknown:secret/path}\n",
110+ expectedError: true,
111+ },
112+ {
113+ name: "env file with malformed secret syntax",
114+ domain: "app.example.com",
115+ fileContent: "SECRET_KEY=${malformed\n",
116+ expectedError: true,
117+ },
118+ {
119+ name: "env file with invalid secret format",
120+ domain: "app.example.com",
121+ fileContent: "SECRET_KEY=${invalid-format}\n",
122+ expectedError: true,
123+ },
124+ {
125+ name: "domain with special characters",
126+ domain: "sub.app.example.com:8080",
127+ fileContent: "KEY1=value1\n",
128+ expectedVars: []string{
129+ "KEY1=value1",
130+ },
131+ },
132+ }
133+
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ envDir := "/tmp/env"
137+ cfg := &config.Config{
138+ Env: config.EnvConfig{
139+ Dir: envDir,
140+ },
141+ }
142+
143+ loader := NewLoader(cfg)
144+
145+ // Setup secret provider if needed
146+ if tt.setupProvider {
147+ mockProvider := &MockSecretProvider{
148+ name: "test",
149+ secret: tt.providerSecret,
150+ }
151+ loader.RegisterSecretProvider("test", mockProvider)
152+ }
153+
154+ // Create env file if content is provided
155+ if tt.fileContent != "" {
156+ // Create safe domain name for file
157+ safeDomain := "app_example_com"
158+ if tt.domain == "sub.app.example.com:8080" {
159+ safeDomain = "sub_app_example_com_8080"
160+ }
161+
162+ // envFile would be: filepath.Join(envDir, safeDomain+".env")
163+
164+ // Create directory and file using real filesystem for this test
165+ realEnvDir := filepath.Join(os.TempDir(), "gordon-test-env")
166+ defer os.RemoveAll(realEnvDir)
167+
168+ err := os.MkdirAll(realEnvDir, 0755)
169+ require.NoError(t, err)
170+
171+ realEnvFile := filepath.Join(realEnvDir, safeDomain+".env")
172+ err = os.WriteFile(realEnvFile, []byte(tt.fileContent), 0644)
173+ require.NoError(t, err)
174+
175+ // Update config to use real directory
176+ cfg.Env.Dir = realEnvDir
177+ }
178+
179+ // Execute test
180+ vars, err := loader.LoadEnvForRoute(tt.domain)
181+
182+ // Verify results
183+ if tt.expectedError {
184+ assert.Error(t, err)
185+ } else {
186+ assert.NoError(t, err)
187+ assert.Equal(t, tt.expectedVars, vars)
188+ }
189+ })
190+ }
191+}
192+
193+func TestLoader_ResolveSecrets(t *testing.T) {
194+ cfg := &config.Config{}
195+ loader := NewLoader(cfg)
196+
197+ // Register mock secret provider
198+ mockProvider := &MockSecretProvider{
199+ name: "test",
200+ secret: "secret-value",
201+ }
202+ loader.RegisterSecretProvider("test", mockProvider)
203+
204+ tests := []struct {
205+ name string
206+ input string
207+ expected string
208+ expectedError bool
209+ }{
210+ {
211+ name: "no secret syntax",
212+ input: "plain-value",
213+ expected: "plain-value",
214+ },
215+ {
216+ name: "single secret replacement",
217+ input: "${test:secret/path}",
218+ expected: "secret-value",
219+ },
220+ {
221+ name: "secret in middle of string",
222+ input: "prefix-${test:secret/path}-suffix",
223+ expected: "prefix-secret-value-suffix",
224+ },
225+ {
226+ name: "multiple secrets",
227+ input: "${test:secret1}-${test:secret2}",
228+ expected: "secret-value-secret-value",
229+ },
230+ {
231+ name: "unclosed secret syntax",
232+ input: "${test:secret",
233+ expectedError: true,
234+ },
235+ {
236+ name: "invalid secret format",
237+ input: "${invalid-format}",
238+ expectedError: true,
239+ },
240+ {
241+ name: "unknown provider",
242+ input: "${unknown:secret/path}",
243+ expectedError: true,
244+ },
245+ }
246+
247+ for _, tt := range tests {
248+ t.Run(tt.name, func(t *testing.T) {
249+ result, err := loader.resolveSecrets(tt.input)
250+
251+ if tt.expectedError {
252+ assert.Error(t, err)
253+ } else {
254+ assert.NoError(t, err)
255+ assert.Equal(t, tt.expected, result)
256+ }
257+ })
258+ }
259+}
260+
261+func TestLoader_EnsureEnvDir(t *testing.T) {
262+ tempDir := os.TempDir()
263+ envDir := filepath.Join(tempDir, "gordon-test-env-ensure")
264+ defer os.RemoveAll(envDir)
265+
266+ cfg := &config.Config{
267+ Env: config.EnvConfig{
268+ Dir: envDir,
269+ },
270+ }
271+
272+ loader := NewLoader(cfg)
273+
274+ // Directory should not exist initially
275+ _, err := os.Stat(envDir)
276+ assert.True(t, os.IsNotExist(err))
277+
278+ // Ensure directory
279+ err = loader.EnsureEnvDir()
280+ assert.NoError(t, err)
281+
282+ // Directory should now exist
283+ info, err := os.Stat(envDir)
284+ assert.NoError(t, err)
285+ assert.True(t, info.IsDir())
286+ assert.Equal(t, os.FileMode(0700), info.Mode().Perm())
287+}
288+
289+func TestLoader_CreateEnvFilesForRoutes(t *testing.T) {
290+ tempDir := os.TempDir()
291+ envDir := filepath.Join(tempDir, "gordon-test-env-create")
292+ defer os.RemoveAll(envDir)
293+
294+ cfg := &config.Config{
295+ Env: config.EnvConfig{
296+ Dir: envDir,
297+ },
298+ Routes: map[string]string{
299+ "app1.example.com": "nginx:latest",
300+ "app2.example.com": "redis:alpine",
301+ "api.example.com:8080": "myapi:v1",
302+ },
303+ }
304+
305+ loader := NewLoader(cfg)
306+
307+ // Ensure the env directory exists
308+ err := os.MkdirAll(envDir, 0755)
309+ require.NoError(t, err)
310+
311+ err = loader.CreateEnvFilesForRoutes()
312+ assert.NoError(t, err)
313+
314+ // Check that files were created
315+ expectedFiles := map[string]struct{domain, image string}{
316+ "app1_example_com.env": {domain: "app1.example.com", image: "nginx:latest"},
317+ "app2_example_com.env": {domain: "app2.example.com", image: "redis:alpine"},
318+ "api_example_com_8080.env": {domain: "api.example.com:8080", image: "myapi:v1"},
319+ }
320+
321+ for fileName, expected := range expectedFiles {
322+ filePath := filepath.Join(envDir, fileName)
323+ assert.FileExists(t, filePath)
324+
325+ // Check file content
326+ content, err := os.ReadFile(filePath)
327+ assert.NoError(t, err)
328+
329+ expectedContent := "# Environment variables for route: " + expected.domain + "\n# Image: " + expected.image + "\n\n"
330+ assert.Contains(t, string(content), expectedContent)
331+
332+ // Check file permissions
333+ info, err := os.Stat(filePath)
334+ assert.NoError(t, err)
335+ assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
336+ }
337+}
338+
339+func TestLoader_CreateEnvFilesForRoutes_NoRoutes(t *testing.T) {
340+ cfg := &config.Config{
341+ Env: config.EnvConfig{
342+ Dir: "/tmp/env",
343+ },
344+ Routes: map[string]string{},
345+ }
346+
347+ loader := NewLoader(cfg)
348+
349+ err := loader.CreateEnvFilesForRoutes()
350+ assert.NoError(t, err)
351+}
352+
353+func TestLoader_CreateEnvFilesForRoutes_ExistingFile(t *testing.T) {
354+ tempDir := os.TempDir()
355+ envDir := filepath.Join(tempDir, "gordon-test-env-existing")
356+ defer os.RemoveAll(envDir)
357+
358+ err := os.MkdirAll(envDir, 0755)
359+ require.NoError(t, err)
360+
361+ // Create existing file
362+ existingFile := filepath.Join(envDir, "app_example_com.env")
363+ err = os.WriteFile(existingFile, []byte("EXISTING=value\n"), 0644)
364+ require.NoError(t, err)
365+
366+ cfg := &config.Config{
367+ Env: config.EnvConfig{
368+ Dir: envDir,
369+ },
370+ Routes: map[string]string{
371+ "app.example.com": "nginx:latest",
372+ },
373+ }
374+
375+ loader := NewLoader(cfg)
376+
377+ err = loader.CreateEnvFilesForRoutes()
378+ assert.NoError(t, err)
379+
380+ // File should still have original content
381+ content, err := os.ReadFile(existingFile)
382+ assert.NoError(t, err)
383+ assert.Equal(t, "EXISTING=value\n", string(content))
384+}
385+
386+func TestLoader_CreateEnvFileForRoute(t *testing.T) {
387+ tempDir := os.TempDir()
388+ envDir := filepath.Join(tempDir, "gordon-test-env-single")
389+ defer os.RemoveAll(envDir)
390+
391+ cfg := &config.Config{
392+ Env: config.EnvConfig{
393+ Dir: envDir,
394+ },
395+ }
396+
397+ loader := NewLoader(cfg)
398+
399+ domain := "test.example.com"
400+ image := "test:latest"
401+
402+ err := loader.CreateEnvFileForRoute(domain, image)
403+ assert.NoError(t, err)
404+
405+ // Check file was created
406+ expectedFile := filepath.Join(envDir, "test_example_com.env")
407+ assert.FileExists(t, expectedFile)
408+
409+ // Check content
410+ content, err := os.ReadFile(expectedFile)
411+ assert.NoError(t, err)
412+
413+ expectedContent := "# Environment variables for route: test.example.com\n# Image: test:latest\n\n"
414+ assert.Equal(t, expectedContent, string(content))
415+
416+ // Test creating the same file again (should skip)
417+ err = loader.CreateEnvFileForRoute(domain, image)
418+ assert.NoError(t, err)
419+
420+ // Content should be unchanged
421+ content2, err := os.ReadFile(expectedFile)
422+ assert.NoError(t, err)
423+ assert.Equal(t, string(content), string(content2))
424+}
425+
426+func TestLoader_UpdateConfig(t *testing.T) {
427+ cfg1 := &config.Config{Env: config.EnvConfig{Dir: "/tmp/env1"}}
428+ cfg2 := &config.Config{Env: config.EnvConfig{Dir: "/tmp/env2"}}
429+
430+ loader := NewLoader(cfg1)
431+ assert.Equal(t, cfg1, loader.cfg)
432+
433+ loader.UpdateConfig(cfg2)
434+ assert.Equal(t, cfg2, loader.cfg)
435+}
436+
437+func TestLoader_GetEnvFilePath(t *testing.T) {
438+ cfg := &config.Config{
439+ Env: config.EnvConfig{
440+ Dir: "/tmp/env",
441+ },
442+ }
443+
444+ loader := NewLoader(cfg)
445+
446+ tests := []struct {
447+ domain string
448+ expected string
449+ }{
450+ {
451+ domain: "app.example.com",
452+ expected: "/tmp/env/app_example_com.env",
453+ },
454+ {
455+ domain: "api.example.com:8080",
456+ expected: "/tmp/env/api_example_com_8080.env",
457+ },
458+ {
459+ domain: "sub.domain.example.com/path",
460+ expected: "/tmp/env/sub_domain_example_com_path.env",
461+ },
462+ }
463+
464+ for _, tt := range tests {
465+ t.Run(tt.domain, func(t *testing.T) {
466+ result := loader.GetEnvFilePath(tt.domain)
467+ assert.Equal(t, tt.expected, result)
468+ })
469+ }
470+}
471+
472+// MockSecretProvider is a mock implementation of SecretProvider for testing
473+type MockSecretProvider struct {
474+ name string
475+ secret string
476+ err error
477+}
478+
479+func (m *MockSecretProvider) Name() string {
480+ return m.name
481+}
482+
483+func (m *MockSecretProvider) GetSecret(path string) (string, error) {
484+ if m.err != nil {
485+ return "", m.err
486+ }
487+ return m.secret, nil
488+}⏎̸
A internal/env/providers.go +104 -0
@@ -0,0 +1,104 @@
1+package env
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "strings"
8+ "time"
9+
10+ "github.com/rs/zerolog/log"
11+)
12+
13+type SecretProvider interface {
14+ GetSecret(path string) (string, error)
15+ Name() string
16+}
17+
18+type PassProvider struct {
19+ timeout time.Duration
20+}
21+
22+func NewPassProvider() *PassProvider {
23+ return &PassProvider{
24+ timeout: 10 * time.Second,
25+ }
26+}
27+
28+func (p *PassProvider) Name() string {
29+ return "pass"
30+}
31+
32+func (p *PassProvider) GetSecret(path string) (string, error) {
33+ ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
34+ defer cancel()
35+
36+ cmd := exec.CommandContext(ctx, "pass", "show", path)
37+ output, err := cmd.Output()
38+ if err != nil {
39+ if exitError, ok := err.(*exec.ExitError); ok {
40+ return "", fmt.Errorf("pass command failed: %s", string(exitError.Stderr))
41+ }
42+ return "", fmt.Errorf("failed to execute pass command: %w", err)
43+ }
44+
45+ secret := strings.TrimSpace(string(output))
46+ if secret == "" {
47+ return "", fmt.Errorf("empty secret returned from pass for path: %s", path)
48+ }
49+
50+ log.Debug().
51+ Str("path", path).
52+ Msg("Successfully retrieved secret from pass")
53+
54+ return secret, nil
55+}
56+
57+type SopsProvider struct {
58+ timeout time.Duration
59+}
60+
61+func NewSopsProvider() *SopsProvider {
62+ return &SopsProvider{
63+ timeout: 10 * time.Second,
64+ }
65+}
66+
67+func (s *SopsProvider) Name() string {
68+ return "sops"
69+}
70+
71+func (s *SopsProvider) GetSecret(path string) (string, error) {
72+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
73+ defer cancel()
74+
75+ // Extract key from path format: file.yaml:key.nested.path
76+ parts := strings.SplitN(path, ":", 2)
77+ if len(parts) != 2 {
78+ return "", fmt.Errorf("sops path must be in format 'file:key', got: %s", path)
79+ }
80+
81+ filePath := parts[0]
82+ keyPath := parts[1]
83+
84+ // Use sops -d --extract to get specific key
85+ cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), filePath)
86+ output, err := cmd.Output()
87+ if err != nil {
88+ if exitError, ok := err.(*exec.ExitError); ok {
89+ return "", fmt.Errorf("sops command failed: %s", string(exitError.Stderr))
90+ }
91+ return "", fmt.Errorf("failed to execute sops command: %w", err)
92+ }
93+
94+ secret := strings.TrimSpace(string(output))
95+ if secret == "" {
96+ return "", fmt.Errorf("empty secret returned from sops for path: %s", path)
97+ }
98+
99+ log.Debug().
100+ Str("path", path).
101+ Msg("Successfully retrieved secret from sops")
102+
103+ return secret, nil
104+}⏎̸
A internal/env/providers_test.go +248 -0
@@ -0,0 +1,248 @@
1+package env
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestNewPassProvider(t *testing.T) {
13+ provider := NewPassProvider()
14+ assert.NotNil(t, provider)
15+ assert.Equal(t, "pass", provider.Name())
16+ assert.Equal(t, 10*time.Second, provider.timeout)
17+}
18+
19+func TestPassProvider_Name(t *testing.T) {
20+ provider := NewPassProvider()
21+ assert.Equal(t, "pass", provider.Name())
22+}
23+
24+func TestPassProvider_GetSecret(t *testing.T) {
25+ provider := NewPassProvider()
26+
27+ tests := []struct {
28+ name string
29+ path string
30+ mockCommand bool
31+ mockOutput string
32+ mockError bool
33+ expectedSecret string
34+ expectedError bool
35+ }{
36+ {
37+ name: "successful secret retrieval",
38+ path: "test/secret",
39+ mockCommand: true,
40+ mockOutput: "secret-value\n",
41+ expectedSecret: "secret-value",
42+ },
43+ {
44+ name: "command execution fails",
45+ path: "test/nonexistent",
46+ mockCommand: true,
47+ mockError: true,
48+ expectedError: true,
49+ },
50+ {
51+ name: "empty secret returned",
52+ path: "test/empty",
53+ mockCommand: true,
54+ mockOutput: "\n",
55+ expectedError: true,
56+ },
57+ }
58+
59+ for _, tt := range tests {
60+ t.Run(tt.name, func(t *testing.T) {
61+ // Check if pass command is available
62+ _, err := exec.LookPath("pass")
63+ if err != nil {
64+ t.Skip("pass command not available, skipping test")
65+ }
66+
67+ // Since we can't easily mock exec.Command in this context,
68+ // we'll test the interface and structure, but skip actual execution
69+ // unless we have a real pass installation
70+
71+ // Test the provider structure
72+ assert.Equal(t, "pass", provider.Name())
73+ assert.NotZero(t, provider.timeout)
74+
75+ // For actual testing, we would need a more sophisticated mocking approach
76+ // or integration tests with a real pass setup
77+ if !tt.mockCommand {
78+ secret, err := provider.GetSecret(tt.path)
79+ if tt.expectedError {
80+ assert.Error(t, err)
81+ } else {
82+ assert.NoError(t, err)
83+ assert.Equal(t, tt.expectedSecret, secret)
84+ }
85+ }
86+ })
87+ }
88+}
89+
90+func TestNewSopsProvider(t *testing.T) {
91+ provider := NewSopsProvider()
92+ assert.NotNil(t, provider)
93+ assert.Equal(t, "sops", provider.Name())
94+ assert.Equal(t, 10*time.Second, provider.timeout)
95+}
96+
97+func TestSopsProvider_Name(t *testing.T) {
98+ provider := NewSopsProvider()
99+ assert.Equal(t, "sops", provider.Name())
100+}
101+
102+func TestSopsProvider_GetSecret(t *testing.T) {
103+ provider := NewSopsProvider()
104+
105+ tests := []struct {
106+ name string
107+ path string
108+ expectedSecret string
109+ expectedError bool
110+ }{
111+ {
112+ name: "invalid path format - no colon",
113+ path: "invalid-path",
114+ expectedError: true,
115+ },
116+ {
117+ name: "invalid path format - empty key",
118+ path: "file.yaml:",
119+ expectedError: true,
120+ },
121+ {
122+ name: "invalid path format - empty file",
123+ path: ":key.path",
124+ expectedError: true,
125+ },
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ // Check if sops command is available
131+ _, err := exec.LookPath("sops")
132+ if err != nil {
133+ t.Skip("sops command not available, skipping test")
134+ }
135+
136+ // Test the provider structure
137+ assert.Equal(t, "sops", provider.Name())
138+ assert.NotZero(t, provider.timeout)
139+
140+ // Test path validation
141+ secret, err := provider.GetSecret(tt.path)
142+ if tt.expectedError {
143+ assert.Error(t, err)
144+ assert.Empty(t, secret)
145+ } else {
146+ // For successful cases, we'd need actual sops files
147+ // which is complex to set up in unit tests
148+ // This would be better tested in integration tests
149+ }
150+ })
151+ }
152+}
153+
154+func TestSopsProvider_GetSecret_ValidFormat(t *testing.T) {
155+ provider := NewSopsProvider()
156+
157+ // Check if sops command is available
158+ _, err := exec.LookPath("sops")
159+ if err != nil {
160+ t.Skip("sops command not available, skipping test")
161+ }
162+
163+ // Test with valid format but non-existent file (should fail gracefully)
164+ _, err = provider.GetSecret("nonexistent.yaml:some.key")
165+ assert.Error(t, err)
166+ assert.Contains(t, err.Error(), "sops command failed")
167+}
168+
169+func TestSecretProvider_Interface(t *testing.T) {
170+ // Test that our providers implement the SecretProvider interface
171+ var _ SecretProvider = NewPassProvider()
172+ var _ SecretProvider = NewSopsProvider()
173+}
174+
175+func TestProviders_Timeout(t *testing.T) {
176+ // Test that providers have reasonable timeouts
177+ passProvider := NewPassProvider()
178+ sopsProvider := NewSopsProvider()
179+
180+ assert.Equal(t, 10*time.Second, passProvider.timeout)
181+ assert.Equal(t, 10*time.Second, sopsProvider.timeout)
182+}
183+
184+func TestProviders_ContextCancellation(t *testing.T) {
185+ // Test that providers respect context cancellation
186+ passProvider := NewPassProvider()
187+ sopsProvider := NewSopsProvider()
188+
189+ // Create a context that's already cancelled
190+ _, cancel := context.WithCancel(context.Background())
191+ cancel()
192+
193+ // Providers should handle cancelled contexts gracefully
194+ // (This is more of a behavioral test - the actual implementation
195+ // uses exec.CommandContext which should respect cancellation)
196+ assert.NotNil(t, passProvider)
197+ assert.NotNil(t, sopsProvider)
198+}
199+
200+// Mock implementations for more controlled testing
201+type MockPassProvider struct {
202+ output string
203+ err error
204+}
205+
206+func (m *MockPassProvider) Name() string {
207+ return "pass"
208+}
209+
210+func (m *MockPassProvider) GetSecret(path string) (string, error) {
211+ if m.err != nil {
212+ return "", m.err
213+ }
214+ return m.output, nil
215+}
216+
217+type MockSopsProvider struct {
218+ output string
219+ err error
220+}
221+
222+func (m *MockSopsProvider) Name() string {
223+ return "sops"
224+}
225+
226+func (m *MockSopsProvider) GetSecret(path string) (string, error) {
227+ if m.err != nil {
228+ return "", m.err
229+ }
230+ return m.output, nil
231+}
232+
233+func TestMockProviders(t *testing.T) {
234+ // Test our mock implementations
235+ mockPass := &MockPassProvider{output: "test-secret", err: nil}
236+ mockSops := &MockSopsProvider{output: "sops-secret", err: nil}
237+
238+ assert.Equal(t, "pass", mockPass.Name())
239+ assert.Equal(t, "sops", mockSops.Name())
240+
241+ secret, err := mockPass.GetSecret("test/path")
242+ assert.NoError(t, err)
243+ assert.Equal(t, "test-secret", secret)
244+
245+ secret, err = mockSops.GetSecret("file.yaml:key")
246+ assert.NoError(t, err)
247+ assert.Equal(t, "sops-secret", secret)
248+}⏎̸
A internal/events/bus.go +169 -0
@@ -0,0 +1,169 @@
1+package events
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "time"
8+
9+ "github.com/google/uuid"
10+ "github.com/rs/zerolog/log"
11+)
12+
13+type InMemoryEventBus struct {
14+ handlers []EventHandler
15+ eventChan chan Event
16+ done chan struct{}
17+ mu sync.RWMutex
18+ ctx context.Context
19+ cancel context.CancelFunc
20+ bufferSize int
21+}
22+
23+func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus {
24+ if bufferSize <= 0 {
25+ bufferSize = 100
26+ }
27+
28+ ctx, cancel := context.WithCancel(context.Background())
29+
30+ return &InMemoryEventBus{
31+ handlers: make([]EventHandler, 0),
32+ eventChan: make(chan Event, bufferSize),
33+ done: make(chan struct{}),
34+ ctx: ctx,
35+ cancel: cancel,
36+ bufferSize: bufferSize,
37+ }
38+}
39+
40+func (bus *InMemoryEventBus) Publish(eventType EventType, payload interface{}) error {
41+ event := Event{
42+ ID: uuid.New().String(),
43+ Type: eventType,
44+ Timestamp: time.Now(),
45+ Data: payload,
46+ }
47+
48+ // Add specific fields for known event types
49+ switch p := payload.(type) {
50+ case ImagePushedPayload:
51+ event.ImageName = p.Name
52+ event.Tag = p.Reference
53+ }
54+
55+ select {
56+ case bus.eventChan <- event:
57+ log.Debug().
58+ Str("event_id", event.ID).
59+ Str("event_type", string(event.Type)).
60+ Str("image_name", event.ImageName).
61+ Str("tag", event.Tag).
62+ Msg("Event published")
63+ return nil
64+ case <-bus.ctx.Done():
65+ return fmt.Errorf("event bus is stopped")
66+ default:
67+ return fmt.Errorf("event channel is full, dropping event %s", event.ID)
68+ }
69+}
70+
71+func (bus *InMemoryEventBus) Subscribe(handler EventHandler) error {
72+ bus.mu.Lock()
73+ defer bus.mu.Unlock()
74+
75+ bus.handlers = append(bus.handlers, handler)
76+ log.Debug().
77+ Str("handler_type", fmt.Sprintf("%T", handler)).
78+ Int("total_handlers", len(bus.handlers)).
79+ Msg("Event handler subscribed")
80+
81+ return nil
82+}
83+
84+func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error {
85+ bus.mu.Lock()
86+ defer bus.mu.Unlock()
87+
88+ for i, h := range bus.handlers {
89+ if h == handler {
90+ bus.handlers = append(bus.handlers[:i], bus.handlers[i+1:]...)
91+ log.Debug().
92+ Str("handler_type", fmt.Sprintf("%T", handler)).
93+ Int("total_handlers", len(bus.handlers)).
94+ Msg("Event handler unsubscribed")
95+ return nil
96+ }
97+ }
98+
99+ return fmt.Errorf("handler not found")
100+}
101+
102+func (bus *InMemoryEventBus) Start() error {
103+ log.Info().
104+ Int("buffer_size", bus.bufferSize).
105+ Msg("Starting event bus")
106+
107+ go bus.processEvents()
108+ return nil
109+}
110+
111+func (bus *InMemoryEventBus) Stop() error {
112+ log.Info().Msg("Stopping event bus")
113+
114+ bus.cancel()
115+
116+ select {
117+ case <-bus.done:
118+ log.Info().Msg("Event bus stopped")
119+ return nil
120+ case <-time.After(5 * time.Second):
121+ log.Warn().Msg("Event bus stop timeout")
122+ return fmt.Errorf("timeout waiting for event bus to stop")
123+ }
124+}
125+
126+func (bus *InMemoryEventBus) processEvents() {
127+ defer close(bus.done)
128+
129+ for {
130+ select {
131+ case event := <-bus.eventChan:
132+ bus.handleEvent(event)
133+ case <-bus.ctx.Done():
134+ log.Debug().Msg("Event bus processing stopped")
135+ return
136+ }
137+ }
138+}
139+
140+func (bus *InMemoryEventBus) handleEvent(event Event) {
141+ bus.mu.RLock()
142+ handlers := make([]EventHandler, len(bus.handlers))
143+ copy(handlers, bus.handlers)
144+ bus.mu.RUnlock()
145+
146+ for _, handler := range handlers {
147+ if handler.CanHandle(event.Type) {
148+ go func(h EventHandler) {
149+ start := time.Now()
150+
151+ if err := h.Handle(event); err != nil {
152+ log.Error().
153+ Err(err).
154+ Str("event_id", event.ID).
155+ Str("event_type", string(event.Type)).
156+ Str("handler_type", fmt.Sprintf("%T", h)).
157+ Msg("Error handling event")
158+ } else {
159+ log.Debug().
160+ Str("event_id", event.ID).
161+ Str("event_type", string(event.Type)).
162+ Str("handler_type", fmt.Sprintf("%T", h)).
163+ Dur("duration", time.Since(start)).
164+ Msg("Event handled successfully")
165+ }
166+ }(handler)
167+ }
168+ }
169+}
A internal/events/bus_test.go +337 -0
@@ -0,0 +1,337 @@
1+package events
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// MockEventHandler is a mock implementation of EventHandler
14+type MockEventHandler struct {
15+ mock.Mock
16+ mu sync.Mutex
17+ events []Event
18+ canHandle bool
19+}
20+
21+func (m *MockEventHandler) Handle(event Event) error {
22+ m.mu.Lock()
23+ defer m.mu.Unlock()
24+ m.events = append(m.events, event)
25+ args := m.Called(event)
26+ return args.Error(0)
27+}
28+
29+func (m *MockEventHandler) CanHandle(eventType EventType) bool {
30+ args := m.Called(eventType)
31+ return args.Bool(0)
32+}
33+
34+func (m *MockEventHandler) GetReceivedEvents() []Event {
35+ m.mu.Lock()
36+ defer m.mu.Unlock()
37+ return append([]Event{}, m.events...)
38+}
39+
40+func TestInMemoryEventBus_NewInMemoryEventBus(t *testing.T) {
41+ bus := NewInMemoryEventBus(50)
42+
43+ assert.NotNil(t, bus)
44+ assert.Equal(t, 50, cap(bus.eventChan))
45+ assert.Empty(t, bus.handlers)
46+ assert.Equal(t, 50, bus.bufferSize)
47+}
48+
49+func TestInMemoryEventBus_Subscribe(t *testing.T) {
50+ bus := NewInMemoryEventBus(10)
51+ handler := &MockEventHandler{}
52+
53+ bus.Subscribe(handler)
54+
55+ assert.Len(t, bus.handlers, 1)
56+ assert.Contains(t, bus.handlers, handler)
57+}
58+
59+func TestInMemoryEventBus_Subscribe_DuplicateHandler(t *testing.T) {
60+ bus := NewInMemoryEventBus(10)
61+ handler := &MockEventHandler{}
62+
63+ bus.Subscribe(handler)
64+ bus.Subscribe(handler) // Subscribe same handler twice
65+
66+ // Implementation allows duplicates (handlers are appended to slice)
67+ assert.Len(t, bus.handlers, 2)
68+}
69+
70+func TestInMemoryEventBus_Unsubscribe(t *testing.T) {
71+ bus := NewInMemoryEventBus(10)
72+ handler1 := &MockEventHandler{}
73+ handler2 := &MockEventHandler{}
74+
75+ bus.Subscribe(handler1)
76+ bus.Subscribe(handler2)
77+ assert.Len(t, bus.handlers, 2)
78+
79+ err := bus.Unsubscribe(handler1)
80+
81+ assert.NoError(t, err)
82+ assert.Len(t, bus.handlers, 1)
83+ // Check that handler2 is still in the list (compare by position)
84+ assert.Equal(t, handler2, bus.handlers[0])
85+}
86+
87+func TestInMemoryEventBus_Unsubscribe_NonExistentHandler(t *testing.T) {
88+ bus := NewInMemoryEventBus(10)
89+ handler1 := &MockEventHandler{}
90+ handler2 := &MockEventHandler{}
91+
92+ bus.Subscribe(handler1)
93+ err := bus.Unsubscribe(handler2) // Try to unsubscribe handler that wasn't subscribed
94+
95+ assert.Error(t, err)
96+ assert.Contains(t, err.Error(), "handler not found")
97+ assert.Len(t, bus.handlers, 1)
98+ assert.Equal(t, handler1, bus.handlers[0])
99+}
100+
101+func TestInMemoryEventBus_StartStop(t *testing.T) {
102+ bus := NewInMemoryEventBus(10)
103+
104+ // Start the bus
105+ err := bus.Start()
106+ assert.NoError(t, err)
107+
108+ // Stop the bus
109+ err = bus.Stop()
110+ assert.NoError(t, err)
111+}
112+
113+func TestInMemoryEventBus_Publish_Running(t *testing.T) {
114+ bus := NewInMemoryEventBus(10)
115+
116+ handler := &MockEventHandler{}
117+ handler.On("CanHandle", ImagePushed).Return(true)
118+ handler.On("Handle", mock.AnythingOfType("Event")).Return(nil)
119+
120+ bus.Subscribe(handler)
121+ bus.Start()
122+ defer bus.Stop()
123+
124+ payload := ImagePushedPayload{
125+ Name: "nginx:latest",
126+ Reference: "latest",
127+ }
128+
129+ bus.Publish(ImagePushed, payload)
130+
131+ // Give some time for async processing
132+ time.Sleep(50 * time.Millisecond)
133+
134+ handler.AssertExpectations(t)
135+ events := handler.GetReceivedEvents()
136+ require.Len(t, events, 1)
137+ assert.Equal(t, ImagePushed, events[0].Type)
138+ assert.Equal(t, "nginx:latest", events[0].ImageName)
139+ assert.Equal(t, "latest", events[0].Tag)
140+}
141+
142+func TestInMemoryEventBus_Publish_NotRunning(t *testing.T) {
143+ bus := NewInMemoryEventBus(10)
144+
145+ handler := &MockEventHandler{}
146+ bus.Subscribe(handler)
147+
148+ payload := ImagePushedPayload{
149+ Name: "nginx:latest",
150+ Reference: "latest",
151+ }
152+
153+ // Publish when bus is not running
154+ bus.Publish(ImagePushed, payload)
155+
156+ // Give some time in case it processes (it shouldn't)
157+ time.Sleep(10 * time.Millisecond)
158+
159+ // Handler should not have received any events
160+ events := handler.GetReceivedEvents()
161+ assert.Empty(t, events)
162+}
163+
164+func TestInMemoryEventBus_HandlerFiltering(t *testing.T) {
165+ bus := NewInMemoryEventBus(10)
166+
167+ // Handler that only handles ImagePushed events
168+ imagePushedHandler := &MockEventHandler{}
169+ imagePushedHandler.On("CanHandle", ImagePushed).Return(true)
170+ imagePushedHandler.On("CanHandle", ConfigReload).Return(false)
171+ imagePushedHandler.On("Handle", mock.AnythingOfType("Event")).Return(nil)
172+
173+ // Handler that only handles ConfigReload events
174+ configReloadHandler := &MockEventHandler{}
175+ configReloadHandler.On("CanHandle", ImagePushed).Return(false)
176+ configReloadHandler.On("CanHandle", ConfigReload).Return(true)
177+ configReloadHandler.On("Handle", mock.AnythingOfType("Event")).Return(nil)
178+
179+ bus.Subscribe(imagePushedHandler)
180+ bus.Subscribe(configReloadHandler)
181+ bus.Start()
182+ defer bus.Stop()
183+
184+ // Publish ImagePushed event
185+ bus.Publish(ImagePushed, ImagePushedPayload{
186+ Name: "nginx:latest",
187+ Reference: "latest",
188+ })
189+
190+ // Publish ConfigReload event
191+ bus.Publish(ConfigReload, nil)
192+
193+ // Give time for processing
194+ time.Sleep(20 * time.Millisecond)
195+
196+ // Check that each handler only received appropriate events
197+ imagePushedEvents := imagePushedHandler.GetReceivedEvents()
198+ configReloadEvents := configReloadHandler.GetReceivedEvents()
199+
200+ require.Len(t, imagePushedEvents, 1)
201+ assert.Equal(t, ImagePushed, imagePushedEvents[0].Type)
202+
203+ require.Len(t, configReloadEvents, 1)
204+ assert.Equal(t, ConfigReload, configReloadEvents[0].Type)
205+
206+ imagePushedHandler.AssertExpectations(t)
207+ configReloadHandler.AssertExpectations(t)
208+}
209+
210+func TestInMemoryEventBus_HandlerError(t *testing.T) {
211+ bus := NewInMemoryEventBus(10)
212+
213+ // Handler that returns an error
214+ errorHandler := &MockEventHandler{}
215+ errorHandler.On("CanHandle", ImagePushed).Return(true)
216+ errorHandler.On("Handle", mock.AnythingOfType("Event")).Return(assert.AnError)
217+
218+ // Handler that succeeds
219+ successHandler := &MockEventHandler{}
220+ successHandler.On("CanHandle", ImagePushed).Return(true)
221+ successHandler.On("Handle", mock.AnythingOfType("Event")).Return(nil)
222+
223+ bus.Subscribe(errorHandler)
224+ bus.Subscribe(successHandler)
225+ bus.Start()
226+ defer bus.Stop()
227+
228+ payload := ImagePushedPayload{
229+ Name: "nginx:latest",
230+ Reference: "latest",
231+ }
232+
233+ bus.Publish(ImagePushed, payload)
234+
235+ // Give time for processing
236+ time.Sleep(20 * time.Millisecond)
237+
238+ // Both handlers should have been called despite error
239+ errorHandler.AssertExpectations(t)
240+ successHandler.AssertExpectations(t)
241+
242+ // Both should have received the event
243+ errorEvents := errorHandler.GetReceivedEvents()
244+ successEvents := successHandler.GetReceivedEvents()
245+
246+ assert.Len(t, errorEvents, 1)
247+ assert.Len(t, successEvents, 1)
248+}
249+
250+func TestInMemoryEventBus_ContextCancellation(t *testing.T) {
251+ bus := NewInMemoryEventBus(10)
252+
253+ handler := &MockEventHandler{}
254+ handler.On("CanHandle", ImagePushed).Return(true)
255+ handler.On("Handle", mock.AnythingOfType("Event")).Return(nil)
256+
257+ bus.Subscribe(handler)
258+ bus.Start()
259+
260+ // Stop the bus to trigger shutdown
261+ err := bus.Stop()
262+ assert.NoError(t, err)
263+}
264+
265+func TestInMemoryEventBus_FullChannel(t *testing.T) {
266+ // Create bus with very small buffer
267+ bus := NewInMemoryEventBus(1)
268+
269+ handler := &MockEventHandler{}
270+ handler.On("CanHandle", ImagePushed).Return(true)
271+ // Make handler slow to process events
272+ handler.On("Handle", mock.AnythingOfType("Event")).Return(nil).Run(func(args mock.Arguments) {
273+ time.Sleep(50 * time.Millisecond)
274+ })
275+
276+ bus.Subscribe(handler)
277+ bus.Start()
278+ defer bus.Stop()
279+
280+ payload := ImagePushedPayload{
281+ Name: "nginx:latest",
282+ Reference: "latest",
283+ }
284+
285+ // Publish multiple events quickly to potentially fill the channel
286+ for i := 0; i < 5; i++ {
287+ bus.Publish(ImagePushed, payload)
288+ }
289+
290+ // Give time for processing
291+ time.Sleep(200 * time.Millisecond)
292+
293+ // Should still work, events might be dropped but no crash
294+ events := handler.GetReceivedEvents()
295+ assert.True(t, len(events) >= 1) // At least some events should be processed
296+}
297+
298+func TestInMemoryEventBus_ConcurrentPublish(t *testing.T) {
299+ bus := NewInMemoryEventBus(100)
300+
301+ handler := &MockEventHandler{}
302+ handler.On("CanHandle", ImagePushed).Return(true)
303+ handler.On("Handle", mock.AnythingOfType("Event")).Return(nil)
304+
305+ bus.Subscribe(handler)
306+ bus.Start()
307+ defer bus.Stop()
308+
309+ payload := ImagePushedPayload{
310+ Name: "nginx:latest",
311+ Reference: "latest",
312+ }
313+
314+ // Publish events concurrently from multiple goroutines
315+ numGoroutines := 10
316+ eventsPerGoroutine := 5
317+ var wg sync.WaitGroup
318+
319+ for i := 0; i < numGoroutines; i++ {
320+ wg.Add(1)
321+ go func() {
322+ defer wg.Done()
323+ for j := 0; j < eventsPerGoroutine; j++ {
324+ bus.Publish(ImagePushed, payload)
325+ }
326+ }()
327+ }
328+
329+ wg.Wait()
330+
331+ // Give time for processing
332+ time.Sleep(100 * time.Millisecond)
333+
334+ events := handler.GetReceivedEvents()
335+ expectedEvents := numGoroutines * eventsPerGoroutine
336+ assert.Equal(t, expectedEvents, len(events))
337+}⏎̸
A internal/events/handlers.go +393 -0
@@ -0,0 +1,393 @@
1+package events
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/rs/zerolog/log"
9+ "gordon/internal/config"
10+ "gordon/internal/container"
11+ "gordon/pkg/runtime"
12+)
13+
14+type ContainerEventHandler struct {
15+ manager container.ManagerInterface
16+ config *config.Config
17+}
18+
19+func NewContainerEventHandler(manager container.ManagerInterface, config *config.Config) *ContainerEventHandler {
20+ return &ContainerEventHandler{
21+ manager: manager,
22+ config: config,
23+ }
24+}
25+
26+func (h *ContainerEventHandler) CanHandle(eventType EventType) bool {
27+ switch eventType {
28+ case ImagePushed, ConfigReload, ManualReload, ContainerStop, ContainerStart:
29+ return true
30+ default:
31+ return false
32+ }
33+}
34+
35+func (h *ContainerEventHandler) Handle(event Event) error {
36+ switch event.Type {
37+ case ImagePushed:
38+ return h.handleImagePushed(event)
39+ case ConfigReload:
40+ return h.handleConfigReload(event)
41+ case ManualReload:
42+ return h.handleManualReload(event)
43+ case ContainerStop:
44+ return h.handleContainerStop(event)
45+ case ContainerStart:
46+ return h.handleContainerStart(event)
47+ default:
48+ return fmt.Errorf("unsupported event type: %s", event.Type)
49+ }
50+}
51+
52+func (h *ContainerEventHandler) handleImagePushed(event Event) error {
53+ imageName := event.ImageName
54+ tag := event.Tag
55+
56+ if imageName == "" {
57+ return fmt.Errorf("image name is required for ImagePushed event")
58+ }
59+
60+ if tag == "" {
61+ tag = "latest"
62+ }
63+
64+ fullImageName := fmt.Sprintf("%s:%s", imageName, tag)
65+
66+ log.Info().
67+ Str("image", fullImageName).
68+ Str("event_id", event.ID).
69+ Msg("Processing image push event")
70+
71+ routes := h.findRoutesForImage(fullImageName)
72+ if len(routes) == 0 {
73+ log.Debug().
74+ Str("image", fullImageName).
75+ Msg("No routes configured for pushed image")
76+ return nil
77+ }
78+
79+ for _, route := range routes {
80+ if err := h.deployContainerForRoute(route, fullImageName); err != nil {
81+ log.Error().
82+ Err(err).
83+ Str("route", route).
84+ Str("image", fullImageName).
85+ Msg("Failed to deploy container for route")
86+ }
87+ }
88+
89+ return nil
90+}
91+
92+func (h *ContainerEventHandler) handleConfigReload(event Event) error {
93+ log.Info().
94+ Str("event_id", event.ID).
95+ Msg("Processing configuration reload event")
96+
97+ ctx := context.Background()
98+
99+ // Get current containers managed by Gordon
100+ currentContainers := h.manager.ListContainers()
101+
102+ // Track which routes are currently active
103+ activeRoutes := make(map[string]*runtime.Container)
104+ for _, container := range currentContainers {
105+ if route, exists := container.Labels["gordon.route"]; exists {
106+ activeRoutes[route] = container
107+ }
108+ }
109+
110+ // Process each configured route
111+ for route, imageName := range h.config.Routes {
112+ if container, exists := activeRoutes[route]; exists {
113+ // Route exists - check if image has changed
114+ currentImage := container.Labels["gordon.image"]
115+ if currentImage != imageName {
116+ log.Info().
117+ Str("route", route).
118+ Str("old_image", currentImage).
119+ Str("new_image", imageName).
120+ Msg("Image changed for route, redeploying")
121+
122+ if err := h.deployContainerForRoute(route, imageName); err != nil {
123+ log.Error().
124+ Err(err).
125+ Str("route", route).
126+ Msg("Failed to redeploy container for route")
127+ }
128+ }
129+ // Remove from active routes so we know it's been processed
130+ delete(activeRoutes, route)
131+ } else {
132+ // New route - deploy container
133+ log.Info().
134+ Str("route", route).
135+ Str("image", imageName).
136+ Msg("New route detected, deploying container")
137+
138+ if err := h.deployContainerForRoute(route, imageName); err != nil {
139+ log.Error().
140+ Err(err).
141+ Str("route", route).
142+ Msg("Failed to deploy container for new route")
143+ }
144+ }
145+ }
146+
147+ // Stop containers for routes that are no longer configured
148+ for route, container := range activeRoutes {
149+ log.Info().
150+ Str("route", route).
151+ Str("container_id", container.ID).
152+ Msg("Route no longer configured, stopping container")
153+
154+ if err := h.manager.StopContainer(ctx, container.ID); err != nil {
155+ log.Error().
156+ Err(err).
157+ Str("container_id", container.ID).
158+ Msg("Failed to stop container for removed route")
159+ }
160+
161+ if err := h.manager.RemoveContainer(ctx, container.ID, true); err != nil {
162+ log.Error().
163+ Err(err).
164+ Str("container_id", container.ID).
165+ Msg("Failed to remove container for removed route")
166+ }
167+ }
168+
169+ return nil
170+}
171+
172+func (h *ContainerEventHandler) handleManualReload(event Event) error {
173+ log.Info().
174+ Str("event_id", event.ID).
175+ Msg("Processing manual reload event - redeploying all containers with updated environment variables")
176+
177+ ctx := context.Background()
178+ routes := h.config.GetRoutes()
179+ redeployedCount := 0
180+ var errors []error
181+
182+ // Redeploy each container to pick up new environment variables
183+ for _, route := range routes {
184+ // Check if container exists for this route
185+ if _, exists := h.manager.GetContainer(route.Domain); !exists {
186+ log.Debug().Str("domain", route.Domain).Msg("No container found for route, skipping")
187+ continue
188+ }
189+
190+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Redeploying container with updated environment variables")
191+
192+ // Deploy container (this will stop the old one and start a new one with updated env vars)
193+ _, err := h.manager.DeployContainer(ctx, route)
194+ if err != nil {
195+ log.Error().Str("domain", route.Domain).Err(err).Msg("Failed to redeploy container during manual reload")
196+ errors = append(errors, fmt.Errorf("failed to redeploy container for %s: %w", route.Domain, err))
197+ continue
198+ }
199+
200+ redeployedCount++
201+ log.Info().Str("domain", route.Domain).Msg("Container redeployed successfully with updated environment variables")
202+ }
203+
204+ if len(errors) > 0 {
205+ log.Warn().Int("redeployed", redeployedCount).Int("errors", len(errors)).Msg("Manual reload completed with some errors")
206+ return fmt.Errorf("manual reload completed with %d errors: %v", len(errors), errors)
207+ }
208+
209+ log.Info().Int("redeployed", redeployedCount).Int("total", len(routes)).Msg("Manual reload completed successfully")
210+ return nil
211+}
212+
213+func (h *ContainerEventHandler) handleContainerStop(event Event) error {
214+ containerID := event.ContainerID
215+ if containerID == "" {
216+ return fmt.Errorf("container ID is required for ContainerStop event")
217+ }
218+
219+ log.Info().
220+ Str("container_id", containerID).
221+ Str("event_id", event.ID).
222+ Msg("Processing container stop event")
223+
224+ ctx := context.Background()
225+ if err := h.manager.StopContainer(ctx, containerID); err != nil {
226+ return fmt.Errorf("failed to stop container %s: %w", containerID, err)
227+ }
228+
229+ return nil
230+}
231+
232+func (h *ContainerEventHandler) handleContainerStart(event Event) error {
233+ containerID := event.ContainerID
234+ if containerID == "" {
235+ return fmt.Errorf("container ID is required for ContainerStart event")
236+ }
237+
238+ log.Info().
239+ Str("container_id", containerID).
240+ Str("event_id", event.ID).
241+ Msg("Processing container start event")
242+
243+ // StartContainer method doesn't exist on Manager - containers are started via DeployContainer
244+ // For manual start, we'd need to add this method or use the runtime directly
245+ return fmt.Errorf("container start not implemented - use automatic deployment instead")
246+}
247+
248+func (h *ContainerEventHandler) findRoutesForImage(imageName string) []string {
249+ var routes []string
250+
251+ for route, configuredImage := range h.config.Routes {
252+ if strings.EqualFold(configuredImage, imageName) {
253+ routes = append(routes, route)
254+ }
255+ }
256+
257+ return routes
258+}
259+
260+func (h *ContainerEventHandler) deployContainerForRoute(route, imageName string) error {
261+ ctx := context.Background()
262+
263+ containers := h.manager.ListContainers()
264+
265+ containerName := fmt.Sprintf("gordon-%s", strings.ReplaceAll(route, ".", "-"))
266+
267+ // Stop and remove existing container if it exists
268+ for _, container := range containers {
269+ if container.Name == containerName {
270+ log.Info().
271+ Str("container_name", containerName).
272+ Str("old_container_id", container.ID).
273+ Msg("Stopping existing container for route")
274+
275+ if err := h.manager.StopContainer(ctx, container.ID); err != nil {
276+ log.Warn().
277+ Err(err).
278+ Str("container_id", container.ID).
279+ Msg("Failed to stop existing container")
280+ }
281+
282+ if err := h.manager.RemoveContainer(ctx, container.ID, true); err != nil {
283+ log.Warn().
284+ Err(err).
285+ Str("container_id", container.ID).
286+ Msg("Failed to remove existing container")
287+ }
288+ }
289+ }
290+
291+ // Use DeployContainer which handles creation and starting
292+ routeConfig := config.Route{
293+ Domain: route,
294+ Image: imageName,
295+ HTTPS: true,
296+ }
297+
298+ container, err := h.manager.DeployContainer(ctx, routeConfig)
299+ if err != nil {
300+ return fmt.Errorf("failed to deploy container for route %s: %w", route, err)
301+ }
302+
303+ log.Info().
304+ Str("route", route).
305+ Str("image", imageName).
306+ Str("container_id", container.ID).
307+ Str("container_name", containerName).
308+ Msg("Successfully deployed container for route")
309+
310+ return nil
311+}
312+
313+type AutoRouteHandler struct {
314+ config *config.Config
315+ manager container.ManagerInterface
316+}
317+
318+func NewAutoRouteHandler(cfg *config.Config, manager container.ManagerInterface) *AutoRouteHandler {
319+ return &AutoRouteHandler{
320+ config: cfg,
321+ manager: manager,
322+ }
323+}
324+
325+func (h *AutoRouteHandler) CanHandle(eventType EventType) bool {
326+ return eventType == ImagePushed
327+}
328+
329+func (h *AutoRouteHandler) Handle(event Event) error {
330+ if !h.config.AutoRoute.Enabled {
331+ return nil
332+ }
333+
334+ if event.Type != ImagePushed {
335+ return nil
336+ }
337+
338+ payload, ok := event.Data.(ImagePushedPayload)
339+ if !ok {
340+ return fmt.Errorf("invalid payload type for ImagePushed event")
341+ }
342+
343+ // Log annotations if present
344+ if len(payload.Annotations) > 0 {
345+ log.Debug().
346+ Str("image", payload.Name).
347+ Str("reference", payload.Reference).
348+ Interface("annotations", payload.Annotations).
349+ Msg("Image pushed with annotations - auto-route processing")
350+ }
351+
352+ imageName := payload.Name
353+ reference := payload.Reference
354+ fullImageName := fmt.Sprintf("%s:%s", imageName, reference)
355+
356+ // Build the full registry image path for deployment
357+ registryImageName := fullImageName
358+ if h.config.Server.RegistryDomain != "" {
359+ registryImageName = fmt.Sprintf("%s/%s", h.config.Server.RegistryDomain, fullImageName)
360+ }
361+
362+ // Try to extract domain from the image name part (before the colon)
363+ domain, isDomain := config.ExtractDomainFromImageName(imageName)
364+ if !isDomain {
365+ log.Debug().
366+ Str("image", imageName).
367+ Msg("Image name does not contain a valid domain, skipping auto-route creation")
368+ return nil
369+ }
370+
371+ // Check if route already exists
372+ if _, exists := h.config.Routes[domain]; exists {
373+ log.Debug().
374+ Str("domain", domain).
375+ Str("existing_image", h.config.Routes[domain]).
376+ Str("new_image", fullImageName).
377+ Msg("Route already exists for domain, skipping auto-route creation")
378+ return nil
379+ }
380+
381+ // Add the route to config with the full registry image path
382+ if err := h.config.AddRoute(domain, registryImageName); err != nil {
383+ return fmt.Errorf("failed to add auto-route for domain %s: %w", domain, err)
384+ }
385+
386+ log.Info().
387+ Str("domain", domain).
388+ Str("image", registryImageName).
389+ Msg("Successfully auto-created route - deployment will be handled by config reload event")
390+
391+ return nil
392+}
393+
A internal/events/handlers_test.go +852 -0
@@ -0,0 +1,852 @@
1+package events
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "testing"
8+
9+ "github.com/spf13/viper"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "gordon/internal/config"
15+ "gordon/pkg/runtime"
16+)
17+
18+// MockContainerManager is a mock implementation of container.ManagerInterface
19+type MockContainerManager struct {
20+ mock.Mock
21+}
22+
23+func (m *MockContainerManager) DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) {
24+ args := m.Called(ctx, route)
25+ if args.Get(0) == nil {
26+ return nil, args.Error(1)
27+ }
28+ return args.Get(0).(*runtime.Container), args.Error(1)
29+}
30+
31+func (m *MockContainerManager) StopContainer(ctx context.Context, containerID string) error {
32+ args := m.Called(ctx, containerID)
33+ return args.Error(0)
34+}
35+
36+func (m *MockContainerManager) RemoveContainer(ctx context.Context, containerID string, force bool) error {
37+ args := m.Called(ctx, containerID, force)
38+ return args.Error(0)
39+}
40+
41+func (m *MockContainerManager) ListContainers() map[string]*runtime.Container {
42+ args := m.Called()
43+ if args.Get(0) == nil {
44+ return nil
45+ }
46+ return args.Get(0).(map[string]*runtime.Container)
47+}
48+
49+func (m *MockContainerManager) GetContainer(domain string) (*runtime.Container, bool) {
50+ args := m.Called(domain)
51+ if args.Get(0) == nil {
52+ return nil, args.Bool(1)
53+ }
54+ return args.Get(0).(*runtime.Container), args.Bool(1)
55+}
56+
57+func (m *MockContainerManager) StopContainerByDomain(ctx context.Context, domain string) error {
58+ args := m.Called(ctx, domain)
59+ return args.Error(0)
60+}
61+
62+func (m *MockContainerManager) Runtime() runtime.Runtime {
63+ args := m.Called()
64+ return args.Get(0).(runtime.Runtime)
65+}
66+
67+func (m *MockContainerManager) HealthCheck(ctx context.Context) map[string]bool {
68+ args := m.Called(ctx)
69+ return args.Get(0).(map[string]bool)
70+}
71+
72+func (m *MockContainerManager) SyncContainers(ctx context.Context) error {
73+ args := m.Called(ctx)
74+ return args.Error(0)
75+}
76+
77+func (m *MockContainerManager) GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) {
78+ args := m.Called(ctx, domain, internalPort)
79+ return args.Int(0), args.Error(1)
80+}
81+
82+func (m *MockContainerManager) GetNetworkForApp(domain string) string {
83+ args := m.Called(domain)
84+ return args.String(0)
85+}
86+
87+func (m *MockContainerManager) AutoStartContainers(ctx context.Context) error {
88+ args := m.Called(ctx)
89+ return args.Error(0)
90+}
91+
92+func (m *MockContainerManager) StopAllManagedContainers(ctx context.Context) error {
93+ args := m.Called(ctx)
94+ return args.Error(0)
95+}
96+
97+func TestContainerEventHandler_CanHandle(t *testing.T) {
98+ handler := &ContainerEventHandler{}
99+
100+ testCases := []struct {
101+ eventType EventType
102+ expected bool
103+ }{
104+ {ImagePushed, true},
105+ {ConfigReload, true},
106+ {ManualReload, true},
107+ {ContainerStop, true},
108+ {ContainerStart, true},
109+ {ImageDeleted, false},
110+ {ContainerHealthCheck, false},
111+ }
112+
113+ for _, tc := range testCases {
114+ t.Run(string(tc.eventType), func(t *testing.T) {
115+ result := handler.CanHandle(tc.eventType)
116+ assert.Equal(t, tc.expected, result)
117+ })
118+ }
119+}
120+
121+func TestContainerEventHandler_Handle_ImagePushed(t *testing.T) {
122+ mockManager := &MockContainerManager{}
123+ mockConfig := &config.Config{
124+ Routes: map[string]string{
125+ "app.example.com": "nginx:latest",
126+ },
127+ }
128+
129+ handler := &ContainerEventHandler{
130+ manager: mockManager,
131+ config: mockConfig,
132+ }
133+
134+ // Mock container listing (empty list initially)
135+ mockManager.On("ListContainers").Return(map[string]*runtime.Container{})
136+
137+ // Mock successful deployment
138+ expectedRoute := config.Route{
139+ Domain: "app.example.com",
140+ Image: "nginx:latest",
141+ HTTPS: true,
142+ }
143+ mockContainer := &runtime.Container{
144+ ID: "container123",
145+ Name: "gordon-app-example-com",
146+ }
147+ mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil)
148+
149+ event := Event{
150+ Type: ImagePushed,
151+ ImageName: "nginx",
152+ Tag: "latest",
153+ }
154+
155+ err := handler.Handle(event)
156+
157+ assert.NoError(t, err)
158+ mockManager.AssertExpectations(t)
159+}
160+
161+func TestContainerEventHandler_Handle_ImagePushed_NoMatchingRoutes(t *testing.T) {
162+ mockManager := &MockContainerManager{}
163+ mockConfig := &config.Config{
164+ Routes: map[string]string{
165+ "app.example.com": "postgres:14", // Different image
166+ },
167+ }
168+
169+ handler := &ContainerEventHandler{
170+ manager: mockManager,
171+ config: mockConfig,
172+ }
173+
174+ event := Event{
175+ Type: ImagePushed,
176+ ImageName: "nginx",
177+ Tag: "latest",
178+ }
179+
180+ err := handler.Handle(event)
181+
182+ // Should succeed but not deploy anything
183+ assert.NoError(t, err)
184+ mockManager.AssertExpectations(t) // No calls expected
185+}
186+
187+func TestContainerEventHandler_Handle_ImagePushed_MultipleRoutes(t *testing.T) {
188+ mockManager := &MockContainerManager{}
189+ mockConfig := &config.Config{
190+ Routes: map[string]string{
191+ "app1.example.com": "nginx:latest",
192+ "app2.example.com": "nginx:latest",
193+ "app3.example.com": "postgres:14", // Different image
194+ },
195+ }
196+
197+ handler := &ContainerEventHandler{
198+ manager: mockManager,
199+ config: mockConfig,
200+ }
201+
202+ // Mock successful deployments for matching routes
203+ mockManager.On("ListContainers").Return(map[string]*runtime.Container{}).Times(2)
204+
205+ expectedRoute1 := config.Route{Domain: "app1.example.com", Image: "nginx:latest", HTTPS: true}
206+ expectedRoute2 := config.Route{Domain: "app2.example.com", Image: "nginx:latest", HTTPS: true}
207+
208+ mockContainer1 := &runtime.Container{ID: "container1", Name: "gordon-app1-example-com"}
209+ mockContainer2 := &runtime.Container{ID: "container2", Name: "gordon-app2-example-com"}
210+
211+ mockManager.On("DeployContainer", mock.Anything, expectedRoute1).Return(mockContainer1, nil)
212+ mockManager.On("DeployContainer", mock.Anything, expectedRoute2).Return(mockContainer2, nil)
213+
214+ event := Event{
215+ Type: ImagePushed,
216+ ImageName: "nginx",
217+ Tag: "latest",
218+ }
219+
220+ err := handler.Handle(event)
221+
222+ assert.NoError(t, err)
223+ mockManager.AssertExpectations(t)
224+}
225+
226+func TestContainerEventHandler_Handle_ConfigReload(t *testing.T) {
227+ mockManager := &MockContainerManager{}
228+
229+ // Mock existing containers
230+ existingContainers := map[string]*runtime.Container{
231+ "old.example.com": {
232+ ID: "container1",
233+ Name: "old-container",
234+ Labels: map[string]string{
235+ "gordon.route": "old.example.com",
236+ "gordon.image": "old:image",
237+ },
238+ },
239+ "app.example.com": {
240+ ID: "container2",
241+ Name: "app-container",
242+ Labels: map[string]string{
243+ "gordon.route": "app.example.com",
244+ "gordon.image": "nginx:latest",
245+ },
246+ },
247+ }
248+
249+ // New config with different routes
250+ newConfig := &config.Config{
251+ Routes: map[string]string{
252+ "app.example.com": "nginx:latest", // Keep this one
253+ "new.example.com": "postgres:14", // Add new one
254+ // Remove old.example.com
255+ },
256+ }
257+
258+ handler := &ContainerEventHandler{
259+ manager: mockManager,
260+ config: newConfig,
261+ }
262+
263+ // Mock manager calls
264+ mockManager.On("ListContainers").Return(existingContainers).Once() // First call in handleConfigReload
265+ mockManager.On("StopContainer", mock.Anything, "container1").Return(nil) // Stop removed container
266+ mockManager.On("RemoveContainer", mock.Anything, "container1", true).Return(nil) // Remove removed container
267+
268+ // Mock deployment for new route
269+ expectedRoute := config.Route{Domain: "new.example.com", Image: "postgres:14", HTTPS: true}
270+ mockContainer := &runtime.Container{ID: "container3", Name: "gordon-new-example-com"}
271+ mockManager.On("ListContainers").Return(map[string]*runtime.Container{}).Once() // Second call in deployContainerForRoute
272+ mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil)
273+
274+ event := Event{
275+ Type: ConfigReload,
276+ }
277+
278+ err := handler.Handle(event)
279+
280+ assert.NoError(t, err)
281+ mockManager.AssertExpectations(t)
282+}
283+
284+func TestContainerEventHandler_Handle_ManualReload(t *testing.T) {
285+ mockManager := &MockContainerManager{}
286+ mockConfig := &config.Config{
287+ Routes: map[string]string{
288+ "app.example.com": "nginx:latest",
289+ },
290+ }
291+
292+ handler := &ContainerEventHandler{
293+ manager: mockManager,
294+ config: mockConfig,
295+ }
296+
297+ // Mock GetContainer to return true (container exists)
298+ mockContainer := &runtime.Container{ID: "container1", Name: "app-container"}
299+ mockManager.On("GetContainer", "app.example.com").Return(mockContainer, true)
300+
301+ // Mock deployment
302+ expectedRoute := config.Route{Domain: "app.example.com", Image: "nginx:latest", HTTPS: true}
303+ mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil)
304+
305+ event := Event{
306+ Type: ManualReload,
307+ }
308+
309+ err := handler.Handle(event)
310+
311+ assert.NoError(t, err)
312+ mockManager.AssertExpectations(t)
313+}
314+
315+func TestContainerEventHandler_Handle_ContainerStop(t *testing.T) {
316+ mockManager := &MockContainerManager{}
317+
318+ handler := &ContainerEventHandler{
319+ manager: mockManager,
320+ }
321+
322+ // Mock container stop
323+ mockManager.On("StopContainer", mock.Anything, "container123").Return(nil)
324+
325+ event := Event{
326+ Type: ContainerStop,
327+ ContainerID: "container123",
328+ }
329+
330+ err := handler.Handle(event)
331+
332+ assert.NoError(t, err)
333+ mockManager.AssertExpectations(t)
334+}
335+
336+func TestContainerEventHandler_Handle_ContainerStart(t *testing.T) {
337+ handler := &ContainerEventHandler{}
338+
339+ event := Event{
340+ Type: ContainerStart,
341+ ContainerID: "container123",
342+ }
343+
344+ err := handler.Handle(event)
345+
346+ // Should return error as start is not implemented
347+ assert.Error(t, err)
348+ assert.Contains(t, err.Error(), "container start not implemented")
349+}
350+
351+func TestContainerEventHandler_Handle_UnsupportedEvent(t *testing.T) {
352+ handler := &ContainerEventHandler{}
353+
354+ event := Event{
355+ Type: ImageDeleted, // Unsupported event type
356+ }
357+
358+ err := handler.Handle(event)
359+
360+ assert.Error(t, err)
361+ assert.Contains(t, err.Error(), "unsupported event type")
362+}
363+
364+func TestAutoRouteHandler_CanHandle(t *testing.T) {
365+ handler := &AutoRouteHandler{}
366+
367+ testCases := []struct {
368+ eventType EventType
369+ expected bool
370+ }{
371+ {ImagePushed, true},
372+ {ImageDeleted, false},
373+ {ConfigReload, false},
374+ {ManualReload, false},
375+ {ContainerStop, false},
376+ {ContainerStart, false},
377+ {ContainerHealthCheck, false},
378+ }
379+
380+ for _, tc := range testCases {
381+ t.Run(string(tc.eventType), func(t *testing.T) {
382+ result := handler.CanHandle(tc.eventType)
383+ assert.Equal(t, tc.expected, result)
384+ })
385+ }
386+}
387+
388+func TestAutoRouteHandler_Handle_AutoRouteEnabled(t *testing.T) {
389+ mockManager := &MockContainerManager{}
390+
391+ // Create a temporary config file for testing
392+ tempConfigFile := "/tmp/test-gordon-config.toml"
393+ configContent := `[server]
394+registry_domain = "registry.example.com"
395+
396+[auto_route]
397+enabled = true
398+
399+[routes]
400+`
401+ err := os.WriteFile(tempConfigFile, []byte(configContent), 0644)
402+ require.NoError(t, err)
403+ defer os.Remove(tempConfigFile)
404+
405+ // Load config from temp file to set viper config file path
406+ viper.SetConfigFile(tempConfigFile)
407+ err = viper.ReadInConfig()
408+ require.NoError(t, err)
409+
410+ mockConfig := &config.Config{
411+ AutoRoute: config.AutoRouteConfig{
412+ Enabled: true,
413+ },
414+ Routes: make(map[string]string),
415+ Server: config.ServerConfig{
416+ RegistryDomain: "registry.example.com",
417+ },
418+ }
419+
420+ handler := &AutoRouteHandler{
421+ config: mockConfig,
422+ manager: mockManager,
423+ }
424+
425+ payload := ImagePushedPayload{
426+ Name: "myapp.example.com",
427+ Reference: "latest",
428+ }
429+
430+ event := Event{
431+ Type: ImagePushed,
432+ Data: payload,
433+ }
434+
435+ err = handler.Handle(event)
436+
437+ assert.NoError(t, err)
438+ // Check that route was added to config
439+ assert.Equal(t, "registry.example.com/myapp.example.com:latest", mockConfig.Routes["myapp.example.com"])
440+ mockManager.AssertExpectations(t)
441+}
442+
443+func TestAutoRouteHandler_Handle_AutoRouteDisabled(t *testing.T) {
444+ mockManager := &MockContainerManager{}
445+ mockConfig := &config.Config{
446+ AutoRoute: config.AutoRouteConfig{
447+ Enabled: false,
448+ },
449+ Routes: make(map[string]string),
450+ }
451+
452+ handler := &AutoRouteHandler{
453+ config: mockConfig,
454+ manager: mockManager,
455+ }
456+
457+ payload := ImagePushedPayload{
458+ Name: "myapp.example.com",
459+ Reference: "latest",
460+ }
461+
462+ event := Event{
463+ Type: ImagePushed,
464+ Data: payload,
465+ }
466+
467+ err := handler.Handle(event)
468+
469+ // Should succeed but not create route
470+ assert.NoError(t, err)
471+ assert.Empty(t, mockConfig.Routes)
472+ mockManager.AssertExpectations(t) // No calls expected
473+}
474+
475+func TestAutoRouteHandler_Handle_RouteAlreadyExists(t *testing.T) {
476+ mockManager := &MockContainerManager{}
477+ mockConfig := &config.Config{
478+ AutoRoute: config.AutoRouteConfig{
479+ Enabled: true,
480+ },
481+ Routes: map[string]string{
482+ "myapp.example.com": "existing:image",
483+ },
484+ }
485+
486+ handler := &AutoRouteHandler{
487+ config: mockConfig,
488+ manager: mockManager,
489+ }
490+
491+ payload := ImagePushedPayload{
492+ Name: "myapp.example.com",
493+ Reference: "latest",
494+ }
495+
496+ event := Event{
497+ Type: ImagePushed,
498+ Data: payload,
499+ }
500+
501+ err := handler.Handle(event)
502+
503+ // Should succeed but not modify existing route
504+ assert.NoError(t, err)
505+ assert.Equal(t, "existing:image", mockConfig.Routes["myapp.example.com"])
506+ mockManager.AssertExpectations(t) // No calls expected
507+}
508+
509+func TestAutoRouteHandler_Handle_InvalidImageName(t *testing.T) {
510+ mockManager := &MockContainerManager{}
511+ mockConfig := &config.Config{
512+ AutoRoute: config.AutoRouteConfig{
513+ Enabled: true,
514+ },
515+ Routes: make(map[string]string),
516+ }
517+
518+ handler := &AutoRouteHandler{
519+ config: mockConfig,
520+ manager: mockManager,
521+ }
522+
523+ payload := ImagePushedPayload{
524+ Name: "nginx", // No domain extractable
525+ Reference: "latest",
526+ }
527+
528+ event := Event{
529+ Type: ImagePushed,
530+ Data: payload,
531+ }
532+
533+ err := handler.Handle(event)
534+
535+ // Should succeed but not create route
536+ assert.NoError(t, err)
537+ assert.Empty(t, mockConfig.Routes)
538+ mockManager.AssertExpectations(t) // No calls expected
539+}
540+
541+func TestNewContainerEventHandler(t *testing.T) {
542+ mockManager := &MockContainerManager{}
543+ mockConfig := &config.Config{}
544+
545+ handler := NewContainerEventHandler(mockManager, mockConfig)
546+
547+ require.NotNil(t, handler)
548+ assert.Equal(t, mockManager, handler.manager)
549+ assert.Equal(t, mockConfig, handler.config)
550+}
551+
552+func TestNewAutoRouteHandler(t *testing.T) {
553+ mockManager := &MockContainerManager{}
554+ mockConfig := &config.Config{}
555+
556+ handler := NewAutoRouteHandler(mockConfig, mockManager)
557+
558+ require.NotNil(t, handler)
559+ assert.Equal(t, mockManager, handler.manager)
560+ assert.Equal(t, mockConfig, handler.config)
561+}
562+
563+func TestVersionHandler_CanHandle(t *testing.T) {
564+ handler := &VersionHandler{}
565+
566+ testCases := []struct {
567+ eventType EventType
568+ expected bool
569+ }{
570+ {ImagePushed, true},
571+ {ImageDeleted, false},
572+ {ConfigReload, false},
573+ {ManualReload, false},
574+ {ContainerStop, false},
575+ {ContainerStart, false},
576+ {ContainerHealthCheck, false},
577+ }
578+
579+ for _, tc := range testCases {
580+ t.Run(string(tc.eventType), func(t *testing.T) {
581+ result := handler.CanHandle(tc.eventType)
582+ assert.Equal(t, tc.expected, result)
583+ })
584+ }
585+}
586+
587+func TestVersionHandler_Handle_VersionedDeployment(t *testing.T) {
588+ // Create a temporary config file for testing
589+ tempConfigFile := "/tmp/test-gordon-version-config.toml"
590+ configContent := `[server]
591+registry_domain = "registry.example.com"
592+
593+[routes]
594+"app.example.com" = "myapp:v1.0"
595+`
596+ err := os.WriteFile(tempConfigFile, []byte(configContent), 0644)
597+ require.NoError(t, err)
598+ defer os.Remove(tempConfigFile)
599+
600+ // Load config from temp file to set viper config file path
601+ viper.SetConfigFile(tempConfigFile)
602+ err = viper.ReadInConfig()
603+ require.NoError(t, err)
604+
605+ mockManager := &MockContainerManager{}
606+ mockConfig := &config.Config{
607+ Routes: map[string]string{
608+ "app.example.com": "myapp:v2.0", // Match the payload reference so it finds routes
609+ },
610+ }
611+
612+ handler := &VersionHandler{
613+ manager: mockManager,
614+ config: mockConfig,
615+ }
616+
617+ // Mock deployment
618+ expectedRoute := config.Route{Domain: "app.example.com", Image: "myapp:v2.0", HTTPS: true}
619+ mockContainer := &runtime.Container{ID: "container1", Name: "app-container"}
620+ mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil)
621+
622+ payload := ImagePushedPayload{
623+ Name: "myapp",
624+ Reference: "v2.0",
625+ Annotations: map[string]string{
626+ "version": "v2.0",
627+ },
628+ }
629+
630+ event := Event{
631+ Type: ImagePushed,
632+ Data: payload,
633+ }
634+
635+ err = handler.Handle(event)
636+
637+ assert.NoError(t, err)
638+ // Check that route was updated to new version
639+ assert.Equal(t, "myapp:v2.0", mockConfig.Routes["app.example.com"])
640+ mockManager.AssertExpectations(t)
641+}
642+
643+func TestVersionHandler_Handle_NonVersionedDeployment(t *testing.T) {
644+ mockManager := &MockContainerManager{}
645+ mockConfig := &config.Config{
646+ Routes: make(map[string]string),
647+ }
648+
649+ handler := &VersionHandler{
650+ manager: mockManager,
651+ config: mockConfig,
652+ }
653+
654+ payload := ImagePushedPayload{
655+ Name: "myapp",
656+ Reference: "latest",
657+ Annotations: map[string]string{}, // No version annotations
658+ }
659+
660+ event := Event{
661+ Type: ImagePushed,
662+ Data: payload,
663+ }
664+
665+ err := handler.Handle(event)
666+
667+ // Should succeed but not process anything
668+ assert.NoError(t, err)
669+ assert.Empty(t, mockConfig.Routes)
670+ mockManager.AssertExpectations(t) // No calls expected
671+}
672+
673+func TestVersionHandler_Handle_NoMatchingRoutes(t *testing.T) {
674+ mockManager := &MockContainerManager{}
675+ mockConfig := &config.Config{
676+ Routes: map[string]string{
677+ "app.example.com": "nginx:latest", // Different image
678+ },
679+ }
680+
681+ handler := &VersionHandler{
682+ manager: mockManager,
683+ config: mockConfig,
684+ }
685+
686+ payload := ImagePushedPayload{
687+ Name: "myapp",
688+ Reference: "v2.0",
689+ Annotations: map[string]string{
690+ "version": "v2.0",
691+ },
692+ }
693+
694+ event := Event{
695+ Type: ImagePushed,
696+ Data: payload,
697+ }
698+
699+ err := handler.Handle(event)
700+
701+ // Should succeed but not deploy anything
702+ assert.NoError(t, err)
703+ assert.Equal(t, "nginx:latest", mockConfig.Routes["app.example.com"])
704+ mockManager.AssertExpectations(t) // No calls expected
705+}
706+
707+func TestVersionHandler_Handle_InvalidPayload(t *testing.T) {
708+ handler := &VersionHandler{}
709+
710+ event := Event{
711+ Type: ImagePushed,
712+ Data: "invalid payload",
713+ }
714+
715+ err := handler.Handle(event)
716+
717+ assert.Error(t, err)
718+ assert.Contains(t, err.Error(), "invalid payload type")
719+}
720+
721+func TestVersionHandler_Handle_WrongEventType(t *testing.T) {
722+ handler := &VersionHandler{}
723+
724+ event := Event{
725+ Type: ConfigReload,
726+ }
727+
728+ err := handler.Handle(event)
729+
730+ assert.NoError(t, err) // Should succeed but do nothing
731+}
732+
733+func TestVersionHandler_Handle_DeploymentFailure(t *testing.T) {
734+ // Create a temporary config file for testing
735+ tempConfigFile := "/tmp/test-gordon-version-fail-config.toml"
736+ configContent := `[server]
737+registry_domain = "registry.example.com"
738+
739+[routes]
740+"app.example.com" = "myapp:v1.0"
741+`
742+ err := os.WriteFile(tempConfigFile, []byte(configContent), 0644)
743+ require.NoError(t, err)
744+ defer os.Remove(tempConfigFile)
745+
746+ // Load config from temp file to set viper config file path
747+ viper.SetConfigFile(tempConfigFile)
748+ err = viper.ReadInConfig()
749+ require.NoError(t, err)
750+
751+ mockManager := &MockContainerManager{}
752+ mockConfig := &config.Config{
753+ Routes: map[string]string{
754+ "app.example.com": "myapp:v2.0", // Match the payload reference so it finds routes
755+ },
756+ }
757+
758+ handler := &VersionHandler{
759+ manager: mockManager,
760+ config: mockConfig,
761+ }
762+
763+ // Mock deployment failure
764+ expectedRoute := config.Route{Domain: "app.example.com", Image: "myapp:v2.0", HTTPS: true}
765+ mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(nil, fmt.Errorf("deployment failed"))
766+
767+ payload := ImagePushedPayload{
768+ Name: "myapp",
769+ Reference: "v2.0",
770+ Annotations: map[string]string{
771+ "version": "v2.0",
772+ },
773+ }
774+
775+ event := Event{
776+ Type: ImagePushed,
777+ Data: payload,
778+ }
779+
780+ err = handler.Handle(event)
781+
782+ assert.Error(t, err)
783+ assert.Contains(t, err.Error(), "version deployment completed with")
784+ mockManager.AssertExpectations(t)
785+}
786+
787+func TestVersionHandler_Handle_FullImageReference(t *testing.T) {
788+ // Create a temporary config file for testing
789+ tempConfigFile := "/tmp/test-gordon-version-full-config.toml"
790+ configContent := `[server]
791+registry_domain = "registry.example.com"
792+
793+[routes]
794+"app.example.com" = "myapp:v1.0"
795+`
796+ err := os.WriteFile(tempConfigFile, []byte(configContent), 0644)
797+ require.NoError(t, err)
798+ defer os.Remove(tempConfigFile)
799+
800+ // Load config from temp file to set viper config file path
801+ viper.SetConfigFile(tempConfigFile)
802+ err = viper.ReadInConfig()
803+ require.NoError(t, err)
804+
805+ mockManager := &MockContainerManager{}
806+ mockConfig := &config.Config{
807+ Routes: map[string]string{
808+ "app.example.com": "myapp:v2.0", // Match the payload reference so it finds routes
809+ },
810+ }
811+
812+ handler := &VersionHandler{
813+ manager: mockManager,
814+ config: mockConfig,
815+ }
816+
817+ // Mock deployment with full image reference
818+ expectedRoute := config.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:v2.0", HTTPS: true}
819+ mockContainer := &runtime.Container{ID: "container1", Name: "app-container"}
820+ mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil)
821+
822+ payload := ImagePushedPayload{
823+ Name: "myapp",
824+ Reference: "v2.0",
825+ Annotations: map[string]string{
826+ "version": "registry.example.com/myapp:v2.0", // Full image reference
827+ },
828+ }
829+
830+ event := Event{
831+ Type: ImagePushed,
832+ Data: payload,
833+ }
834+
835+ err = handler.Handle(event)
836+
837+ assert.NoError(t, err)
838+ // Check that route was updated to full image reference
839+ assert.Equal(t, "registry.example.com/myapp:v2.0", mockConfig.Routes["app.example.com"])
840+ mockManager.AssertExpectations(t)
841+}
842+
843+func TestNewVersionHandler(t *testing.T) {
844+ mockManager := &MockContainerManager{}
845+ mockConfig := &config.Config{}
846+
847+ handler := NewVersionHandler(mockManager, mockConfig)
848+
849+ require.NotNil(t, handler)
850+ assert.Equal(t, mockManager, handler.manager)
851+ assert.Equal(t, mockConfig, handler.config)
852+}⏎̸
A internal/events/rollback_handler.go +188 -0
@@ -0,0 +1,188 @@
1+package events
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/rs/zerolog/log"
9+ "gordon/internal/config"
10+ "gordon/internal/container"
11+ "gordon/pkg/manifest"
12+)
13+
14+// VersionHandler handles version deployments based on manifest annotations
15+type VersionHandler struct {
16+ manager container.ManagerInterface
17+ config *config.Config
18+}
19+
20+// NewVersionHandler creates a new version handler
21+func NewVersionHandler(manager container.ManagerInterface, config *config.Config) *VersionHandler {
22+ return &VersionHandler{
23+ manager: manager,
24+ config: config,
25+ }
26+}
27+
28+// CanHandle returns true for ImagePushed events
29+func (h *VersionHandler) CanHandle(eventType EventType) bool {
30+ return eventType == ImagePushed
31+}
32+
33+// Handle processes ImagePushed events and performs version deployments if necessary
34+func (h *VersionHandler) Handle(event Event) error {
35+ if event.Type != ImagePushed {
36+ return nil
37+ }
38+
39+ payload, ok := event.Data.(ImagePushedPayload)
40+ if !ok {
41+ return fmt.Errorf("invalid payload type for ImagePushed event")
42+ }
43+
44+ // Skip if no annotations
45+ if len(payload.Annotations) == 0 {
46+ return nil
47+ }
48+
49+ imageName := payload.Name
50+ reference := payload.Reference
51+ annotations := payload.Annotations
52+
53+ log.Debug().
54+ Str("image", imageName).
55+ Str("reference", reference).
56+ Interface("annotations", annotations).
57+ Msg("Processing annotations for version deployment")
58+
59+ // Check if this is a versioned deployment
60+ if manifest.IsVersionedDeployment(annotations) {
61+ return h.handleVersionedDeployment(imageName, reference, annotations)
62+ }
63+
64+ return nil
65+}
66+
67+// handleVersionedDeployment handles a versioned deployment
68+func (h *VersionHandler) handleVersionedDeployment(imageName, reference string, annotations map[string]string) error {
69+ version := manifest.GetDeploymentVersion(annotations)
70+ if version == "" {
71+ return nil
72+ }
73+
74+ log.Info().
75+ Str("image", imageName).
76+ Str("reference", reference).
77+ Str("version", version).
78+ Msg("Deploying version via manifest annotation")
79+
80+ // Deploy the versioned image - treat this as a deployment to the specified version
81+ return h.performVersionedDeployment(imageName, reference, version, annotations)
82+}
83+
84+// performVersionedDeployment deploys a specific version based on manifest annotations
85+func (h *VersionHandler) performVersionedDeployment(imageName, reference, version string, annotations map[string]string) error {
86+ ctx := context.Background()
87+
88+ // Find routes that use this image
89+ routes := h.findRoutesForImage(fmt.Sprintf("%s:%s", imageName, reference))
90+ if len(routes) == 0 {
91+ log.Debug().
92+ Str("image", imageName).
93+ Str("reference", reference).
94+ Msg("No routes configured for versioned deployment image")
95+ return nil
96+ }
97+
98+ var errors []error
99+ for _, route := range routes {
100+ if err := h.deployVersion(ctx, route, imageName, version, annotations); err != nil {
101+ log.Error().
102+ Err(err).
103+ Str("route", route).
104+ Str("version", version).
105+ Msg("Failed to deploy version to route")
106+ errors = append(errors, fmt.Errorf("version deployment failed for route %s: %w", route, err))
107+ } else {
108+ log.Info().
109+ Str("route", route).
110+ Str("version", version).
111+ Msg("Version deployment completed successfully")
112+ }
113+ }
114+
115+ if len(errors) > 0 {
116+ return fmt.Errorf("version deployment completed with %d errors: %v", len(errors), errors)
117+ }
118+
119+ return nil
120+}
121+
122+
123+// findRoutesForImage finds all routes that match the given image
124+func (h *VersionHandler) findRoutesForImage(imageName string) []string {
125+ var routes []string
126+
127+ for route, configuredImage := range h.config.Routes {
128+ if strings.EqualFold(configuredImage, imageName) {
129+ routes = append(routes, route)
130+ }
131+ }
132+
133+ return routes
134+}
135+
136+// deployVersion deploys a specific version to a route
137+func (h *VersionHandler) deployVersion(ctx context.Context, route, imageName, version string, annotations map[string]string) error {
138+ // Construct the versioned image name
139+ var versionedImage string
140+
141+ if strings.Contains(version, ":") {
142+ // Full image reference provided
143+ versionedImage = version
144+ } else {
145+ // Just a version/tag provided, construct full image reference
146+ versionedImage = fmt.Sprintf("%s:%s", imageName, version)
147+ }
148+
149+ log.Info().
150+ Str("route", route).
151+ Str("current_image", h.config.Routes[route]).
152+ Str("versioned_image", versionedImage).
153+ Msg("Performing version deployment")
154+
155+ // Update the route configuration to the versioned image
156+ if err := h.config.UpdateRoute(route, versionedImage); err != nil {
157+ return fmt.Errorf("failed to update route configuration for version deployment: %w", err)
158+ }
159+
160+ // Deploy the versioned image
161+ routeConfig := config.Route{
162+ Domain: route,
163+ Image: versionedImage,
164+ HTTPS: true,
165+ }
166+
167+ _, err := h.manager.DeployContainer(ctx, routeConfig)
168+ if err != nil {
169+ // Attempt to restore original configuration
170+ if originalImage := h.config.Routes[route]; originalImage != versionedImage {
171+ log.Warn().
172+ Str("route", route).
173+ Str("original_image", originalImage).
174+ Msg("Version deployment failed, attempting to restore original configuration")
175+ _ = h.config.UpdateRoute(route, originalImage)
176+ }
177+ return fmt.Errorf("failed to deploy versioned container: %w", err)
178+ }
179+
180+ // Log successful deployment
181+ log.Info().
182+ Str("route", route).
183+ Str("versioned_image", versionedImage).
184+ Interface("version_annotations", annotations).
185+ Msg("Version deployment completed successfully")
186+
187+ return nil
188+}⏎̸
A internal/events/types.go +48 -0
@@ -0,0 +1,48 @@
1+package events
2+
3+import (
4+ "time"
5+)
6+
7+type EventType string
8+
9+const (
10+ ImagePushed EventType = "image.pushed"
11+ ImageDeleted EventType = "image.deleted"
12+ ConfigReload EventType = "config.reload"
13+ ManualReload EventType = "manual.reload"
14+ ContainerStop EventType = "container.stop"
15+ ContainerStart EventType = "container.start"
16+ ContainerHealthCheck EventType = "container.health_check"
17+)
18+
19+type Event struct {
20+ ID string `json:"id"`
21+ Type EventType `json:"type"`
22+ Timestamp time.Time `json:"timestamp"`
23+ ImageName string `json:"image_name,omitempty"`
24+ Tag string `json:"tag,omitempty"`
25+ Route string `json:"route,omitempty"`
26+ ContainerID string `json:"container_id,omitempty"`
27+ Data interface{} `json:"data,omitempty"`
28+}
29+
30+type ImagePushedPayload struct {
31+ Name string `json:"name"`
32+ Reference string `json:"reference"`
33+ Manifest []byte `json:"-"`
34+ Annotations map[string]string `json:"annotations,omitempty"`
35+}
36+
37+type EventHandler interface {
38+ Handle(event Event) error
39+ CanHandle(eventType EventType) bool
40+}
41+
42+type EventBus interface {
43+ Publish(eventType EventType, payload interface{}) error
44+ Subscribe(handler EventHandler) error
45+ Unsubscribe(handler EventHandler) error
46+ Start() error
47+ Stop() error
48+}
D internal/httpserve/handlers/admin_view.go +0 -66
@@ -1,66 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5-
6- "net/http"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/internal/templating/render"
10- "github.com/labstack/echo/v4"
11-)
12-
13-var (
14- mainPath = "html/admin"
15- fragmentsPath = "html/fragments"
16-)
17-
18-// GetLocalizedData returns the data for the localization
19-func GetLocalizedData(c echo.Context, a *server.App) (map[string]interface{}, error) {
20- lang := c.Get("LangKey")
21- if lang == nil {
22- return nil, fmt.Errorf("LangKey not found in context")
23- }
24-
25- yamlData, err := render.GetLocalization(lang.(string), a)
26- if err != nil {
27- return nil, fmt.Errorf("failed to get localization: %w", err)
28- }
29- return map[string]interface{}{
30- "Lang": yamlData,
31- "LogoutURL": a.Config.Admin.Path + "/logout",
32- "AdminPath": a.Config.Admin.Path,
33- }, nil
34-}
35-
36-// renderAdminPage is a generalized function to render admin pages
37-func renderAdminPage(c echo.Context, a *server.App, templateName string) error {
38- data, err := GetLocalizedData(c, a)
39- if err != nil {
40- return err
41- }
42-
43- data["AdminPath"] = a.Config.Admin.Path
44-
45- rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath)
46- if err != nil {
47- return err
48- }
49-
50- renderedHTML, err := rendererData.Render(data, a)
51- if err != nil {
52- return err
53- }
54-
55- return c.HTML(http.StatusOK, renderedHTML)
56-}
57-
58-// AdminRoute is the route for the admin panel
59-func AdminRoute(c echo.Context, a *server.App) error {
60- return renderAdminPage(c, a, "index.gohtml")
61-}
62-
63-// AdminManagerRoute is the route of the manager page
64-func AdminManagerRoute(c echo.Context, a *server.App) error {
65- return renderAdminPage(c, a, "manager.gohtml")
66-}
D internal/httpserve/handlers/container_crud.go +0 -175
@@ -1,175 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "log"
6- "net/http"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/internal/templating/cmdparams"
10- "github.com/bnema/gordon/internal/templating/render"
11- "github.com/bnema/gordon/pkg/docker"
12- "github.com/bnema/gordon/pkg/sanitize"
13- "github.com/labstack/echo/v4"
14-)
15-
16-// FromShortIDToImageID converts a short image ID to a full image ID
17-func FromShortIDToImageID(ShortID string) (string, error) {
18- imageID, exists := safelyInteractWithIDMap(Fetch, ShortID)
19- if !exists {
20- log.Printf("No mapping found for short ID: %s", ShortID)
21- return "", fmt.Errorf("image ID not found")
22- }
23- return imageID, nil
24-}
25-
26-// CreateContainerRoute is the route for creating a new container
27-func CreateContainerGET(c echo.Context, a *server.App) error {
28- log.Printf("Full request URL: %s", c.Request().URL.String())
29- // Retrieve the ShortID of the image from the URL
30- shortID := c.Param("ID")
31- log.Printf("Received ShortID: %s", shortID)
32-
33- fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
34- if !exists {
35- log.Printf("No mapping found for short ID: %s", shortID)
36- return c.String(http.StatusNotFound, "Image ID not found")
37- }
38-
39- log.Printf("Found mapping: %s -> %s", shortID, fullID)
40-
41- // Get the image info
42- imageInfo, err := docker.GetImageInfo(fullID)
43- if err != nil {
44- return sendError(c, err)
45- }
46-
47- // Extract image name
48- var imageName string
49- if len(imageInfo.RepoTags) > 0 {
50- imageName = imageInfo.RepoTags[0]
51- }
52-
53- data := map[string]interface{}{
54- "Title": "Create a new container",
55- "ShortID": shortID,
56- "ImageID": fullID,
57- "ImageName": imageName,
58- }
59-
60- // Render the create container page
61- rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a)
62-
63- if err != nil {
64- return sendError(c, err)
65- }
66-
67- renderedHTML, err := rendererData.Render(data, a)
68-
69- if err != nil {
70- return sendError(c, err)
71- }
72-
73- return c.HTML(200, renderedHTML)
74-
75-}
76-
77-// CreateContainerFullGET handles the full HTML page for creating a new container
78-func CreateContainerFullGET(c echo.Context, a *server.App) error {
79- // Retrieve the ShortID of the image from the URL
80- shortID := c.Param("ID")
81- log.Printf("Received ShortID: %s", shortID)
82-
83- fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
84- if !exists {
85- log.Printf("No mapping found for short ID: %s", shortID)
86- return c.String(http.StatusNotFound, "Image ID not found")
87- }
88-
89- log.Printf("Found mapping: %s -> %s", shortID, fullID)
90-
91- // Get the image info
92- imageInfo, err := docker.GetImageInfo(fullID)
93- if err != nil {
94- return sendError(c, err)
95- }
96-
97- // Extract image name
98- var imageName string
99- if len(imageInfo.RepoTags) > 0 {
100- imageName = imageInfo.RepoTags[0]
101- }
102-
103- data := map[string]interface{}{
104- "Title": "Create a new container",
105- "ShortID": shortID,
106- "ImageID": fullID,
107- "ImageName": imageName,
108- "AdminPath": a.Config.Admin.Path,
109- }
110-
111- // Specify both admin and fragments directories
112- rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments")
113- if err != nil {
114- return sendError(c, err)
115- }
116-
117- renderedHTML, err := rendererData.Render(data, a)
118- if err != nil {
119- log.Printf("Error rendering template: %v", err)
120- return sendError(c, err)
121- }
122-
123- return c.HTML(200, renderedHTML)
124-}
125-
126-// CreateContainerPOST handles the create container form submission
127-func CreateContainerPOST(c echo.Context, a *server.App) error {
128- // Retreive the ShortID of the image from the URL
129- ShortID := c.Param("ID")
130-
131- // Convert the ShortID to a full image ID
132- imageID, err := FromShortIDToImageID(ShortID)
133- if err != nil {
134- return sendError(c, err)
135- }
136-
137- // Get the image info
138- _, err = docker.GetImageInfo(imageID)
139- if err != nil {
140- return sendError(c, err)
141- }
142-
143- // Retrieve all form parameters
144- formParams, err := c.FormParams()
145- if err != nil {
146- return sendError(c, err)
147- }
148-
149- // Initialize a map to store sanitized values
150- sanitizedInputs := make(map[string]string)
151-
152- // Iterate over all form parameters and sanitize them
153- for k, v := range formParams {
154- if len(v) > 0 {
155- sanitized, err := sanitize.SanitizeHTML(v[0])
156- if err != nil {
157- return sendError(c, err)
158- }
159- sanitizedInputs[k] = sanitized
160- }
161- }
162-
163- cmdParams, err := cmdparams.FromInputsToCmdParams(sanitizedInputs, a)
164- if err != nil {
165- return sendError(c, err)
166- }
167-
168- // Create the container
169- _, err = docker.CreateContainer(cmdParams)
170- if err != nil {
171- return sendError(c, err)
172- }
173-
174- return c.HTML(http.StatusOK, ActionSuccess(a))
175-}
D internal/httpserve/handlers/container_management.go +0 -251
@@ -1,251 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "net/http"
6- "strings"
7- "sync"
8- "time"
9-
10- "github.com/bnema/gordon/internal/server"
11- "github.com/bnema/gordon/internal/templating/load"
12- "github.com/bnema/gordon/internal/templating/render"
13- "github.com/bnema/gordon/pkg/docker"
14- "github.com/bnema/gordon/pkg/humanize"
15- "github.com/docker/docker/api/types"
16- "github.com/labstack/echo/v4"
17-)
18-
19-type MapOperation string
20-
21-var (
22- idMap = make(map[string]string)
23- idMapMutex sync.Mutex
24-)
25-
26-const (
27- Fetch MapOperation = "Fetch"
28- Update MapOperation = "Update"
29- Delete MapOperation = "Delete"
30-)
31-
32-func safelyInteractWithIDMap(op MapOperation, key string, value ...string) (string, bool) {
33- idMapMutex.Lock()
34- defer idMapMutex.Unlock()
35-
36- switch op {
37- case Fetch:
38- val, exists := idMap[key]
39- return val, exists
40- case Update:
41- if len(value) > 0 {
42- idMap[key] = value[0]
43- }
44- return "", true
45- case Delete:
46- delete(idMap, key)
47- return "", true
48- default:
49- return "", false
50- }
51-}
52-
53-type HumanReadableContainerImage struct {
54- ID string
55- Name string
56- ShortID string
57- CreatedStr string
58- SizeStr string
59- RepoDigests []string
60- RepoTags []string
61-}
62-
63-type HumanReadableContainer struct {
64- *types.Container
65- Name string
66- Ports []string
67- ShortID string
68- CreatedStr string
69- SizeStr string
70- UpSince string
71- StateColor string
72- TraefikEntryPoint string
73-}
74-
75-// renderHTML is a generalized function to render HTML
76-func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {
77- rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
78- if err != nil {
79- return err
80- }
81- renderedHTML, err := rendererData.Render(data, a)
82- if err != nil {
83- return err
84- }
85- return c.HTML(http.StatusOK, renderedHTML)
86-}
87-
88-// ActionSuccess returns the success HTML fragment
89-func ActionSuccess(a *server.App) string {
90- successFragment, err := load.Fragment(a, "success")
91- if err != nil {
92- fmt.Println(err)
93- return ""
94- }
95-
96- return successFragment
97-}
98-
99-// ImageManagerComponent handles the /image-manager route (HTMX route)
100-func ImageManagerComponent(c echo.Context, a *server.App) error {
101- images, err := docker.ListContainerImages()
102- if err != nil {
103- return sendError(c, err)
104- }
105-
106- var humanReadableImages []HumanReadableContainerImage
107-
108- for _, img := range images {
109- shortID := img.ID[7:19]
110- safelyInteractWithIDMap(Update, shortID, img.ID)
111- createdTime := time.Unix(img.Created, 0)
112- createdStr := humanize.TimeAgo(createdTime)
113- sizeStr := humanize.BytesToReadableSize(img.Size)
114-
115- for _, repoTag := range img.RepoTags {
116- humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
117- ID: img.ID,
118- ShortID: shortID,
119- CreatedStr: createdStr,
120- SizeStr: sizeStr,
121- Name: repoTag,
122- RepoDigests: img.RepoDigests,
123- RepoTags: img.RepoTags,
124- })
125- }
126- }
127-
128- data := map[string]interface{}{
129- "Images": humanReadableImages,
130- }
131-
132- return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)
133-}
134-
135-// ImageManagerDelete handles the /image-manager/delete route
136-func ImageManagerDelete(c echo.Context, a *server.App) error {
137- ShortID := c.Param("ID")
138-
139- imageID, exists := safelyInteractWithIDMap(Fetch, ShortID)
140- if !exists {
141- return c.String(http.StatusBadRequest, "Invalid ShortImgID")
142- }
143-
144- err := docker.DeleteContainerImage(imageID)
145- if err != nil {
146- c.Response().Header().Set("X-Error-Type", "image")
147- return c.String(http.StatusInternalServerError, err.Error())
148- }
149-
150- safelyInteractWithIDMap(Delete, ShortID)
151-
152- return c.String(http.StatusOK, ActionSuccess(a))
153-}
154-
155-// ContainerManagerComponent handles the /container-manager route
156-func ContainerManagerComponent(c echo.Context, a *server.App) error {
157- containers, err := docker.ListRunningContainers()
158- if err != nil {
159- return sendError(c, err)
160- }
161-
162- var humanReadableContainers []HumanReadableContainer
163-
164- for _, container := range containers {
165- localContainer := container // Make a local copy
166- sizeStr := humanize.BytesToReadableSize(container.SizeRw)
167- stateColor := "blue"
168-
169- if container.State == "running" {
170- stateColor = "green"
171- } else if container.State == "exited" {
172- stateColor = "red"
173- }
174- var ports []string
175- for _, port := range container.Ports {
176- portStr := fmt.Sprintf("%d:%d", port.PublicPort, port.PrivatePort)
177- ports = append(ports, portStr)
178- }
179-
180- for _, name := range container.Names {
181- name = name[1:]
182- humanReadableContainers = append(humanReadableContainers, HumanReadableContainer{
183- Container: &localContainer,
184- SizeStr: sizeStr,
185- UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)),
186- StateColor: stateColor,
187- Name: name,
188- Ports: ports,
189- TraefikEntryPoint: extractTraefikEntryPoint(container.Labels),
190- CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)),
191- })
192- }
193- }
194- yamlData, err := GetLocalizedData(c, a)
195- if err != nil {
196- return err
197- }
198- data := map[string]interface{}{
199- "Lang": yamlData["Lang"],
200- "containers": humanReadableContainers,
201- }
202-
203- return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
204-}
205-
206-func extractTraefikEntryPoint(labels map[string]string) string {
207- for key, value := range labels {
208- if strings.HasSuffix(key, ".loadbalancer.server.port") {
209- return value
210- }
211- }
212- return ""
213-}
214-
215-// ContainerManagerDelete handles the /container-manager/delete route
216-func ContainerManagerDelete(c echo.Context, a *server.App) error {
217- err := docker.RemoveContainer(c.Param("ID"))
218- if err != nil {
219- return sendError(c, err)
220- }
221- return c.HTML(http.StatusOK, ActionSuccess(a))
222-}
223-
224-// ContainerManagerStop handles the /container-manager/stop route
225-func ContainerManagerStop(c echo.Context, a *server.App) error {
226- // Stop the container gracefully with a timeout
227- stopped, err := docker.StopContainerGracefully(c.Param("ID"), 3*time.Second)
228- if err != nil {
229- return sendError(c, err)
230- }
231- // if stopped is true, the container was stopped gracefully
232- if stopped {
233- return c.HTML(http.StatusOK, ActionSuccess(a))
234- }
235- // If stopped is false, the container was not stopped gracefully we force stop it
236- err = docker.StopContainerRagefully(c.Param("ID"))
237- if err != nil {
238- return sendError(c, err)
239- }
240-
241- return c.HTML(http.StatusOK, ActionSuccess(a))
242-}
243-
244-// ContainerManagerStart handles the /container-manager/start route
245-func ContainerManagerStart(c echo.Context, a *server.App) error {
246- err := docker.StartContainer(c.Param("ID"))
247- if err != nil {
248- return sendError(c, err)
249- }
250- return c.HTML(http.StatusOK, ActionSuccess(a))
251-}
D internal/httpserve/handlers/container_transactions.go +0 -303
@@ -1,303 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "log"
6- "strings"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/internal/templating/cmdparams"
10- "github.com/bnema/gordon/internal/templating/render"
11- "github.com/bnema/gordon/pkg/docker"
12- "github.com/labstack/echo/v4"
13- "gopkg.in/yaml.v3"
14-)
15-
16-func generateOrderedYAML(infoMap map[string]interface{}) (string, error) {
17- orderedKeys := []string{"Name", "Image", "Hostname", "Ports", "Environment", "Labels", "Network", "Volumes", "Restart"}
18-
19- var yamlString string
20- for _, key := range orderedKeys {
21- value, exists := infoMap[key]
22- if !exists {
23- continue
24- }
25-
26- yamlBytes, err := yaml.Marshal(map[string]interface{}{key: value})
27- if err != nil {
28- return "", err
29- }
30-
31- yamlString += string(yamlBytes)
32- }
33-
34- return yamlString, nil
35-}
36-
37-// ContainerManagerEditGET displays the edit container view
38-func ContainerManagerEditGET(c echo.Context, a *server.App) error {
39- containerID := c.Param("ID")
40-
41- // Get the container info
42- containerInfo, err := docker.GetContainerInfo(containerID)
43- if err != nil {
44- return sendError(c, err)
45- }
46-
47- // Trim the slash from the container name
48- containerInfo.Name = strings.TrimPrefix(containerInfo.Name, "/")
49- // Get the network names
50- networkNames := make([]string, 0, len(containerInfo.NetworkSettings.Networks))
51- for networkName := range containerInfo.NetworkSettings.Networks {
52- networkNames = append(networkNames, networkName)
53- }
54-
55- // Prepare Ports
56- portMappings := make([]string, 0)
57- for port, bindings := range containerInfo.HostConfig.PortBindings {
58- for _, binding := range bindings {
59- portMappings = append(portMappings, fmt.Sprintf("%s:%s/%s", binding.HostPort, port.Port(), port.Proto()))
60- }
61- }
62-
63- // Prepare Volumes (Mounts)
64- volumeMappings := make([]string, 0)
65- for _, mount := range containerInfo.Mounts {
66- volumeMappings = append(volumeMappings, fmt.Sprintf("%s:%s", mount.Source, mount.Destination))
67- }
68-
69- // Initialize a map to store the information from the container
70- infoMap := make(map[string]interface{})
71-
72- // Populate the map with container details
73- infoMap["Name"] = containerInfo.Name
74- infoMap["Image"] = containerInfo.Config.Image
75- infoMap["Hostname"] = containerInfo.Config.Hostname
76- infoMap["Ports"] = portMappings
77- infoMap["Volumes"] = volumeMappings
78- infoMap["Environment"] = containerInfo.Config.Env
79- infoMap["Labels"] = containerInfo.Config.Labels
80- infoMap["Network"] = networkNames
81- infoMap["Restart"] = containerInfo.HostConfig.RestartPolicy.Name
82-
83- // Convert the infoMap to YAML format
84- yamlString, err := generateOrderedYAML(infoMap)
85- if err != nil {
86- return sendError(c, err)
87- }
88-
89- // Pass the containerInfo to the data map
90- data := map[string]interface{}{
91- "Title": "Edit container",
92- "ID": containerID,
93- "ContainerInfo": yamlString,
94- }
95-
96- rendererData, err := render.GetHTMLRenderer("html/fragments", "editcontainer.gohtml", a.TemplateFS, a)
97-
98- if err != nil {
99- return sendError(c, err)
100- }
101-
102- renderedHTML, err := rendererData.Render(data, a)
103-
104- if err != nil {
105- return sendError(c, err)
106- }
107-
108- return c.HTML(200, renderedHTML)
109-}
110-
111-type TransactionStep func() error
112-type RollbackStep func() error
113-
114-type TransactionQueue struct {
115- NewContainerID string
116- NewContainerName string
117- TempContainerName string
118- OldContainerID string
119- OldContainerName string
120- steps []TransactionStep
121- rollbacks []RollbackStep
122-}
123-
124-func (tq *TransactionQueue) Add(step TransactionStep, rollback RollbackStep) {
125- tq.steps = append(tq.steps, step)
126- tq.rollbacks = append(tq.rollbacks, rollback)
127-}
128-
129-func (tq *TransactionQueue) Execute() error {
130- var transactionErrors []error
131- var rollbackErrors []error
132- for i, step := range tq.steps {
133- err := step()
134- if err != nil {
135- log.Printf("Error occurred at step index %d: %v", i, err)
136- transactionErrors = append(transactionErrors, err)
137- for j := i - 1; j >= 0; j-- {
138- rollback := tq.rollbacks[j]
139- if rollback != nil {
140- if rollbackErr := rollback(); rollbackErr != nil {
141- log.Printf("Failed to execute rollback at index %d: %v", j, rollbackErr)
142- rollbackErrors = append(rollbackErrors, rollbackErr)
143- }
144- }
145- }
146- return fmt.Errorf("transaction failed: %v; rollback errors: %v", transactionErrors, rollbackErrors)
147- }
148- }
149- return nil
150-}
151-
152-// ContainerManagerEditPOST handles the edit container form submission
153-func ContainerManagerEditPOST(c echo.Context, a *server.App) error {
154- tq := &TransactionQueue{}
155- oldContainerID := c.Param("ID")
156-
157- oldContainerInfo, err := docker.GetContainerInfo(oldContainerID)
158- if err != nil {
159- return sendError(c, fmt.Errorf("getting old container info failed: %w", err))
160- }
161-
162- containerConfig := c.FormValue("container_config")
163- var containerParams cmdparams.YAMLContainerParams
164- err = yaml.Unmarshal([]byte(containerConfig), &containerParams)
165- if err != nil {
166- return sendError(c, fmt.Errorf("YAML unmarshal failed: %w", err))
167- }
168-
169- // Use render.FromYAMLStructToCmdParams to convert YAMLContainerParams to ContainerCommandParams
170- cmdParams, err := cmdparams.FromYAMLStructToCmdParams(containerParams)
171- if err != nil {
172- return fmt.Errorf("failed to convert YAML: %w", err)
173- }
174-
175- tq.OldContainerID = oldContainerID
176- tq.OldContainerName = strings.TrimPrefix(oldContainerInfo.Name, "/")
177- tq.NewContainerName = cmdParams.ContainerName
178- tq.TempContainerName = fmt.Sprintf("%s-temp", tq.NewContainerName)
179-
180- cmdParams.ContainerName = tq.TempContainerName
181-
182- // 1. Stop the old container
183- tq.Add(StopOldContainerStep(tq), StartOldContainerRollback(tq))
184-
185- // 2. Create the new container
186- tq.Add(CreateNewContainerStep(tq, cmdParams), RemoveNewContainerRollback(tq))
187-
188- // 3. Start the new container
189- tq.Add(StartNewContainerStep(tq), StopNewContainerRollback(tq))
190-
191- // 4. Rename the new container to the original name
192- tq.Add(RemoveOldContainerStep(tq), nil)
193-
194- // 5. Remove the old container
195- tq.Add(RenameNewContainerStep(tq), RenameNewContainerRollback(tq))
196-
197- err = tq.Execute()
198- if err != nil {
199- return sendError(c, fmt.Errorf("transaction failed: %w", err))
200- }
201-
202- return c.HTML(200, ActionSuccess(a))
203-}
204-
205-// CreateNewContainerStep creates a new container with the given parameters
206-func CreateNewContainerStep(tq *TransactionQueue, cmdParams docker.ContainerCommandParams) TransactionStep {
207- return func() error {
208- var err error
209- tq.NewContainerID, err = docker.CreateContainer(cmdParams)
210- if err != nil {
211- return fmt.Errorf("creating new container failed: %w", err)
212- }
213- return nil
214- }
215-}
216-
217-// StartNewContainerStep starts the new container
218-func StartNewContainerStep(tq *TransactionQueue) TransactionStep {
219- return func() error {
220- err := docker.StartContainer(tq.NewContainerID)
221- if err != nil {
222- return fmt.Errorf("starting new container failed: %w", err)
223- }
224- return nil
225- }
226-}
227-
228-// StopOldContainerStep stops the old container
229-func StopOldContainerStep(tq *TransactionQueue) TransactionStep {
230- return func() error {
231- err := docker.StopContainer(tq.OldContainerID)
232- if err != nil {
233- return fmt.Errorf("stopping old container failed: %w", err)
234- }
235- return nil
236- }
237-}
238-
239-// RemoveOldContainerStep removes the old container
240-func RemoveOldContainerStep(tq *TransactionQueue) TransactionStep {
241- return func() error {
242- err := docker.RemoveContainer(tq.OldContainerID)
243- if err != nil {
244- return fmt.Errorf("removing old container failed: %w", err)
245- }
246- return nil
247- }
248-}
249-
250-// RemoveNewContainerRollback removes the new container if the transaction fails
251-func RemoveNewContainerRollback(tq *TransactionQueue) RollbackStep {
252- return func() error {
253- err := docker.RemoveContainer(tq.NewContainerID)
254- if err != nil {
255- return fmt.Errorf("rollback: removing new container failed: %w", err)
256- }
257- return nil
258- }
259-}
260-
261-// StopNewContainerRollback stops the new container if the transaction fails
262-func StopNewContainerRollback(tq *TransactionQueue) RollbackStep {
263- return func() error {
264- err := docker.StopContainer(tq.NewContainerID)
265- if err != nil {
266- return fmt.Errorf("rollback: stopping new container failed: %w", err)
267- }
268- return nil
269- }
270-}
271-
272-// StartOldContainerRollback starts the old container if the transaction fails
273-func StartOldContainerRollback(tq *TransactionQueue) RollbackStep {
274- return func() error {
275- err := docker.StartContainer(tq.OldContainerID)
276- if err != nil {
277- return fmt.Errorf("rollback: starting old container failed: %w", err)
278- }
279- return nil
280- }
281-}
282-
283-// RenameNewContainerStep renames the new temporary container to the original name
284-func RenameNewContainerStep(tq *TransactionQueue) TransactionStep {
285- return func() error {
286- err := docker.RenameContainer(tq.NewContainerID, tq.NewContainerName)
287- if err != nil {
288- return fmt.Errorf("renaming new container failed: %w", err)
289- }
290- return nil
291- }
292-}
293-
294-// RenameNewContainerRollback renames the new container back to the temporary name if it fails
295-func RenameNewContainerRollback(tq *TransactionQueue) RollbackStep {
296- return func() error {
297- err := docker.RenameContainer(tq.NewContainerID, tq.TempContainerName)
298- if err != nil {
299- return fmt.Errorf("rollback: renaming new container back failed: %w", err)
300- }
301- return nil
302- }
303-}
D internal/httpserve/handlers/endpoint_deploy.go +0 -373
@@ -1,373 +0,0 @@
1-// endpoint_deploy.go
2-package handlers
3-
4-import (
5- "encoding/json"
6- "fmt"
7- "io"
8- "net/http"
9- "os"
10- "path/filepath"
11- "strings"
12-
13- authToken "github.com/bnema/gordon/internal/cli/auth"
14- "github.com/bnema/gordon/internal/common"
15- "github.com/bnema/gordon/internal/server"
16- "github.com/bnema/gordon/pkg/docker"
17- "github.com/charmbracelet/log"
18- "github.com/labstack/echo/v4"
19-)
20-
21-// PostDeploy handles the container deployment request
22-func PostDeploy(c echo.Context, a *server.App) error {
23- payload, err := validateAndPrepareDeployPayload(c)
24- if err != nil {
25- // Debug
26- log.Error("Failed to validate and prepare payload", "error", err)
27- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
28- Success: false,
29- Message: fmt.Sprintf("Invalid payload: %v", err),
30- StatusCode: http.StatusBadRequest,
31- })
32- }
33-
34- _, err = saveAndImportImage(c, a, payload)
35- if err != nil {
36- // Debug
37- log.Error("Failed to save and import image", "error", err)
38- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
39- Success: false,
40- Message: fmt.Sprintf("Failed to save or import image: %v", err),
41- StatusCode: http.StatusInternalServerError,
42- })
43- }
44-
45- containerID, containerName, err := createAndStartContainer(a, payload)
46- if err != nil {
47- if strings.Contains(err.Error(), "is already in use") {
48- log.Warn("Container already exists", "error", err)
49- existingContainerID := extractContainerID(err.Error())
50- existingContainerName, _ := docker.GetContainerName(existingContainerID)
51- // Remove the / from the container name
52- existingContainerName = strings.TrimLeft(existingContainerName, "/")
53-
54- if existingContainerID == "" || existingContainerName == "" {
55- log.Error("Failed to extract container ID or Name from error message")
56- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
57- Success: false,
58- Message: "A container for this deploy already exists but could not be identified.",
59- StatusCode: http.StatusConflict,
60- })
61- }
62-
63- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
64- Success: false,
65- Message: "A container for this deploy already exists.",
66- ContainerID: existingContainerID,
67- ContainerName: existingContainerName,
68- StatusCode: http.StatusConflict,
69- })
70- }
71- log.Error("Failed to create or start container", "error", err)
72- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
73- Success: false,
74- Message: fmt.Sprintf("Failed to create or start container: %v", err),
75- StatusCode: http.StatusInternalServerError,
76- })
77- }
78-
79- // Arrived here means deployment was successful
80- return sendJSONResponse(c, http.StatusOK, DeployResponse{
81- Success: true,
82- Message: "Deployment successful",
83- Domain: payload.TargetDomain,
84- ContainerID: containerID,
85- ContainerName: containerName,
86- })
87-}
88-
89-// PostDeployChunked handles the chunked container deployment request
90-func PostDeployChunked(c echo.Context, a *server.App) error {
91- return handleChunkedDeploy(c, a, c.Request().Header.Get("X-Chunk-Metadata"))
92-}
93-
94-func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) error {
95- var metadata ChunkMetadata
96- if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
97- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
98- Success: false,
99- Message: "Invalid chunk metadata",
100- StatusCode: http.StatusBadRequest,
101- })
102- }
103-
104- payload, err := validateAndPrepareDeployPayload(c)
105- if err != nil {
106- log.Error("Failed to validate and prepare payload", "error", err)
107- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
108- Success: false,
109- Message: fmt.Sprintf("Invalid payload: %v", err),
110- StatusCode: http.StatusBadRequest,
111- })
112- }
113-
114- // Auth verification steps
115-
116- auth := c.Request().Header.Get("Authorization")
117- if auth == "" {
118- return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{
119- Success: false,
120- Message: "Authentication required",
121- StatusCode: http.StatusUnauthorized,
122- })
123- }
124-
125- // Validate the token through GitHub API
126- token := strings.TrimPrefix(auth, "Bearer ")
127- if !authToken.IsValidGitHubToken(token) {
128- return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{
129- Success: false,
130- Message: "Invalid or expired token",
131- StatusCode: http.StatusUnauthorized,
132- })
133- }
134-
135- // TODO: Here we should probably check that the github user is the same as the one in DB
136-
137- if metadata.TotalChunks == 1 {
138- chunkData, err := io.ReadAll(c.Request().Body)
139- if err != nil {
140- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
141- Success: false,
142- Message: "Failed to read chunk data",
143- StatusCode: http.StatusInternalServerError,
144- })
145- }
146-
147- // Store the data directly
148- chunkStore.mu.Lock()
149- chunkStore.chunks[metadata.TransferID] = make(map[int][]byte)
150- chunkStore.chunks[metadata.TransferID][0] = chunkData
151- chunkStore.metadata[metadata.TransferID] = &metadata
152- chunkStore.mu.Unlock()
153-
154- // Process the complete transfer immediately
155- return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID)
156- }
157-
158- // Store chunk data
159- chunkStore.mu.Lock()
160- if chunkStore.chunks[metadata.TransferID] == nil {
161- chunkStore.chunks[metadata.TransferID] = make(map[int][]byte)
162- chunkStore.metadata[metadata.TransferID] = &metadata
163- }
164-
165- chunkData, err := io.ReadAll(c.Request().Body)
166- if err != nil {
167- chunkStore.mu.Unlock()
168- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
169- Success: false,
170- Message: "Failed to read chunk data",
171- StatusCode: http.StatusInternalServerError,
172- })
173- }
174-
175- // Chunks validation steps
176- //
177- if len(chunkData) == 0 {
178- log.Error("Received empty chunk")
179- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
180- Success: false,
181- Message: "Received empty chunk",
182- StatusCode: http.StatusBadRequest,
183- })
184- }
185-
186- // Verify chunk size matches metadata
187- if int64(len(chunkData)) != metadata.ChunkSize {
188- log.Error("Chunk size mismatch",
189- "expected", metadata.ChunkSize,
190- "received", len(chunkData))
191- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
192- Success: false,
193- Message: fmt.Sprintf("Chunk size mismatch: expected %d, got %d",
194- metadata.ChunkSize, len(chunkData)),
195- StatusCode: http.StatusBadRequest,
196- })
197- }
198-
199- // Store the complete chunk into our store
200- chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData
201- chunkStore.mu.Unlock()
202-
203- // If this is the last chunk, process the complete transfer
204- if isTransferComplete(metadata.TransferID) {
205- return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID)
206- }
207-
208- // Include domain information in intermediate chunk responses
209- return sendJSONResponse(c, http.StatusOK, DeployResponse{
210- Success: true,
211- Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks),
212- Domain: payload.TargetDomain,
213- StatusCode: http.StatusOK,
214- })
215-}
216-
217-func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error {
218- log.Info("Starting complete deploy transfer processing",
219- "transferID", transferID)
220-
221- // Get transfer data
222- metadata, chunks, err := getTransferData(transferID)
223- if err != nil {
224- return sendDeployErrorResponse(c, "Failed to get transfer data", err)
225- }
226-
227- // Create temporary directory for processing
228- tmpDir, err := os.MkdirTemp("", "docker-deploy-*")
229- if err != nil {
230- return sendDeployErrorResponse(c, "Failed to create temp directory", err)
231- }
232- defer os.RemoveAll(tmpDir)
233-
234- tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar"))
235- if err != nil {
236- return sendDeployErrorResponse(c, "Failed to create temp file", err)
237- }
238- defer tmpFile.Close()
239-
240- // Write chunks to file
241- if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil {
242- return sendDeployErrorResponse(c, "Failed to write chunks", err)
243- }
244-
245- // Get deployment-specific headers
246- port := normalizePort(c.Request().Header.Get("X-Ports"))
247- targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain"))
248- imageName := c.Request().Header.Get("X-Image-Name")
249-
250- if port == "" || targetDomain == "" || imageName == "" {
251- return sendDeployErrorResponse(c, "Missing required deployment parameters",
252- fmt.Errorf("port: %s, domain: %s, image: %s", port, targetDomain, imageName))
253- }
254-
255- // Extract container name from domain and perform safety checks
256- cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://")
257- containerName := strings.Split(cleanDomain, ".")[0]
258-
259- if containerName == "" {
260- return sendDeployErrorResponse(c, "Invalid target domain",
261- fmt.Errorf("couldn't extract container name from domain: %s", targetDomain))
262- }
263-
264- // Check for existing container
265- existingContainerID := docker.GetContainerIDByName(containerName)
266- if existingContainerID != "" {
267- log.Warn("Container already exists",
268- "name", containerName,
269- "id", existingContainerID)
270- return sendDeployErrorResponse(c, "Container already exists", nil)
271- }
272-
273- // Import the image
274- log.Info("Importing image from file",
275- "path", tmpFile.Name(),
276- "imageName", imageName)
277-
278- imageID, err := docker.ImportImageToEngine(tmpFile.Name())
279- if err != nil {
280- if strings.Contains(err.Error(), "platform compatibility check failed") {
281- log.Error("Platform compatibility check failed",
282- "error", err,
283- "imageName", imageName)
284- return sendDeployErrorResponse(c, "Platform compatibility check failed", err)
285- }
286- return sendDeployErrorResponse(c, "Failed to import image", err)
287- }
288-
289- // Prepare deployment payload
290- deployPayload := &common.DeployPayload{
291- Port: port,
292- TargetDomain: targetDomain,
293- ImageName: imageName,
294- ImageID: imageID,
295- }
296-
297- // Create and start the container
298- containerID, containerName, err := createAndStartContainer(a, deployPayload)
299- if err != nil {
300- if strings.Contains(err.Error(), "is already in use") {
301- existingContainerID := extractContainerID(err.Error())
302- existingContainerName, _ := docker.GetContainerName(existingContainerID)
303- existingContainerName = strings.TrimPrefix(existingContainerName, "/")
304-
305- return sendDeployErrorResponse(c, "Container already in use", err)
306- }
307-
308- return sendDeployErrorResponse(c, "Failed to create or start container", err)
309- }
310-
311- // Cleanup transfer data
312- cleanupTransfer(transferID)
313-
314- log.Info("Deployment completed successfully",
315- "containerID", containerID,
316- "containerName", containerName,
317- "domain", targetDomain)
318-
319- return sendJSONResponse(c, http.StatusOK, DeployResponse{
320- Success: true,
321- Message: "Deployment successful",
322- Domain: targetDomain,
323- ContainerID: containerID,
324- ContainerName: strings.TrimPrefix(containerName, "/"),
325- })
326-}
327-
328-func sendDeployErrorResponse(c echo.Context, message string, err error) error {
329- // The error becomes the message
330- errorMsg := fmt.Sprintf("%s: %s", message, err.Error())
331- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
332- Success: false,
333- Message: errorMsg,
334- StatusCode: http.StatusInternalServerError,
335- })
336-}
337-
338-// func waitForDeployment(domain string, containerID string) error {
339-// client := &http.Client{Timeout: 10 * time.Second}
340-// maxRetries := 10
341-// retryInterval := time.Second
342-
343-// var shortContainerID string
344-// if containerID != "" {
345-// shortContainerID = containerID[:12]
346-// }
347-
348-// log.Info("Waiting for deployment to be reachable",
349-// "domain", domain,
350-// "container_id", shortContainerID)
351-
352-// for i := 0; i < maxRetries; i++ {
353-// resp, err := client.Get(domain)
354-// if err == nil {
355-// defer resp.Body.Close()
356-// if resp.StatusCode == http.StatusOK {
357-// log.Info("Deployment successful",
358-// "domain", domain,
359-// "container_id", shortContainerID)
360-// return nil
361-// }
362-// body, _ := io.ReadAll(resp.Body)
363-// if strings.Contains(string(body), "failed to create container:") {
364-// return fmt.Errorf("deployment failed: %s", string(body))
365-// }
366-// }
367-// log.Warn("Deployment not ready yet, retrying",
368-// "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))
369-// time.Sleep(retryInterval)
370-// }
371-
372-// return fmt.Errorf("deployment not ready after %d attempts", maxRetries)
373-// }
D internal/httpserve/handlers/endpoint_deploy_check.go +0 -75
@@ -1,75 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "net/http"
6- "strings"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/pkg/docker"
10- "github.com/charmbracelet/log"
11- "github.com/labstack/echo/v4"
12-)
13-
14-func CheckDeployConflict(c echo.Context, a *server.App) error {
15- targetDomain := c.QueryParam("domain")
16- if targetDomain == "" {
17- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
18- Success: false,
19- Message: "Target domain is required",
20- StatusCode: http.StatusBadRequest,
21- })
22- }
23-
24- // Extract container name from the target domain
25- cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://")
26- containerName := strings.Split(cleanDomain, ".")[0]
27-
28- if containerName == "" {
29- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
30- Success: false,
31- Message: "Invalid target domain format",
32- StatusCode: http.StatusBadRequest,
33- })
34- }
35-
36- // Check if container exists
37- containerID := docker.GetContainerIDByName(containerName)
38- if containerID == "" {
39- return sendJSONResponse(c, http.StatusOK, DeployResponse{
40- Success: true,
41- Message: "No conflicts found",
42- StatusCode: http.StatusOK,
43- })
44- }
45-
46- // Get container details
47- state, err := docker.GetContainerState(containerID)
48- if err != nil {
49- log.Error("Failed to get container state", "error", err)
50- state = "unknown"
51- }
52-
53- ports, err := docker.GetContainerPorts(containerID)
54- if err != nil {
55- log.Error("Failed to get container ports", "error", err)
56- ports = "unknown"
57- }
58-
59- uptime, err := docker.GetContainerUptime(containerID)
60- if err != nil {
61- log.Error("Failed to get container uptime", "error", err)
62- uptime = "unknown"
63- }
64-
65- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
66- Success: false,
67- Message: fmt.Sprintf("Container with name '%s' already exists", containerName),
68- ContainerID: containerID,
69- ContainerName: containerName,
70- State: state,
71- RunningTime: uptime,
72- Ports: ports,
73- StatusCode: http.StatusConflict,
74- })
75-}
D internal/httpserve/handlers/endpoint_helpers.go +0 -445
@@ -1,445 +0,0 @@
1-// endpoint_helpers.go
2-package handlers
3-
4-import (
5- "bytes"
6- "errors"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "regexp"
12- "sync"
13- "time"
14-
15- "github.com/bnema/gordon/internal/common"
16- "github.com/bnema/gordon/internal/server"
17- "github.com/bnema/gordon/internal/templating/cmdparams"
18- "github.com/bnema/gordon/pkg/docker"
19- "github.com/bnema/gordon/pkg/store"
20- "github.com/charmbracelet/log"
21- "github.com/labstack/echo/v4"
22-)
23-
24-type ChunkMetadata common.ChunkMetadata
25-
26-type ChunkStore struct {
27- mu sync.Mutex
28- chunks map[string]map[int][]byte
29- metadata map[string]*ChunkMetadata
30- started map[string]time.Time
31-}
32-
33-type InfoResponse struct {
34- Uptime string `json:"uptime"`
35- Version string `json:"version"`
36-}
37-
38-type DeployResponse = common.DeployResponse
39-type PushResponse = common.PushResponse
40-type StopResponse = common.StopResponse
41-type RemoveResponse = common.RemoveResponse
42-
43-var chunkStore = &ChunkStore{
44- chunks: make(map[string]map[int][]byte),
45- metadata: make(map[string]*ChunkMetadata),
46- started: make(map[string]time.Time),
47-}
48-
49-const (
50- cleanupInterval = 5 * time.Minute
51- transferTimeout = 30 * time.Minute
52-)
53-
54-func init() {
55- // Start a cleanup goroutine
56- go func() {
57- ticker := time.NewTicker(5 * time.Minute)
58- for range ticker.C {
59- cleanupOldTransfers()
60- }
61- }()
62-}
63-
64-// Populate fills the InfoResponse struct with data from the App
65-func (info *InfoResponse) Populate(a *server.App) {
66- info.Uptime = a.GetUptime()
67- info.Version = a.GetVersionstring()
68-}
69-
70-func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
71- err := c.JSON(statusCode, response)
72- if err != nil {
73- log.Error("Failed to send JSON response",
74- "error", err,
75- "statusCode", statusCode)
76- }
77- return err
78-}
79-
80-// GetInfos handles GET requests on /api/ping endpoint
81-func GetInfos(c echo.Context, a *server.App) error {
82- payload := new(common.RequestPayload)
83- if err := c.Bind(payload); err != nil {
84- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
85- }
86-
87- if payload.Type != "ping" {
88- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
89- }
90-
91- pingPayload, ok := payload.Payload.(common.PingPayload)
92- if !ok {
93- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
94- }
95-
96- if pingPayload.Message != "ping" {
97- return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message")
98- }
99-
100- info := &InfoResponse{}
101- info.Populate(a)
102-
103- return sendJSONResponse(c, http.StatusOK, info)
104-}
105-
106-// validateAndPrepareDeployPayload validates and prepares the deploy payload
107-func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
108- payload := &common.DeployPayload{
109- Port: c.Request().Header.Get("X-Ports"),
110- ImageName: c.Request().Header.Get("X-Image-Name"),
111- TargetDomain: c.Request().Header.Get("X-Target-Domain"),
112- }
113-
114- if payload.ImageName == "" {
115- return nil, errors.New("invalid image name")
116- }
117-
118- payload.Port = normalizePort(payload.Port)
119- payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain)
120-
121- return payload, nil
122-}
123-
124-// ssaveAndImportImage saves and imports an image from the request body
125-func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (string, error) {
126- var imageName, imageID string
127-
128- // Determine payload type and extract image name
129- switch p := payload.(type) {
130- case *common.PushPayload:
131- imageName = p.ImageName
132- defer func() { p.ImageID = imageID }()
133- case *common.DeployPayload:
134- imageName = p.ImageName
135- defer func() { p.ImageID = imageID }()
136- default:
137- return "", fmt.Errorf("unsupported payload type: %T", payload)
138- }
139-
140- imageReader := c.Request().Body
141- defer imageReader.Close()
142-
143- imageFileName := sanitizeImageFileName(imageName)
144-
145- imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
146- if err != nil {
147- return "", fmt.Errorf("failed to save image: %v", err)
148- }
149-
150- imageID, err = docker.ImportImageToEngine(imagePath)
151- if err != nil {
152- if removeErr := store.RemoveFromStorage(imagePath); removeErr != nil {
153- log.Error("Failed to remove temporary image file after import failure",
154- "error", removeErr,
155- "path", imagePath)
156- }
157- return "", fmt.Errorf("failed to import image: %v", err)
158- }
159-
160- if imageID == "" {
161- if removeErr := store.RemoveFromStorage(imagePath); removeErr != nil {
162- log.Error("Failed to remove temporary image file after empty ID",
163- "error", removeErr,
164- "path", imagePath)
165- }
166- return "", errors.New("imported image ID is empty")
167- }
168-
169- err = store.RemoveFromStorage(imagePath)
170- if err != nil {
171- log.Error("Failed to remove temporary image file after successful import",
172- "error", err,
173- "path", imagePath)
174- return "", fmt.Errorf("failed to remove temporary image file: %v", err)
175- }
176-
177- return imagePath, nil
178-}
179-
180-// createAndStartContainer creates a container from the image, starts it and returns the container ID
181-func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
182- log.Info("Starting container creation process",
183- "imageName", payload.ImageName,
184- "imageID", payload.ImageID,
185- "port", payload.Port)
186-
187- params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
188- if err != nil {
189- return "", "", fmt.Errorf("failed to create command parameters: %v", err)
190- }
191-
192- log.Debug("Container parameters prepared", "params", params)
193-
194- containerID, err := docker.CreateContainer(params)
195- if err != nil {
196- return "", "", fmt.Errorf("failed to create container: %v", err)
197- }
198-
199- log.Info("Container created successfully", "containerID", containerID)
200-
201- containerName, err := docker.GetContainerName(containerID)
202- if err != nil {
203- return "", "", fmt.Errorf("failed to get container name: %v", err)
204- }
205-
206- log.Info("Starting container",
207- "containerID", containerID,
208- "name", containerName)
209-
210- // Use regular StartContainer instead of StartContainerWithContext
211- err = docker.StartContainer(containerID)
212- if err != nil {
213- // Get container logs if start failed
214- logs, logErr := docker.GetContainerLogs(containerID)
215- if logErr == nil {
216- log.Error("Container start failed - container logs",
217- "logs", logs,
218- "containerID", containerID)
219- }
220-
221- // Cleanup the failed container
222- cleanupErr := docker.RemoveContainer(containerID)
223- if cleanupErr != nil {
224- log.Error("Failed to cleanup container after start failure",
225- "containerID", containerID,
226- "error", cleanupErr)
227- }
228- return "", "", fmt.Errorf("failed to start container: %v", err)
229- }
230-
231- log.Info("Container started successfully",
232- "containerID", containerID,
233- "name", containerName)
234-
235- return containerID, containerName, nil
236-}
237-
238-// Helpers for sanitizing and normalizing input
239-func sanitizeImageFileName(imageName string) string {
240- imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
241- imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
242- return imageName + ".tar"
243-}
244-
245-// normalizePort ensures that the port is in the format of "port/protocol"
246-func normalizePort(port string) string {
247- if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
248- return port + "/tcp"
249- }
250- return port
251-}
252-
253-// normalizeTargetDomain ensures that the target domain is in the format of "https://domain" or "http://domain"
254-func normalizeTargetDomain(domain string) string {
255- if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
256- return "https://" + domain
257- }
258- return domain
259-}
260-
261-// generateShortID generates a short ID from the full image ID
262-func generateShortID(imageID string) string {
263- shortID := imageID[7:]
264- if len(shortID) > 12 {
265- shortID = shortID[:12]
266- }
267- return shortID
268-}
269-
270-// generateCreateContainerURL generates the URL for creating a container
271-func generateCreateContainerURL(a *server.App, shortID string) string {
272- return fmt.Sprintf("%s://%s%s/cc/%s",
273- a.Config.Http.Protocol(),
274- a.Config.Http.FullDomain(),
275- a.Config.Admin.Path,
276- shortID)
277-}
278-
279-// generateContainerLogsURL generates the URL for viewing container logs
280-func isTransferComplete(transferID string) bool {
281- chunkStore.mu.Lock()
282- defer chunkStore.mu.Unlock()
283-
284- metadata := chunkStore.metadata[transferID]
285- if metadata == nil {
286- log.Warn("No metadata found for transfer", "transferID", transferID)
287- return false
288- }
289-
290- chunksReceived := len(chunkStore.chunks[transferID])
291- isComplete := chunksReceived == metadata.TotalChunks
292-
293- log.Debug("Transfer completion check",
294- "transferID", transferID,
295- "chunksReceived", chunksReceived,
296- "totalExpected", metadata.TotalChunks,
297- "isComplete", isComplete)
298-
299- return isComplete
300-}
301-
302-// Cleanup helpers for old transfers
303-func cleanupTransfer(transferID string) {
304- chunkStore.mu.Lock()
305- defer chunkStore.mu.Unlock()
306-
307- delete(chunkStore.chunks, transferID)
308- delete(chunkStore.metadata, transferID)
309- delete(chunkStore.started, transferID)
310-}
311-
312-// cleanupOldTransfers periodically checks for and cleans up old transfers
313-func cleanupOldTransfers() {
314- ticker := time.NewTicker(cleanupInterval)
315- defer ticker.Stop() // Good practice to clean up the ticker
316-
317- for range ticker.C {
318- now := time.Now()
319- chunkStore.mu.Lock()
320- for id, startTime := range chunkStore.started {
321- if now.Sub(startTime) > transferTimeout {
322- log.Debug("Cleaning up stale transfer",
323- "transferID", id,
324- "age", now.Sub(startTime))
325- cleanupTransfer(id)
326- }
327- }
328- chunkStore.mu.Unlock()
329- }
330-}
331-
332-// extractContainerID extracts the container ID from an error message
333-func extractContainerID(errorMessage string) string {
334- re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
335- match := re.FindStringSubmatch(errorMessage)
336- if len(match) > 1 {
337- return match[1]
338- }
339- return ""
340-}
341-
342-// storeIDMapping stores a mapping between a short ID and a full ID
343-func storeIDMapping(shortID, fullID string) {
344- safelyInteractWithIDMap(Update, shortID, fullID)
345-}
346-
347-func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) error {
348- // Validate input parameters
349- if file == nil {
350- return errors.New("nil file handle provided")
351- }
352- if len(chunks) == 0 {
353- return errors.New("no chunks provided")
354- }
355- if totalChunks <= 0 {
356- return fmt.Errorf("invalid total chunks count: %d", totalChunks)
357- }
358-
359- // Verify we have all chunks before starting
360- if len(chunks) != totalChunks {
361- return fmt.Errorf("chunk count mismatch: got %d, expected %d", len(chunks), totalChunks)
362- }
363-
364- // Calculate total expected size
365- var totalSize int64
366- for _, chunk := range chunks {
367- totalSize += int64(len(chunk))
368- }
369-
370- log.Debug("Starting chunk assembly",
371- "totalChunks", totalChunks,
372- "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))
373-
374- // First, seek to the beginning of the file
375- if _, err := file.Seek(0, 0); err != nil {
376- return fmt.Errorf("failed to seek to beginning: %w", err)
377- }
378-
379- // Create a temporary buffer with capacity hint
380- tempBuffer := bytes.NewBuffer(make([]byte, 0, totalSize))
381-
382- // Write chunks in order with validation
383- for i := 0; i < totalChunks; i++ {
384- chunk, exists := chunks[i]
385- if !exists {
386- return fmt.Errorf("missing chunk %d", i)
387- }
388-
389- if len(chunk) == 0 {
390- return fmt.Errorf("empty chunk found at index %d", i)
391- }
392-
393- n, err := tempBuffer.Write(chunk)
394- if err != nil {
395- return fmt.Errorf("failed to write chunk %d to buffer: %w", i, err)
396- }
397- if n != len(chunk) {
398- return fmt.Errorf("incomplete chunk write: wrote %d of %d bytes for chunk %d",
399- n, len(chunk), i)
400- }
401- }
402-
403- // Verify buffer size matches expected total
404- if int64(tempBuffer.Len()) != totalSize {
405- return fmt.Errorf("buffer size mismatch: got %d, expected %d",
406- tempBuffer.Len(), totalSize)
407- }
408-
409- // Write the complete buffer to file
410- written, err := io.Copy(file, tempBuffer)
411- if err != nil {
412- return fmt.Errorf("failed to write buffer to file: %w", err)
413- }
414- if written != totalSize {
415- return fmt.Errorf("file write size mismatch: wrote %d, expected %d",
416- written, totalSize)
417- }
418-
419- // Ensure all data is written to disk
420- if err := file.Sync(); err != nil {
421- return fmt.Errorf("failed to sync file: %w", err)
422- }
423-
424- // Reset file pointer to beginning
425- if _, err := file.Seek(0, 0); err != nil {
426- return fmt.Errorf("failed to reset file pointer: %w", err)
427- }
428-
429- log.Debug("Chunk assembly completed successfully",
430- "totalBytesWritten", written,
431- "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))
432-
433- return nil
434-}
435-
436-func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) {
437- chunkStore.mu.Lock()
438- defer chunkStore.mu.Unlock()
439-
440- metadata := chunkStore.metadata[transferID]
441- if metadata == nil {
442- return nil, nil, fmt.Errorf("transfer metadata not found")
443- }
444- return metadata, chunkStore.chunks[transferID], nil
445-}
D internal/httpserve/handlers/endpoint_push.go +0 -249
@@ -1,249 +0,0 @@
1-// endpoint_push.go
2-package handlers
3-
4-import (
5- "encoding/json"
6- "errors"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "path/filepath"
12- "strings"
13-
14- "github.com/bnema/gordon/internal/common"
15- "github.com/bnema/gordon/internal/server"
16- "github.com/bnema/gordon/pkg/docker"
17- "github.com/charmbracelet/log"
18- "github.com/labstack/echo/v4"
19-)
20-
21-// PostPush handles the image push request
22-func PostPush(c echo.Context, a *server.App) error {
23- requestID := c.Response().Header().Get(echo.HeaderXRequestID)
24- log.Info("PostPush started",
25- "requestID", requestID,
26- "path", c.Request().URL.Path)
27- payload, err := validateAndPreparePushPayload(c)
28- if err != nil {
29- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
30- Success: false,
31- Message: err.Error(),
32- })
33- }
34-
35- _, err = saveAndImportImage(c, a, payload)
36- if err != nil {
37- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
38- Success: false,
39- Message: err.Error(),
40- })
41- }
42-
43- shortID := generateShortID(payload.ImageID)
44- storeIDMapping(shortID, payload.ImageID)
45-
46- createContainerURL := generateCreateContainerURL(a, shortID)
47-
48- return sendJSONResponse(c, http.StatusOK, common.PushResponse{
49- Success: true,
50- Message: "Image pushed and imported successfully",
51- CreateContainerURL: createContainerURL,
52- })
53-}
54-
55-// PostPushChunked handles chunked (large) image pushes
56-func PostPushChunked(c echo.Context, a *server.App) error {
57- return handleChunkedPush(c, a, c.Request().Header.Get("X-Chunk-Metadata"))
58-}
59-
60-func handleChunkedPush(c echo.Context, a *server.App, metadataStr string) error {
61- var metadata ChunkMetadata
62- if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
63- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
64- Success: false,
65- Message: "Invalid chunk metadata",
66- })
67- }
68-
69- // Initialize chunk storage first
70- chunkStore.mu.Lock()
71- if chunkStore.chunks[metadata.TransferID] == nil {
72- chunkStore.chunks[metadata.TransferID] = make(map[int][]byte)
73- chunkStore.metadata[metadata.TransferID] = &metadata
74- }
75- chunkStore.mu.Unlock()
76-
77- chunkData, err := io.ReadAll(c.Request().Body)
78- if err != nil {
79- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
80- Success: false,
81- Message: "Failed to read chunk data",
82- })
83- }
84-
85- // Validate chunk data
86- if err := validatePushChunk(chunkData, &metadata); err != nil {
87- log.Error("Chunk validation failed",
88- "error", err,
89- "chunkNumber", metadata.ChunkNumber,
90- "transferID", metadata.TransferID)
91- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
92- Success: false,
93- Message: fmt.Sprintf("Invalid chunk: %v", err),
94- })
95- }
96-
97- // Store chunk data
98- chunkStore.mu.Lock()
99- chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData
100- chunkStore.mu.Unlock()
101-
102- // Process complete transfer if this was the last chunk
103- if isTransferComplete(metadata.TransferID) {
104- return processCompleteChunkedPushTransfer(c, a, metadata.TransferID)
105- }
106-
107- // Acknowledge intermediate chunk
108- return sendJSONResponse(c, http.StatusOK, PushResponse{
109- Success: true,
110- Message: fmt.Sprintf("Chunk %d/%d received successfully",
111- metadata.ChunkNumber+1, metadata.TotalChunks),
112- })
113-}
114-
115-func processCompleteChunkedPushTransfer(c echo.Context, a *server.App, transferID string) error {
116- log.Info("Starting complete push transfer processing",
117- "transferID", transferID)
118-
119- // Get transfer data
120- metadata, chunks, err := getTransferData(transferID)
121- if err != nil {
122- return sendPushErrorResponse(c, "Failed to get transfer data", err)
123- }
124-
125- // Validate we have all chunks
126- if len(chunks) != metadata.TotalChunks {
127- return sendPushErrorResponse(c, "Incomplete transfer",
128- fmt.Errorf("expected %d chunks, got %d", metadata.TotalChunks, len(chunks)))
129- }
130-
131- // Create temporary directory with specific naming
132- tmpDir, err := os.MkdirTemp("", "docker-push-*")
133- if err != nil {
134- return sendPushErrorResponse(c, "Failed to create temp directory", err)
135- }
136- defer os.RemoveAll(tmpDir)
137-
138- // Create temporary file with .tar extension
139- tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar"))
140- if err != nil {
141- return sendPushErrorResponse(c, "Failed to create temp file", err)
142- }
143- defer tmpFile.Close()
144-
145- // Write chunks in order to ensure proper tar assembly
146- for i := 0; i < metadata.TotalChunks; i++ {
147- chunk, exists := chunks[i]
148- if !exists {
149- return sendPushErrorResponse(c, "Missing chunk",
150- fmt.Errorf("chunk %d not found", i))
151- }
152- if _, err := tmpFile.Write(chunk); err != nil {
153- return sendPushErrorResponse(c, "Failed to write chunk",
154- fmt.Errorf("error writing chunk %d: %v", i, err))
155- }
156- }
157-
158- // Ensure all data is written to disk
159- if err := tmpFile.Sync(); err != nil {
160- return sendPushErrorResponse(c, "Failed to sync file", err)
161- }
162-
163- // Reset file pointer to beginning
164- if _, err := tmpFile.Seek(0, 0); err != nil {
165- return sendPushErrorResponse(c, "Failed to reset file pointer", err)
166- }
167-
168- // Verify file size
169- fileInfo, err := tmpFile.Stat()
170- if err != nil {
171- return sendPushErrorResponse(c, "Failed to get file info", err)
172- }
173-
174- log.Info("Image file created",
175- "size", fmt.Sprintf("%.2f MB", float64(fileInfo.Size())/(1024*1024)),
176- "path", tmpFile.Name())
177-
178- imageName := c.Request().Header.Get("X-Image-Name")
179- if imageName == "" {
180- return sendPushErrorResponse(c, "Missing image name", fmt.Errorf("X-Image-Name header is required"))
181- }
182-
183- log.Info("Importing image",
184- "path", tmpFile.Name(),
185- "imageName", imageName)
186-
187- // Import the image
188- imageID, err := docker.ImportImageToEngine(tmpFile.Name())
189- if err != nil {
190- if strings.Contains(err.Error(), "platform compatibility check failed") {
191- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
192- Success: false,
193- Message: fmt.Sprintf("Image platform not compatible with server: %v", err),
194- })
195- }
196- return sendPushErrorResponse(c, "Failed to import image", err)
197- }
198-
199- // Cleanup and prepare response
200- cleanupTransfer(transferID)
201- shortID := generateShortID(imageID)
202- storeIDMapping(shortID, imageID)
203- createContainerURL := generateCreateContainerURL(a, shortID)
204-
205- return sendJSONResponse(c, http.StatusOK, PushResponse{
206- Success: true,
207- Message: "Image pushed and imported successfully",
208- CreateContainerURL: createContainerURL,
209- ImageID: imageID,
210- })
211-}
212-
213-func validatePushChunk(chunk []byte, metadata *ChunkMetadata) error {
214- if len(chunk) == 0 {
215- return errors.New("empty chunk received")
216- }
217-
218- if metadata == nil {
219- return errors.New("missing chunk metadata")
220- }
221-
222- if int64(len(chunk)) != metadata.ChunkSize {
223- return fmt.Errorf("chunk size mismatch: expected %d, got %d",
224- metadata.ChunkSize, len(chunk))
225- }
226-
227- return nil
228-}
229-
230-func sendPushErrorResponse(c echo.Context, message string, err error) error {
231- log.Error(message, "error", err)
232- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
233- Success: false,
234- Message: message,
235- })
236-}
237-
238-// validateAndPreparePushPayload validates and prepares the push payload
239-func validateAndPreparePushPayload(c echo.Context) (*common.PushPayload, error) {
240- payload := &common.PushPayload{
241- ImageName: c.Request().Header.Get("X-Image-Name"),
242- }
243-
244- if payload.ImageName == "" {
245- return nil, errors.New("invalid image name")
246- }
247-
248- return payload, nil
249-}
D internal/httpserve/handlers/endpoint_remove.go +0 -44
@@ -1,44 +0,0 @@
1-package handlers
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/internal/common"
7- "github.com/bnema/gordon/internal/server"
8- "github.com/bnema/gordon/pkg/docker"
9- "github.com/labstack/echo/v4"
10-)
11-
12-// PostContainerRemove handles the container remove request
13-func PostContainerRemove(c echo.Context, a *server.App) error {
14- payload := new(common.RequestPayload)
15- if err := c.Bind(payload); err != nil {
16- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17- }
18-
19- if payload.Type != "remove" {
20- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21- }
22-
23- removePayload, ok := payload.Payload.(common.RemovePayload)
24- if !ok {
25- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26- }
27-
28- if removePayload.ContainerID == "" {
29- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30- }
31-
32- err := docker.RemoveContainer(removePayload.ContainerID)
33- if err != nil {
34- return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{
35- Success: false,
36- Message: err.Error(),
37- })
38- }
39-
40- return sendJSONResponse(c, http.StatusOK, RemoveResponse{
41- Success: true,
42- Message: "Container removed successfully",
43- })
44-}
D internal/httpserve/handlers/endpoint_stop.go +0 -44
@@ -1,44 +0,0 @@
1-package handlers
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/internal/common"
7- "github.com/bnema/gordon/internal/server"
8- "github.com/bnema/gordon/pkg/docker"
9- "github.com/labstack/echo/v4"
10-)
11-
12-// PostContainerStop handles the container stop request
13-func PostContainerStop(c echo.Context, a *server.App) error {
14- payload := new(common.RequestPayload)
15- if err := c.Bind(payload); err != nil {
16- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17- }
18-
19- if payload.Type != "stop" {
20- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21- }
22-
23- stopPayload, ok := payload.Payload.(common.StopPayload)
24- if !ok {
25- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26- }
27-
28- if stopPayload.ContainerID == "" {
29- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30- }
31-
32- err := docker.StopContainer(stopPayload.ContainerID)
33- if err != nil {
34- return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{
35- Success: false,
36- Message: err.Error(),
37- })
38- }
39-
40- return sendJSONResponse(c, http.StatusOK, StopResponse{
41- Success: true,
42- Message: "Container stopped successfully",
43- })
44-}
D internal/httpserve/handlers/login.go +0 -380
@@ -1,380 +0,0 @@
1-package handlers
2-
3-import (
4- "encoding/base64"
5- "encoding/json"
6- "fmt"
7- "io"
8- "net/http"
9- "net/url"
10- "time"
11-
12- "github.com/bnema/gordon/internal/db"
13- "github.com/bnema/gordon/internal/db/queries"
14- "github.com/bnema/gordon/internal/server"
15- "github.com/bnema/gordon/internal/templating/render"
16- "github.com/charmbracelet/log"
17- "github.com/gorilla/sessions"
18- _ "github.com/joho/godotenv/autoload"
19- "github.com/labstack/echo-contrib/session"
20- "github.com/labstack/echo/v4"
21-)
22-
23-var urlToken string
24-
25-// ClLI :
26-// compareGordonToken compares the token from the URL query parameter with the one from the config.yml
27-func CompareGordonToken(c echo.Context, a *server.App) error {
28- configToken, err := a.Config.GetToken()
29- if err != nil {
30- return err
31- }
32-
33- if urlToken != configToken {
34- return fmt.Errorf("token is empty or not valid")
35- }
36-
37- return nil
38-}
39-
40-// RenderLoginPage renders the login.html template
41-func RenderLoginPage(c echo.Context, a *server.App) error {
42- urlToken = c.QueryParam("token")
43- data := map[string]interface{}{
44- "Title": "Login",
45- }
46-
47- // Navigate inside the fs.FS to get the template
48- path := "html/login"
49- rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a)
50- if err != nil {
51- return err
52- }
53-
54- renderedHTML, err := rendererData.Render(data, a)
55- if err != nil {
56- return err
57- }
58-
59- return c.HTML(200, renderedHTML)
60-}
61-
62-// StartOAuthGithub starts the Github OAuth flow.
63-
64-func StartOAuthGithub(c echo.Context, a *server.App) error {
65- log.Info("Attempting to get an existing session.")
66- sess, err := getSession(c)
67- if err != nil || sess == nil || sess.Values == nil {
68- log.Info("Session retrieval failed, initiating new GitHub OAuth flow.")
69- return initiateGithubOAuthFlow(c, a)
70- }
71-
72- log.Info("Attempting to assert session values.")
73- sessionID, ok1 := sess.Values["sessionID"].(string)
74- accountID, ok2 := sess.Values["accountID"].(string)
75- authenticated, ok3 := sess.Values["authenticated"].(bool)
76-
77- if !ok1 || !ok2 || !ok3 || !authenticated {
78- log.Info("Session assertion failed, or user not authenticated. Initiating new OAuth flow.")
79- return initiateGithubOAuthFlow(c, a)
80- }
81-
82- log.Info("Checking if session exists in DB.")
83- sessionExists, err := queries.CheckDBSessionExists(a, sessionID)
84- if err != nil {
85- log.Info("Error checking if session exists", "error", err)
86- return fmt.Errorf("could not check if session exists: %w", err)
87- }
88-
89- if !sessionExists {
90- log.Info("Session does not exist in DB, initiating new OAuth flow.")
91- return initiateGithubOAuthFlow(c, a)
92- }
93-
94- log.Info("Getting the expiration time of the session.")
95- currentTime := time.Now()
96- sessionExpiration, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime)
97- if err != nil {
98- log.Info("Error getting session expiration", "error", err)
99- return fmt.Errorf("could not get session expiration: %w", err)
100- }
101-
102- if currentTime.After(sessionExpiration) {
103- log.Info("Current time is after session expiration, initiating new OAuth flow.")
104- return initiateGithubOAuthFlow(c, a)
105- }
106-
107- log.Info("Extending session expiration time by 30 minutes.")
108- newExpirationTime := currentTime.Add(30 * time.Minute)
109- err = queries.ExtendSessionExpiration(a, accountID, sessionID, newExpirationTime)
110- if err != nil {
111- log.Info("Error extending session expiration", "error", err)
112- return fmt.Errorf("could not extend session expiration: %w", err)
113- }
114-
115- log.Info("Session is valid and extended. Redirecting to admin panel.")
116- return c.Redirect(http.StatusFound, a.Config.Admin.Path)
117-}
118-
119-// initiateGithubOAuthFlow starts a new GitHub OAuth flow
120-func initiateGithubOAuthFlow(c echo.Context, a *server.App) error {
121- clientID := "" // IDK why it needs that even empty, but it does
122- redirectDomain := a.GenerateOauthCallbackURL()
123- encodedState := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain))
124-
125- // Redirect to GitHub's OAuth endpoint to grab the OAuth access
126- proxyURL := a.Config.Build.ProxyURL
127- oauthURL := fmt.Sprintf(
128- "%s/github/authorize?client_id=%s&redirect_uri=%s&state=%s",
129- proxyURL,
130- clientID,
131- redirectDomain,
132- encodedState,
133- )
134- return c.Redirect(http.StatusFound, oauthURL)
135-}
136-
137-type Sessions struct {
138- *db.Sessions
139-}
140-
141-// OAuthCallback handles the callback response from Github OAuth
142-func OAuthCallback(c echo.Context, a *server.App) error {
143- redirectPath := a.Config.Admin.Path
144-
145- accessToken, encodedState, err := parseQueryParams(c)
146- if err != nil {
147- return c.String(http.StatusBadRequest, err.Error())
148- }
149-
150- // update the struct with the new access token
151- a.DBTables.Sessions.AccessToken = accessToken
152-
153- // Compare the state parameter with the redirect domain
154- redirectDomain := a.GenerateOauthCallbackURL()
155- encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain))
156- if encodedState != encodedRedirectDomain {
157- return c.String(http.StatusBadRequest, "invalid state parameter")
158- }
159-
160- sess, err := getSession(c)
161- if err != nil {
162- return err
163- }
164-
165- browserInfo := c.Request().UserAgent()
166- userInfo, err := githubGetUserDetails(c)
167- if err != nil {
168- return err
169- }
170-
171- _, err = handleUser(c, a, accessToken, browserInfo, userInfo)
172- if err != nil {
173- return err
174- }
175-
176- err = setSessionValues(c, sess, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID)
177- if err != nil {
178- return err
179- }
180-
181- return c.Redirect(http.StatusFound, redirectPath)
182-}
183-
184-// parseQueryParams parses the query parameters from the callback URL
185-func parseQueryParams(c echo.Context) (string, string, error) {
186- accessToken := c.QueryParam("access_token")
187- encodedState := c.QueryParam("state")
188-
189- _, err := base64.StdEncoding.DecodeString(encodedState)
190- if err != nil {
191- return "", "", fmt.Errorf("invalid state parameter")
192- }
193-
194- return accessToken, encodedState, nil
195-}
196-
197-// getSession gets the session from the context
198-func getSession(c echo.Context) (*sessions.Session, error) {
199- sess, err := session.Get("session", c)
200- if err != nil {
201- return nil, echo.NewHTTPError(http.StatusInternalServerError, "Could not get session")
202- }
203- return sess, nil
204-}
205-
206-// handleUser handles the user creation or update
207-func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
208- userExists, err := queries.CheckDBUserExists(a)
209- if err != nil {
210- return nil, fmt.Errorf("could not check if user exists: %w", err)
211- }
212-
213- if !userExists {
214- // if it is a new user creation we compare the gordon token
215- err := CompareGordonToken(c, a)
216- if err != nil {
217- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is empty or not valid")
218- }
219-
220- return createUser(a, accessToken, browserInfo, userInfo)
221- }
222-
223- if userExists {
224- // if it is an existing user we compare the login and email to see if it is the same user
225- isGoodUser, err := queries.CheckDBUserIsGood(a, userInfo)
226- if err != nil {
227- return nil, fmt.Errorf("could not check if user is good: %w", err)
228- }
229-
230- if !isGoodUser {
231- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user")
232- }
233-
234- if isGoodUser {
235- // Update the user
236- updatedUser, err := updateUser(a, accessToken, browserInfo, userInfo)
237- if err != nil {
238- return nil, fmt.Errorf("could not update user: %w", err)
239- }
240- return updatedUser, nil
241- }
242- }
243-
244- return nil, fmt.Errorf("could not handle user")
245-}
246-
247-// createUser creates a new user in the database
248-func createUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
249- err := queries.CreateUser(a, accessToken, browserInfo, userInfo)
250- if err != nil {
251- return nil, fmt.Errorf("could not create user: %w", err)
252- }
253- return &a.DBTables.User, nil
254-}
255-
256-// updateUser updates an existing user in the database
257-func updateUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
258- user, err := queries.UpdateUser(a, accessToken, browserInfo, userInfo)
259- if err != nil {
260- return nil, fmt.Errorf("could not update user: %w", err)
261- }
262- return user, nil
263-}
264-
265-// setSessionValues sets the session values for the user
266-func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, expires string, sessionID string) error {
267- sess.Values["authenticated"] = true
268- sess.Values["accountID"] = accountID
269- sess.Values["expires"] = expires
270- sess.Values["sessionID"] = sessionID
271-
272- if err := sess.Save(c.Request(), c.Response()); err != nil {
273- return fmt.Errorf("could not save session: %w", err)
274- }
275- return nil
276-}
277-
278-// fetchGithubAPI fetches the Github API
279-func fetchGithubAPI(url string, authHeader string, result interface{}) error {
280- req, err := http.NewRequest("GET", url, nil)
281- if err != nil {
282- return err
283- }
284-
285- req.Header.Set("Authorization", authHeader)
286-
287- resp, err := http.DefaultClient.Do(req)
288- if err != nil {
289- return err
290- }
291- defer resp.Body.Close()
292-
293- if resp.StatusCode != http.StatusOK {
294- return fmt.Errorf("got status code %d", resp.StatusCode)
295- }
296-
297- body, err := io.ReadAll(resp.Body)
298- if err != nil {
299- return err
300- }
301-
302- err = json.Unmarshal(body, &result)
303- if err != nil {
304- return err
305- }
306-
307- return nil
308-}
309-
310-// githubGetUserDetails gets the user details from the Github API using the access token
311-func githubGetUserDetails(c echo.Context) (userInfo *queries.GithubUserInfo, err error) {
312- accessToken := c.QueryParam("access_token")
313-
314- // Fetch user info
315- err = fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo)
316- if err != nil {
317- return nil, err
318- }
319- // Fetch user emails
320- var emailObjects []struct {
321- Email string `json:"email"`
322- }
323- err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects)
324- if err != nil {
325- return nil, err
326- }
327-
328- // Extract emails and populate the Emails field in userInfo
329- for _, emailObj := range emailObjects {
330- userInfo.Emails = append(userInfo.Emails, emailObj.Email)
331- }
332-
333- return userInfo, nil
334-}
335-
336-func DeviceCodeRequest(c echo.Context, a *server.App) error {
337- proxyURL := a.Config.Build.ProxyURL
338- resp, err := http.Post(proxyURL+"/github/device/code", "application/json", nil)
339- if err != nil {
340- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
341- }
342- defer resp.Body.Close()
343-
344- if resp.StatusCode != http.StatusOK {
345- body, _ := io.ReadAll(resp.Body)
346- return c.JSON(resp.StatusCode, map[string]string{"error": string(body)})
347- }
348-
349- var deviceCode map[string]interface{}
350- if err := json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil {
351- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"})
352- }
353-
354- return c.JSON(http.StatusOK, deviceCode)
355-}
356-
357-func DeviceTokenRequest(c echo.Context, a *server.App) error {
358- proxyURL := a.Config.Build.ProxyURL
359- deviceCode := c.FormValue("device_code")
360-
361- resp, err := http.PostForm(proxyURL+"/github/device/token", url.Values{
362- "device_code": {deviceCode},
363- })
364- if err != nil {
365- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
366- }
367- defer resp.Body.Close()
368-
369- if resp.StatusCode != http.StatusOK {
370- body, _ := io.ReadAll(resp.Body)
371- return c.JSON(resp.StatusCode, map[string]string{"error": string(body)})
372- }
373-
374- var tokenResp map[string]interface{}
375- if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
376- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"})
377- }
378-
379- return c.JSON(http.StatusOK, tokenResp)
380-}
D internal/httpserve/handlers/logout.go +0 -52
@@ -1,52 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "net/http"
6- "time"
7-
8- "github.com/bnema/gordon/internal/db/queries"
9- "github.com/bnema/gordon/internal/server"
10- "github.com/gorilla/sessions"
11- "github.com/labstack/echo/v4"
12-)
13-
14-func Logout(c echo.Context, a *server.App) error {
15- // Retrieve the session
16- sess, err := getSession(c)
17- if err != nil || sess == nil {
18- // if there is no session, redirect to the login page
19- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
20-
21- }
22-
23- // Invalidate the session in the database
24- sessionID, ok := sess.Values["sessionID"].(string)
25- if ok {
26- err := queries.InvalidateDBSession(a, sessionID)
27- if err != nil {
28- return fmt.Errorf("failed to invalidate session: %w", err)
29- }
30- }
31-
32- // Delete the session
33- sess.Options = &sessions.Options{
34- Path: "/",
35- MaxAge: -1,
36- HttpOnly: true,
37- }
38-
39- err = sess.Save(c.Request(), c.Response())
40- if err != nil {
41- return fmt.Errorf("failed to save session: %w", err)
42- }
43-
44- // Drop the cookie
45- cookie := new(http.Cookie)
46- cookie.Name = "session"
47- cookie.Value = ""
48- cookie.Expires = time.Now().Add(-1 * time.Hour)
49- c.SetCookie(cookie)
50-
51- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
52-}
D internal/httpserve/handlers/send_error.go +0 -17
@@ -1,17 +0,0 @@
1-package handlers
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/pkg/sanitize"
7- "github.com/labstack/echo/v4"
8-)
9-
10-func sendError(c echo.Context, err error) error {
11- rawErrHTML := `<div>Error: ` + err.Error() + `</div>`
12- sanitizedHTML, err := sanitize.SanitizeHTML(rawErrHTML)
13- if err != nil {
14- return c.String(http.StatusInternalServerError, "An error occurred during sanitization")
15- }
16- return c.HTML(http.StatusInternalServerError, sanitizedHTML)
17-}
D internal/httpserve/handlers/session_db.go +0 -92
@@ -1,92 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/gordon/internal/db/queries"
8- "github.com/bnema/gordon/internal/server"
9- "github.com/labstack/echo-contrib/session"
10- "github.com/labstack/echo/v4"
11-)
12-
13-func ValidateSessionAndUser(c echo.Context, a *server.App) error {
14- sessionExpiryTime := 30 * time.Minute
15-
16- sess, err := session.Get("session", c)
17- if err != nil {
18- return err
19- }
20-
21- accountID, ok := sess.Values["accountID"].(string)
22- if !ok {
23- return fmt.Errorf("accountID not found or invalid type")
24- }
25-
26- sessionID, ok := sess.Values["sessionID"].(string)
27- if !ok {
28- return fmt.Errorf("sessionID not found or invalid type")
29- }
30-
31- accountExists, err := IsAccountIDInDB(a, accountID)
32- if err != nil || !accountExists {
33- return fmt.Errorf("account does not exist")
34- }
35-
36- sessionExpired, err := IsSessionExpiredInDB(a, accountID, sessionID)
37- if err != nil || sessionExpired {
38- return fmt.Errorf("session is expired")
39- }
40-
41- // Extend session expiration conditionally, e.g., if it's close to expiring
42- err = queries.ExtendSessionExpiration(a, accountID, sessionID, time.Now().Add(sessionExpiryTime))
43- if err != nil {
44- return fmt.Errorf("failed to extend session: %v", err)
45- }
46-
47- return nil
48-}
49-
50-func IsSessionExpiredInDB(a *server.App, accountID string, sessionID string) (bool, error) {
51- currentTime := time.Now()
52- expirationTime, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime)
53- if err != nil {
54- return false, err
55- }
56-
57- return currentTime.After(expirationTime), nil
58-}
59-
60-func IsAccountIDInDB(a *server.App, accountID string) (bool, error) {
61- accountExists, err := queries.CheckDBAccountExists(a, accountID)
62- if err != nil {
63- return false, err
64- }
65-
66- return accountExists, nil
67-}
68-
69-// ResetSession resets the session cookie and session data
70-// func ResetSession(c echo.Context) error {
71-// // Manually expire the cookie
72-// cookie := new(http.Cookie)
73-// cookie.Name = "session"
74-// cookie.Value = ""
75-// cookie.Path = "/"
76-// cookie.Expires = time.Unix(0, 0)
77-// cookie.MaxAge = -1 // Clear immediately
78-// c.SetCookie(cookie)
79-// // Invalidate the existing session
80-// sess, err := session.Get("session", c)
81-// if err != nil {
82-// log.Println(err)
83-// return echo.NewHTTPError(http.StatusInternalServerError, "Could not get session")
84-// }
85-// sess.Options.MaxAge = -1
86-// if err := sess.Save(c.Request(), c.Response()); err != nil {
87-// log.Println("Could not delete session:", err)
88-// return echo.NewHTTPError(http.StatusInternalServerError, "Could not delete session")
89-// }
90-
91-// return nil
92-// }
D internal/httpserve/handlers/static.go +0 -55
@@ -1,55 +0,0 @@
1-package handlers
2-
3-import (
4- "io/fs"
5- "net/http"
6- "os"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/labstack/echo/v4"
10-)
11-
12-// NoDirFileSys restricts directory listing
13-type NoDirFileSys struct {
14- fs http.FileSystem
15-}
16-
17-func (nfs NoDirFileSys) Open(name string) (http.File, error) {
18- f, err := nfs.fs.Open(name)
19- if err != nil {
20- return nil, err
21- }
22-
23- return NoDirFile{f}, nil
24-}
25-
26-// NoDirFile restricts directory listing
27-type NoDirFile struct {
28- http.File
29-}
30-
31-func (f NoDirFile) Readdir(count int) ([]fs.FileInfo, error) {
32- // Disable directory listing
33- return nil, nil
34-}
35-
36-// StaticRoute serves static files from the embedded filesystem
37-func StaticRoute(c echo.Context, a *server.App) error {
38- // Set the cache-control header if we are not in dev mode
39- if os.Getenv("RUN_ENV") != "dev" {
40- c.Response().Header().Set("Cache-Control", "public, max-age=86400")
41- } else {
42- c.Response().Header().Set("Cache-Control", "no-cache")
43- }
44-
45- // Convert fs.FS to http.FileSystem
46- httpFS := http.FS(a.PublicFS)
47-
48- // Use custom file system wrapper to disable directory listing
49- noDirFS := NoDirFileSys{httpFS}
50-
51- // Serve the file from the custom filesystem
52- publicFS := http.FileServer(noDirFS)
53- publicFS.ServeHTTP(c.Response(), c.Request())
54- return nil
55-}
D internal/httpserve/handlers/upload_image.go +0 -100
@@ -1,100 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "io"
6- "net/http"
7- "os"
8-
9- "github.com/bnema/gordon/internal/server"
10- "github.com/bnema/gordon/internal/templating/render"
11- "github.com/bnema/gordon/pkg/docker"
12- "github.com/bnema/gordon/pkg/store"
13- "github.com/labstack/echo/v4"
14-)
15-
16-// 10G
17-const MaxUploadSize = 10 * 1024 * 1024 * 1024 // 10GB
18-
19-// UploadImageHandler handles the /upload-image route to show the form
20-func UploadImageGETHandler(c echo.Context, a *server.App) error {
21- data := map[string]interface{}{
22- "Title": "Upload Image",
23- }
24-
25- rendererData, err := render.GetHTMLRenderer("html/fragments", "uploadimage.gohtml", a.TemplateFS, a)
26- if err != nil {
27- return sendError(c, err)
28- }
29-
30- renderedHTML, err := rendererData.Render(data, a)
31- if err != nil {
32- return err
33- }
34- return c.HTML(200, renderedHTML)
35-}
36-
37-// UploadImageHandler handles the /upload-image
38-func UploadImagePOSTHandler(c echo.Context, a *server.App) error {
39- // Set upload size limit
40- c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, MaxUploadSize)
41-
42- // Parse the multipart form
43- form, err := c.MultipartForm()
44- if err != nil {
45- return sendError(c, err)
46- }
47-
48- // Get the file from the form
49- files := form.File["file"]
50- if len(files) == 0 {
51- return c.String(http.StatusBadRequest, "No file uploaded")
52- }
53- file := files[0]
54-
55- // Open the file
56- src, err := file.Open()
57- if err != nil {
58- return sendError(c, err)
59- }
60- defer src.Close()
61-
62- // Create a temporary file to store the uploaded chunks
63- tempFile, err := os.CreateTemp("", "upload-*")
64- if err != nil {
65- return sendError(c, err)
66- }
67- defer tempFile.Close()
68-
69- // Copy the file chunks to the temporary file
70- _, err = io.Copy(tempFile, src)
71- if err != nil {
72- return sendError(c, err)
73- }
74-
75- // Reset the temporary file pointer
76- _, err = tempFile.Seek(0, io.SeekStart)
77- if err != nil {
78- return sendError(c, err)
79- }
80-
81- // Save the image to the storage directory
82- saveInPath, err := store.SaveImageToStorage(&a.Config, file.Filename, tempFile)
83- if err != nil {
84- return sendError(c, err)
85- }
86-
87- // Import the image into Docker
88- _, err = docker.ImportImageToEngine(saveInPath)
89- if err != nil {
90- return fmt.Errorf("failed to import image to Docker: %v", err)
91- }
92-
93- // Remove the image from the storage directory
94- err = store.RemoveFromStorage(saveInPath)
95- if err != nil {
96- return sendError(c, err)
97- }
98-
99- return c.HTML(http.StatusOK, ActionSuccess(a))
100-}
D internal/httpserve/middleware/colorschemedetect.go +0 -35
@@ -1,35 +0,0 @@
1-package middleware
2-
3-import (
4- "github.com/labstack/echo-contrib/session"
5- "github.com/labstack/echo/v4"
6-)
7-
8-// ColorSchemeDetection detects the current color scheme and sets it in the context based on the "Sec-CH-Prefers-Color-Scheme" header
9-func ColorSchemeDetection(next echo.HandlerFunc) echo.HandlerFunc {
10- ColorSchemeKey := "CurrentColorScheme"
11- return func(c echo.Context) error {
12- colorScheme := detectCurrentColorScheme(c)
13- c.Set(ColorSchemeKey, colorScheme)
14- return next(c)
15- }
16-}
17-
18-func detectCurrentColorScheme(c echo.Context) string {
19- // Check if the color scheme key is set in the session storage
20- sess, err := session.Get("session", c)
21- if err != nil {
22- return "light"
23- }
24-
25- if colorSchemeValue, ok := sess.Values["colorScheme"]; ok && colorSchemeValue != nil {
26- return colorSchemeValue.(string) // Return the color scheme value from the session storage
27- }
28-
29- // If color scheme key is not set, proceed with "Sec-CH-Prefers-Color-Scheme" header detection
30- header := c.Request().Header.Get("Sec-CH-Prefers-Color-Scheme")
31- if header == "dark" {
32- return "dark"
33- }
34- return "light" // Default to light mode
35-}
D internal/httpserve/middleware/commondata.go +0 -15
@@ -1,15 +0,0 @@
1-package middleware
2-
3-import (
4- "github.com/bnema/gordon/internal/server"
5- "github.com/labstack/echo/v4"
6-)
7-
8-func SetCommonDataMiddleware(a *server.App) echo.MiddlewareFunc {
9- return func(next echo.HandlerFunc) echo.HandlerFunc {
10- return func(c echo.Context) error {
11- // TODO: set common data
12- return next(c)
13- }
14- }
15-}
D internal/httpserve/middleware/error.go +0 -20
@@ -1,20 +0,0 @@
1-package middleware
2-
3-import (
4- "github.com/charmbracelet/log"
5- "github.com/labstack/echo/v4"
6-)
7-
8-func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc {
9- return func(c echo.Context) error {
10- err := next(c)
11- if err != nil {
12- log.Error("Caught http error",
13- "path", c.Request().URL.Path,
14- "method", c.Request().Method,
15- "error", err,
16- "status", c.Response().Status)
17- }
18- return err
19- }
20-}
D internal/httpserve/middleware/langdetect.go +0 -25
@@ -1,25 +0,0 @@
1-package middleware
2-
3-import (
4- "strings"
5-
6- "github.com/labstack/echo/v4"
7-)
8-
9-// LanguageDetectiondetects the current language and sets it in the context
10-func LanguageDetection(next echo.HandlerFunc) echo.HandlerFunc {
11- return func(c echo.Context) error {
12- lang := detectCurrentLanguage(c)
13- c.Set("LangKey", lang)
14- return next(c)
15- }
16-}
17-
18-func detectCurrentLanguage(c echo.Context) string {
19- // If lang key is not set, proceed with "Accept-Language" header detection
20- header := c.Request().Header.Get("Accept-Language")
21- if strings.Contains(header, "fr") {
22- return "fr"
23- }
24- return "en" // Default to English
25-}
D internal/httpserve/middleware/requirelogin.go +0 -47
@@ -1,47 +0,0 @@
1-package middleware
2-
3-import (
4- "net/http"
5- "time"
6-
7- "github.com/bnema/gordon/internal/httpserve/handlers"
8- "github.com/bnema/gordon/internal/server"
9-
10- "github.com/labstack/echo/v4"
11-)
12-
13-func redirectToLogin(c echo.Context, a *server.App) error {
14- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
15-}
16-
17-func RequireLogin(a *server.App) echo.MiddlewareFunc {
18- return func(next echo.HandlerFunc) echo.HandlerFunc {
19- return func(c echo.Context) error {
20- const maxRetries = 3
21- var err error
22- for i := 0; i < maxRetries; i++ {
23- err = handlers.ValidateSessionAndUser(c, a)
24- if err == nil {
25- return next(c)
26- }
27- if i < maxRetries-1 {
28- time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
29- }
30- }
31- // propose the redirect auth url as httpcode
32- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
33- }
34-
35- }
36-}
37-
38-// func DeleteCookieAndRedirectToLogin(c echo.Context, a *server.App) error {
39-// // Delete session cookie
40-// cookie := new(http.Cookie)
41-// cookie.Name = "session"
42-// cookie.Value = ""
43-// cookie.Expires = time.Now().Add(-1 * time.Hour)
44-// c.SetCookie(cookie)
45-
46-// return redirectToLogin(c, a)
47-// }
D internal/httpserve/middleware/requiretoken.go +0 -223
@@ -1,223 +0,0 @@
1-package middleware
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "io"
7- "net/http"
8- "strings"
9- "sync"
10- "time"
11-
12- "github.com/bnema/gordon/internal/cli/auth"
13- "github.com/bnema/gordon/internal/db/queries"
14- "github.com/bnema/gordon/internal/server"
15- "github.com/charmbracelet/log"
16- "github.com/labstack/echo/v4"
17-)
18-
19-type GithubUserInfo = queries.GithubUserInfo
20-
21-// middleware/require_token.go
22-func RequireToken(a *server.App) echo.MiddlewareFunc {
23- tokenCache := auth.GetTokenCache()
24-
25- return func(next echo.HandlerFunc) echo.HandlerFunc {
26- return func(c echo.Context) error {
27- token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
28- if token == "" {
29- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
30- "success": false,
31- "error": "Token is missing",
32- })
33- }
34-
35- // Check cache first
36- if _, cachedUser, found := tokenCache.GetWithUser(token); found {
37- // Verify if user exists in database and is authorized
38- isAuthorized, err := queries.CheckDBUserIsGood(a, cachedUser)
39- if err != nil || !isAuthorized {
40- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
41- "success": false,
42- "error": "User not authorized",
43- })
44- }
45- c.Set("user", cachedUser)
46- return next(c)
47- }
48-
49- // Validate with GitHub for new tokens
50- githubUser, err := validateGitHubToken(c)
51- if err != nil {
52- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
53- "success": false,
54- "error": "Invalid token",
55- })
56- }
57-
58- // Verify if user exists in database and is authorized
59- isAuthorized, err := queries.CheckDBUserIsGood(a, githubUser)
60- if err != nil {
61- return c.JSON(http.StatusInternalServerError, map[string]interface{}{
62- "success": false,
63- "error": "Internal server error",
64- })
65- }
66-
67- if !isAuthorized {
68- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
69- "success": false,
70- "error": "User not authorized. Please login through the web interface first.",
71- })
72- }
73-
74- tokenCache.SetWithUser(token, token, githubUser, 15*time.Minute)
75- c.Set("user", githubUser)
76- return next(c)
77- }
78- }
79-}
80-
81-var sessionUpdateCache sync.Map
82-
83-func shouldUpdateSession(token string) bool {
84- key := token
85- now := time.Now()
86-
87- if lastUpdate, exists := sessionUpdateCache.Load(key); exists {
88- if now.Sub(lastUpdate.(time.Time)) < 5*time.Minute {
89- log.Debug("skipping session update", "reason", "too_recent")
90- return false
91- }
92- }
93-
94- sessionUpdateCache.Store(key, now)
95- log.Debug("session update required")
96- return true
97-}
98-
99-func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {
100- requestID := c.Response().Header().Get(echo.HeaderXRequestID)
101- token := c.Request().Header.Get("Authorization")
102- if token == "" {
103- log.Warn("missing token in validation request",
104- "request_id", requestID)
105- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing")
106- }
107-
108- token = strings.TrimPrefix(token, "Bearer ")
109- log.Debug("validating GitHub token",
110- "request_id", requestID)
111-
112- client := &http.Client{}
113- req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
114- if err != nil {
115- log.Error("failed to create GitHub API request",
116- "request_id", requestID,
117- "error", err)
118- return nil, fmt.Errorf("failed to create request: %w", err)
119- }
120-
121- req.Header.Set("Authorization", "token "+token)
122- req.Header.Set("Accept", "application/vnd.github.v3+json")
123-
124- resp, err := client.Do(req)
125- if err != nil {
126- log.Error("GitHub API request failed",
127- "request_id", requestID,
128- "error", err)
129- return nil, fmt.Errorf("failed to send request: %w", err)
130- }
131- defer resp.Body.Close()
132-
133- if resp.StatusCode != http.StatusOK {
134- log.Warn("invalid GitHub token",
135- "request_id", requestID,
136- "status", resp.Status)
137- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
138- }
139-
140- body, err := io.ReadAll(resp.Body)
141- if err != nil {
142- log.Error("failed to read GitHub response",
143- "request_id", requestID,
144- "error", err)
145- return nil, fmt.Errorf("failed to read response body: %w", err)
146- }
147-
148- var githubUser GithubUserInfo
149- if err := json.Unmarshal(body, &githubUser); err != nil {
150- log.Error("failed to parse GitHub user data",
151- "request_id", requestID,
152- "error", err)
153- return nil, fmt.Errorf("failed to parse user data: %w", err)
154- }
155-
156- log.Debug("GitHub user info fetched",
157- "request_id", requestID,
158- "user", githubUser.Login)
159-
160- emails, err := fetchGitHubUserEmails(token)
161- if err != nil {
162- log.Error("failed to fetch GitHub user emails",
163- "request_id", requestID,
164- "user", githubUser.Login,
165- "error", err)
166- return nil, fmt.Errorf("failed to fetch user emails: %w", err)
167- }
168- githubUser.Emails = emails
169-
170- log.Debug("completed GitHub user validation",
171- "request_id", requestID,
172- "user", githubUser.Login,
173- "email_count", len(emails))
174-
175- return &githubUser, nil
176-}
177-
178-func fetchGitHubUserEmails(token string) ([]string, error) {
179- log.Debug("fetching GitHub user emails")
180-
181- client := &http.Client{}
182- req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
183- if err != nil {
184- log.Error("failed to create email request",
185- "error", err)
186- return nil, fmt.Errorf("failed to create request: %w", err)
187- }
188-
189- req.Header.Set("Authorization", "token "+token)
190- req.Header.Set("Accept", "application/vnd.github.v3+json")
191-
192- resp, err := client.Do(req)
193- if err != nil {
194- log.Error("failed to send email request",
195- "error", err)
196- return nil, fmt.Errorf("failed to send request: %w", err)
197- }
198- defer resp.Body.Close()
199-
200- if resp.StatusCode != http.StatusOK {
201- log.Warn("failed to fetch emails",
202- "status", resp.Status)
203- return nil, fmt.Errorf("failed to fetch user emails: %s", resp.Status)
204- }
205-
206- var emailsResp []struct {
207- Email string `json:"email"`
208- }
209- if err := json.NewDecoder(resp.Body).Decode(&emailsResp); err != nil {
210- log.Error("failed to parse email response",
211- "error", err)
212- return nil, fmt.Errorf("failed to parse email response: %w", err)
213- }
214-
215- emails := make([]string, len(emailsResp))
216- for i, e := range emailsResp {
217- emails[i] = e.Email
218- }
219-
220- log.Debug("fetched GitHub emails successfully",
221- "email_count", len(emails))
222- return emails, nil
223-}
D internal/httpserve/middleware/secure.go +0 -56
@@ -1,56 +0,0 @@
1-package middleware
2-
3-import (
4- "log"
5- "os"
6-
7- "github.com/bnema/gordon/internal/server"
8- "github.com/gorilla/sessions"
9- _ "github.com/joho/godotenv/autoload"
10- "github.com/labstack/echo-contrib/session"
11- "github.com/labstack/echo/v4"
12- "github.com/labstack/echo/v4/middleware"
13-)
14-
15-func SecureRoutes(a *server.App) echo.MiddlewareFunc {
16- proxyURL := a.Config.Build.ProxyURL
17- urlCheckVersion := a.Config.Build.ProxyURL + "/version"
18- return middleware.SecureWithConfig(middleware.SecureConfig{
19- XSSProtection: "1; mode=block",
20- ContentTypeNosniff: "nosniff",
21- XFrameOptions: "SAMEORIGIN",
22- HSTSMaxAge: 3600,
23- HSTSExcludeSubdomains: false,
24- ContentSecurityPolicy: "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' " + proxyURL + " " + urlCheckVersion,
25- })
26-}
27-
28-// InitSessionMiddleware initializes the session middleware with secure options
29-func InitSessionMiddleware(a *server.App) echo.MiddlewareFunc {
30- isHttps := a.Config.Http.Https
31- secret := os.Getenv("SESSION_SECRET")
32- if secret == "" {
33- log.Fatal("Environment variable SESSION_SECRET is not set or cannot be read")
34- }
35- store := sessions.NewCookieStore([]byte(secret))
36- store.Options = &sessions.Options{
37- Path: "/",
38- HttpOnly: true,
39- Secure: isHttps,
40- MaxAge: 86400,
41- }
42- return func(next echo.HandlerFunc) echo.HandlerFunc {
43- return func(c echo.Context) error {
44- err := session.Middleware(store)(next)(c)
45- if err != nil {
46- log.Printf("Error in session middleware: %v", err)
47- return err
48- }
49- _, err = session.Get("session", c)
50- if err != nil {
51- log.Printf("Could not retrieve session: %v", err)
52- }
53- return nil
54- }
55- }
56-}
D internal/httpserve/router.go +0 -190
@@ -1,190 +0,0 @@
1-package httpserve
2-
3-import (
4- "github.com/bnema/gordon/internal/httpserve/handlers"
5- "github.com/bnema/gordon/internal/httpserve/middleware"
6- "github.com/bnema/gordon/internal/server"
7- _ "github.com/joho/godotenv/autoload"
8- "github.com/labstack/echo/v4"
9- echomid "github.com/labstack/echo/v4/middleware"
10-)
11-
12-// RegisterRoutes registers all routes and middlewares
13-func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
14- // e.Use(echomid.Logger()) too verbose
15- e.Use(echomid.Recover())
16- // Initiate the session middleware
17- e.Use(middleware.InitSessionMiddleware(a))
18-
19- // Language detection
20- e.Use(middleware.LanguageDetection)
21-
22- AdminPath := a.Config.Admin.Path
23- bindLoginRoute(e, a, AdminPath)
24- bindAPIEndpoints(e, a)
25-
26- // SetCommonDataMiddleware will pass data to the renderer
27- e.Use(middleware.SetCommonDataMiddleware(a))
28-
29- // Error handler middleware
30- e.Use(middleware.ErrorHandler)
31-
32- // Use middlewares
33- e.Use(middleware.SecureRoutes(a))
34-
35- // Color scheme detection for dark/light mode
36- e.Use(middleware.ColorSchemeDetection)
37-
38- bindAdminRoute(e, a, AdminPath)
39- bindStaticRoute(e, a, "/*")
40- bindHTMXEndpoints(e, a)
41-
42- // Protect the root path with a 403
43- e.GET("/", func(c echo.Context) error {
44- return c.String(403, "403 Forbidden")
45- })
46-
47- return e
48-}
49-
50-// bindAPIEndpoints expose /api endpoints and speaks only JSON
51-func bindAPIEndpoints(e *echo.Echo, a *server.App) {
52- apiGroup := e.Group("/api")
53-
54- // Device flow endpoints (without RequireToken middleware)
55- apiGroup.POST("/device/code", func(c echo.Context) error {
56- return handlers.DeviceCodeRequest(c, a)
57- })
58- apiGroup.POST("/device/token", func(c echo.Context) error {
59- return handlers.DeviceTokenRequest(c, a)
60- })
61-
62- // Other API endpoints that require a token
63- protectedApiGroup := apiGroup.Group("", middleware.RequireToken(a))
64- protectedApiGroup.GET("/ping", func(c echo.Context) error {
65- return handlers.GetInfos(c, a)
66- })
67- protectedApiGroup.POST("/deploy", func(c echo.Context) error {
68- return handlers.PostDeploy(c, a)
69- })
70- protectedApiGroup.POST("/deploy/chunked", func(c echo.Context) error {
71- return handlers.PostDeployChunked(c, a)
72- })
73- protectedApiGroup.GET("/deploy/check-conflict", func(c echo.Context) error {
74- return handlers.CheckDeployConflict(c, a)
75- })
76- // Regular push endpoint for small images
77- protectedApiGroup.POST("/push", func(c echo.Context) error {
78- return handlers.PostPush(c, a)
79- })
80-
81- // Chunked push endpoint for large images
82- protectedApiGroup.POST("/push/chunked", func(c echo.Context) error {
83- return handlers.PostPushChunked(c, a)
84- })
85- protectedApiGroup.POST("/stop", func(c echo.Context) error {
86- return handlers.PostContainerStop(c, a)
87- })
88- protectedApiGroup.POST("/remove", func(c echo.Context) error {
89- return handlers.PostContainerRemove(c, a)
90- })
91-}
92-
93-// bindStaticRoute bind static path
94-func bindStaticRoute(e *echo.Echo, a *server.App, path string) {
95- e.GET(path, func(c echo.Context) error {
96- return handlers.StaticRoute(c, a)
97- }, echomid.Gzip())
98-}
99-
100-// bindLoginRoute binds all login routes
101-func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
102- e.GET(adminPath+"/login", func(c echo.Context) error {
103- return handlers.RenderLoginPage(c, a)
104- })
105- e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error {
106- return handlers.StartOAuthGithub(c, a)
107- })
108- e.GET(adminPath+"/login/oauth/callback", func(c echo.Context) error {
109- return handlers.OAuthCallback(c, a)
110- })
111- e.GET(adminPath+"/logout", func(c echo.Context) error {
112- return handlers.Logout(c, a)
113- })
114-}
115-
116-// bindAdminRoute binds all admin routes
117-func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
118- adminGroup := e.Group(adminPath)
119- // Since login is behind the admin path, we cannot use group middleware
120- adminGroup.GET("", func(c echo.Context) error {
121- return c.Redirect(302, adminPath+"/manager")
122- }, middleware.RequireLogin(a))
123-
124- adminGroup.GET("/manager", func(c echo.Context) error {
125- return handlers.AdminManagerRoute(c, a)
126- }, middleware.RequireLogin(a))
127- adminGroup.GET("/cc/:ID", func(c echo.Context) error {
128- return handlers.CreateContainerFullGET(c, a)
129- }, middleware.RequireLogin(a))
130-}
131-
132-// bindHTMXEndpoints binds all HTMX endpoints
133-func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
134- // Create a group for /htmx endpoints
135- htmxGroup := e.Group("/htmx")
136-
137- // Apply middleware to the group, so all endpoints are protected
138- htmxGroup.Use(middleware.RequireLogin(a))
139-
140- // List all images component
141- htmxGroup.GET("/image-manager", func(c echo.Context) error {
142- return handlers.ImageManagerComponent(c, a)
143- })
144- // Delete an image
145- htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error {
146- return handlers.ImageManagerDelete(c, a)
147- })
148-
149- // List all containers
150- htmxGroup.GET("/container-manager", func(c echo.Context) error {
151- return handlers.ContainerManagerComponent(c, a)
152- })
153- // Stop a container
154- htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {
155- return handlers.ContainerManagerStop(c, a)
156- })
157- // Delete a container
158- htmxGroup.DELETE("/container-manager/delete/:ID", func(c echo.Context) error {
159- return handlers.ContainerManagerDelete(c, a)
160- })
161- // Start a container
162- htmxGroup.POST("/container-manager/start/:ID", func(c echo.Context) error {
163- return handlers.ContainerManagerStart(c, a)
164- })
165- // Edit a container view
166- htmxGroup.GET("/container-manager/edit/:ID", func(c echo.Context) error {
167- return handlers.ContainerManagerEditGET(c, a)
168- })
169- // Edit a container action
170- htmxGroup.POST("/container-manager/edit/:ID", func(c echo.Context) error {
171- return handlers.ContainerManagerEditPOST(c, a)
172- })
173-
174- // Display upload-image component
175- htmxGroup.GET("/upload-image", func(c echo.Context) error {
176- return handlers.UploadImageGETHandler(c, a)
177- })
178- // Upload image
179- htmxGroup.POST("/upload-image", func(c echo.Context) error {
180- return handlers.UploadImagePOSTHandler(c, a)
181- })
182- // Display create-container component
183- htmxGroup.GET("/create-container/:ID", func(c echo.Context) error {
184- return handlers.CreateContainerGET(c, a)
185- })
186- // Create container
187- htmxGroup.POST("/create-container/:ID", func(c echo.Context) error {
188- return handlers.CreateContainerPOST(c, a)
189- })
190-}
A internal/logging/collector.go +236 -0 collapsed to keep the page light
A internal/logging/collector_test.go +597 -0 collapsed to keep the page light
A internal/logging/setup.go +161 -0
@@ -0,0 +1,161 @@
1+package logging
2+
3+import (
4+ "fmt"
5+ "io"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/rs/zerolog"
10+ "github.com/rs/zerolog/log"
11+ "gopkg.in/natefinch/lumberjack.v2"
12+ "gordon/internal/config"
13+)
14+
15+var (
16+ MainLogger zerolog.Logger
17+ ProxyLogger zerolog.Logger
18+)
19+
20+// Setup initializes the logging system based on the configuration
21+func Setup(cfg *config.Config) error {
22+ if !cfg.Logging.Enabled {
23+ // Keep console logging only
24+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
25+ MainLogger = log.Logger
26+ ProxyLogger = log.Logger
27+ return nil
28+ }
29+
30+ // Create logs directory with secure permissions (0700 - owner only)
31+ if err := os.MkdirAll(cfg.Logging.Dir, 0700); err != nil {
32+ return fmt.Errorf("failed to create logs directory: %w", err)
33+ }
34+
35+ // Create container logs directory with secure permissions
36+ containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir)
37+ if err := os.MkdirAll(containerLogDir, 0700); err != nil {
38+ return fmt.Errorf("failed to create container logs directory: %w", err)
39+ }
40+
41+ // Set log level
42+ level, err := zerolog.ParseLevel(cfg.Logging.Level)
43+ if err != nil {
44+ level = zerolog.InfoLevel
45+ log.Warn().Str("invalid_level", cfg.Logging.Level).Msg("Invalid log level, using info")
46+ }
47+ zerolog.SetGlobalLevel(level)
48+
49+ // Configure time format
50+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
51+
52+ // Setup main application logger
53+ mainLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.MainLogFile)
54+ mainFileWriter := &lumberjack.Logger{
55+ Filename: mainLogFile,
56+ MaxSize: cfg.Logging.MaxSize,
57+ MaxBackups: cfg.Logging.MaxBackups,
58+ MaxAge: cfg.Logging.MaxAge,
59+ Compress: cfg.Logging.Compress,
60+ }
61+
62+ // Set file permissions to be secure (readable only by owner)
63+ if err := os.Chmod(mainLogFile, 0600); err != nil && !os.IsNotExist(err) {
64+ log.Warn().Err(err).Str("file", mainLogFile).Msg("Failed to set secure permissions on log file")
65+ }
66+
67+ // Setup proxy logger
68+ proxyLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.ProxyLogFile)
69+ proxyFileWriter := &lumberjack.Logger{
70+ Filename: proxyLogFile,
71+ MaxSize: cfg.Logging.MaxSize,
72+ MaxBackups: cfg.Logging.MaxBackups,
73+ MaxAge: cfg.Logging.MaxAge,
74+ Compress: cfg.Logging.Compress,
75+ }
76+
77+ // Set file permissions to be secure (readable only by owner)
78+ if err := os.Chmod(proxyLogFile, 0600); err != nil && !os.IsNotExist(err) {
79+ log.Warn().Err(err).Str("file", proxyLogFile).Msg("Failed to set secure permissions on log file")
80+ }
81+
82+ // Create multi-writers (console + file)
83+ consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr}
84+
85+ mainMultiWriter := io.MultiWriter(consoleWriter, mainFileWriter)
86+ proxyMultiWriter := io.MultiWriter(consoleWriter, proxyFileWriter)
87+
88+ // Initialize loggers
89+ MainLogger = zerolog.New(mainMultiWriter).With().Timestamp().Logger()
90+ ProxyLogger = zerolog.New(proxyMultiWriter).With().Timestamp().Logger()
91+
92+ // Set the global logger to the main logger
93+ log.Logger = MainLogger
94+
95+ log.Info().
96+ Str("main_log", mainLogFile).
97+ Str("proxy_log", proxyLogFile).
98+ Str("container_log_dir", containerLogDir).
99+ Str("level", level.String()).
100+ Msg("File logging initialized")
101+
102+ return nil
103+}
104+
105+// GetContainerLogWriter returns a log writer for a specific container
106+func GetContainerLogWriter(cfg *config.Config, containerID string, containerName string) (*lumberjack.Logger, error) {
107+ if !cfg.Logging.Enabled {
108+ return nil, fmt.Errorf("logging is disabled")
109+ }
110+
111+ containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir)
112+
113+ // Ensure container log directory exists with secure permissions
114+ if err := os.MkdirAll(containerLogDir, 0700); err != nil {
115+ return nil, fmt.Errorf("failed to create container log directory: %w", err)
116+ }
117+
118+ // Use container ID as the main log file name
119+ logFile := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerID))
120+
121+ writer := &lumberjack.Logger{
122+ Filename: logFile,
123+ MaxSize: cfg.Logging.MaxSize,
124+ MaxBackups: cfg.Logging.MaxBackups,
125+ MaxAge: cfg.Logging.MaxAge,
126+ Compress: cfg.Logging.Compress,
127+ }
128+
129+ // Set file permissions to be secure (readable only by owner)
130+ if err := os.Chmod(logFile, 0600); err != nil && !os.IsNotExist(err) {
131+ log.Warn().Err(err).Str("file", logFile).Msg("Failed to set secure permissions on container log file")
132+ }
133+
134+ // Create a symlink for easier access using container name (if provided)
135+ if containerName != "" && containerName != containerID {
136+ symlinkPath := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerName))
137+
138+ // Remove existing symlink if it exists
139+ if _, err := os.Lstat(symlinkPath); err == nil {
140+ os.Remove(symlinkPath)
141+ }
142+
143+ // Create new symlink
144+ relativeLogFile := fmt.Sprintf("%s.log", containerID)
145+ if err := os.Symlink(relativeLogFile, symlinkPath); err != nil {
146+ log.Warn().
147+ Err(err).
148+ Str("symlink", symlinkPath).
149+ Str("target", relativeLogFile).
150+ Msg("Failed to create container name symlink")
151+ }
152+ }
153+
154+ return writer, nil
155+}
156+
157+// Close gracefully closes all logging writers
158+func Close() {
159+ // Lumberjack loggers will be closed automatically when the program exits
160+ // This function is provided for future extensibility
161+}⏎̸
A internal/logging/setup_test.go +381 -0 collapsed to keep the page light
A internal/middleware/auth.go +76 -0 collapsed to keep the page light
A internal/middleware/auth_test.go +301 -0 collapsed to keep the page light
A internal/middleware/logging.go +188 -0 collapsed to keep the page light
A internal/middleware/logging_test.go +541 -0 collapsed to keep the page light
A internal/proxy/server.go +296 -0 collapsed to keep the page light
A internal/proxy/server_test.go +1101 -0 collapsed to keep the page light
A internal/registry/manifest.go +4 -0
@@ -0,0 +1,4 @@
1+package registry
2+
3+// This file is deprecated - manifest processing has been moved to pkg/manifest
4+// to avoid circular import issues. This file will be removed in a future version.⏎̸
A internal/registry/mocks/storage.go +106 -0 collapsed to keep the page light
A internal/registry/server.go +466 -0 collapsed to keep the page light
A internal/registry/server_test.go +614 -0 collapsed to keep the page light
A internal/registry/storage.go +503 -0 collapsed to keep the page light
A internal/registry/storage_test.go +466 -0 collapsed to keep the page light
D internal/server/app_server.go +0 -71 collapsed to keep the page light
D internal/server/cronjobs.go +0 -30
@@ -1,30 +0,0 @@
1-package server
2-
3-import (
4- "log"
5- "time"
6-)
7-
8-// SessionCleaner, time as a string
9-func SessionCleaner(a *App, currentTime string) error {
10- query := "DELETE FROM sessions WHERE expires < ?"
11- _, err := a.DB.Exec(query, currentTime)
12- if err != nil {
13- return err
14- }
15-
16- return nil
17-}
18-
19-// StartSessionCleaner initializes the session cleaner to run at regular intervals.
20-func (a *App) StartSessionCleaner() {
21- go func() {
22- for {
23- currentTime := time.Now().Format(time.RFC3339)
24- if err := SessionCleaner(a, currentTime); err != nil {
25- log.Printf("Failed to clean up sessions: %v", err)
26- }
27- time.Sleep(1 * time.Hour)
28- }
29- }()
30-}
D internal/server/new_server.go +0 -57 collapsed to keep the page light
D internal/server/sqlite_close.go +0 -30 collapsed to keep the page light
D internal/server/sqlite_init.go +0 -186 collapsed to keep the page light
D internal/server/token_init.go +0 -50 collapsed to keep the page light
D internal/templating/cmdparams/from_cli.go +0 -59 collapsed to keep the page light
D internal/templating/cmdparams/from_inputs.go +0 -47 collapsed to keep the page light
D internal/templating/cmdparams/from_yaml.go +0 -58 collapsed to keep the page light
D internal/templating/cmdparams/parsing.go +0 -39 collapsed to keep the page light
D internal/templating/cmdparams/traefik.go +0 -37 collapsed to keep the page light
D internal/templating/embed.go +0 -13
@@ -1,13 +0,0 @@
1-package templating
2-
3-import (
4- "embed"
5-
6- "github.com/labstack/echo/v4"
7-)
8-
9-// Embedding templates directories
10-
11-//go:embed models/*
12-var template embed.FS
13-var TemplateFS = echo.MustSubFS(template, "models")
D internal/templating/load/fragment.go +0 -28 collapsed to keep the page light
D internal/templating/models/html/admin/createcontainerfull.gohtml +0 -21 collapsed to keep the page light
D internal/templating/models/html/admin/dashboard.gohtml +0 -9 collapsed to keep the page light
D internal/templating/models/html/admin/index.gohtml +0 -29 collapsed to keep the page light
D internal/templating/models/html/admin/install.gohtml +0 -137 collapsed to keep the page light
D internal/templating/models/html/admin/manager.gohtml +0 -36 collapsed to keep the page light
D internal/templating/models/html/admin/user.gohtml +0 -0

no textual change.

D internal/templating/models/html/fragments/components.gohtml +0 -33
@@ -1,33 +0,0 @@
1-<!-- @format -->
2-
3-<div id="header">
4- <h1
5- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
6- >
7- {{ .body.header.helloworld }}
8- </h1>
9-</div>
10-
11-<div id="hello">
12- <h1
13- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
14- >
15- {{ .body.div.hello }}
16- </h1>
17-</div>
18-<div
19- id="header"
20- hx-get="/htmx"
21- hx-headers='{"HX-Fragment": "header"}'
22- hx-trigger="load"
23- hx-swap="outerHTML"
24-></div>
25-
26-<!-- App Fragment -->
27-<div
28- id="app"
29- hx-get="/htmx"
30- hx-headers='{"HX-Fragment": "hello"}'
31- hx-trigger="load"
32- hx-swap="outerHTML"
33-></div>
D internal/templating/models/html/fragments/containerlist.gohtml +0 -103 collapsed to keep the page light
D internal/templating/models/html/fragments/createcontainer.gohtml +0 -100 collapsed to keep the page light
D internal/templating/models/html/fragments/editcontainer.gohtml +0 -30 collapsed to keep the page light
D internal/templating/models/html/fragments/footer.gohtml +0 -0

no textual change.

D internal/templating/models/html/fragments/header.gohtml +0 -13 collapsed to keep the page light
D internal/templating/models/html/fragments/imagelist.gohtml +0 -75 collapsed to keep the page light
D internal/templating/models/html/fragments/menu.gohtml +0 -45 collapsed to keep the page light
D internal/templating/models/html/fragments/success.gohtml +0 -6 collapsed to keep the page light
D internal/templating/models/html/fragments/uploadimage.gohtml +0 -47 collapsed to keep the page light
D internal/templating/models/html/login/index.gohtml +0 -64 collapsed to keep the page light
D internal/templating/models/txt/locstrings.yml +0 -149 collapsed to keep the page light
D internal/templating/render/html.go +0 -77 collapsed to keep the page light
D internal/templating/render/localization.go +0 -58 collapsed to keep the page light
A internal/testutils/fixtures/configs/basic.toml +27 -0 collapsed to keep the page light
A internal/testutils/fixtures/configs/invalid.toml +2 -0 collapsed to keep the page light
A internal/testutils/fixtures/configs/minimal.toml +2 -0 collapsed to keep the page light
A internal/testutils/helpers.go +83 -0 collapsed to keep the page light
A internal/testutils/mocks/runtime_gen.go +480 -0 generated file: collapsed
D internal/webui/embed.go +0 -14 collapsed to keep the page light
D internal/webui/public/404.gohtml +0 -12 collapsed to keep the page light
D internal/webui/public/500.gohtml +0 -12 collapsed to keep the page light
D internal/webui/public/android-chrome-192x192.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/android-chrome-512x512.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/apple-touch-icon.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/css/custom.css +0 -128 collapsed to keep the page light
D internal/webui/public/assets/css/tailwind.css +0 -1321 collapsed to keep the page light
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Light.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Medium.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Thin.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFontMono-Regular.ttf +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.ttf +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/imgs/github/github-mark-white.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/imgs/github/github-mark-white.svg +0 -1 collapsed to keep the page light
D internal/webui/public/assets/imgs/github/github-mark.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/imgs/github/github-mark.svg +0 -1 collapsed to keep the page light
D internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/js/custom.js +0 -53 collapsed to keep the page light
D internal/webui/public/favicon-16x16.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/favicon-32x32.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/favicon.ico +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/libs/chart.js +0 -20 collapsed to keep the page light
D internal/webui/public/libs/htmx-debug.js +0 -11 collapsed to keep the page light
D internal/webui/public/libs/htmx-response-targets.js +0 -122 collapsed to keep the page light
D internal/webui/public/libs/htmx-sse.js +0 -322 collapsed to keep the page light
D internal/webui/public/libs/htmx.min.js +0 -1 generated file: collapsed
D internal/webui/public/libs/hyperscript.min.js +0 -1 generated file: collapsed
D internal/webui/public/site.webmanifest +0 -1 collapsed to keep the page light
M main.go +6 -8 collapsed to keep the page light
D package.json +0 -16 collapsed to keep the page light
D pkg/docker/client.go +0 -81 collapsed to keep the page light
D pkg/docker/containers.go +0 -696 collapsed to keep the page light
D pkg/docker/detect.go +0 -15 collapsed to keep the page light
D pkg/docker/images.go +0 -326 collapsed to keep the page light
D pkg/htmx/htmlfragments.go +0 -42 collapsed to keep the page light
D pkg/humanize/humanize.go +0 -56 collapsed to keep the page light
A pkg/manifest/annotations.go +68 -0 collapsed to keep the page light
A pkg/manifest/annotations_test.go +661 -0 collapsed to keep the page light
A pkg/manifest/types.go +52 -0 collapsed to keep the page light
A pkg/manifest/types_test.go +557 -0 collapsed to keep the page light
D pkg/parser/yaml.go +0 -55 collapsed to keep the page light
A pkg/runtime/interface.go +92 -0 collapsed to keep the page light
D pkg/sanitize/html_sanitizer.go +0 -22 collapsed to keep the page light
D pkg/sqlite/db_create.go +0 -75 collapsed to keep the page light
D pkg/store/store_images.go +0 -70 collapsed to keep the page light
D pkg/verify/tar.go +0 -31 collapsed to keep the page light
D tailwind.config.js +0 -21 collapsed to keep the page light

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