Merge pull request #3 from bnema/gordon-v2
Gordon v2: Complete platform rewrite with enhanced container management
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 | gordon | 1 | 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: true | 7 | draft: true | ||
D .vscode/launch.json +0 -18
| @@ -1,18 +0,0 @@ | |||||
| 1 | - | { | |||
| 2 | - | // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. | |||
| 3 | - | // Pointez pour afficher la description des attributs existants. | |||
| 4 | - | // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 | |||
| 5 | - | "version": "0.2.0", | |||
| 6 | - | "configurations": [ | |||
| 7 | - | { | |||
| 8 | - | "name": "Launch file", | |||
| 9 | - | "type": "go", | |||
| 10 | - | "request": "launch", | |||
| 11 | - | "mode": "debug", | |||
| 12 | - | "program": "./main.go", | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | ||||
| 16 | - | ] | |||
| 17 | - | ||||
| 18 | - | }⏎̸ | |||
M Dockerfile +69 -5
| @@ -1,8 +1,72 @@ | |||||
| 1 | - | FROM --platform=$TARGETPLATFORM alpine:latest | 1 | + | # Gordon v2 - Production Dockerfile |
| 2 | + | # Multi-stage build for optimized container image | |||
| 2 | 3 | ||||
| 3 | - | COPY gordon /gordon | 4 | + | # Build stage |
| 5 | + | FROM golang:1.24-alpine AS builder | |||
| 4 | 6 | ||||
| 5 | - | RUN touch /.iscontainer | 7 | + | # 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 purposes | 1 | # This Makefile is used for dev purposes | ||
| 2 | # Variables | 2 | # Variables | ||
| 3 | REPO := ghcr.io/bnema/gordon | 3 | REPO := ghcr.io/bnema/gordon | ||
| 4 | - | TAG := dev | 4 | + | TAG := v2-dev |
| 5 | + | DEV_TAG := v2-dev-$(shell date +%Y%m%d-%H%M%S) | |||
| 5 | DIST_DIR := ./dist | 6 | DIST_DIR := ./dist | ||
| 6 | ENGINE := podman | 7 | ENGINE := podman | ||
| 7 | 8 | ||||
| @@ -20,7 +21,7 @@ LDFLAGS := -s -w \ | |||||
| 20 | ARCHS := amd64 arm64 | 21 | ARCHS := amd64 arm64 | ||
| 21 | 22 | ||||
| 22 | # Phony targets | 23 | # Phony targets | ||
| 23 | - | .PHONY: all build build-push clean | 24 | + | .PHONY: all build build-push clean dev-release test test-unit test-coverage test-race clean-test |
| 24 | 25 | ||||
| 25 | # Default target | 26 | # Default target | ||
| 26 | all: build | 27 | 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 up | 113 | # 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 -f | 117 | @$(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 | - |  | 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 | - |  | 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 handling | 100 | + | ### 6. Configure Gordon |
| 56 | - | - [x] Improved logging | 101 | + | ```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` command | 103 | + | # Edit it to set your registry domain and credentials: |
| 59 | - | - [x] `version` command | 104 | + | # 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/gordon | 1 | + | module gordon |
| 2 | 2 | ||||
| 3 | - | go 1.23 | 3 | + | go 1.24.3 |
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/AlecAivazis/survey/v2 v2.3.7 | 6 | + | 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.0 | 7 | github.com/docker/go-connections v0.5.0 | ||
| 14 | - | github.com/fatih/color v1.17.0 | 8 | + | github.com/google/uuid v1.6.0 |
| 15 | - | github.com/gorilla/sessions v1.4.0 | 9 | + | github.com/rs/zerolog v1.34.0 |
| 16 | - | github.com/joho/godotenv v1.5.1 | 10 | + | github.com/spf13/cobra v1.9.1 |
| 17 | - | github.com/labstack/echo-contrib v0.17.1 | 11 | + | github.com/spf13/viper v1.20.1 |
| 18 | - | github.com/labstack/echo/v4 v4.12.0 | 12 | + | github.com/stretchr/testify v1.10.0 |
| 19 | - | github.com/microcosm-cc/bluemonday v1.0.27 | 13 | + | 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 // indirect | 17 | + | github.com/davecgh/go-spew v1.1.1 // indirect |
| 28 | - | github.com/charmbracelet/bubbletea v1.1.0 // direct | 18 | + | github.com/pmezard/go-difflib v1.0.0 // indirect |
| 29 | - | github.com/charmbracelet/x/ansi v0.2.3 // indirect | 19 | + | github.com/stretchr/objx v0.5.2 // indirect |
| 30 | - | github.com/charmbracelet/x/term v0.2.0 // indirect | 20 | + | go.uber.org/mock v0.5.2 // direct |
| 21 | + | golang.org/x/time v0.12.0 // indirect | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | require ( | |||
| 25 | + | github.com/Microsoft/go-winio v0.4.14 // indirect | |||
| 26 | + | github.com/containerd/errdefs v1.0.0 // indirect | |||
| 27 | + | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |||
| 31 | github.com/containerd/log v0.1.0 // indirect | 28 | 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 // indirect | 29 | github.com/distribution/reference v0.6.0 // indirect | ||
| 34 | - | github.com/dustin/go-humanize v1.0.1 // indirect | 30 | + | 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 // indirect | 31 | github.com/felixge/httpsnoop v1.0.4 // indirect | ||
| 37 | - | github.com/go-logfmt/logfmt v0.6.0 // indirect | 32 | + | github.com/fsnotify/fsnotify v1.8.0 // direct |
| 38 | github.com/go-logr/logr v1.4.2 // indirect | 33 | github.com/go-logr/logr v1.4.2 // indirect | ||
| 39 | github.com/go-logr/stdr v1.2.2 // indirect | 34 | github.com/go-logr/stdr v1.2.2 // indirect | ||
| 40 | - | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect | 35 | + | 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 // indirect | 36 | github.com/gogo/protobuf v1.3.2 // indirect | ||
| 80 | - | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect | 37 | + | 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 // indirect | 38 | github.com/mattn/go-colorable v0.1.13 // indirect | ||
| 87 | - | github.com/mattn/go-isatty v0.0.20 // indirect | 39 | + | github.com/mattn/go-isatty v0.0.19 // indirect |
| 88 | - | github.com/moby/term v0.5.0 // indirect | 40 | + | github.com/moby/docker-image-spec v1.3.1 // indirect |
| 41 | + | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |||
| 42 | + | github.com/moby/term v0.5.2 // indirect | |||
| 89 | github.com/morikuni/aec v1.0.0 // indirect | 43 | github.com/morikuni/aec v1.0.0 // indirect | ||
| 90 | github.com/opencontainers/go-digest v1.0.0 // indirect | 44 | github.com/opencontainers/go-digest v1.0.0 // indirect | ||
| 91 | - | github.com/opencontainers/image-spec v1.1.0 // indirect | 45 | + | github.com/opencontainers/image-spec v1.1.1 // indirect |
| 46 | + | github.com/pelletier/go-toml/v2 v2.2.3 // indirect | |||
| 92 | github.com/pkg/errors v0.9.1 // indirect | 47 | github.com/pkg/errors v0.9.1 // indirect | ||
| 93 | - | github.com/rogpeppe/go-internal v1.11.0 // indirect | 48 | + | github.com/sagikazarmark/locafero v0.7.0 // indirect |
| 94 | - | github.com/valyala/bytebufferpool v1.0.0 // indirect | 49 | + | github.com/sourcegraph/conc v0.3.0 // indirect |
| 95 | - | github.com/valyala/fasttemplate v1.2.2 // indirect | 50 | + | github.com/spf13/afero v1.14.0 |
| 96 | - | golang.org/x/crypto v0.26.0 // indirect | 51 | + | github.com/spf13/cast v1.7.1 // indirect |
| 97 | - | golang.org/x/net v0.28.0 // indirect | 52 | + | github.com/spf13/pflag v1.0.6 // indirect |
| 98 | - | golang.org/x/sys v0.24.0 // indirect | 53 | + | github.com/subosito/gotenv v1.6.0 // indirect |
| 99 | - | golang.org/x/text v0.17.0 // indirect | 54 | + | go.opentelemetry.io/auto/sdk v1.1.0 // indirect |
| 100 | - | golang.org/x/time v0.6.0 // indirect | 55 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect |
| 101 | - | golang.org/x/tools v0.24.0 // indirect | 56 | + | go.opentelemetry.io/otel v1.36.0 // indirect |
| 102 | - | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | 57 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect |
| 103 | - | gotest.tools/v3 v3.5.0 // indirect | 58 | + | go.opentelemetry.io/otel/metric v1.36.0 // indirect |
| 59 | + | go.opentelemetry.io/otel/sdk v1.36.0 // indirect | |||
| 60 | + | go.opentelemetry.io/otel/trace v1.36.0 // indirect | |||
| 61 | + | go.uber.org/atomic v1.9.0 // indirect | |||
| 62 | + | go.uber.org/multierr v1.9.0 // indirect | |||
| 63 | + | golang.org/x/sys v0.33.0 // indirect | |||
| 64 | + | golang.org/x/text v0.26.0 // indirect | |||
| 65 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |||
| 66 | + | gotest.tools/v3 v3.5.2 // indirect | |||
| 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