detached 81621ee

Merge pull request #3 from bnema/gordon-v2

Gordon v2: Complete platform rewrite with enhanced container management
commit
81621ee240bf03c0bdfe112aff40eff53a799849
author
bnema · · 2025-06-09 16:41 UTC
committer
GitHub · · 2025-06-09 16:41 UTC
merge of
15dc736 + d380e09 · diff against the first parent
signature
unsigned
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
11 gordon1 gordon
12-dist/2+.env
13-data/3+dist/⏎̸
14-setup.sh
15-.iscontainer
16-sqlite.db
17-.vscode/
M .goreleaser.yaml +0 -1
@@ -2,7 +2,6 @@ version: 2
2 2
3 before:3 before:
4 hooks:4 hooks:
5- - go mod tidy
6 5
7 release:6 release:
8 draft: true7 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:latest1+# Gordon v2 - Production Dockerfile
2+# Multi-stage build for optimized container image
2 3
3-COPY gordon /gordon4+# Build stage
5+FROM golang:1.24-alpine AS builder
4 6
5-RUN touch /.iscontainer7+# Install build dependencies
8+RUN apk add --no-cache git ca-certificates tzdata
6 9
7-ENTRYPOINT ["/gordon"]10+# Set working directory
8-CMD ["serve"]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 @@
1 # This Makefile is used for dev purposes1 # This Makefile is used for dev purposes
2 # Variables2 # Variables
3 REPO := ghcr.io/bnema/gordon3 REPO := ghcr.io/bnema/gordon
4-TAG := dev4+TAG := v2-dev
5+DEV_TAG := v2-dev-$(shell date +%Y%m%d-%H%M%S)
5 DIST_DIR := ./dist6 DIST_DIR := ./dist
6 ENGINE := podman7 ENGINE := podman
7 8
@@ -20,7 +21,7 @@ LDFLAGS := -s -w \
20 ARCHS := amd64 arm6421 ARCHS := amd64 arm64
21 22
22 # Phony targets23 # Phony targets
23-.PHONY: all build build-push clean24+.PHONY: all build build-push clean dev-release test test-unit test-coverage test-race clean-test
24 25
25 # Default target26 # Default target
26 all: build27 all: build
@@ -68,9 +69,50 @@ build-push: build
68 69
69 @echo "Script completed successfully."70 @echo "Script completed successfully."
70 71
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+
71 # Clean up113 # Clean up
72 clean:114 clean:
73 @echo "Cleaning up..."115 @echo "Cleaning up..."
74 @rm -rf $(DIST_DIR)116 @rm -rf $(DIST_DIR)
75 @$(ENGINE) system prune -f117 @$(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">1+# Gordon
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>
8 2
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>
10 8
9+## Why Gordon?
11 10
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.**
13 12
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.
15 14
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
17 20
18-- **Ease of use**:21+## How It Works
19- - Push your container images effortlessly using the **`gordon push`** command. A container creation URL is generated upon successful push.
20 22
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!
22 27
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
24 31
25- ![Demo Deploy](assets/vhs/demo_deploy.gif?raw=true)32+# 3. That's it. Gordon deploys it automatically.
33+```
26 34
27-- **Self-Hosted**: Operates within your existing self-hosted environment without interfering with your setup.35+**No build servers. No CI/CD complexity. If it runs on your machine, it runs in production.**
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.
33 36
34-## **Why ?**37+## Key Features
35 38
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
37 48
38-## **Installation / Getting Started / Usage**49+## Quick Start (5 minutes)
39 50
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
41 55
42-- [Setting up Gordon’s Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon%E2%80%99s-Backend)56+# Configure firewall
43-- [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client)57+sudo ufw --force enable
44-- [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment)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+```
45 63
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+```
46 72
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+```
48 80
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']
50 87
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+```
52 92
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+```
54 99
55-- [x] Improved error handling100+### 6. Configure Gordon
56-- [x] Improved logging101+```bash
57-- [x] Refined CLI to backend authentication process (utilizing GitHub OAuth Device flow)102+# Gordon creates a default config file on first run
58-- [x] `push` command103+# Edit it to set your registry domain and credentials:
59-- [x] `version` command104+# gordon.toml location: ./ → ~/.config/gordon/ → ~/.gordon/ → ~/ → /etc/gordon/
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
72 105
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+```
74 111
75-## **License**112+### 7. Point DNS
113+```
114+A *.mydomain.com → YOUR_VPS_IP
115+A mydomain.com → YOUR_VPS_IP
116+```
76 117
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/gordon1+module gordon
2 2
3-go 1.233+go 1.24.3
4 4
5 require (5 require (
6- github.com/AlecAivazis/survey/v2 v2.3.76+ github.com/docker/docker v28.2.2+incompatible
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
13 github.com/docker/go-connections v0.5.07 github.com/docker/go-connections v0.5.0
14- github.com/fatih/color v1.17.08+ github.com/google/uuid v1.6.0
15- github.com/gorilla/sessions v1.4.09+ github.com/rs/zerolog v1.34.0
16- github.com/joho/godotenv v1.5.110+ github.com/spf13/cobra v1.9.1
17- github.com/labstack/echo-contrib v0.17.111+ github.com/spf13/viper v1.20.1
18- github.com/labstack/echo/v4 v4.12.012+ github.com/stretchr/testify v1.10.0
19- github.com/microcosm-cc/bluemonday v1.0.2713+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
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
24 )14 )
25 15
26 require (16 require (
27- github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect17+ github.com/davecgh/go-spew v1.1.1 // indirect
28- github.com/charmbracelet/bubbletea v1.1.0 // direct18+ github.com/pmezard/go-difflib v1.0.0 // indirect
29- github.com/charmbracelet/x/ansi v0.2.3 // indirect19+ github.com/stretchr/objx v0.5.2 // indirect
30- github.com/charmbracelet/x/term v0.2.0 // indirect20+ 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
31 github.com/containerd/log v0.1.0 // indirect28 github.com/containerd/log v0.1.0 // indirect
32- github.com/creack/pty v1.1.18 // indirect
33 github.com/distribution/reference v0.6.0 // indirect29 github.com/distribution/reference v0.6.0 // indirect
34- github.com/dustin/go-humanize v1.0.1 // indirect30+ github.com/docker/go-units v0.5.0 // indirect
35- github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
36 github.com/felixge/httpsnoop v1.0.4 // indirect31 github.com/felixge/httpsnoop v1.0.4 // indirect
37- github.com/go-logfmt/logfmt v0.6.0 // indirect32+ github.com/fsnotify/fsnotify v1.8.0 // direct
38 github.com/go-logr/logr v1.4.2 // indirect33 github.com/go-logr/logr v1.4.2 // indirect
39 github.com/go-logr/stdr v1.2.2 // indirect34 github.com/go-logr/stdr v1.2.2 // indirect
40- github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect35+ github.com/go-viper/mapstructure/v2 v2.2.1 // 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
79 github.com/gogo/protobuf v1.3.2 // indirect36 github.com/gogo/protobuf v1.3.2 // indirect
80- github.com/golang-jwt/jwt v3.2.2+incompatible // indirect37+ github.com/inconshreveable/mousetrap v1.1.0 // 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
86 github.com/mattn/go-colorable v0.1.13 // indirect38 github.com/mattn/go-colorable v0.1.13 // indirect
87- github.com/mattn/go-isatty v0.0.20 // indirect39+ github.com/mattn/go-isatty v0.0.19 // indirect
88- github.com/moby/term v0.5.0 // indirect40+ 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
89 github.com/morikuni/aec v1.0.0 // indirect43 github.com/morikuni/aec v1.0.0 // indirect
90 github.com/opencontainers/go-digest v1.0.0 // indirect44 github.com/opencontainers/go-digest v1.0.0 // indirect
91- github.com/opencontainers/image-spec v1.1.0 // indirect45+ github.com/opencontainers/image-spec v1.1.1 // indirect
46+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
92 github.com/pkg/errors v0.9.1 // indirect47 github.com/pkg/errors v0.9.1 // indirect
93- github.com/rogpeppe/go-internal v1.11.0 // indirect48+ github.com/sagikazarmark/locafero v0.7.0 // indirect
94- github.com/valyala/bytebufferpool v1.0.0 // indirect49+ github.com/sourcegraph/conc v0.3.0 // indirect
95- github.com/valyala/fasttemplate v1.2.2 // indirect50+ github.com/spf13/afero v1.14.0
96- golang.org/x/crypto v0.26.0 // indirect51+ github.com/spf13/cast v1.7.1 // indirect
97- golang.org/x/net v0.28.0 // indirect52+ github.com/spf13/pflag v1.0.6 // indirect
98- golang.org/x/sys v0.24.0 // indirect53+ github.com/subosito/gotenv v1.6.0 // indirect
99- golang.org/x/text v0.17.0 // indirect54+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
100- golang.org/x/time v0.6.0 // indirect55+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
101- golang.org/x/tools v0.24.0 // indirect56+ go.opentelemetry.io/otel v1.36.0 // indirect
102- gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect57+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
103- gotest.tools/v3 v3.5.0 // indirect58+ 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
104 )67 )
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 collapsed to keep the page light
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