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 | 1 | gordon | |
| 12 | - | dist/ | |
| 13 | - | data/ | |
| 14 | - | setup.sh | |
| 15 | - | .iscontainer | |
| 16 | - | sqlite.db | |
| 17 | - | .vscode/ | |
| 2 | + | .env | |
| 3 | + | dist/⏎̸ | |
M .goreleaser.yaml +0 -1
| @@ -2,7 +2,6 @@ version: 2 | |||
| 2 | 2 | ||
| 3 | 3 | before: | |
| 4 | 4 | hooks: | |
| 5 | - | - go mod tidy | |
| 6 | 5 | ||
| 7 | 6 | release: | |
| 8 | 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"] | |
| 8 | - | CMD ["serve"] | |
| 10 | + | # Set working directory | |
| 11 | + | WORKDIR /app | |
| 12 | + | ||
| 13 | + | # Copy go mod files | |
| 14 | + | COPY go.mod go.sum ./ | |
| 15 | + | ||
| 16 | + | # Download dependencies | |
| 17 | + | RUN go mod download | |
| 18 | + | ||
| 19 | + | # Copy source code | |
| 20 | + | COPY . . | |
| 21 | + | ||
| 22 | + | # Build the binary | |
| 23 | + | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ | |
| 24 | + | -ldflags='-w -s -extldflags "-static"' \ | |
| 25 | + | -a -installsuffix cgo \ | |
| 26 | + | -o gordon . | |
| 27 | + | ||
| 28 | + | # Runtime stage | |
| 29 | + | FROM alpine:latest | |
| 30 | + | ||
| 31 | + | # Install runtime dependencies | |
| 32 | + | RUN apk add --no-cache \ | |
| 33 | + | ca-certificates \ | |
| 34 | + | docker-cli \ | |
| 35 | + | curl \ | |
| 36 | + | wget \ | |
| 37 | + | tzdata \ | |
| 38 | + | && rm -rf /var/cache/apk/* | |
| 39 | + | ||
| 40 | + | # Create non-root user | |
| 41 | + | RUN adduser -D -s /bin/sh gordon | |
| 42 | + | ||
| 43 | + | # Set working directory | |
| 44 | + | WORKDIR /app | |
| 45 | + | ||
| 46 | + | # Copy binary from builder | |
| 47 | + | COPY --from=builder /app/gordon . | |
| 48 | + | ||
| 49 | + | # Create data directory | |
| 50 | + | RUN mkdir -p /data && chown gordon:gordon /data | |
| 51 | + | ||
| 52 | + | # Copy default configuration (optional) | |
| 53 | + | COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example | |
| 54 | + | ||
| 55 | + | # Switch to non-root user | |
| 56 | + | USER gordon | |
| 57 | + | ||
| 58 | + | # Expose ports | |
| 59 | + | EXPOSE 8080 5000 | |
| 60 | + | ||
| 61 | + | # Health check | |
| 62 | + | HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ | |
| 63 | + | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | |
| 64 | + | ||
| 65 | + | # Default command | |
| 66 | + | CMD ["./gordon", "start"] | |
| 67 | + | ||
| 68 | + | # Metadata | |
| 69 | + | LABEL maintainer="bnemam" | |
| 70 | + | LABEL version="2.0" | |
| 71 | + | LABEL description="Event-driven container deployment platform" | |
| 72 | + | LABEL org.opencontainers.image.source="https://github.com/bnema/gordon"⏎̸ | |
M Makefile +45 -3
| @@ -1,7 +1,8 @@ | |||
| 1 | 1 | # This Makefile is used for dev purposes | |
| 2 | 2 | # Variables | |
| 3 | 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 | 6 | DIST_DIR := ./dist | |
| 6 | 7 | ENGINE := podman | |
| 7 | 8 | ||
| @@ -20,7 +21,7 @@ LDFLAGS := -s -w \ | |||
| 20 | 21 | ARCHS := amd64 arm64 | |
| 21 | 22 | ||
| 22 | 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 | 26 | # Default target | |
| 26 | 27 | all: build | |
| @@ -68,9 +69,50 @@ build-push: build | |||
| 68 | 69 | ||
| 69 | 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 | 113 | # Clean up | |
| 72 | 114 | clean: | |
| 73 | 115 | @echo "Cleaning up..." | |
| 74 | 116 | @rm -rf $(DIST_DIR) | |
| 75 | 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"> | |
| 2 | - | <br> | |
| 3 | - | <img src="https://github.com/bnema/gordon/blob/main/internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png?raw=true" alt="Gordon" width="192"> | |
| 4 | - | <br> | |
| 5 | - | Gordon | |
| 6 | - | <br> | |
| 7 | - | </h1> | |
| 1 | + | # Gordon | |
| 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**: | |
| 19 | - | - Push your container images effortlessly using the **`gordon push`** command. A container creation URL is generated upon successful push. | |
| 21 | + | ## How It Works | |
| 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. | |
| 28 | - | - **Self-Updated**: The client can update itself to the latest version with `gordon update`. The backend is updated by pulling the latest image from ghcr.io. | |
| 29 | - | - **Minimal WebUI**: Provides a [simple web UI](assets/screenshots/webui-image-deploy.png) to manage your deployments, domains, exposed ports. Using a very minimal stack (sqlite3, Go templates, HTMX and Tailwind CSS). | |
| 30 | - | - **Simple authentication**: Use GitHub OAuth for the web UI and OAuth 2.0 device authorization grant (device flow) for the CLI client. | |
| 31 | - | - **Automated Routing**: Collaborates with Traefik to automatically route your application to your desired domain or subdomain. | |
| 32 | - | - **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities. | |
| 35 | + | **No build servers. No CI/CD complexity. If it runs on your machine, it runs in production.** | |
| 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) | |
| 43 | - | - [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client) | |
| 44 | - | - [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment) | |
| 56 | + | # Configure firewall | |
| 57 | + | sudo ufw --force enable | |
| 58 | + | sudo ufw default deny incoming | |
| 59 | + | sudo ufw default allow outgoing | |
| 60 | + | sudo ufw allow 22/tcp 80/tcp 443/tcp | |
| 61 | + | sudo ufw enable | |
| 62 | + | ``` | |
| 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 | |
| 56 | - | - [x] Improved logging | |
| 57 | - | - [x] Refined CLI to backend authentication process (utilizing GitHub OAuth Device flow) | |
| 58 | - | - [x] `push` command | |
| 59 | - | - [x] `version` command | |
| 60 | - | - [x] `update` command | |
| 61 | - | - [x] `deploy` command | |
| 62 | - | - [x] Survey(s) on deploy command (stop/remove if already exists) | |
| 63 | - | - [ ] Improved web UI | |
| 64 | - | - [ ] Quick database deploy templates (libsql, postgresql) | |
| 65 | - | - [ ] GitHub Repository Integration: | |
| 66 | - | - Repository selection and branch/tag filtering | |
| 67 | - | - Dockerfile detection and validation | |
| 68 | - | - Build configuration UI | |
| 69 | - | - Build logs streaming | |
| 70 | - | - Automated deployment triggers | |
| 71 | - | - Build status notifications | |
| 100 | + | ### 6. Configure Gordon | |
| 101 | + | ```bash | |
| 102 | + | # Gordon creates a default config file on first run | |
| 103 | + | # Edit it to set your registry domain and credentials: | |
| 104 | + | # gordon.toml location: ./ → ~/.config/gordon/ → ~/.gordon/ → ~/ → /etc/gordon/ | |
| 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 | 5 | require ( | |
| 6 | - | github.com/AlecAivazis/survey/v2 v2.3.7 | |
| 7 | - | github.com/Masterminds/semver/v3 v3.3.1 | |
| 8 | - | github.com/PuerkitoBio/goquery v1.9.2 | |
| 9 | - | github.com/charmbracelet/bubbles v0.20.0 | |
| 10 | - | github.com/charmbracelet/lipgloss v0.13.0 | |
| 11 | - | github.com/charmbracelet/log v0.4.0 | |
| 12 | - | github.com/docker/docker v27.1.2+incompatible | |
| 6 | + | github.com/docker/docker v28.2.2+incompatible | |
| 13 | 7 | github.com/docker/go-connections v0.5.0 | |
| 14 | - | github.com/fatih/color v1.17.0 | |
| 15 | - | github.com/gorilla/sessions v1.4.0 | |
| 16 | - | github.com/joho/godotenv v1.5.1 | |
| 17 | - | github.com/labstack/echo-contrib v0.17.1 | |
| 18 | - | github.com/labstack/echo/v4 v4.12.0 | |
| 19 | - | github.com/microcosm-cc/bluemonday v1.0.27 | |
| 20 | - | github.com/pocketbase/pocketbase v0.22.19 | |
| 21 | - | github.com/spf13/cobra v1.8.1 | |
| 22 | - | gopkg.in/yaml.v3 v3.0.1 | |
| 23 | - | modernc.org/sqlite v1.32.0 | |
| 8 | + | github.com/google/uuid v1.6.0 | |
| 9 | + | github.com/rs/zerolog v1.34.0 | |
| 10 | + | github.com/spf13/cobra v1.9.1 | |
| 11 | + | github.com/spf13/viper v1.20.1 | |
| 12 | + | github.com/stretchr/testify v1.10.0 | |
| 13 | + | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| 24 | 14 | ) | |
| 25 | 15 | ||
| 26 | 16 | require ( | |
| 27 | - | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 28 | - | github.com/charmbracelet/bubbletea v1.1.0 // direct | |
| 29 | - | github.com/charmbracelet/x/ansi v0.2.3 // indirect | |
| 30 | - | github.com/charmbracelet/x/term v0.2.0 // indirect | |
| 17 | + | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 18 | + | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 19 | + | github.com/stretchr/objx v0.5.2 // indirect | |
| 20 | + | go.uber.org/mock v0.5.2 // direct | |
| 21 | + | golang.org/x/time v0.12.0 // indirect | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | require ( | |
| 25 | + | github.com/Microsoft/go-winio v0.4.14 // indirect | |
| 26 | + | github.com/containerd/errdefs v1.0.0 // indirect | |
| 27 | + | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 31 | 28 | github.com/containerd/log v0.1.0 // indirect | |
| 32 | - | github.com/creack/pty v1.1.18 // indirect | |
| 33 | 29 | github.com/distribution/reference v0.6.0 // indirect | |
| 34 | - | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 35 | - | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 30 | + | github.com/docker/go-units v0.5.0 // indirect | |
| 36 | 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 | 33 | github.com/go-logr/logr v1.4.2 // indirect | |
| 39 | 34 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 40 | - | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect | |
| 41 | - | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 42 | - | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | |
| 43 | - | github.com/kr/pretty v0.3.1 // indirect | |
| 44 | - | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| 45 | - | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 46 | - | github.com/mattn/go-runewidth v0.0.16 // indirect | |
| 47 | - | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | |
| 48 | - | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 49 | - | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 50 | - | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 51 | - | github.com/muesli/termenv v0.15.2 // indirect | |
| 52 | - | github.com/ncruces/go-strftime v0.1.9 // indirect | |
| 53 | - | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 54 | - | github.com/rivo/uniseg v0.4.7 // indirect | |
| 55 | - | github.com/spf13/pflag v1.0.5 // indirect | |
| 56 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect | |
| 57 | - | go.opentelemetry.io/otel v1.29.0 // indirect | |
| 58 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect | |
| 59 | - | go.opentelemetry.io/otel/metric v1.29.0 // indirect | |
| 60 | - | go.opentelemetry.io/otel/sdk v1.21.0 // indirect | |
| 61 | - | go.opentelemetry.io/otel/trace v1.29.0 // indirect | |
| 62 | - | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct | |
| 63 | - | golang.org/x/sync v0.8.0 // indirect | |
| 64 | - | golang.org/x/term v0.23.0 // indirect | |
| 65 | - | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect | |
| 66 | - | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect | |
| 67 | - | modernc.org/libc v1.59.9 // indirect | |
| 68 | - | modernc.org/mathutil v1.6.0 // indirect | |
| 69 | - | modernc.org/memory v1.8.0 // indirect | |
| 70 | - | modernc.org/strutil v1.2.0 // indirect | |
| 71 | - | modernc.org/token v1.1.0 // indirect | |
| 72 | - | ) | |
| 73 | - | ||
| 74 | - | require ( | |
| 75 | - | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 76 | - | github.com/andybalholm/cascadia v1.3.2 // indirect | |
| 77 | - | github.com/aymerick/douceur v0.2.0 // indirect | |
| 78 | - | github.com/docker/go-units v0.5.0 // indirect | |
| 35 | + | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect | |
| 79 | 36 | github.com/gogo/protobuf v1.3.2 // indirect | |
| 80 | - | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect | |
| 81 | - | github.com/google/uuid v1.6.0 // direct | |
| 82 | - | github.com/gorilla/context v1.1.2 // indirect | |
| 83 | - | github.com/gorilla/css v1.0.1 // indirect | |
| 84 | - | github.com/gorilla/securecookie v1.1.2 // indirect | |
| 85 | - | github.com/labstack/gommon v0.4.2 // indirect | |
| 37 | + | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 86 | 38 | github.com/mattn/go-colorable v0.1.13 // indirect | |
| 87 | - | github.com/mattn/go-isatty v0.0.20 // indirect | |
| 88 | - | github.com/moby/term v0.5.0 // indirect | |
| 39 | + | github.com/mattn/go-isatty v0.0.19 // indirect | |
| 40 | + | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 41 | + | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |
| 42 | + | github.com/moby/term v0.5.2 // indirect | |
| 89 | 43 | github.com/morikuni/aec v1.0.0 // indirect | |
| 90 | 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 | 47 | github.com/pkg/errors v0.9.1 // indirect | |
| 93 | - | github.com/rogpeppe/go-internal v1.11.0 // indirect | |
| 94 | - | github.com/valyala/bytebufferpool v1.0.0 // indirect | |
| 95 | - | github.com/valyala/fasttemplate v1.2.2 // indirect | |
| 96 | - | golang.org/x/crypto v0.26.0 // indirect | |
| 97 | - | golang.org/x/net v0.28.0 // indirect | |
| 98 | - | golang.org/x/sys v0.24.0 // indirect | |
| 99 | - | golang.org/x/text v0.17.0 // indirect | |
| 100 | - | golang.org/x/time v0.6.0 // indirect | |
| 101 | - | golang.org/x/tools v0.24.0 // indirect | |
| 102 | - | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | |
| 103 | - | gotest.tools/v3 v3.5.0 // indirect | |
| 48 | + | github.com/sagikazarmark/locafero v0.7.0 // indirect | |
| 49 | + | github.com/sourcegraph/conc v0.3.0 // indirect | |
| 50 | + | github.com/spf13/afero v1.14.0 | |
| 51 | + | github.com/spf13/cast v1.7.1 // indirect | |
| 52 | + | github.com/spf13/pflag v1.0.6 // indirect | |
| 53 | + | github.com/subosito/gotenv v1.6.0 // indirect | |
| 54 | + | go.opentelemetry.io/auto/sdk v1.1.0 // indirect | |
| 55 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect | |
| 56 | + | go.opentelemetry.io/otel v1.36.0 // indirect | |
| 57 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect | |
| 58 | + | go.opentelemetry.io/otel/metric v1.36.0 // indirect | |
| 59 | + | go.opentelemetry.io/otel/sdk v1.36.0 // indirect | |
| 60 | + | go.opentelemetry.io/otel/trace v1.36.0 // indirect | |
| 61 | + | go.uber.org/atomic v1.9.0 // indirect | |
| 62 | + | go.uber.org/multierr v1.9.0 // indirect | |
| 63 | + | golang.org/x/sys v0.33.0 // indirect | |
| 64 | + | golang.org/x/text v0.26.0 // indirect | |
| 65 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 66 | + | gotest.tools/v3 v3.5.2 // indirect | |
| 104 | 67 | ) | |
M go.sum +115 -229 generated file: collapsed
D internal/cli/auth/device_flow.go +0 -114
| @@ -1,114 +0,0 @@ | |||
| 1 | - | // internal/cli/auth/device_flow.go | |
| 2 | - | package auth | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "encoding/json" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "net/http" | |
| 9 | - | "net/url" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/cli" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func DeviceFlowAuth(a *cli.App) error { | |
| 16 | - | fmt.Println("Starting device flow authentication...") | |
| 17 | - | ||
| 18 | - | // Request device code | |
| 19 | - | deviceCode, err := requestDeviceCode(a) | |
| 20 | - | if err != nil { | |
| 21 | - | return fmt.Errorf("error requesting device code: %w", err) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | fmt.Printf("Please go to %v and enter the code: %v\n", deviceCode["verification_uri"], deviceCode["user_code"]) | |
| 25 | - | ||
| 26 | - | // Poll for access token | |
| 27 | - | interval := 5 // Default to 5 seconds if not provided | |
| 28 | - | if i, ok := deviceCode["interval"].(float64); ok { | |
| 29 | - | interval = int(i) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | token, err := pollForAccessToken(a, deviceCode["device_code"].(string), interval) | |
| 33 | - | if err != nil { | |
| 34 | - | return fmt.Errorf("error polling for access token: %w", err) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // Save the token | |
| 38 | - | a.Config.SetToken(token) | |
| 39 | - | ||
| 40 | - | fmt.Println("Authentication successful!") | |
| 41 | - | return nil | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func requestDeviceCode(a *cli.App) (map[string]interface{}, error) { | |
| 45 | - | resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/code", url.Values{}) | |
| 46 | - | if err != nil { | |
| 47 | - | return nil, fmt.Errorf("error making request: %w", err) | |
| 48 | - | } | |
| 49 | - | defer resp.Body.Close() | |
| 50 | - | ||
| 51 | - | body, err := io.ReadAll(resp.Body) | |
| 52 | - | if err != nil { | |
| 53 | - | return nil, fmt.Errorf("error reading response body: %w", err) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | if resp.StatusCode != http.StatusOK { | |
| 57 | - | return nil, fmt.Errorf("server returned non-200 status code: %d. Body: %s", resp.StatusCode, string(body)) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | var deviceCode map[string]interface{} | |
| 61 | - | if err := json.Unmarshal(body, &deviceCode); err != nil { | |
| 62 | - | return nil, fmt.Errorf("error parsing JSON response: %w", err) | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // Check for required fields | |
| 66 | - | requiredFields := []string{"verification_uri", "user_code", "device_code", "interval"} | |
| 67 | - | for _, field := range requiredFields { | |
| 68 | - | if _, ok := deviceCode[field]; !ok { | |
| 69 | - | return nil, fmt.Errorf("missing required field in response: %s", field) | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | return deviceCode, nil | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func pollForAccessToken(a *cli.App, deviceCode string, interval int) (string, error) { | |
| 77 | - | maxAttempts := 60 // Maximum number of attempts (5 minutes with 5-second intervals) | |
| 78 | - | for attempt := 0; attempt < maxAttempts; attempt++ { | |
| 79 | - | time.Sleep(time.Duration(interval) * time.Second) | |
| 80 | - | ||
| 81 | - | resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/token", url.Values{ | |
| 82 | - | "device_code": {deviceCode}, | |
| 83 | - | }) | |
| 84 | - | if err != nil { | |
| 85 | - | continue | |
| 86 | - | } | |
| 87 | - | defer resp.Body.Close() | |
| 88 | - | ||
| 89 | - | body, err := io.ReadAll(resp.Body) | |
| 90 | - | if err != nil { | |
| 91 | - | continue | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | var tokenResp map[string]interface{} | |
| 95 | - | if err := json.Unmarshal(body, &tokenResp); err != nil { | |
| 96 | - | continue | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | if accessToken, ok := tokenResp["access_token"].(string); ok && accessToken != "" { | |
| 100 | - | return accessToken, nil | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | if tokenResp["error"] == "authorization_pending" { | |
| 104 | - | // Optionally, you can print a dot to show progress without cluttering the output | |
| 105 | - | fmt.Print(".") | |
| 106 | - | continue | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // If we get here, there's an unexpected response but we can continue to try | |
| 110 | - | // fmt.Printf("Unexpected response from server (attempt %d): %v\n", attempt+1, tokenResp) | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | return "", fmt.Errorf("max attempts reached, failed to obtain access token") | |
| 114 | - | } | |
D internal/cli/auth/token_cache.go +0 -87
| @@ -1,87 +0,0 @@ | |||
| 1 | - | package auth | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sync" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 8 | - | "github.com/charmbracelet/log" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | type TokenCache struct { | |
| 12 | - | cache sync.Map | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | type cacheEntry struct { | |
| 16 | - | token string | |
| 17 | - | expiration time.Time | |
| 18 | - | githubUser *queries.GithubUserInfo | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | var ( | |
| 22 | - | defaultCache *TokenCache | |
| 23 | - | once sync.Once | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | // GetTokenCache returns a singleton instance of TokenCache | |
| 27 | - | func GetTokenCache() *TokenCache { | |
| 28 | - | once.Do(func() { | |
| 29 | - | defaultCache = &TokenCache{} | |
| 30 | - | log.Debug("initialized token cache singleton") | |
| 31 | - | }) | |
| 32 | - | return defaultCache | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // GetWithUser retrieves a token and GitHub user info from the cache | |
| 36 | - | func (tc *TokenCache) GetWithUser(key string) (string, *queries.GithubUserInfo, bool) { | |
| 37 | - | if value, ok := tc.cache.Load(key); ok { | |
| 38 | - | entry := value.(cacheEntry) | |
| 39 | - | if time.Now().Before(entry.expiration) { | |
| 40 | - | log.Debug("cache hit", "key", key, "expires_in", time.Until(entry.expiration)) | |
| 41 | - | return entry.token, entry.githubUser, true | |
| 42 | - | } | |
| 43 | - | log.Debug("cache entry expired", "key", key) | |
| 44 | - | tc.cache.Delete(key) | |
| 45 | - | } | |
| 46 | - | log.Debug("cache miss", "key", key) | |
| 47 | - | return "", nil, false | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Get retrieves just the token validation status | |
| 51 | - | func (tc *TokenCache) Get(key string) (bool, bool) { | |
| 52 | - | if value, ok := tc.cache.Load(key); ok { | |
| 53 | - | entry := value.(cacheEntry) | |
| 54 | - | if time.Now().Before(entry.expiration) { | |
| 55 | - | log.Debug("cache hit (validation status)", "key", key, "expires_in", time.Until(entry.expiration)) | |
| 56 | - | return true, true | |
| 57 | - | } | |
| 58 | - | log.Debug("cache entry expired (validation status)", "key", key) | |
| 59 | - | tc.cache.Delete(key) | |
| 60 | - | } | |
| 61 | - | log.Debug("cache miss (validation status)", "key", key) | |
| 62 | - | return false, false | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // SetWithUser stores a token and GitHub user info in the cache | |
| 66 | - | func (tc *TokenCache) SetWithUser(key string, token string, user *queries.GithubUserInfo, duration time.Duration) { | |
| 67 | - | tc.cache.Store(key, cacheEntry{ | |
| 68 | - | token: token, | |
| 69 | - | githubUser: user, | |
| 70 | - | expiration: time.Now().Add(duration), | |
| 71 | - | }) | |
| 72 | - | log.Debug("stored token with user info in cache", | |
| 73 | - | "key", key, | |
| 74 | - | "duration", duration, | |
| 75 | - | "github_user", user.Login) | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Set stores just the token validation status | |
| 79 | - | func (tc *TokenCache) Set(key string, duration time.Duration) { | |
| 80 | - | tc.cache.Store(key, cacheEntry{ | |
| 81 | - | token: key, | |
| 82 | - | expiration: time.Now().Add(duration), | |
| 83 | - | }) | |
| 84 | - | log.Debug("stored token validation status in cache", | |
| 85 | - | "key", key, | |
| 86 | - | "duration", duration) | |
| 87 | - | } | |
D internal/cli/auth/token_check.go +0 -64
| @@ -1,64 +0,0 @@ | |||
| 1 | - | package auth | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | "strings" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/charmbracelet/log" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // IsValidToken validates a GitHub token | |
| 12 | - | func IsValidGitHubToken(token string) bool { | |
| 13 | - | // Use the existing token cache | |
| 14 | - | tokenCache := GetTokenCache() | |
| 15 | - | ||
| 16 | - | // Check if token exists in cache | |
| 17 | - | if _, _, found := tokenCache.GetWithUser(token); found { | |
| 18 | - | log.Debug("token found in cache") | |
| 19 | - | return true | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // If not in cache, validate with GitHub | |
| 23 | - | client := &http.Client{Timeout: 10 * time.Second} | |
| 24 | - | req, err := http.NewRequest("GET", "https://api.github.com/user", nil) | |
| 25 | - | if err != nil { | |
| 26 | - | log.Error("failed to create GitHub API request", "error", err) | |
| 27 | - | return false | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | req.Header.Set("Authorization", "token "+token) | |
| 31 | - | req.Header.Set("Accept", "application/vnd.github.v3+json") | |
| 32 | - | ||
| 33 | - | resp, err := client.Do(req) | |
| 34 | - | if err != nil { | |
| 35 | - | log.Error("GitHub API request failed", "error", err) | |
| 36 | - | return false | |
| 37 | - | } | |
| 38 | - | defer resp.Body.Close() | |
| 39 | - | ||
| 40 | - | if resp.StatusCode != http.StatusOK { | |
| 41 | - | log.Warn("invalid GitHub token", "status", resp.Status) | |
| 42 | - | return false | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return true | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // ExtractAndValidateToken extracts and validates a token from an Authorization header | |
| 49 | - | func ExtractAndValidateToken(authHeader string) (string, bool) { | |
| 50 | - | if authHeader == "" { | |
| 51 | - | return "", false | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | token := strings.TrimPrefix(authHeader, "Bearer ") | |
| 55 | - | if token == "" { | |
| 56 | - | return "", false | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | if !IsValidGitHubToken(token) { | |
| 60 | - | return "", false | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | return token, true | |
| 64 | - | } | |
D internal/cli/cmd/deploy.go +0 -238
| @@ -1,238 +0,0 @@ | |||
| 1 | - | // gordon/internal/cli/cmd/deploy.go | |
| 2 | - | ||
| 3 | - | package cmd | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "bytes" | |
| 7 | - | "context" | |
| 8 | - | "encoding/json" | |
| 9 | - | "fmt" | |
| 10 | - | "io" | |
| 11 | - | "net/http" | |
| 12 | - | "os" | |
| 13 | - | "strings" | |
| 14 | - | "time" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/gordon/internal/cli" | |
| 17 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 18 | - | "github.com/bnema/gordon/internal/common" | |
| 19 | - | "github.com/charmbracelet/log" | |
| 20 | - | "github.com/spf13/cobra" | |
| 21 | - | ) | |
| 22 | - | ||
| 23 | - | func init() { | |
| 24 | - | log.SetReportTimestamp(true) | |
| 25 | - | log.SetTimeFormat("15:04") | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func NewDeployCommand(a *cli.App) *cobra.Command { | |
| 29 | - | var port string | |
| 30 | - | var targetDomain string | |
| 31 | - | ||
| 32 | - | deployCmd := &cobra.Command{ | |
| 33 | - | Use: "deploy [image:tag]", | |
| 34 | - | Short: "Deploy an image to your remote Gordon instance", | |
| 35 | - | Args: cobra.ExactArgs(1), | |
| 36 | - | PreRun: func(cmd *cobra.Command, args []string) { | |
| 37 | - | if err := handler.FieldCheck(a); err != nil { | |
| 38 | - | log.Error("Field check failed", "error", err) | |
| 39 | - | os.Exit(1) | |
| 40 | - | } | |
| 41 | - | }, | |
| 42 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 43 | - | imageName := args[0] | |
| 44 | - | log.Info("Pushing image", "image", imageName) | |
| 45 | - | ||
| 46 | - | if err := validateDeployInputs(imageName, port, targetDomain); err != nil { | |
| 47 | - | log.Error("Validation failed", "error", err) | |
| 48 | - | return | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | reader, actualSize, err := handler.ExportDockerImage(imageName) | |
| 52 | - | if err != nil { | |
| 53 | - | log.Error("Error exporting image", "error", err) | |
| 54 | - | return | |
| 55 | - | } | |
| 56 | - | defer reader.Close() | |
| 57 | - | ||
| 58 | - | sizeInMB := float64(actualSize) / 1024 / 1024 | |
| 59 | - | ||
| 60 | - | log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB)) | |
| 61 | - | ||
| 62 | - | if err := deployImage(a, reader, imageName, port, targetDomain); err != nil { | |
| 63 | - | os.Exit(1) | |
| 64 | - | } | |
| 65 | - | }, | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container and Traefik entry point") | |
| 69 | - | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik") | |
| 70 | - | ||
| 71 | - | return deployCmd | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error { | |
| 75 | - | // Check authentication early | |
| 76 | - | if err := handler.CheckAndRefreshAuth(a); err != nil { | |
| 77 | - | log.Error("Authentication check failed", "error", err) | |
| 78 | - | return err | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | log.Info("Deploying image", "image", imageName) | |
| 82 | - | // Check for conflicts first | |
| 83 | - | resp, err := checkDeployConflict(a, targetDomain, port) | |
| 84 | - | if err != nil { | |
| 85 | - | return fmt.Errorf("conflict check failed: %w", err) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | var shortID string | |
| 89 | - | if len(resp.ContainerID) >= 12 { | |
| 90 | - | shortID = resp.ContainerID[:12] | |
| 91 | - | } else { | |
| 92 | - | shortID = resp.ContainerID // Use full ID if less than 12 chars | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // If there's a conflict, handle it before proceeding | |
| 96 | - | if !resp.Success && resp.ContainerID != "" { | |
| 97 | - | log.Warn("Container already exists", | |
| 98 | - | "name", resp.ContainerName, | |
| 99 | - | "id", shortID, | |
| 100 | - | "state", resp.State, | |
| 101 | - | "uptime", resp.RunningTime) | |
| 102 | - | ||
| 103 | - | if err := handler.HandleExistingContainer(a, resp); err != nil { | |
| 104 | - | if strings.Contains(err.Error(), "cancelled by user") { | |
| 105 | - | return fmt.Errorf("deployment cancelled by user") | |
| 106 | - | } | |
| 107 | - | return fmt.Errorf("failed to handle existing container: %w", err) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | } | |
| 111 | - | ||
| 112 | - | var buf bytes.Buffer | |
| 113 | - | size, err := io.Copy(&buf, reader) | |
| 114 | - | if err != nil { | |
| 115 | - | return fmt.Errorf("failed to read image data: %w", err) | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | imageReader := bytes.NewReader(buf.Bytes()) | |
| 119 | - | sizeInMB := float64(size) / 1024 / 1024 | |
| 120 | - | log.Info("Attempting to deploy...", | |
| 121 | - | "image", imageName, | |
| 122 | - | "size", fmt.Sprintf("%.2fMB", sizeInMB), | |
| 123 | - | "port", port, | |
| 124 | - | "target", targetDomain, | |
| 125 | - | ) | |
| 126 | - | ||
| 127 | - | headers := http.Header{ | |
| 128 | - | "X-Image-Name": {imageName}, | |
| 129 | - | "X-Ports": {port}, | |
| 130 | - | "X-Target-Domain": {targetDomain}, | |
| 131 | - | "Content-Type": {"application/octet-stream"}, | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | chunkedClient := handler.NewChunkedClient(a) | |
| 135 | - | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | |
| 136 | - | defer cancel() | |
| 137 | - | ||
| 138 | - | chunkResp, err := chunkedClient.SendFileAsChunks(ctx, "/deploy/chunked", headers, imageReader, size, imageName) | |
| 139 | - | if err != nil { | |
| 140 | - | return fmt.Errorf("failed to deploy image: %w", err) | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | if chunkResp == nil { | |
| 144 | - | return fmt.Errorf("received nil response from server") | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | var deployResp common.DeployResponse | |
| 148 | - | if err := json.Unmarshal(chunkResp.Body, &deployResp); err != nil { | |
| 149 | - | return fmt.Errorf("failed to parse deployment response: %w", err) | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | return waitForDeployment(deployResp.Domain, deployResp.ContainerID) | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | func waitForDeployment(domain string, containerID string) error { | |
| 156 | - | client := &http.Client{Timeout: 10 * time.Second} | |
| 157 | - | maxRetries := 10 | |
| 158 | - | retryInterval := time.Second | |
| 159 | - | ||
| 160 | - | var shortContainerID string | |
| 161 | - | if containerID != "" { | |
| 162 | - | shortContainerID = containerID[:12] | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | log.Info("Waiting for deployment to be reachable", | |
| 166 | - | "domain", domain, | |
| 167 | - | "container_id", shortContainerID) | |
| 168 | - | ||
| 169 | - | for i := 0; i < maxRetries; i++ { | |
| 170 | - | resp, err := client.Get(domain) | |
| 171 | - | if err == nil { | |
| 172 | - | defer resp.Body.Close() | |
| 173 | - | if resp.StatusCode == http.StatusOK { | |
| 174 | - | log.Info("Deployment successful", | |
| 175 | - | "domain", domain, | |
| 176 | - | "container_id", shortContainerID) | |
| 177 | - | return nil | |
| 178 | - | } | |
| 179 | - | body, _ := io.ReadAll(resp.Body) | |
| 180 | - | if strings.Contains(string(body), "failed to create container:") { | |
| 181 | - | return fmt.Errorf("deployment failed: %s", string(body)) | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | log.Warn("Deployment not ready yet, retrying", | |
| 185 | - | "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) | |
| 186 | - | time.Sleep(retryInterval) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | func validateDeployInputs(imageName, port, targetDomain string) error { | |
| 193 | - | if err := handler.ValidateImageName(imageName); err != nil { | |
| 194 | - | return err | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | if err := handler.EnsureImageTag(&imageName); err != nil { | |
| 198 | - | return fmt.Errorf("failed to ensure image tag: %w", err) | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | if port == "" { | |
| 202 | - | return fmt.Errorf("port is required") | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | return handler.ValidateTargetDomain(targetDomain) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.DeployResponse, error) { | |
| 209 | - | ||
| 210 | - | // Create request to check-conflict endpoint with both domain and port parameters | |
| 211 | - | reqUrl := fmt.Sprintf("%s/api/deploy/check-conflict?domain=%s&port=%s", | |
| 212 | - | a.Config.Http.BackendURL, targetDomain, port) | |
| 213 | - | ||
| 214 | - | req, err := http.NewRequest("GET", reqUrl, nil) | |
| 215 | - | if err != nil { | |
| 216 | - | return nil, fmt.Errorf("failed to create conflict check request: %w", err) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // Set auth header | |
| 220 | - | req.Header.Set("Authorization", "Bearer "+a.Config.General.Token) | |
| 221 | - | ||
| 222 | - | // Send request | |
| 223 | - | client := &http.Client{ | |
| 224 | - | Timeout: 10 * time.Second, | |
| 225 | - | } | |
| 226 | - | resp, err := client.Do(req) | |
| 227 | - | if err != nil { | |
| 228 | - | return nil, fmt.Errorf("failed to send conflict check request: %w", err) | |
| 229 | - | } | |
| 230 | - | defer resp.Body.Close() | |
| 231 | - | ||
| 232 | - | var conflictResp common.DeployResponse | |
| 233 | - | if err := json.NewDecoder(resp.Body).Decode(&conflictResp); err != nil { | |
| 234 | - | return nil, fmt.Errorf("failed to parse conflict check response: %w", err) | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | return &conflictResp, nil | |
| 238 | - | } | |
D internal/cli/cmd/ping.go +0 -50
| @@ -1,50 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "os" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/cli" | |
| 9 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 10 | - | "github.com/bnema/gordon/pkg/humanize" | |
| 11 | - | ||
| 12 | - | "github.com/spf13/cobra" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func NewPingCommand(a *cli.App) *cobra.Command { | |
| 16 | - | // handler.FieldCheck(a) | |
| 17 | - | return &cobra.Command{ | |
| 18 | - | Use: "ping", | |
| 19 | - | Short: "Send a ping request to your remote Gordon instance", | |
| 20 | - | PreRun: func(cmd *cobra.Command, args []string) { | |
| 21 | - | if err := handler.FieldCheck(a); err != nil { | |
| 22 | - | fmt.Println("Field check failed:", err) | |
| 23 | - | os.Exit(1) | |
| 24 | - | } | |
| 25 | - | }, | |
| 26 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 27 | - | pingResp, err := handler.PerformPingRequest(a) | |
| 28 | - | if err != nil { | |
| 29 | - | fmt.Println("Error:", err) | |
| 30 | - | return | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // Parse the uptime duration string | |
| 34 | - | duration, err := time.ParseDuration(pingResp.Uptime) | |
| 35 | - | if err != nil { | |
| 36 | - | fmt.Println("Error parsing uptime duration:", err) | |
| 37 | - | return | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // Calculate the start time of the server | |
| 41 | - | startTime := time.Now().Add(-duration) | |
| 42 | - | ||
| 43 | - | // Use humanize to get a human-readable representation of the start time | |
| 44 | - | humanizedUptime := humanize.TimeAgo(startTime) | |
| 45 | - | ||
| 46 | - | // Print the information | |
| 47 | - | fmt.Printf("Pong!\nServer up since: %s\nServer version: %s\n", humanizedUptime, pingResp.Version) | |
| 48 | - | }, | |
| 49 | - | } | |
| 50 | - | } | |
D internal/cli/cmd/push.go +0 -136
| @@ -1,136 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "os" | |
| 11 | - | "strings" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/cli" | |
| 15 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 16 | - | "github.com/bnema/gordon/internal/common" | |
| 17 | - | "github.com/charmbracelet/log" | |
| 18 | - | "github.com/spf13/cobra" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | func init() { | |
| 22 | - | log.SetReportTimestamp(true) | |
| 23 | - | log.SetTimeFormat("15:04") | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func NewPushCommand(a *cli.App) *cobra.Command { | |
| 27 | - | var port string | |
| 28 | - | ||
| 29 | - | pushCmd := &cobra.Command{ | |
| 30 | - | Use: "push [image:tag]", | |
| 31 | - | Short: "Push an image to your remote Gordon instance", | |
| 32 | - | Args: cobra.ExactArgs(1), | |
| 33 | - | PreRun: func(cmd *cobra.Command, args []string) { | |
| 34 | - | if err := handler.FieldCheck(a); err != nil { | |
| 35 | - | log.Error("Field check failed", "error", err) | |
| 36 | - | os.Exit(1) | |
| 37 | - | } | |
| 38 | - | }, | |
| 39 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 40 | - | imageName := args[0] | |
| 41 | - | log.Info("Pushing image", "image", imageName) | |
| 42 | - | ||
| 43 | - | if err := validatePushInputs(imageName); err != nil { | |
| 44 | - | log.Error("Validation failed", "error", err) | |
| 45 | - | return | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | reader, actualSize, err := handler.ExportDockerImage(imageName) | |
| 49 | - | if err != nil { | |
| 50 | - | log.Error("Error exporting image", "error", err) | |
| 51 | - | return | |
| 52 | - | } | |
| 53 | - | defer reader.Close() | |
| 54 | - | ||
| 55 | - | sizeInMB := float64(actualSize) / 1024 / 1024 | |
| 56 | - | ||
| 57 | - | log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB)) | |
| 58 | - | ||
| 59 | - | if err := pushImage(a, reader, imageName); err != nil { | |
| 60 | - | os.Exit(1) | |
| 61 | - | } | |
| 62 | - | }, | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | pushCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container (e.g., 8080:80/tcp)") | |
| 66 | - | ||
| 67 | - | return pushCmd | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func pushImage(a *cli.App, reader io.Reader, imageName string) error { | |
| 71 | - | // Check authentication early | |
| 72 | - | if err := handler.CheckAndRefreshAuth(a); err != nil { | |
| 73 | - | log.Error("Authentication check failed", "error", err) | |
| 74 | - | return err | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | var buf bytes.Buffer | |
| 78 | - | size, err := io.Copy(&buf, reader) | |
| 79 | - | if err != nil { | |
| 80 | - | return fmt.Errorf("failed to read image data: %w", err) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | imageReader := bytes.NewReader(buf.Bytes()) | |
| 84 | - | sizeInMB := float64(size) / 1024 / 1024 | |
| 85 | - | log.Info("Attempting to push...", | |
| 86 | - | "image", imageName, | |
| 87 | - | "size", fmt.Sprintf("%.2fMB", sizeInMB), | |
| 88 | - | ) | |
| 89 | - | ||
| 90 | - | headers := http.Header{ | |
| 91 | - | "X-Image-Name": {imageName}, | |
| 92 | - | "Content-Type": {"application/octet-stream"}, | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | chunkedClient := handler.NewChunkedClient(a) | |
| 96 | - | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | |
| 97 | - | defer cancel() | |
| 98 | - | ||
| 99 | - | resp, err := chunkedClient.SendFileAsChunks(ctx, "/push/chunked", headers, imageReader, size, imageName) | |
| 100 | - | if err != nil { | |
| 101 | - | log.Error("Failed to send chunks", "error", err) | |
| 102 | - | return fmt.Errorf("failed to send chunks: %w", err) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | if resp == nil { | |
| 106 | - | return fmt.Errorf("received nil response from server") | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | var pushResponse common.PushResponse | |
| 110 | - | if err := json.Unmarshal(resp.Body, &pushResponse); err != nil { | |
| 111 | - | log.Error("Error parsing response", "error", err) | |
| 112 | - | return fmt.Errorf("failed to parse response: %w", err) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | if !pushResponse.Success { | |
| 116 | - | log.Error("Push failed", "resp", pushResponse.Message) | |
| 117 | - | return fmt.Errorf(pushResponse.Message) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // Remove the double quotes from the message | |
| 121 | - | message := strings.Trim(pushResponse.Message, "\"") | |
| 122 | - | log.Info(message) | |
| 123 | - | ||
| 124 | - | if pushResponse.CreateContainerURL != "" { | |
| 125 | - | log.Info("Container creation available", "url", pushResponse.CreateContainerURL) | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | return nil | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | func validatePushInputs(imageName string) error { | |
| 132 | - | if err := handler.ValidateImageName(imageName); err != nil { | |
| 133 | - | return err | |
| 134 | - | } | |
| 135 | - | return handler.EnsureImageTag(&imageName) | |
| 136 | - | } | |
D internal/cli/cmd/root.go +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | // the root command is the entrypoint for the gordon cli (default: client) | |
| 2 | - | package cmd | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "fmt" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/cli" | |
| 8 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 9 | - | "github.com/fatih/color" | |
| 10 | - | "github.com/spf13/cobra" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // NewRootCommand creates a new root command | |
| 14 | - | func NewRootCommand(a *cli.App) *cobra.Command { | |
| 15 | - | return &cobra.Command{ | |
| 16 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 17 | - | ||
| 18 | - | backendVersion := getBackendVersion(a) | |
| 19 | - | color.Green("Gordon %s", a.Config.GetVersion()) | |
| 20 | - | color.Blue("Gordon Backend %s", backendVersion) | |
| 21 | - | ||
| 22 | - | if backendVersion != a.Config.GetVersion() { | |
| 23 | - | color.Yellow("A new version of Gordon is available") | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | fmt.Println() | |
| 27 | - | fmt.Println("Use \"gordon --help\" for more information about a command.") | |
| 28 | - | }, | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func getBackendVersion(a *cli.App) string { | |
| 33 | - | pingResp, err := handler.PerformPingRequest(a) | |
| 34 | - | if err != nil { | |
| 35 | - | return fmt.Sprintf("Error: %s", err) | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | return pingResp.Version | |
| 39 | - | } | |
D internal/cli/cmd/serve.go +0 -41
| @@ -1,41 +0,0 @@ | |||
| 1 | - | // the serve command is used to start the gordon server | |
| 2 | - | // Path: internal/cli/cmd/serve.go | |
| 3 | - | package cmd | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/bnema/gordon/pkg/docker" | |
| 9 | - | "github.com/spf13/cobra" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // NewServeCommand creates a new serve command | |
| 13 | - | func NewServeCommand(a *server.App) *cobra.Command { | |
| 14 | - | defaultport := "1323" | |
| 15 | - | var port string | |
| 16 | - | ||
| 17 | - | // if the server is running in a docker container, the default port is 80 | |
| 18 | - | if docker.IsRunningInContainer() { | |
| 19 | - | defaultport = "80" | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // if no flags -p or --port are specified, the default port is used | |
| 23 | - | serveCmd := &cobra.Command{ | |
| 24 | - | Use: "serve", | |
| 25 | - | Short: "Start a new Gordon server instance", | |
| 26 | - | Args: cobra.NoArgs, | |
| 27 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 28 | - | // handler.StartServer will use the value of port, which will be the default | |
| 29 | - | // value if no flag is specified | |
| 30 | - | a.Config.Http.Port = port | |
| 31 | - | err := handler.StartServer(a, port) | |
| 32 | - | if err != nil { | |
| 33 | - | panic(err) | |
| 34 | - | } | |
| 35 | - | }, | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // Attach the flag to serveCmd and store its value in the variable port | |
| 39 | - | serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on") | |
| 40 | - | return serveCmd | |
| 41 | - | } | |
D internal/cli/cmd/update.go +0 -406
| @@ -1,406 +0,0 @@ | |||
| 1 | - | // This upload feature is based on pocketbase's ghupdate command. | |
| 2 | - | // Thank you for your work, @Gani Georgiev | |
| 3 | - | // Check the original code here: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go | |
| 4 | - | package cmd | |
| 5 | - | ||
| 6 | - | import ( | |
| 7 | - | "context" | |
| 8 | - | "encoding/json" | |
| 9 | - | "errors" | |
| 10 | - | "fmt" | |
| 11 | - | "io" | |
| 12 | - | "log" | |
| 13 | - | "net/http" | |
| 14 | - | "os" | |
| 15 | - | "path/filepath" | |
| 16 | - | "runtime" | |
| 17 | - | "strconv" | |
| 18 | - | "strings" | |
| 19 | - | ||
| 20 | - | "github.com/AlecAivazis/survey/v2" | |
| 21 | - | "github.com/bnema/gordon/internal/cli" | |
| 22 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 23 | - | "github.com/bnema/gordon/pkg/docker" | |
| 24 | - | "github.com/fatih/color" | |
| 25 | - | "github.com/pocketbase/pocketbase/tools/archive" | |
| 26 | - | "github.com/spf13/cobra" | |
| 27 | - | ) | |
| 28 | - | ||
| 29 | - | type HttpClient interface { | |
| 30 | - | Do(req *http.Request) (*http.Response, error) | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | type Config struct { | |
| 34 | - | Owner string | |
| 35 | - | Repo string | |
| 36 | - | ArchiveExecutable string | |
| 37 | - | Context context.Context | |
| 38 | - | HttpClient HttpClient | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | var pluginInstance *plugin | |
| 42 | - | ||
| 43 | - | type plugin struct { | |
| 44 | - | app *cli.App | |
| 45 | - | currentVersion string | |
| 46 | - | config Config | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | func NewUpdateCommand(a *cli.App) *cobra.Command { | |
| 50 | - | ||
| 51 | - | pluginInstance = &plugin{ | |
| 52 | - | app: a, | |
| 53 | - | currentVersion: a.Config.Build.BuildVersion, | |
| 54 | - | config: Config{ | |
| 55 | - | Owner: "bnema", | |
| 56 | - | Repo: "gordon", | |
| 57 | - | ArchiveExecutable: "gordon", | |
| 58 | - | Context: context.Background(), | |
| 59 | - | HttpClient: http.DefaultClient, | |
| 60 | - | }, | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | if pluginInstance.app != a { | |
| 64 | - | panic("app instance is not the same") | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | if a == nil { | |
| 68 | - | panic("app instance is nil") | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | command := &cobra.Command{ | |
| 72 | - | Use: "update", | |
| 73 | - | Short: "Update the Gordon executable", | |
| 74 | - | SilenceErrors: true, | |
| 75 | - | SilenceUsage: true, | |
| 76 | - | RunE: func(command *cobra.Command, args []string) error { | |
| 77 | - | var needConfirm bool | |
| 78 | - | if docker.IsRunningInContainer() { | |
| 79 | - | needConfirm = true | |
| 80 | - | color.Yellow("It seems that you are in a Docker container.") | |
| 81 | - | color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.") | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | if needConfirm { | |
| 85 | - | confirm := false | |
| 86 | - | prompt := &survey.Confirm{ | |
| 87 | - | Message: "Do you want to proceed with the update?", | |
| 88 | - | } | |
| 89 | - | survey.AskOne(prompt, &confirm) | |
| 90 | - | if !confirm { | |
| 91 | - | fmt.Println("The command has been cancelled.") | |
| 92 | - | return nil | |
| 93 | - | } | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // Vérifier si l'utilisateur est root | |
| 97 | - | if isRoot() { | |
| 98 | - | color.Yellow("Running update as root. Skipping configuration check.") | |
| 99 | - | return pluginInstance.update() | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | // Si non root, effectuer la vérification des champs | |
| 103 | - | if err := handler.FieldCheck(a); err != nil { | |
| 104 | - | return fmt.Errorf("field check failed: %w", err) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | return pluginInstance.update() | |
| 108 | - | }, | |
| 109 | - | } | |
| 110 | - | return command | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (p *plugin) update() error { | |
| 114 | - | color.Yellow("Fetching release information...") | |
| 115 | - | ||
| 116 | - | latest, err := fetchLatestRelease( | |
| 117 | - | p.config.Context, | |
| 118 | - | p.config.HttpClient, | |
| 119 | - | p.config.Owner, | |
| 120 | - | p.config.Repo, | |
| 121 | - | ) | |
| 122 | - | if err != nil { | |
| 123 | - | return err | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 { | |
| 127 | - | color.Green("You already have the latest version %s.", p.currentVersion) | |
| 128 | - | return nil | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // Prompt user for update | |
| 132 | - | confirm := false | |
| 133 | - | prompt := &survey.Confirm{ | |
| 134 | - | Message: fmt.Sprintf("A new version (%s) is available. Do you want to update?", latest.Tag), | |
| 135 | - | } | |
| 136 | - | survey.AskOne(prompt, &confirm) | |
| 137 | - | if !confirm { | |
| 138 | - | fmt.Println("Update cancelled by user.") | |
| 139 | - | return nil | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH) | |
| 143 | - | if suffix == "" { | |
| 144 | - | return errors.New("unsupported platform") | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | asset, err := latest.findAssetBySuffix(suffix) | |
| 148 | - | if err != nil { | |
| 149 | - | return err | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // temporary file is under common.Config.General.StorageDir | |
| 153 | - | releaseDir := filepath.Join(p.app.Config.General.StorageDir, "update") | |
| 154 | - | defer os.RemoveAll(releaseDir) | |
| 155 | - | ||
| 156 | - | color.Yellow("Downloading %s...", asset.Name) | |
| 157 | - | ||
| 158 | - | // download the release asset | |
| 159 | - | assetZip := filepath.Join(releaseDir, asset.Name) | |
| 160 | - | if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil { | |
| 161 | - | return err | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | color.Yellow("Extracting %s...", asset.Name) | |
| 165 | - | ||
| 166 | - | extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name) | |
| 167 | - | defer os.RemoveAll(extractDir) | |
| 168 | - | ||
| 169 | - | if err := archive.Extract(assetZip, extractDir); err != nil { | |
| 170 | - | return err | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | color.Yellow("Replacing the executable...") | |
| 174 | - | ||
| 175 | - | oldExec, err := os.Executable() | |
| 176 | - | if err != nil { | |
| 177 | - | return err | |
| 178 | - | } | |
| 179 | - | renamedOldExec := oldExec + ".old" | |
| 180 | - | defer os.Remove(renamedOldExec) | |
| 181 | - | ||
| 182 | - | // Check if the user has write permissions | |
| 183 | - | if err := checkWritePermission(oldExec); err != nil { | |
| 184 | - | color.Yellow("Administrator rights are required to perform the update.") | |
| 185 | - | color.Yellow("Please run the following command with sudo:") | |
| 186 | - | color.HiBlack("sudo gordon update") | |
| 187 | - | return nil | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | newExec := filepath.Join(extractDir, p.config.ArchiveExecutable) | |
| 191 | - | if _, err := os.Stat(newExec); err != nil { | |
| 192 | - | // try again with an .exe extension | |
| 193 | - | newExec = newExec + ".exe" | |
| 194 | - | if _, fallbackErr := os.Stat(newExec); fallbackErr != nil { | |
| 195 | - | return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr) | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // rename the current executable | |
| 200 | - | if err := os.Rename(oldExec, renamedOldExec); err != nil { | |
| 201 | - | return fmt.Errorf("failed to rename the current executable: %w", err) | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | tryToRevertExecChanges := func() { | |
| 205 | - | if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil { | |
| 206 | - | log.Println(revertErr) | |
| 207 | - | } | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | // replace with the extracted binary | |
| 211 | - | if err := os.Rename(newExec, oldExec); err != nil { | |
| 212 | - | tryToRevertExecChanges() | |
| 213 | - | return fmt.Errorf("failed replacing the executable: %w", err) | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | color.HiBlack("---") | |
| 217 | - | color.Green("Update completed successfully! You can start the executable as usual.") | |
| 218 | - | ||
| 219 | - | return nil | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | func (r *release) findAssetBySuffix(suffix string) (*asset, error) { | |
| 223 | - | for _, asset := range r.Assets { | |
| 224 | - | if strings.HasSuffix(asset.Name, suffix) { | |
| 225 | - | return &asset, nil | |
| 226 | - | } | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | return nil, fmt.Errorf("no asset found with suffix %q", suffix) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | type release struct { | |
| 233 | - | Tag string `json:"tag_name"` | |
| 234 | - | Assets []asset `json:"assets"` | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | type asset struct { | |
| 238 | - | Name string `json:"name"` | |
| 239 | - | DownloadUrl string `json:"browser_download_url"` | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | func fetchLatestRelease( | |
| 243 | - | ctx context.Context, | |
| 244 | - | client HttpClient, | |
| 245 | - | owner string, | |
| 246 | - | repo string, | |
| 247 | - | ) (*release, error) { | |
| 248 | - | url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) | |
| 249 | - | ||
| 250 | - | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 251 | - | if err != nil { | |
| 252 | - | return nil, err | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | res, err := client.Do(req) | |
| 256 | - | if err != nil { | |
| 257 | - | return nil, err | |
| 258 | - | } | |
| 259 | - | defer res.Body.Close() | |
| 260 | - | ||
| 261 | - | rawBody, err := io.ReadAll(res.Body) | |
| 262 | - | if err != nil { | |
| 263 | - | return nil, err | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | // http.Client doesn't treat non 2xx responses as error | |
| 267 | - | if res.StatusCode >= 400 { | |
| 268 | - | return nil, fmt.Errorf( | |
| 269 | - | "(%d) failed to fetch latest releases:\n%s", | |
| 270 | - | res.StatusCode, | |
| 271 | - | string(rawBody), | |
| 272 | - | ) | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | result := &release{} | |
| 276 | - | if err := json.Unmarshal(rawBody, result); err != nil { | |
| 277 | - | return nil, err | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | return result, nil | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | func downloadFile( | |
| 284 | - | ctx context.Context, | |
| 285 | - | client HttpClient, | |
| 286 | - | url string, | |
| 287 | - | destPath string, | |
| 288 | - | ) error { | |
| 289 | - | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 290 | - | if err != nil { | |
| 291 | - | return err | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | res, err := client.Do(req) | |
| 295 | - | if err != nil { | |
| 296 | - | return err | |
| 297 | - | } | |
| 298 | - | defer res.Body.Close() | |
| 299 | - | ||
| 300 | - | // http.Client doesn't treat non 2xx responses as error | |
| 301 | - | if res.StatusCode >= 400 { | |
| 302 | - | return fmt.Errorf("(%d) failed to send download file request", res.StatusCode) | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | // ensure that the dest parent dir(s) exist | |
| 306 | - | if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { | |
| 307 | - | return err | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | dest, err := os.Create(destPath) | |
| 311 | - | if err != nil { | |
| 312 | - | return err | |
| 313 | - | } | |
| 314 | - | defer dest.Close() | |
| 315 | - | ||
| 316 | - | if _, err := io.Copy(dest, res.Body); err != nil { | |
| 317 | - | return err | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | return nil | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | // archives: | |
| 324 | - | // - format: zip | |
| 325 | - | // name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" | |
| 326 | - | ||
| 327 | - | func archiveSuffix(goos, goarch string) string { | |
| 328 | - | // Define a map to hold the combinations of goos and goarch. | |
| 329 | - | var archMap = map[string]map[string]string{ | |
| 330 | - | "linux": { | |
| 331 | - | "amd64": "_linux_amd64.zip", | |
| 332 | - | "arm64": "_linux_arm64.zip", | |
| 333 | - | "arm": "_linux_armv7.zip", | |
| 334 | - | }, | |
| 335 | - | "darwin": { | |
| 336 | - | "amd64": "_darwin_amd64.zip", | |
| 337 | - | "arm64": "_darwin_arm64.zip", | |
| 338 | - | }, | |
| 339 | - | "windows": { | |
| 340 | - | "amd64": "_windows_amd64.zip", | |
| 341 | - | "arm64": "_windows_arm64.zip", | |
| 342 | - | }, | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | // Look up goos then goarch. | |
| 346 | - | if arch, ok := archMap[goos]; ok { | |
| 347 | - | if suffix, ok := arch[goarch]; ok { | |
| 348 | - | return suffix | |
| 349 | - | } | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | // Return a default value if no match is found. | |
| 353 | - | return "" | |
| 354 | - | } | |
| 355 | - | func compareVersions(a, b string) int { | |
| 356 | - | aSplit := strings.Split(a, ".") | |
| 357 | - | aTotal := len(aSplit) | |
| 358 | - | ||
| 359 | - | bSplit := strings.Split(b, ".") | |
| 360 | - | bTotal := len(bSplit) | |
| 361 | - | ||
| 362 | - | limit := aTotal | |
| 363 | - | if bTotal > aTotal { | |
| 364 | - | limit = bTotal | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | for i := 0; i < limit; i++ { | |
| 368 | - | var x, y int | |
| 369 | - | ||
| 370 | - | if i < aTotal { | |
| 371 | - | x, _ = strconv.Atoi(aSplit[i]) | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | if i < bTotal { | |
| 375 | - | y, _ = strconv.Atoi(bSplit[i]) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | if x < y { | |
| 379 | - | return 1 // b is newer | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | if x > y { | |
| 383 | - | return -1 // a is newer | |
| 384 | - | } | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | return 0 // equal | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | // New function to check write permissions | |
| 391 | - | func checkWritePermission(path string) error { | |
| 392 | - | // Try to create a temporary file in the same directory | |
| 393 | - | dir := filepath.Dir(path) | |
| 394 | - | tmpFile := filepath.Join(dir, ".gordon_update_test") | |
| 395 | - | f, err := os.Create(tmpFile) | |
| 396 | - | if err != nil { | |
| 397 | - | return err | |
| 398 | - | } | |
| 399 | - | f.Close() | |
| 400 | - | os.Remove(tmpFile) | |
| 401 | - | return nil | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | func isRoot() bool { | |
| 405 | - | return os.Geteuid() == 0 | |
| 406 | - | } | |
D internal/cli/cmd/version.go +0 -40
| @@ -1,40 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/cli" | |
| 7 | - | "github.com/bnema/gordon/internal/common" | |
| 8 | - | "github.com/spf13/cobra" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | func NewVersionCommand(app *cli.App) *cobra.Command { | |
| 12 | - | cmd := &cobra.Command{ | |
| 13 | - | Use: "version", | |
| 14 | - | Short: "Print version information", | |
| 15 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 16 | - | info := common.GetVersionInfo( | |
| 17 | - | app.Config.Build.BuildVersion, | |
| 18 | - | app.Config.Build.BuildCommit, | |
| 19 | - | app.Config.Build.BuildDate, | |
| 20 | - | app.Config.Build.ProxyURL, | |
| 21 | - | ) | |
| 22 | - | fmt.Println(info.String()) | |
| 23 | - | ||
| 24 | - | hasUpdate, latestVersion, err := common.CheckForNewVersion( | |
| 25 | - | info.Version, | |
| 26 | - | info.ProxyURL, | |
| 27 | - | ) | |
| 28 | - | if err != nil { | |
| 29 | - | fmt.Printf("Error checking for updates: %v\n", err) | |
| 30 | - | return | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | if hasUpdate { | |
| 34 | - | fmt.Printf("\nA new version is available: %s\n", latestVersion) | |
| 35 | - | fmt.Println("You can update using: gordon update") | |
| 36 | - | } | |
| 37 | - | }, | |
| 38 | - | } | |
| 39 | - | return cmd | |
| 40 | - | } | |
D internal/cli/handler/auth.go +0 -77
| @@ -1,77 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/cli" | |
| 8 | - | "github.com/bnema/gordon/internal/cli/auth" | |
| 9 | - | "github.com/bnema/gordon/internal/common" | |
| 10 | - | "github.com/charmbracelet/log" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func CheckAndRefreshAuth(a *cli.App) error { | |
| 14 | - | if a.Config.General.Token == "" { | |
| 15 | - | log.Info("No authentication token found. Initiating authentication...") | |
| 16 | - | return ReAuthenticate(a) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | resp, err := SendHTTPRequest(a, &common.RequestPayload{ | |
| 20 | - | Type: "ping", | |
| 21 | - | Payload: common.PingPayload{ | |
| 22 | - | Message: "ping", | |
| 23 | - | }, | |
| 24 | - | }, "GET", "/ping") | |
| 25 | - | ||
| 26 | - | if err != nil { | |
| 27 | - | log.Debug("Token validation request failed", "error", err) | |
| 28 | - | return ReAuthenticate(a) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // If we get a 200 status code, and the resp contains message = pong, we're good | |
| 32 | - | if resp.StatusCode != 200 { | |
| 33 | - | log.Debug("Token validation failed", | |
| 34 | - | "status_code", resp.StatusCode, | |
| 35 | - | "response", resp.Body) | |
| 36 | - | return ReAuthenticate(a) | |
| 37 | - | ||
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return nil | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | func ReAuthenticate(a *cli.App) error { | |
| 44 | - | fmt.Println("Re-authenticating...") | |
| 45 | - | ||
| 46 | - | // Small delay for visual clarity | |
| 47 | - | time.Sleep(100 * time.Millisecond) | |
| 48 | - | ||
| 49 | - | err := auth.DeviceFlowAuth(a) | |
| 50 | - | if err != nil { | |
| 51 | - | return fmt.Errorf("device flow authentication failed: %w", err) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // After successful re-authentication, get the new token | |
| 55 | - | newToken, err := a.Config.GetToken() | |
| 56 | - | if err != nil { | |
| 57 | - | return fmt.Errorf("failed to get new token: %w", err) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Update the token in the app config | |
| 61 | - | a.Config.General.Token = newToken | |
| 62 | - | ||
| 63 | - | // Save the new token to config file | |
| 64 | - | err = a.Config.SaveConfig() | |
| 65 | - | if err != nil { | |
| 66 | - | return fmt.Errorf("failed to save new token to config: %w", err) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Clear any remaining auth UI elements | |
| 70 | - | fmt.Print("\033[2K\r") | |
| 71 | - | fmt.Println("Re-authentication successful.") | |
| 72 | - | ||
| 73 | - | // Add delay before resuming upload display | |
| 74 | - | time.Sleep(500 * time.Millisecond) | |
| 75 | - | ||
| 76 | - | return nil | |
| 77 | - | } | |
D internal/cli/handler/chunked_transfer.go +0 -568
| @@ -1,568 +0,0 @@ | |||
| 1 | - | // internal/cli/handler/progress_display.go | |
| 2 | - | ||
| 3 | - | package handler | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "bytes" | |
| 7 | - | "context" | |
| 8 | - | "crypto/sha256" | |
| 9 | - | "encoding/json" | |
| 10 | - | "errors" | |
| 11 | - | "fmt" | |
| 12 | - | "io" | |
| 13 | - | "net" | |
| 14 | - | "net/http" | |
| 15 | - | "os" | |
| 16 | - | "os/signal" | |
| 17 | - | "strings" | |
| 18 | - | "sync" | |
| 19 | - | "syscall" | |
| 20 | - | "time" | |
| 21 | - | ||
| 22 | - | "github.com/bnema/gordon/internal/cli" | |
| 23 | - | "github.com/bnema/gordon/internal/common" | |
| 24 | - | "github.com/charmbracelet/bubbles/spinner" | |
| 25 | - | tea "github.com/charmbracelet/bubbletea" | |
| 26 | - | "github.com/charmbracelet/lipgloss" | |
| 27 | - | "github.com/charmbracelet/log" | |
| 28 | - | "github.com/google/uuid" | |
| 29 | - | "golang.org/x/exp/rand" | |
| 30 | - | ) | |
| 31 | - | ||
| 32 | - | type DeployResponse = common.DeployResponse | |
| 33 | - | ||
| 34 | - | // UploadProgress represents the current state of the upload | |
| 35 | - | type UploadProgress struct { | |
| 36 | - | Completed int | |
| 37 | - | Total int | |
| 38 | - | Percentage float64 | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // ProgressDisplay manages the upload progress display | |
| 42 | - | type ProgressDisplay struct { | |
| 43 | - | mu sync.Mutex | |
| 44 | - | model *uploadModel | |
| 45 | - | program *tea.Program | |
| 46 | - | updateChan chan UploadProgress | |
| 47 | - | done chan struct{} | |
| 48 | - | paused bool | |
| 49 | - | pauseChan chan bool | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | type uploadModel struct { | |
| 53 | - | spinner spinner.Model | |
| 54 | - | completed int | |
| 55 | - | total int | |
| 56 | - | percentage float64 | |
| 57 | - | done bool | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // NewProgressDisplay creates a new progress display | |
| 61 | - | func NewProgressDisplay() *ProgressDisplay { | |
| 62 | - | return &ProgressDisplay{ | |
| 63 | - | updateChan: make(chan UploadProgress), | |
| 64 | - | done: make(chan struct{}), | |
| 65 | - | pauseChan: make(chan bool), | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func (pd *ProgressDisplay) Start() { | |
| 70 | - | pd.mu.Lock() | |
| 71 | - | defer pd.mu.Unlock() | |
| 72 | - | ||
| 73 | - | pd.model = &uploadModel{ | |
| 74 | - | spinner: spinner.New( | |
| 75 | - | spinner.WithSpinner(spinner.MiniDot), | |
| 76 | - | spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("69"))), | |
| 77 | - | ), | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | pd.program = tea.NewProgram(pd.model) | |
| 81 | - | ||
| 82 | - | // Run the UI in a goroutine | |
| 83 | - | go func() { | |
| 84 | - | if _, err := pd.program.Run(); err != nil { | |
| 85 | - | fmt.Printf("Error running progress display: %v\n", err) | |
| 86 | - | } | |
| 87 | - | }() | |
| 88 | - | ||
| 89 | - | // Handle updates in a separate goroutine | |
| 90 | - | go pd.handleUpdates() | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func (pd *ProgressDisplay) Pause() { | |
| 94 | - | pd.mu.Lock() | |
| 95 | - | defer pd.mu.Unlock() | |
| 96 | - | pd.paused = true | |
| 97 | - | pd.Clear() // Clear the current display | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func (pd *ProgressDisplay) Resume() { | |
| 101 | - | pd.mu.Lock() | |
| 102 | - | defer pd.mu.Unlock() | |
| 103 | - | pd.paused = false | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func (pd *ProgressDisplay) handleUpdates() { | |
| 107 | - | for { | |
| 108 | - | select { | |
| 109 | - | case progress := <-pd.updateChan: | |
| 110 | - | pd.mu.Lock() | |
| 111 | - | if !pd.paused { | |
| 112 | - | pd.program.Send(progressMsg{ | |
| 113 | - | completed: progress.Completed, | |
| 114 | - | total: progress.Total, | |
| 115 | - | percentage: progress.Percentage, | |
| 116 | - | }) | |
| 117 | - | } | |
| 118 | - | pd.mu.Unlock() | |
| 119 | - | case <-pd.done: | |
| 120 | - | pd.program.Send(doneMsg{}) | |
| 121 | - | return | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func (pd *ProgressDisplay) Update(completed, total int, percentage float64) { | |
| 127 | - | pd.updateChan <- UploadProgress{ | |
| 128 | - | Completed: completed, | |
| 129 | - | Total: total, | |
| 130 | - | Percentage: percentage, | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | func (pd *ProgressDisplay) Stop() { | |
| 135 | - | close(pd.done) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | func (pd *ProgressDisplay) Clear() { | |
| 139 | - | pd.mu.Lock() | |
| 140 | - | defer pd.mu.Unlock() | |
| 141 | - | ||
| 142 | - | // Send a special clear message | |
| 143 | - | pd.program.Send(clearMsg{}) | |
| 144 | - | // Wait a brief moment for the clear to take effect | |
| 145 | - | time.Sleep(50 * time.Millisecond) | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | type progressMsg struct { | |
| 149 | - | completed int | |
| 150 | - | total int | |
| 151 | - | percentage float64 | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | type doneMsg struct{} | |
| 155 | - | ||
| 156 | - | type clearMsg struct{} | |
| 157 | - | ||
| 158 | - | func (m *uploadModel) Init() tea.Cmd { | |
| 159 | - | return m.spinner.Tick | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func (m *uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 163 | - | switch msg := msg.(type) { | |
| 164 | - | case tea.KeyMsg: | |
| 165 | - | if msg.Type == tea.KeyCtrlC { | |
| 166 | - | return m, tea.Quit | |
| 167 | - | } | |
| 168 | - | case spinner.TickMsg: | |
| 169 | - | var cmd tea.Cmd | |
| 170 | - | m.spinner, cmd = m.spinner.Update(msg) | |
| 171 | - | return m, cmd | |
| 172 | - | case progressMsg: | |
| 173 | - | m.completed = msg.completed | |
| 174 | - | m.total = msg.total | |
| 175 | - | m.percentage = msg.percentage | |
| 176 | - | return m, m.spinner.Tick | |
| 177 | - | case doneMsg: | |
| 178 | - | m.done = true | |
| 179 | - | return m, tea.Quit | |
| 180 | - | case clearMsg: | |
| 181 | - | m.done = true | |
| 182 | - | return m, tea.Quit | |
| 183 | - | } | |
| 184 | - | return m, nil | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | func (m *uploadModel) View() string { | |
| 188 | - | if m.done { | |
| 189 | - | return "" | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | return fmt.Sprintf("\r%s Uploading image... %d/%d chunks (%.2f%%)", | |
| 193 | - | m.spinner.View(), | |
| 194 | - | m.completed, | |
| 195 | - | m.total, | |
| 196 | - | m.percentage, | |
| 197 | - | ) | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | type ChunkMetadata = common.ChunkMetadata | |
| 201 | - | ||
| 202 | - | type ChunkedClient struct { | |
| 203 | - | client *http.Client | |
| 204 | - | baseURL string | |
| 205 | - | token string | |
| 206 | - | chunkSize int64 | |
| 207 | - | app *cli.App | |
| 208 | - | progress *ProgressDisplay | |
| 209 | - | cancelFunc context.CancelFunc | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | type transferStore struct { | |
| 213 | - | mu sync.Mutex | |
| 214 | - | chunks map[string]map[int][]byte | |
| 215 | - | metadata map[string]*ChunkMetadata | |
| 216 | - | started map[string]time.Time | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | var clientChunkStore = &transferStore{ | |
| 220 | - | chunks: make(map[string]map[int][]byte), | |
| 221 | - | metadata: make(map[string]*ChunkMetadata), | |
| 222 | - | started: make(map[string]time.Time), | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | // NewChunkedClient creates a new chunked client | |
| 226 | - | func NewChunkedClient(app *cli.App) *ChunkedClient { | |
| 227 | - | baseURL := app.Config.Http.BackendURL + "/api" | |
| 228 | - | token := app.Config.General.Token | |
| 229 | - | ||
| 230 | - | return &ChunkedClient{ | |
| 231 | - | client: &http.Client{ | |
| 232 | - | Timeout: 30 * time.Minute, | |
| 233 | - | }, | |
| 234 | - | baseURL: baseURL, | |
| 235 | - | token: token, | |
| 236 | - | chunkSize: 5 * 1024 * 1024, | |
| 237 | - | app: app, | |
| 238 | - | progress: NewProgressDisplay(), | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | // SendFileAsChunks sends a file as chunks to the specified endpoint | |
| 243 | - | func (c *ChunkedClient) SendFileAsChunks(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (*Response, error) { | |
| 244 | - | ctx, cancel := context.WithCancel(ctx) | |
| 245 | - | c.cancelFunc = cancel | |
| 246 | - | defer cancel() | |
| 247 | - | ||
| 248 | - | // Set up signal handling | |
| 249 | - | sigChan := make(chan os.Signal, 1) | |
| 250 | - | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) | |
| 251 | - | defer signal.Stop(sigChan) | |
| 252 | - | ||
| 253 | - | // Start progress display | |
| 254 | - | c.progress.Start() | |
| 255 | - | defer c.progress.Stop() | |
| 256 | - | ||
| 257 | - | // Handle signals in a goroutine | |
| 258 | - | go func() { | |
| 259 | - | select { | |
| 260 | - | case <-sigChan: | |
| 261 | - | log.Info("Received interrupt signal, cancelling upload...") | |
| 262 | - | cancel() | |
| 263 | - | case <-ctx.Done(): | |
| 264 | - | return | |
| 265 | - | } | |
| 266 | - | }() | |
| 267 | - | ||
| 268 | - | transferID := uuid.New().String() | |
| 269 | - | totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize) | |
| 270 | - | buf := make([]byte, c.chunkSize) | |
| 271 | - | ||
| 272 | - | // Initialize transfer tracking | |
| 273 | - | clientChunkStore.mu.Lock() | |
| 274 | - | clientChunkStore.started[transferID] = time.Now() | |
| 275 | - | clientChunkStore.mu.Unlock() | |
| 276 | - | ||
| 277 | - | defer cleanupClientTransfer(transferID) | |
| 278 | - | ||
| 279 | - | var lastResponse *Response | |
| 280 | - | for chunkNum := 0; chunkNum < totalChunks; chunkNum++ { | |
| 281 | - | select { | |
| 282 | - | case <-ctx.Done(): | |
| 283 | - | return nil, fmt.Errorf("upload cancelled") | |
| 284 | - | default: | |
| 285 | - | n, err := io.ReadFull(reader, buf) | |
| 286 | - | if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { | |
| 287 | - | return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err) | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | if n == 0 { | |
| 291 | - | continue | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | chunk := buf[:n] | |
| 295 | - | metadata := common.ChunkMetadata{ | |
| 296 | - | ChunkNumber: chunkNum, | |
| 297 | - | TotalChunks: totalChunks, | |
| 298 | - | ChunkSize: int64(n), | |
| 299 | - | TotalSize: totalSize, | |
| 300 | - | ImageName: imageName, | |
| 301 | - | TransferID: transferID, | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata) | |
| 305 | - | if err != nil { | |
| 306 | - | return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err) | |
| 307 | - | } | |
| 308 | - | lastResponse = resp | |
| 309 | - | ||
| 310 | - | // Update progress | |
| 311 | - | c.progress.Update(chunkNum+1, totalChunks, float64(chunkNum+1)/float64(totalChunks)*100) | |
| 312 | - | } | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | if lastResponse != nil { | |
| 316 | - | c.progress.Clear() | |
| 317 | - | responseType := determineResponseType(endpoint) | |
| 318 | - | if err := handleFinalResponse(lastResponse, responseType); err != nil { | |
| 319 | - | log.Error("Failed to handle final response", "error", err) | |
| 320 | - | } | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | return lastResponse, nil | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | // sendChunkWithRetry sends a single chunk to the server and retries on failure | |
| 327 | - | func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 328 | - | maxRetries := 3 | |
| 329 | - | backoff := time.Second | |
| 330 | - | ||
| 331 | - | isRetryableError := func(err error) bool { | |
| 332 | - | if err == nil { | |
| 333 | - | return false | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | if netErr, ok := err.(net.Error); ok { | |
| 337 | - | return netErr.Timeout() | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 341 | - | return false | |
| 342 | - | } | |
| 343 | - | ||
| 344 | - | errorStr := err.Error() | |
| 345 | - | retryableErrors := []string{ | |
| 346 | - | "connection reset by peer", | |
| 347 | - | "broken pipe", | |
| 348 | - | "connection refused", | |
| 349 | - | "no such host", | |
| 350 | - | "i/o timeout", | |
| 351 | - | "unexpected EOF", | |
| 352 | - | "connection timed out", | |
| 353 | - | "network is unreachable", | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | for _, retryableErr := range retryableErrors { | |
| 357 | - | if strings.Contains(strings.ToLower(errorStr), retryableErr) { | |
| 358 | - | return true | |
| 359 | - | } | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | return false | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | var lastErr error | |
| 366 | - | for attempt := 0; attempt < maxRetries; attempt++ { | |
| 367 | - | resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata) | |
| 368 | - | if err == nil { | |
| 369 | - | return resp, nil | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | lastErr = err | |
| 373 | - | ||
| 374 | - | if !isRetryableError(err) { | |
| 375 | - | return nil, err | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | if attempt < maxRetries-1 { | |
| 379 | - | retryDelay := backoff * time.Duration(attempt+1) | |
| 380 | - | jitter := time.Duration(rand.Int63n(int64(retryDelay) / 2)) | |
| 381 | - | ||
| 382 | - | select { | |
| 383 | - | case <-ctx.Done(): | |
| 384 | - | return nil, ctx.Err() | |
| 385 | - | case <-time.After(retryDelay + jitter): | |
| 386 | - | continue | |
| 387 | - | } | |
| 388 | - | } | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | return nil, fmt.Errorf("max retries exceeded after %d attempts: %w", maxRetries, lastErr) | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | func (c *ChunkedClient) Cancel() { | |
| 395 | - | if c.cancelFunc != nil { | |
| 396 | - | c.cancelFunc() | |
| 397 | - | } | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | // Response handling functions | |
| 401 | - | func determineResponseType(endpoint string) string { | |
| 402 | - | switch { | |
| 403 | - | case strings.Contains(endpoint, "/deploy"): | |
| 404 | - | return "deploy" | |
| 405 | - | case strings.Contains(endpoint, "/push"): | |
| 406 | - | return "push" | |
| 407 | - | default: | |
| 408 | - | return "unknown" | |
| 409 | - | } | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | // handleFinalResponse determines the response type and calls the appropriate handler | |
| 413 | - | func handleFinalResponse(resp *Response, responseType string) error { | |
| 414 | - | if resp == nil || len(resp.Body) == 0 { | |
| 415 | - | return fmt.Errorf("empty response received") | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | switch responseType { | |
| 419 | - | case "deploy": | |
| 420 | - | return handleDeployResponse(resp) | |
| 421 | - | case "push": | |
| 422 | - | return handlePushResponse(resp) | |
| 423 | - | default: | |
| 424 | - | return fmt.Errorf("unknown response type: %s", responseType) | |
| 425 | - | } | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | // handleDeployResponse handles the response from a deploy operation | |
| 429 | - | func handleDeployResponse(resp *Response) error { | |
| 430 | - | finalResp := DeployResponse{} | |
| 431 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 432 | - | return fmt.Errorf("failed to unmarshal deploy response: %w", err) | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | // Always log the success message with available information | |
| 436 | - | logFields := []interface{}{} | |
| 437 | - | ||
| 438 | - | if finalResp.Domain != "" { | |
| 439 | - | logFields = append(logFields, "domain", finalResp.Domain) | |
| 440 | - | } | |
| 441 | - | if finalResp.ContainerID != "" { | |
| 442 | - | shortContainerID := finalResp.ContainerID | |
| 443 | - | if len(shortContainerID) > 8 { | |
| 444 | - | shortContainerID = shortContainerID[:8] | |
| 445 | - | } | |
| 446 | - | logFields = append(logFields, "containerID", shortContainerID) | |
| 447 | - | } | |
| 448 | - | if finalResp.ContainerName != "" { | |
| 449 | - | logFields = append(logFields, "containerName", finalResp.ContainerName) | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | // If there's a message, use it; otherwise, use a default success message | |
| 453 | - | message := finalResp.Message | |
| 454 | - | if message == "" { | |
| 455 | - | message = "Deployment completed successfully" | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | // Ensure we log the result even if there are no additional fields | |
| 459 | - | if len(logFields) > 0 { | |
| 460 | - | log.Info(message, logFields...) | |
| 461 | - | } else { | |
| 462 | - | log.Info(message) | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | return nil | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | // handlePushResponse handles the response from a push operation | |
| 469 | - | func handlePushResponse(resp *Response) error { | |
| 470 | - | var finalResp common.PushResponse | |
| 471 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 472 | - | return fmt.Errorf("failed to unmarshal push response: %w", err) | |
| 473 | - | } | |
| 474 | - | return nil | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | // sendChunk sends a single chunk to the server | |
| 478 | - | func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 479 | - | if err := verifyChunkIntegrity(chunk, metadata); err != nil { | |
| 480 | - | return nil, fmt.Errorf("chunk integrity check failed: %w", err) | |
| 481 | - | } | |
| 482 | - | for { | |
| 483 | - | select { | |
| 484 | - | case <-ctx.Done(): | |
| 485 | - | return nil, ctx.Err() | |
| 486 | - | default: | |
| 487 | - | resp, err := c.attemptChunkSend(ctx, endpoint, headers, chunk, metadata) | |
| 488 | - | if err != nil { | |
| 489 | - | return nil, err | |
| 490 | - | } | |
| 491 | - | return resp, nil | |
| 492 | - | } | |
| 493 | - | } | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | func (c *ChunkedClient) attemptChunkSend(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 497 | - | metadataJSON, err := json.Marshal(metadata) | |
| 498 | - | if err != nil { | |
| 499 | - | return nil, fmt.Errorf("failed to marshal metadata: %w", err) | |
| 500 | - | } | |
| 501 | - | ||
| 502 | - | url := c.baseURL + endpoint | |
| 503 | - | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk)) | |
| 504 | - | if err != nil { | |
| 505 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | // Set headers | |
| 509 | - | req.Header.Set("Authorization", "Bearer "+c.token) | |
| 510 | - | req.Header.Set("Content-Type", "application/octet-stream") | |
| 511 | - | req.Header.Set("X-Chunk-Metadata", string(metadataJSON)) | |
| 512 | - | req.Header.Set("X-Chunk-Hash", fmt.Sprintf("%x", sha256.Sum256(chunk))) | |
| 513 | - | ||
| 514 | - | for k, v := range headers { | |
| 515 | - | req.Header[k] = v | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | resp, err := c.client.Do(req) | |
| 519 | - | if err != nil { | |
| 520 | - | return nil, err | |
| 521 | - | } | |
| 522 | - | defer resp.Body.Close() | |
| 523 | - | ||
| 524 | - | body, err := io.ReadAll(resp.Body) | |
| 525 | - | if err != nil { | |
| 526 | - | return nil, fmt.Errorf("failed to read response: %w", err) | |
| 527 | - | } | |
| 528 | - | ||
| 529 | - | if resp.StatusCode != http.StatusOK { | |
| 530 | - | return &Response{ | |
| 531 | - | Http: resp, | |
| 532 | - | Body: body, | |
| 533 | - | StatusCode: resp.StatusCode, | |
| 534 | - | Header: resp.Header, | |
| 535 | - | }, fmt.Errorf("unexpected status code: %d", resp.StatusCode) | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | return &Response{ | |
| 539 | - | Http: resp, | |
| 540 | - | Body: body, | |
| 541 | - | StatusCode: resp.StatusCode, | |
| 542 | - | Header: resp.Header, | |
| 543 | - | }, nil | |
| 544 | - | } | |
| 545 | - | ||
| 546 | - | // verifyChunkIntegrity verifies the integrity of a chunk | |
| 547 | - | func verifyChunkIntegrity(chunk []byte, metadata common.ChunkMetadata) error { | |
| 548 | - | if len(chunk) == 0 { | |
| 549 | - | return fmt.Errorf("empty chunk received") | |
| 550 | - | } | |
| 551 | - | ||
| 552 | - | if int64(len(chunk)) != metadata.ChunkSize { | |
| 553 | - | return fmt.Errorf("chunk size mismatch: expected %d, got %d", | |
| 554 | - | metadata.ChunkSize, len(chunk)) | |
| 555 | - | } | |
| 556 | - | ||
| 557 | - | return nil | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | // cleanupClientTransfer cleans up a client transfer | |
| 561 | - | func cleanupClientTransfer(transferID string) { | |
| 562 | - | clientChunkStore.mu.Lock() | |
| 563 | - | defer clientChunkStore.mu.Unlock() | |
| 564 | - | ||
| 565 | - | delete(clientChunkStore.chunks, transferID) | |
| 566 | - | delete(clientChunkStore.metadata, transferID) | |
| 567 | - | delete(clientChunkStore.started, transferID) | |
| 568 | - | } | |
D internal/cli/handler/deploy_survey.go +0 -116
| @@ -1,116 +0,0 @@ | |||
| 1 | - | // gordon/internal/cli/handler/deploy_survey.go | |
| 2 | - | ||
| 3 | - | package handler | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/AlecAivazis/survey/v2" | |
| 11 | - | "github.com/bnema/gordon/internal/cli" | |
| 12 | - | "github.com/bnema/gordon/internal/common" | |
| 13 | - | "github.com/charmbracelet/log" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func HandleExistingContainer(app *cli.App, conflictResp *common.DeployResponse) error { | |
| 17 | - | // Add a small delay to ensure terminal is clear | |
| 18 | - | time.Sleep(100 * time.Millisecond) | |
| 19 | - | ||
| 20 | - | // Clear the current line | |
| 21 | - | fmt.Print("\033[2K\r") | |
| 22 | - | ||
| 23 | - | containerName := conflictResp.ContainerName | |
| 24 | - | shortID := conflictResp.ContainerID | |
| 25 | - | if len(shortID) > 12 { | |
| 26 | - | shortID = shortID[:12] | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | var proceed bool | |
| 30 | - | prompt := &survey.Confirm{ | |
| 31 | - | Message: fmt.Sprintf("Container '%s' (ID: %s) is already running for %s. Stop and remove it?", | |
| 32 | - | containerName, | |
| 33 | - | shortID, | |
| 34 | - | conflictResp.RunningTime, | |
| 35 | - | ), | |
| 36 | - | Default: true, | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | if err := survey.AskOne(prompt, &proceed); err != nil { | |
| 40 | - | return fmt.Errorf("survey failed: %w", err) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | if !proceed { | |
| 44 | - | return fmt.Errorf("operation cancelled by user") | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | log.Info("Stopping container...", "name", containerName, "id", shortID) | |
| 48 | - | ||
| 49 | - | // Stop the container | |
| 50 | - | if err := stopContainer(app, conflictResp.ContainerID); err != nil { | |
| 51 | - | return fmt.Errorf("failed to stop container '%s': %w", containerName, err) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | log.Info("Removing container...", "name", containerName, "id", shortID) | |
| 55 | - | ||
| 56 | - | // Remove the container | |
| 57 | - | if err := removeContainer(app, conflictResp.ContainerID); err != nil { | |
| 58 | - | return fmt.Errorf("failed to remove container '%s': %w", containerName, err) | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | log.Info("Container stopped and removed successfully", | |
| 62 | - | "name", containerName, | |
| 63 | - | "id", shortID, | |
| 64 | - | ) | |
| 65 | - | return nil | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func removeContainer(a *cli.App, containerID string) error { | |
| 69 | - | reqPayload := common.RequestPayload{ | |
| 70 | - | Type: "remove", | |
| 71 | - | Payload: common.RemovePayload{ | |
| 72 | - | ContainerID: containerID, | |
| 73 | - | }, | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/remove") | |
| 77 | - | if err != nil { | |
| 78 | - | return err | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | var removeResponse common.RemoveResponse | |
| 82 | - | if err := json.Unmarshal(resp.Body, &removeResponse); err != nil { | |
| 83 | - | return fmt.Errorf("failed to parse remove response: %w", err) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | if !removeResponse.Success { | |
| 87 | - | return fmt.Errorf("failed to remove container: %s", removeResponse.Message) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | return nil | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func stopContainer(a *cli.App, containerID string) error { | |
| 94 | - | reqPayload := common.RequestPayload{ | |
| 95 | - | Type: "stop", | |
| 96 | - | Payload: common.StopPayload{ | |
| 97 | - | ContainerID: containerID, | |
| 98 | - | }, | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/stop") | |
| 102 | - | if err != nil { | |
| 103 | - | return err | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | var stopResponse common.StopResponse | |
| 107 | - | if err := json.Unmarshal(resp.Body, &stopResponse); err != nil { | |
| 108 | - | return fmt.Errorf("failed to parse stop response: %w", err) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | if !stopResponse.Success { | |
| 112 | - | return fmt.Errorf("failed to stop container: %s", stopResponse.Message) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | return nil | |
| 116 | - | } | |
D internal/cli/handler/docker.go +0 -51
| @@ -1,51 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "io" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/pkg/docker" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) { | |
| 11 | - | // Initialize Docker client | |
| 12 | - | if err := docker.CheckIfInitialized(); err != nil { | |
| 13 | - | return nil, 0, fmt.Errorf("failed to initialize Docker client: %w", err) | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // Get the image ID first | |
| 17 | - | imageID, err := docker.GetImageIDByName(imageName) | |
| 18 | - | if err != nil { | |
| 19 | - | return nil, 0, fmt.Errorf("failed to get image ID: %w", err) | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // Create a pipe for streaming | |
| 23 | - | pr, pw := io.Pipe() | |
| 24 | - | ||
| 25 | - | go func() { | |
| 26 | - | defer pw.Close() | |
| 27 | - | ||
| 28 | - | // Export the image using save | |
| 29 | - | reader, err := docker.ExportImageFromEngine(imageID) | |
| 30 | - | if err != nil { | |
| 31 | - | pw.CloseWithError(fmt.Errorf("failed to export image: %w", err)) | |
| 32 | - | return | |
| 33 | - | } | |
| 34 | - | defer reader.Close() | |
| 35 | - | ||
| 36 | - | // Copy the data to the pipe | |
| 37 | - | _, err = io.Copy(pw, reader) | |
| 38 | - | if err != nil { | |
| 39 | - | pw.CloseWithError(fmt.Errorf("failed to copy image data: %w", err)) | |
| 40 | - | return | |
| 41 | - | } | |
| 42 | - | }() | |
| 43 | - | ||
| 44 | - | // Get the actual size | |
| 45 | - | size, err := docker.GetImageSize(imageID) | |
| 46 | - | if err != nil { | |
| 47 | - | return nil, 0, fmt.Errorf("failed to get image size: %w", err) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | return pr, size, nil | |
| 51 | - | } | |
D internal/cli/handler/field_check.go +0 -53
| @@ -1,53 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/cli" | |
| 7 | - | "github.com/bnema/gordon/internal/cli/auth" | |
| 8 | - | "github.com/bnema/gordon/internal/common" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // config.Http.BackendURL = readUserInput("Enter the backend URL (e.g. https://gordon.mydomain.com):") | |
| 12 | - | // config.General.Token = readUserInput("Enter the token (check your backend config.yml):") | |
| 13 | - | // check in the config.yml if BackendURL and Token are set | |
| 14 | - | // if not, prompt the user to enter them | |
| 15 | - | ||
| 16 | - | // Helper function to check, prompt and set a value | |
| 17 | - | func checkAndSetField(value *string, promptMessage, errorMessage string) (bool, error) { | |
| 18 | - | wasEmpty := false | |
| 19 | - | if *value == "" { | |
| 20 | - | wasEmpty = true | |
| 21 | - | fmt.Println(errorMessage) | |
| 22 | - | *value = common.ReadUserInput(promptMessage) | |
| 23 | - | } | |
| 24 | - | return wasEmpty, nil | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | func FieldCheck(a *cli.App) error { | |
| 28 | - | wasBackendURLEmpty, err := checkAndSetField(&a.Config.Http.BackendURL, | |
| 29 | - | "Enter the backend URL (e.g. https://gordon.mydomain.com):", | |
| 30 | - | "BackendURL is not set in config.yml") | |
| 31 | - | if err != nil { | |
| 32 | - | return err | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | if a.Config.General.Token == "" { | |
| 36 | - | fmt.Println("Token is not set. Starting OAuth device flow...") | |
| 37 | - | err := auth.DeviceFlowAuth(a) | |
| 38 | - | if err != nil { | |
| 39 | - | fmt.Printf("Device flow authentication error: %v\n", err) | |
| 40 | - | return fmt.Errorf("failed to complete device flow authentication: %w", err) | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Save config if BackendURL was empty or if a new token was set | |
| 45 | - | if wasBackendURLEmpty || a.Config.General.Token != "" { | |
| 46 | - | err = a.Config.SaveConfig() | |
| 47 | - | if err != nil { | |
| 48 | - | return err | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | return nil | |
| 53 | - | } | |
D internal/cli/handler/http.go +0 -145
| @@ -1,145 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "encoding/json" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "net/http" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/cli" | |
| 11 | - | "github.com/bnema/gordon/internal/common" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // PrepareJSONPayload marshals the payload into JSON | |
| 15 | - | func PrepareJSONPayload(payload common.Payload) ([]byte, error) { | |
| 16 | - | return json.Marshal(payload) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // CreateNewRequest creates a new HTTP request | |
| 20 | - | func CreateNewRequest(method, url string, body []byte) (*http.Request, error) { | |
| 21 | - | ||
| 22 | - | return http.NewRequest(method, url, bytes.NewBuffer(body)) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | // SetRequestHeaders sets the headers for the request | |
| 26 | - | func setAuthRequestHeaders(req *http.Request, token string) { | |
| 27 | - | req.Header.Set("Authorization", "Bearer "+token) | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // SendRequest sends the HTTP request and returns the response | |
| 31 | - | func SendRequest(req *http.Request) (*http.Response, error) { | |
| 32 | - | client := &http.Client{} | |
| 33 | - | return client.Do(req) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | type Response struct { | |
| 37 | - | Http *http.Response | |
| 38 | - | Header http.Header | |
| 39 | - | Body []byte | |
| 40 | - | StatusCode int | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // SendHTTPRequest sends the HTTP request | |
| 44 | - | func SendHTTPRequest(a *cli.App, rp *common.RequestPayload, method string, endpoint string) (*Response, error) { | |
| 45 | - | apiUrl := a.Config.Http.BackendURL + "/api" | |
| 46 | - | client := &http.Client{} | |
| 47 | - | ||
| 48 | - | reauthenticated := false | |
| 49 | - | ||
| 50 | - | for { | |
| 51 | - | req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.Token) | |
| 52 | - | if err != nil { | |
| 53 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | resp, err := client.Do(req) | |
| 57 | - | if err != nil { | |
| 58 | - | return nil, fmt.Errorf("failed to send request: %w", err) | |
| 59 | - | } | |
| 60 | - | defer resp.Body.Close() | |
| 61 | - | ||
| 62 | - | body, err := io.ReadAll(resp.Body) | |
| 63 | - | if err != nil { | |
| 64 | - | return nil, fmt.Errorf("failed to read response body: %w", err) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | if resp.StatusCode != http.StatusOK { | |
| 68 | - | var errorResp struct { | |
| 69 | - | Success bool `json:"success"` | |
| 70 | - | Message string `json:"message"` | |
| 71 | - | } | |
| 72 | - | if err := json.Unmarshal(body, &errorResp); err == nil { | |
| 73 | - | if resp.StatusCode == http.StatusUnauthorized && !reauthenticated { | |
| 74 | - | fmt.Println("Token is invalid or expired. Initiating re-authentication...") | |
| 75 | - | err := ReAuthenticate(a) | |
| 76 | - | if err != nil { | |
| 77 | - | return nil, fmt.Errorf("re-authentication failed: %w", err) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | reauthenticated = true | |
| 81 | - | continue | |
| 82 | - | } | |
| 83 | - | return nil, fmt.Errorf("failed to send request: %s", errorResp.Message) | |
| 84 | - | } | |
| 85 | - | return nil, fmt.Errorf("failed to send request: %s", string(body)) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | return &Response{Http: resp, Body: body, StatusCode: resp.StatusCode}, nil | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func createRequest(apiUrl, endpoint, method string, rp *common.RequestPayload, token string) (*http.Request, error) { | |
| 93 | - | var req *http.Request | |
| 94 | - | var err error | |
| 95 | - | ||
| 96 | - | switch rp.Type { | |
| 97 | - | case "deploy": | |
| 98 | - | deployPayload, ok := rp.Payload.(common.DeployPayload) | |
| 99 | - | if !ok { | |
| 100 | - | return nil, fmt.Errorf("invalid payload type for deploy") | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | req, err = http.NewRequest(method, apiUrl+endpoint, deployPayload.Data) | |
| 104 | - | if err != nil { | |
| 105 | - | return nil, fmt.Errorf("failed to create new streaming request: %w", err) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | req.Header.Set("Content-Type", "application/octet-stream") | |
| 109 | - | req.Header.Set("X-Ports", deployPayload.Port) | |
| 110 | - | req.Header.Set("X-Target-Domain", deployPayload.TargetDomain) | |
| 111 | - | req.Header.Set("X-Image-Name", deployPayload.ImageName) | |
| 112 | - | ||
| 113 | - | case "push": | |
| 114 | - | pushPayload, ok := rp.Payload.(common.PushPayload) | |
| 115 | - | if !ok { | |
| 116 | - | return nil, fmt.Errorf("invalid payload type for push") | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | req, err = http.NewRequest(method, apiUrl+endpoint, pushPayload.Data) | |
| 120 | - | if err != nil { | |
| 121 | - | return nil, fmt.Errorf("failed to create new streaming request: %w", err) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | req.Header.Set("Content-Type", "application/octet-stream") | |
| 125 | - | req.Header.Set("X-Image-Name", pushPayload.ImageName) | |
| 126 | - | ||
| 127 | - | default: | |
| 128 | - | jsonPayload, err := json.Marshal(rp) | |
| 129 | - | if err != nil { | |
| 130 | - | return nil, fmt.Errorf("failed to marshal payload: %w", err) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | req, err = http.NewRequest(method, apiUrl+endpoint, bytes.NewBuffer(jsonPayload)) | |
| 134 | - | if err != nil { | |
| 135 | - | return nil, fmt.Errorf("failed to create new JSON request: %w", err) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | req.Header.Set("Content-Type", "application/json") | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | // Set the authorization header for all request types | |
| 142 | - | setAuthRequestHeaders(req, token) | |
| 143 | - | ||
| 144 | - | return req, nil | |
| 145 | - | } | |
D internal/cli/handler/ping_request.go +0 -46
| @@ -1,46 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "fmt" | |
| 6 | - | "net/http" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/cli" | |
| 9 | - | "github.com/bnema/gordon/internal/common" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // Define a struct to match the JSON response structure | |
| 13 | - | type PingResponse struct { | |
| 14 | - | Uptime string `json:"uptime"` | |
| 15 | - | Version string `json:"version"` | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func PerformPingRequest(a *cli.App) (PingResponse, error) { | |
| 19 | - | var pingResp PingResponse | |
| 20 | - | ||
| 21 | - | pingPayload := common.PingPayload{Message: "ping"} | |
| 22 | - | reqPayload := common.RequestPayload{ | |
| 23 | - | Type: "ping", | |
| 24 | - | Payload: pingPayload, | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | resp, err := SendHTTPRequest(a, &reqPayload, "GET", "/ping") | |
| 28 | - | if err != nil { | |
| 29 | - | return pingResp, fmt.Errorf("failed to reach server: %w", err) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | defer resp.Http.Body.Close() | |
| 33 | - | ||
| 34 | - | if resp.StatusCode != http.StatusOK { | |
| 35 | - | // Return the error message from the server | |
| 36 | - | return PingResponse{}, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(resp.Body)) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // Unmarshal the JSON response into the PingResponse struct | |
| 40 | - | err = json.Unmarshal(resp.Body, &pingResp) | |
| 41 | - | if err != nil { | |
| 42 | - | return pingResp, err | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return pingResp, nil | |
| 46 | - | } | |
D internal/cli/handler/regex_check.go +0 -40
| @@ -1,40 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "regexp" | |
| 6 | - | "strings" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | func ValidateImageName(imageName string) error { | |
| 10 | - | match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName) | |
| 11 | - | if !match { | |
| 12 | - | return fmt.Errorf("You must specify a valid image name in the form (registry/)image:tag, check your container engine image list") | |
| 13 | - | } | |
| 14 | - | return nil | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func EnsureImageTag(imageName *string) error { | |
| 18 | - | // if not tags returns an error | |
| 19 | - | if !strings.Contains(*imageName, ":") { | |
| 20 | - | return fmt.Errorf("You must specify a tag for the image") | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | return nil | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func ValidatePortMapping(port string) error { | |
| 27 | - | match, _ := regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port) | |
| 28 | - | if !match { | |
| 29 | - | return fmt.Errorf("You must specify a port mapping in the form port:port/proto, if no protocol is specified, TCP is used") | |
| 30 | - | } | |
| 31 | - | return nil | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func ValidateTargetDomain(targetDomain string) error { | |
| 35 | - | match, _ := regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain) | |
| 36 | - | if !match { | |
| 37 | - | return fmt.Errorf("invalid target domain format") | |
| 38 | - | } | |
| 39 | - | return nil | |
| 40 | - | } | |
D internal/cli/handler/start_server.go +0 -55
| @@ -1,55 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "os" | |
| 6 | - | "os/signal" | |
| 7 | - | "syscall" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/httpserve" | |
| 11 | - | "github.com/bnema/gordon/internal/server" | |
| 12 | - | "github.com/charmbracelet/log" | |
| 13 | - | "github.com/labstack/echo/v4" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // execute cmd/srv/main.go main function | |
| 17 | - | ||
| 18 | - | func StartServer(a *server.App, port string) error { | |
| 19 | - | _, err := server.InitializeDB(a) | |
| 20 | - | if err != nil { | |
| 21 | - | log.Fatal("Failed to initialize database:", err) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // Start the session cleaner cron job | |
| 25 | - | a.StartSessionCleaner() | |
| 26 | - | ||
| 27 | - | _, err = server.HandleNewTokenInitialization(a) | |
| 28 | - | if err != nil { | |
| 29 | - | log.Print(err) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // Setup a channel to capture termination signals | |
| 33 | - | sigs := make(chan os.Signal, 1) | |
| 34 | - | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | |
| 35 | - | ||
| 36 | - | go func() { | |
| 37 | - | sig := <-sigs | |
| 38 | - | log.Info(fmt.Println("Received signal:", sig)) | |
| 39 | - | os.Exit(0) | |
| 40 | - | }() | |
| 41 | - | ||
| 42 | - | e := echo.New() | |
| 43 | - | e.Server.ReadTimeout = 10 * time.Minute | |
| 44 | - | e.Server.WriteTimeout = 10 * time.Minute | |
| 45 | - | e.HideBanner = true | |
| 46 | - | e.HidePort = true | |
| 47 | - | e = httpserve.RegisterRoutes(e, a) | |
| 48 | - | ||
| 49 | - | log.Info(fmt.Sprintf("Starting server on port %s", port)) | |
| 50 | - | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | |
| 51 | - | log.Fatal(err) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | return nil | |
| 55 | - | } | |
D internal/cli/new_client.go +0 -31
| @@ -1,31 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/common" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | type App struct { | |
| 10 | - | Config common.Config | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | // NewClientApp initializes a new App with configuration. | |
| 14 | - | func NewClientApp(buildConfig *common.BuildConfig) (*App, error) { | |
| 15 | - | // Initialize AppConfig | |
| 16 | - | config := common.Config{ | |
| 17 | - | Build: *buildConfig, | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | _, err := config.LoadConfig() | |
| 21 | - | if err != nil { | |
| 22 | - | return nil, fmt.Errorf("failed to load configuration: %w", err) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | // Initialize App | |
| 26 | - | a := &App{ | |
| 27 | - | Config: config, | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | return a, nil | |
| 31 | - | } | |
D internal/common/init_config.go +0 -264
| @@ -1,264 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "io/fs" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "runtime" | |
| 10 | - | "strings" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/pkg/docker" | |
| 13 | - | "github.com/bnema/gordon/pkg/parser" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | type Config struct { | |
| 17 | - | General GeneralConfig `yaml:"General"` | |
| 18 | - | Http HttpConfig `yaml:"Http"` | |
| 19 | - | Admin AdminConfig `yaml:"Admin"` | |
| 20 | - | ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"` | |
| 21 | - | Traefik TraefikConfig `yaml:"Traefik"` | |
| 22 | - | Build BuildConfig `yaml:"-"` | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | type BuildConfig struct { | |
| 26 | - | RunEnv string `yaml:"-"` // come from env | |
| 27 | - | BuildVersion string `yaml:"-"` // come from build ldflags | |
| 28 | - | BuildCommit string `yaml:"-"` // come from build ldflags | |
| 29 | - | BuildDate string `yaml:"-"` // come from build ldflags | |
| 30 | - | ProxyURL string `yaml:"-"` | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | type AdminConfig struct { | |
| 34 | - | Path string `yaml:"path"` | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | type GeneralConfig struct { | |
| 38 | - | StorageDir string `yaml:"storageDir"` | |
| 39 | - | Token string `yaml:"token"` | |
| 40 | - | } | |
| 41 | - | type HttpConfig struct { | |
| 42 | - | Port string `yaml:"port"` | |
| 43 | - | Domain string `yaml:"domain"` | |
| 44 | - | SubDomain string `yaml:"subDomain"` | |
| 45 | - | BackendURL string `yaml:"backendURL"` | |
| 46 | - | Https bool `yaml:"https"` | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | type ContainerEngineConfig struct { | |
| 50 | - | Sock string `yaml:"dockersock"` | |
| 51 | - | PodmanSock string `yaml:"podmansock"` | |
| 52 | - | Podman bool `yaml:"podman"` | |
| 53 | - | Network string `yaml:"network"` | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | type TraefikConfig struct { | |
| 57 | - | EntryPoint string `yaml:"entryPoint"` | |
| 58 | - | SecureEntryPoint string `yaml:"secureEntryPoint"` | |
| 59 | - | Resolver string `yaml:"resolver"` | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // Default values | |
| 63 | - | var ( | |
| 64 | - | sock = "/var/run/docker.sock" | |
| 65 | - | podmansock = "/run/user/1000/podman/podman.sock" | |
| 66 | - | entryPoint = "web" | |
| 67 | - | entryPointSecure = "websecure" | |
| 68 | - | resolver = "myresolver" | |
| 69 | - | ) | |
| 70 | - | ||
| 71 | - | func getConfigDir() (string, error) { | |
| 72 | - | var configDir string | |
| 73 | - | ||
| 74 | - | if isWSL() { | |
| 75 | - | // Use XDG_CONFIG_HOME for WSL | |
| 76 | - | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | |
| 77 | - | configDir = filepath.Join(xdgConfigHome, "Gordon") | |
| 78 | - | } else { | |
| 79 | - | // If XDG_CONFIG_HOME is not set, fall back to default locations | |
| 80 | - | homeDir, err := os.UserHomeDir() | |
| 81 | - | if err != nil { | |
| 82 | - | // If we can't get the home directory, use a fallback | |
| 83 | - | homeDir = os.TempDir() // Use the system's temp directory as a fallback | |
| 84 | - | fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | configDir = filepath.Join(homeDir, ".config", "Gordon") | |
| 88 | - | ||
| 89 | - | } | |
| 90 | - | ||
| 91 | - | return configDir, nil | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | if docker.IsRunningInContainer() { | |
| 95 | - | return "/.", nil | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | // Check for XDG_CONFIG_HOME first | |
| 99 | - | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | |
| 100 | - | configDir = filepath.Join(xdgConfigHome, "Gordon") | |
| 101 | - | } else { | |
| 102 | - | // If XDG_CONFIG_HOME is not set, fall back to default locations | |
| 103 | - | homeDir, err := os.UserHomeDir() | |
| 104 | - | if err != nil { | |
| 105 | - | // If we can't get the home directory, use a fallback | |
| 106 | - | homeDir = os.TempDir() // Use the system's temp directory as a fallback | |
| 107 | - | fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // Select the configuration directory based on the OS | |
| 111 | - | if runtime.GOOS == "windows" { | |
| 112 | - | configDir = filepath.Join(homeDir, "AppData", "Roaming", "Gordon") | |
| 113 | - | } else { | |
| 114 | - | configDir = filepath.Join(homeDir, ".config", "Gordon") | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | return configDir, nil | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error { | |
| 122 | - | err := parser.ParseYAMLFile(fs, filePath, config) | |
| 123 | - | if err != nil { | |
| 124 | - | return fmt.Errorf("error reading and unmarshaling configuration file: %w", err) | |
| 125 | - | } | |
| 126 | - | return nil | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func (config *Config) LoadConfig() (*Config, error) { | |
| 130 | - | configDir, err := getConfigDir() | |
| 131 | - | if err != nil { | |
| 132 | - | return nil, fmt.Errorf("error getting configuration directory: %w", err) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | configFilePath := filepath.Join(configDir, "config.yml") | |
| 136 | - | ||
| 137 | - | _, err = os.Stat(configFilePath) | |
| 138 | - | if errors.Is(err, fs.ErrNotExist) { | |
| 139 | - | fmt.Printf("Config file not found, creating it at %s\n", configFilePath) | |
| 140 | - | ||
| 141 | - | // Create config dir if it doesn't exist | |
| 142 | - | err = os.MkdirAll(configDir, 0755) | |
| 143 | - | if err != nil { | |
| 144 | - | return nil, fmt.Errorf("error creating configuration directory: %w", err) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // Create config file with the default values of ContainerEngineConfig | |
| 148 | - | config = &Config{ | |
| 149 | - | ContainerEngine: ContainerEngineConfig{ | |
| 150 | - | Sock: sock, | |
| 151 | - | PodmanSock: podmansock, | |
| 152 | - | Podman: ReadUserInput("Are you using podman ? (y/n)") == "y", | |
| 153 | - | }, | |
| 154 | - | Traefik: TraefikConfig{ | |
| 155 | - | EntryPoint: entryPoint, | |
| 156 | - | SecureEntryPoint: entryPointSecure, | |
| 157 | - | Resolver: resolver, | |
| 158 | - | }, | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | err = config.SaveConfig() | |
| 162 | - | if err != nil { | |
| 163 | - | return nil, fmt.Errorf("error saving new configuration: %w", err) | |
| 164 | - | } | |
| 165 | - | return config, nil | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | if err != nil { | |
| 169 | - | return nil, fmt.Errorf("error checking configuration file: %w", err) | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | err = readAndUnmarshalConfig(os.DirFS(configDir), "config.yml", config) | |
| 173 | - | if err != nil { | |
| 174 | - | return nil, err | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // Load run env | |
| 178 | - | config.Build.RunEnv = os.Getenv("RUN_ENV") | |
| 179 | - | ||
| 180 | - | if config.Build.RunEnv == "" { | |
| 181 | - | config.Build.RunEnv = "prod" | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | return config, nil | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | func (config *Config) SaveConfig() error { | |
| 188 | - | configDir, err := getConfigDir() | |
| 189 | - | if err != nil { | |
| 190 | - | return fmt.Errorf("error getting configuration directory: %w", err) | |
| 191 | - | } | |
| 192 | - | configFilePath := filepath.Join(configDir, "config.yml") | |
| 193 | - | ||
| 194 | - | err = parser.WriteYAMLFile(configFilePath, config) | |
| 195 | - | if err != nil { | |
| 196 | - | return fmt.Errorf("error writing configuration file: %w", err) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | return nil | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | func (config *Config) GetToken() (string, error) { | |
| 203 | - | token := config.General.Token | |
| 204 | - | if token == "" { | |
| 205 | - | return "", fmt.Errorf("no token found in config.yml") | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | return token, nil | |
| 209 | - | ||
| 210 | - | } | |
| 211 | - | ||
| 212 | - | // GetRunEnv returns the run environment | |
| 213 | - | func (config *BuildConfig) GetRunEnv() string { | |
| 214 | - | return config.RunEnv | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // get storage dir | |
| 218 | - | func (config *Config) GetStorageDir() string { | |
| 219 | - | return config.General.StorageDir | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | func (c *Config) GetBackendURL() string { | |
| 223 | - | return c.Http.BackendURL | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | func (c *Config) SetToken(token string) { | |
| 227 | - | c.General.Token = token | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | func isWSL() bool { | |
| 231 | - | // Check for /proc/version file | |
| 232 | - | if _, err := os.Stat("/proc/version"); err == nil { | |
| 233 | - | content, err := os.ReadFile("/proc/version") | |
| 234 | - | if err == nil && strings.Contains(strings.ToLower(string(content)), "microsoft") { | |
| 235 | - | return true | |
| 236 | - | } | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | // Check for WSL-specific environment variable | |
| 240 | - | if os.Getenv("WSL_DISTRO_NAME") != "" { | |
| 241 | - | return true | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | return false | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | func (c *HttpConfig) Protocol() string { | |
| 248 | - | if c.Https { | |
| 249 | - | return "https" | |
| 250 | - | } | |
| 251 | - | return "http" | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | func (c *HttpConfig) FullDomain() string { | |
| 255 | - | if c.SubDomain != "" { | |
| 256 | - | return fmt.Sprintf("%s.%s", c.SubDomain, c.Domain) | |
| 257 | - | } | |
| 258 | - | return c.Domain | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // GetVersion | |
| 262 | - | func (c *Config) GetVersion() string { | |
| 263 | - | return c.Build.BuildVersion | |
| 264 | - | } | |
D internal/common/init_dockerclient.go +0 -28
| @@ -1,28 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "log" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/pkg/docker" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | func DockerInit(cc *ContainerEngineConfig) { | |
| 10 | - | err := docker.InitializeClient(NewDockerConfig(cc)) | |
| 11 | - | if err != nil { | |
| 12 | - | log.Printf("Error initializing Docker client: %s", err) | |
| 13 | - | } | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // NewDockerConfig creates and returns a new Docker client configuration | |
| 17 | - | func NewDockerConfig(cc *ContainerEngineConfig) *docker.Config { | |
| 18 | - | if cc.Podman { | |
| 19 | - | return &docker.Config{ | |
| 20 | - | Sock: cc.PodmanSock, | |
| 21 | - | PodmanEnable: true, | |
| 22 | - | } | |
| 23 | - | } | |
| 24 | - | return &docker.Config{ | |
| 25 | - | Sock: cc.Sock, | |
| 26 | - | PodmanEnable: false, | |
| 27 | - | } | |
| 28 | - | } | |
D internal/common/inputs.go +0 -72
| @@ -1,72 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "fmt" | |
| 6 | - | "log" | |
| 7 | - | "os" | |
| 8 | - | "strings" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // ReadUserInput reads a string input from the user with a prompt. | |
| 12 | - | // It handles multi-word inputs and properly checks for errors. | |
| 13 | - | func ReadUserInput(prompt string) string { | |
| 14 | - | fmt.Print(prompt + " ") | |
| 15 | - | ||
| 16 | - | // Use bufio.NewReader for better input handling | |
| 17 | - | reader := bufio.NewReader(os.Stdin) | |
| 18 | - | ||
| 19 | - | // ReadString reads until newline, handling spaces in input | |
| 20 | - | input, err := reader.ReadString('\n') | |
| 21 | - | if err != nil { | |
| 22 | - | log.Printf("Error reading input: %v", err) | |
| 23 | - | return "" | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // Trim spaces and newline characters | |
| 27 | - | return strings.TrimSpace(input) | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // ReadUserInputWithValidation reads user input and validates it using a provided function. | |
| 31 | - | // It keeps prompting until valid input is received. | |
| 32 | - | func ReadUserInputWithValidation(prompt string, validate func(string) bool) string { | |
| 33 | - | for { | |
| 34 | - | input := ReadUserInput(prompt) | |
| 35 | - | if validate(input) { | |
| 36 | - | return input | |
| 37 | - | } | |
| 38 | - | fmt.Println("Invalid input. Please try again.") | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // ReadUserInputWithDefault reads user input with a default value if input is empty. | |
| 43 | - | func ReadUserInputWithDefault(prompt string, defaultValue string) string { | |
| 44 | - | fmt.Printf("%s (default: %s) ", prompt, defaultValue) | |
| 45 | - | ||
| 46 | - | reader := bufio.NewReader(os.Stdin) | |
| 47 | - | input, err := reader.ReadString('\n') | |
| 48 | - | if err != nil { | |
| 49 | - | log.Printf("Error reading input: %v", err) | |
| 50 | - | return defaultValue | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | input = strings.TrimSpace(input) | |
| 54 | - | if input == "" { | |
| 55 | - | return defaultValue | |
| 56 | - | } | |
| 57 | - | return input | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // ReadConfirmation reads a yes/no confirmation from the user. | |
| 61 | - | func ReadConfirmation(prompt string) bool { | |
| 62 | - | input := ReadUserInputWithValidation( | |
| 63 | - | prompt+" (y/n)", | |
| 64 | - | func(s string) bool { | |
| 65 | - | s = strings.ToLower(s) | |
| 66 | - | return s == "y" || s == "n" || s == "yes" || s == "no" | |
| 67 | - | }, | |
| 68 | - | ) | |
| 69 | - | ||
| 70 | - | input = strings.ToLower(input) | |
| 71 | - | return input == "y" || input == "yes" | |
| 72 | - | } | |
D internal/common/payload.go +0 -207
| @@ -1,207 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // RequestPayload represents the structure of incoming requests | |
| 10 | - | type RequestPayload struct { | |
| 11 | - | Type string `json:"type"` | |
| 12 | - | Payload Payload `json:"payload"` | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | // Payload is an interface for different types of payloads | |
| 16 | - | type Payload interface { | |
| 17 | - | GetType() string | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | // DeployResponse represents the response structure for deployment requests | |
| 21 | - | type DeployResponse struct { | |
| 22 | - | Success bool `json:"success"` | |
| 23 | - | Message string `json:"message"` | |
| 24 | - | StatusCode int `json:"status_code,omitempty"` | |
| 25 | - | Domain string `json:"domain,omitempty"` | |
| 26 | - | ContainerID string `json:"container_id,omitempty"` | |
| 27 | - | ContainerName string `json:"container_name,omitempty"` | |
| 28 | - | State string `json:"state,omitempty"` | |
| 29 | - | RunningTime string `json:"running_time,omitempty"` | |
| 30 | - | Ports string `json:"ports,omitempty"` | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // PushResponse represents the response structure for push requests | |
| 34 | - | type PushResponse struct { | |
| 35 | - | Success bool `json:"success"` | |
| 36 | - | Message string `json:"message"` | |
| 37 | - | CreateContainerURL string `json:"create_container_url,omitempty"` | |
| 38 | - | ImageID string `json:"image_id,omitempty"` | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | type StopResponse struct { | |
| 42 | - | Success bool `json:"success"` | |
| 43 | - | Message string `json:"message"` | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | type RemoveResponse struct { | |
| 47 | - | Success bool `json:"success"` | |
| 48 | - | Message string `json:"message"` | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | type StartResponse struct { | |
| 52 | - | Success bool `json:"success"` | |
| 53 | - | Message string `json:"message"` | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // DeployPayload represents the payload for deployment requests | |
| 57 | - | type DeployPayload struct { | |
| 58 | - | Port string `json:"port"` | |
| 59 | - | TargetDomain string `json:"targetdomain"` | |
| 60 | - | ImageName string `json:"imagename"` | |
| 61 | - | ImageID string `json:"imageid"` | |
| 62 | - | Data io.ReadCloser | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // PushPayload represents the payload for push requests | |
| 66 | - | type PushPayload struct { | |
| 67 | - | ImageName string `json:"imagename"` | |
| 68 | - | ImageID string `json:"imageid"` | |
| 69 | - | Data io.Reader | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | type ChunkMetadata struct { | |
| 73 | - | ChunkNumber int `json:"chunk_number"` | |
| 74 | - | TotalChunks int `json:"total_chunks"` | |
| 75 | - | ChunkSize int64 `json:"chunk_size"` | |
| 76 | - | TotalSize int64 `json:"total_size"` | |
| 77 | - | ImageName string `json:"image_name"` | |
| 78 | - | TransferID string `json:"transfer_id"` | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | type ConflictAction struct { | |
| 82 | - | Action string `json:"action"` // "stop", "remove", or "cancel" | |
| 83 | - | Force bool `json:"force"` | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | type DeviceFlowResponse struct { | |
| 87 | - | Code string `json:"code"` | |
| 88 | - | Message string `json:"message"` | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // UnmarshalJSON custom unmarshaler for RequestPayload | |
| 92 | - | func (p *RequestPayload) UnmarshalJSON(data []byte) error { | |
| 93 | - | var raw map[string]json.RawMessage | |
| 94 | - | err := json.Unmarshal(data, &raw) | |
| 95 | - | if err != nil { | |
| 96 | - | return err | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | if err := json.Unmarshal(raw["type"], &p.Type); err != nil { | |
| 100 | - | return err | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | // Unmarshal the payload based on the type | |
| 104 | - | switch p.Type { | |
| 105 | - | case "ping": | |
| 106 | - | var payload PingPayload | |
| 107 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 108 | - | return err | |
| 109 | - | } | |
| 110 | - | p.Payload = payload | |
| 111 | - | case "deploy": | |
| 112 | - | var payload DeployPayload | |
| 113 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 114 | - | return err | |
| 115 | - | } | |
| 116 | - | p.Payload = payload | |
| 117 | - | case "push": | |
| 118 | - | var payload PushPayload | |
| 119 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 120 | - | return err | |
| 121 | - | } | |
| 122 | - | p.Payload = payload | |
| 123 | - | case "stop": | |
| 124 | - | var payload StopPayload | |
| 125 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 126 | - | return err | |
| 127 | - | } | |
| 128 | - | p.Payload = payload | |
| 129 | - | case "start": | |
| 130 | - | var payload StartPayload | |
| 131 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 132 | - | return err | |
| 133 | - | } | |
| 134 | - | p.Payload = payload | |
| 135 | - | case "remove": | |
| 136 | - | var payload RemovePayload | |
| 137 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 138 | - | return err | |
| 139 | - | } | |
| 140 | - | p.Payload = payload | |
| 141 | - | default: | |
| 142 | - | return fmt.Errorf("invalid type: %s", p.Type) | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | return nil | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | // PingPayload represents the payload for ping requests | |
| 149 | - | type PingPayload struct { | |
| 150 | - | Message string `json:"message"` | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | // GetType returns the type of the PingPayload | |
| 154 | - | func (p PingPayload) GetType() string { | |
| 155 | - | return "ping" | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | // GetType returns the type of the DeployPayload | |
| 159 | - | func (p DeployPayload) GetType() string { | |
| 160 | - | return "deploy" | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // GetType returns the type of the PushPayload | |
| 164 | - | func (p PushPayload) GetType() string { | |
| 165 | - | return "push" | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // NewPingPayload creates a new PingPayload from a map of data | |
| 169 | - | func NewPingPayload(data map[string]interface{}) (PingPayload, error) { | |
| 170 | - | message, ok := data["message"].(string) | |
| 171 | - | if !ok { | |
| 172 | - | return PingPayload{}, fmt.Errorf("invalid data for message") | |
| 173 | - | } | |
| 174 | - | return PingPayload{Message: message}, nil | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // StopPayload represents the payload for stop requests | |
| 178 | - | type StopPayload struct { | |
| 179 | - | ContainerID string `json:"container_id"` | |
| 180 | - | ContainerName string `json:"container_name"` | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | // GetType returns the type of the StopPayload | |
| 184 | - | func (p StopPayload) GetType() string { | |
| 185 | - | return "stop" | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | // StartPayload represents the payload for start requests | |
| 189 | - | type StartPayload struct { | |
| 190 | - | ContainerID string `json:"container_id"` | |
| 191 | - | ContainerName string `json:"container_name"` | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | // GetType returns the type of the StartPayload | |
| 195 | - | func (p StartPayload) GetType() string { | |
| 196 | - | return "start" | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // RemovePayload represents the payload for remove requests | |
| 200 | - | type RemovePayload struct { | |
| 201 | - | ContainerID string `json:"container_id"` | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | // GetType returns the type of the RemovePayload | |
| 205 | - | func (p RemovePayload) GetType() string { | |
| 206 | - | return "remove" | |
| 207 | - | } | |
D internal/common/version.go +0 -189
| @@ -1,189 +0,0 @@ | |||
| 1 | - | // internal/common/version.go | |
| 2 | - | ||
| 3 | - | package common | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "runtime/debug" | |
| 11 | - | "strings" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/Masterminds/semver/v3" | |
| 15 | - | "github.com/charmbracelet/log" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // Info represents version information | |
| 19 | - | type Info struct { | |
| 20 | - | Version string | |
| 21 | - | Commit string | |
| 22 | - | Date string | |
| 23 | - | ProxyURL string | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | type ProxyVersion struct { | |
| 27 | - | Version string `json:"version"` | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // GetVersionInfo returns the current version information based on BuildConfig | |
| 31 | - | func GetVersionInfo(buildVersion, buildCommit, buildDate, proxyURL string) Info { | |
| 32 | - | // If version was set in BuildConfig, use that | |
| 33 | - | if buildVersion != "" && buildVersion != "dev" { | |
| 34 | - | return Info{ | |
| 35 | - | Version: buildVersion, | |
| 36 | - | Commit: buildCommit, | |
| 37 | - | Date: buildDate, | |
| 38 | - | ProxyURL: proxyURL, | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // Try to get version from runtime build info (for go install) | |
| 43 | - | if buildInfo, ok := debug.ReadBuildInfo(); ok { | |
| 44 | - | return Info{ | |
| 45 | - | Version: buildInfo.Main.Version, | |
| 46 | - | Commit: "unknown", | |
| 47 | - | Date: "unknown", | |
| 48 | - | ProxyURL: proxyURL, | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // Fallback for development builds | |
| 53 | - | return Info{ | |
| 54 | - | Version: "dev", | |
| 55 | - | Commit: "unknown", | |
| 56 | - | Date: "unknown", | |
| 57 | - | ProxyURL: proxyURL, | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // CheckForNewVersion checks if a newer version is available via proxy | |
| 62 | - | func CheckForNewVersion(currentVersion, proxyURL string) (bool, string, error) { | |
| 63 | - | // Don't check for new versions in development builds or dirty builds | |
| 64 | - | if currentVersion == "dev" || strings.Contains(currentVersion, "-dirty") { | |
| 65 | - | return false, "", nil | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // If currentVersion is empty, treat it as v0.0.0 | |
| 69 | - | if currentVersion == "" { | |
| 70 | - | currentVersion = "v0.0.0" | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | client := &http.Client{ | |
| 74 | - | Timeout: 10 * time.Second, | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | resp, err := client.Get(proxyURL + "/version") | |
| 78 | - | if err != nil { | |
| 79 | - | return false, "", fmt.Errorf("error making request: %w", err) | |
| 80 | - | } | |
| 81 | - | defer resp.Body.Close() | |
| 82 | - | ||
| 83 | - | if resp.StatusCode != http.StatusOK { | |
| 84 | - | return false, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | body, err := io.ReadAll(resp.Body) | |
| 88 | - | if err != nil { | |
| 89 | - | return false, "", fmt.Errorf("error reading response: %w", err) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | var proxyVer ProxyVersion | |
| 93 | - | if err := json.Unmarshal(body, &proxyVer); err != nil { | |
| 94 | - | return false, "", fmt.Errorf("error parsing JSON: %w", err) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // Ensure versions start with 'v' | |
| 98 | - | if !strings.HasPrefix(currentVersion, "v") { | |
| 99 | - | currentVersion = "v" + currentVersion | |
| 100 | - | } | |
| 101 | - | if !strings.HasPrefix(proxyVer.Version, "v") { | |
| 102 | - | proxyVer.Version = "v" + proxyVer.Version | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Clean up version strings | |
| 106 | - | latestVersion := strings.TrimPrefix(proxyVer.Version, "v") | |
| 107 | - | currentBase := extractBaseVersion(currentVersion) | |
| 108 | - | ||
| 109 | - | // Handle development versions | |
| 110 | - | if currentBase == "" || currentBase == "dev" { | |
| 111 | - | currentBase = "0.0.0" | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | // Parse versions using semver | |
| 115 | - | current, err := semver.NewVersion(currentBase) | |
| 116 | - | if err != nil { | |
| 117 | - | return false, "", fmt.Errorf("error parsing current version (%s): %w", currentBase, err) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | latest, err := semver.NewVersion(latestVersion) | |
| 121 | - | if err != nil { | |
| 122 | - | return false, "", fmt.Errorf("error parsing latest version (%s): %w", latestVersion, err) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | if latest.GreaterThan(current) { | |
| 126 | - | return true, proxyVer.Version, nil | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | return false, "", nil | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // CheckVersionPeriodically checks for new versions periodically | |
| 133 | - | func CheckVersionPeriodically(info Info, checkInterval time.Duration) { | |
| 134 | - | ticker := time.NewTicker(checkInterval) | |
| 135 | - | defer ticker.Stop() | |
| 136 | - | ||
| 137 | - | // Initial check | |
| 138 | - | checkVersion(info) | |
| 139 | - | ||
| 140 | - | // Periodic checks | |
| 141 | - | for range ticker.C { | |
| 142 | - | checkVersion(info) | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | func checkVersion(info Info) { | |
| 147 | - | hasUpdate, latestVersion, err := CheckForNewVersion(info.Version, info.ProxyURL) | |
| 148 | - | if err != nil { | |
| 149 | - | log.Error("Failed to check for updates", "error", err) | |
| 150 | - | return | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | if hasUpdate { | |
| 154 | - | log.Info("New version available!", | |
| 155 | - | "current", info.Version, | |
| 156 | - | "latest", latestVersion, | |
| 157 | - | "update_url", "https://github.com/bnema/gordon/releases/latest") | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | func extractBaseVersion(version string) string { | |
| 162 | - | // Remove 'v' prefix if present | |
| 163 | - | version = strings.TrimPrefix(version, "v") | |
| 164 | - | ||
| 165 | - | // Handle empty or invalid versions | |
| 166 | - | if version == "" || version == "dev" { | |
| 167 | - | return "0.0.0" | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Split on first hyphen to remove dirty/commit info | |
| 171 | - | parts := strings.SplitN(version, "-", 2) | |
| 172 | - | return parts[0] | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // String returns a formatted version string | |
| 176 | - | func (i Info) String() string { | |
| 177 | - | if i.Version == "dev" { | |
| 178 | - | return "Gordon version dev (development build)" | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | // Check if it's a dirty version | |
| 182 | - | if strings.Contains(i.Version, "-dirty") { | |
| 183 | - | return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)", | |
| 184 | - | i.Version, i.Commit, i.Date) | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)", | |
| 188 | - | i.Version, i.Commit, i.Date) | |
| 189 | - | } | |
A internal/config/config.go +529 -0
| @@ -0,0 +1,529 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "regexp" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/rs/zerolog/log" | |
| 11 | + | "github.com/spf13/viper" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type Config struct { | |
| 15 | + | Server ServerConfig `mapstructure:"server"` | |
| 16 | + | RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"` | |
| 17 | + | Routes map[string]string `mapstructure:"routes"` | |
| 18 | + | AutoRoute AutoRouteConfig `mapstructure:"auto_route"` | |
| 19 | + | Env EnvConfig `mapstructure:"env"` | |
| 20 | + | Logging LoggingConfig `mapstructure:"logging"` | |
| 21 | + | Volumes VolumeConfig `mapstructure:"volumes"` | |
| 22 | + | Attachments map[string][]string `mapstructure:"attachments"` | |
| 23 | + | NetworkGroups map[string][]string `mapstructure:"network_groups"` | |
| 24 | + | NetworkIsolation NetworkIsolationConfig `mapstructure:"network_isolation"` | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type ServerConfig struct { | |
| 28 | + | Port int `mapstructure:"port"` | |
| 29 | + | RegistryPort int `mapstructure:"registry_port"` | |
| 30 | + | RegistryDomain string `mapstructure:"registry_domain"` | |
| 31 | + | Runtime string `mapstructure:"runtime"` | |
| 32 | + | SocketPath string `mapstructure:"socket_path"` | |
| 33 | + | SSLEmail string `mapstructure:"ssl_email"` | |
| 34 | + | DataDir string `mapstructure:"data_dir"` | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type RegistryAuthConfig struct { | |
| 38 | + | Enabled bool `mapstructure:"enabled"` | |
| 39 | + | Username string `mapstructure:"username"` | |
| 40 | + | Password string `mapstructure:"password"` | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type AutoRouteConfig struct { | |
| 44 | + | Enabled bool `mapstructure:"enabled"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type EnvConfig struct { | |
| 48 | + | Dir string `mapstructure:"dir"` | |
| 49 | + | Providers []string `mapstructure:"providers"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type LoggingConfig struct { | |
| 53 | + | Enabled bool `mapstructure:"enabled"` | |
| 54 | + | Level string `mapstructure:"level"` | |
| 55 | + | Dir string `mapstructure:"dir"` | |
| 56 | + | MainLogFile string `mapstructure:"main_log_file"` | |
| 57 | + | ProxyLogFile string `mapstructure:"proxy_log_file"` | |
| 58 | + | ContainerLogDir string `mapstructure:"container_log_dir"` | |
| 59 | + | MaxSize int `mapstructure:"max_size"` | |
| 60 | + | MaxBackups int `mapstructure:"max_backups"` | |
| 61 | + | MaxAge int `mapstructure:"max_age"` | |
| 62 | + | Compress bool `mapstructure:"compress"` | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type VolumeConfig struct { | |
| 66 | + | AutoCreate bool `mapstructure:"auto_create"` | |
| 67 | + | Prefix string `mapstructure:"prefix"` | |
| 68 | + | Preserve bool `mapstructure:"preserve"` | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | type NetworkIsolationConfig struct { | |
| 72 | + | Enabled bool `mapstructure:"enabled"` | |
| 73 | + | NetworkPrefix string `mapstructure:"network_prefix"` | |
| 74 | + | DNSSuffix string `mapstructure:"dns_suffix"` | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | type SecretProvider struct { | |
| 78 | + | Type string `mapstructure:"type"` | |
| 79 | + | Config map[string]string `mapstructure:"config"` | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type Route struct { | |
| 83 | + | Domain string | |
| 84 | + | Image string | |
| 85 | + | HTTPS bool | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func Load() (*Config, error) { | |
| 89 | + | var cfg Config | |
| 90 | + | ||
| 91 | + | // Set defaults | |
| 92 | + | viper.SetDefault("server.port", 8080) | |
| 93 | + | viper.SetDefault("server.registry_port", 5000) | |
| 94 | + | viper.SetDefault("server.runtime", "auto") | |
| 95 | + | viper.SetDefault("server.socket_path", "") | |
| 96 | + | ||
| 97 | + | // Set data_dir default based on environment | |
| 98 | + | defaultDataDir := getDefaultDataDir() | |
| 99 | + | viper.SetDefault("server.data_dir", defaultDataDir) | |
| 100 | + | viper.SetDefault("registry_auth.enabled", true) | |
| 101 | + | viper.SetDefault("auto_route.enabled", false) | |
| 102 | + | viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env")) | |
| 103 | + | viper.SetDefault("env.providers", []string{"pass", "sops"}) | |
| 104 | + | ||
| 105 | + | // Logging defaults | |
| 106 | + | viper.SetDefault("logging.enabled", true) | |
| 107 | + | viper.SetDefault("logging.level", "info") | |
| 108 | + | viper.SetDefault("logging.dir", filepath.Join(defaultDataDir, "logs")) | |
| 109 | + | viper.SetDefault("logging.main_log_file", "gordon.log") | |
| 110 | + | viper.SetDefault("logging.proxy_log_file", "proxy.log") | |
| 111 | + | viper.SetDefault("logging.container_log_dir", "containers") | |
| 112 | + | viper.SetDefault("logging.max_size", 100) // 100MB | |
| 113 | + | viper.SetDefault("logging.max_backups", 3) // Keep 3 old files | |
| 114 | + | viper.SetDefault("logging.max_age", 28) // 28 days | |
| 115 | + | viper.SetDefault("logging.compress", true) | |
| 116 | + | ||
| 117 | + | // Volume defaults | |
| 118 | + | viper.SetDefault("volumes.auto_create", true) | |
| 119 | + | viper.SetDefault("volumes.prefix", "gordon") | |
| 120 | + | viper.SetDefault("volumes.preserve", true) | |
| 121 | + | ||
| 122 | + | // Network isolation defaults | |
| 123 | + | viper.SetDefault("network_isolation.enabled", true) | |
| 124 | + | viper.SetDefault("network_isolation.network_prefix", "gordon") | |
| 125 | + | viper.SetDefault("network_isolation.dns_suffix", ".internal") | |
| 126 | + | ||
| 127 | + | // Handle the routes manually since Viper struggles with domain names | |
| 128 | + | cfg.Routes = make(map[string]string) | |
| 129 | + | cfg.Attachments = make(map[string][]string) | |
| 130 | + | cfg.NetworkGroups = make(map[string][]string) | |
| 131 | + | ||
| 132 | + | // Get server config first | |
| 133 | + | if err := viper.UnmarshalKey("server", &cfg.Server); err != nil { | |
| 134 | + | return nil, fmt.Errorf("unable to decode server config: %v", err) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // If data_dir is empty after loading config, use the default | |
| 138 | + | if cfg.Server.DataDir == "" { | |
| 139 | + | cfg.Server.DataDir = defaultDataDir | |
| 140 | + | log.Debug().Str("data_dir", cfg.Server.DataDir).Msg("Config had empty data_dir, using default") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // If registry_port is 0 after loading config, use the default | |
| 144 | + | if cfg.Server.RegistryPort == 0 { | |
| 145 | + | cfg.Server.RegistryPort = 5000 | |
| 146 | + | log.Debug().Int("registry_port", cfg.Server.RegistryPort).Msg("Config had empty registry_port, using default") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Get registry auth config | |
| 150 | + | if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil { | |
| 151 | + | return nil, fmt.Errorf("unable to decode registry auth config: %v", err) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Get auto route config | |
| 155 | + | if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil { | |
| 156 | + | return nil, fmt.Errorf("unable to decode auto route config: %v", err) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Get env config | |
| 160 | + | if err := viper.UnmarshalKey("env", &cfg.Env); err != nil { | |
| 161 | + | return nil, fmt.Errorf("unable to decode env config: %v", err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // If env.dir is empty after loading config, use the default | |
| 165 | + | if cfg.Env.Dir == "" { | |
| 166 | + | cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env") | |
| 167 | + | log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default") | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Get logging config | |
| 171 | + | if err := viper.UnmarshalKey("logging", &cfg.Logging); err != nil { | |
| 172 | + | return nil, fmt.Errorf("unable to decode logging config: %v", err) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // If logging.dir is empty after loading config, use the default | |
| 176 | + | if cfg.Logging.Dir == "" { | |
| 177 | + | cfg.Logging.Dir = filepath.Join(cfg.Server.DataDir, "logs") | |
| 178 | + | log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default") | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Get volumes config | |
| 182 | + | if err := viper.UnmarshalKey("volumes", &cfg.Volumes); err != nil { | |
| 183 | + | return nil, fmt.Errorf("unable to decode volumes config: %v", err) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Get network isolation config | |
| 187 | + | if err := viper.UnmarshalKey("network_isolation", &cfg.NetworkIsolation); err != nil { | |
| 188 | + | return nil, fmt.Errorf("unable to decode network isolation config: %v", err) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Get routes manually from the raw config | |
| 192 | + | routesRaw := viper.Get("routes") | |
| 193 | + | if routesRaw != nil { | |
| 194 | + | if routes, ok := routesRaw.(map[string]interface{}); ok { | |
| 195 | + | for domain, image := range routes { | |
| 196 | + | if imageStr, ok := image.(string); ok { | |
| 197 | + | cfg.Routes[domain] = imageStr | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // Get attachments manually from the raw config | |
| 204 | + | attachmentsRaw := viper.Get("attachments") | |
| 205 | + | if attachmentsRaw != nil { | |
| 206 | + | if attachments, ok := attachmentsRaw.(map[string]interface{}); ok { | |
| 207 | + | for identifier, services := range attachments { | |
| 208 | + | if servicesList, ok := services.([]interface{}); ok { | |
| 209 | + | var serviceStrings []string | |
| 210 | + | for _, service := range servicesList { | |
| 211 | + | if serviceStr, ok := service.(string); ok { | |
| 212 | + | serviceStrings = append(serviceStrings, serviceStr) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | cfg.Attachments[identifier] = serviceStrings | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Get network_groups manually from the raw config | |
| 222 | + | networkGroupsRaw := viper.Get("network_groups") | |
| 223 | + | if networkGroupsRaw != nil { | |
| 224 | + | if networkGroups, ok := networkGroupsRaw.(map[string]interface{}); ok { | |
| 225 | + | for groupName, domains := range networkGroups { | |
| 226 | + | if domainsList, ok := domains.([]interface{}); ok { | |
| 227 | + | var domainStrings []string | |
| 228 | + | for _, domain := range domainsList { | |
| 229 | + | if domainStr, ok := domain.(string); ok { | |
| 230 | + | domainStrings = append(domainStrings, domainStr) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | cfg.NetworkGroups[groupName] = domainStrings | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Note: ssl_email is optional and only needed for future Let's Encrypt integration | |
| 240 | + | // Currently using Cloudflare for HTTPS | |
| 241 | + | ||
| 242 | + | validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"} | |
| 243 | + | isValid := false | |
| 244 | + | for _, valid := range validRuntimes { | |
| 245 | + | if cfg.Server.Runtime == valid { | |
| 246 | + | isValid = true | |
| 247 | + | break | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | if !isValid { | |
| 251 | + | return nil, fmt.Errorf("server.runtime must be one of: %s", strings.Join(validRuntimes, ", ")) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // Validate registry auth config | |
| 255 | + | if cfg.RegistryAuth.Enabled { | |
| 256 | + | if cfg.RegistryAuth.Username == "" || cfg.RegistryAuth.Password == "" { | |
| 257 | + | return nil, fmt.Errorf("Registry auth enabled but username/password not provided") | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // Validate registry domain if provided | |
| 262 | + | if cfg.Server.RegistryDomain != "" { | |
| 263 | + | // Basic domain validation - should not contain protocol or paths | |
| 264 | + | if strings.Contains(cfg.Server.RegistryDomain, "://") || strings.Contains(cfg.Server.RegistryDomain, "/") { | |
| 265 | + | return nil, fmt.Errorf("registry_domain should be just the domain name (e.g. 'registry.example.com')") | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Validate env config | |
| 270 | + | if err := validateEnvConfig(&cfg.Env); err != nil { | |
| 271 | + | return nil, fmt.Errorf("invalid env config: %w", err) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | return &cfg, nil | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (c *Config) GetRoutes() []Route { | |
| 278 | + | var routes []Route | |
| 279 | + | ||
| 280 | + | for domain, image := range c.Routes { | |
| 281 | + | route := Route{ | |
| 282 | + | Image: image, | |
| 283 | + | HTTPS: true, // Default to HTTPS | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // Check if domain has http:// prefix | |
| 287 | + | if strings.HasPrefix(domain, "http://") { | |
| 288 | + | route.Domain = strings.TrimPrefix(domain, "http://") | |
| 289 | + | route.HTTPS = false | |
| 290 | + | } else { | |
| 291 | + | route.Domain = domain | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | routes = append(routes, route) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | return routes | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // getDefaultDataDir returns a platform-appropriate default data directory | |
| 301 | + | func getDefaultDataDir() string { | |
| 302 | + | uid := os.Getuid() | |
| 303 | + | log.Debug().Int("uid", uid).Msg("Detecting default data directory") | |
| 304 | + | ||
| 305 | + | // Check if we're running in a rootless environment | |
| 306 | + | if uid != 0 { | |
| 307 | + | // For rootless environments, use user's home directory or current directory | |
| 308 | + | if homeDir, err := os.UserHomeDir(); err == nil { | |
| 309 | + | dataDir := filepath.Join(homeDir, ".local/share/gordon") | |
| 310 | + | log.Debug().Str("data_dir", dataDir).Msg("Using user data directory for rootless environment") | |
| 311 | + | return dataDir | |
| 312 | + | } | |
| 313 | + | log.Debug().Msg("Failed to get user home directory, falling back to ./data") | |
| 314 | + | } else { | |
| 315 | + | log.Debug().Msg("Running as root, using ./data") | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // For root or when home directory is not available, use relative path | |
| 319 | + | return "./data" | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest" | |
| 323 | + | func ExtractDomainFromImageName(imageName string) (string, bool) { | |
| 324 | + | // Split by colon to separate image name from tag | |
| 325 | + | parts := strings.Split(imageName, ":") | |
| 326 | + | imageNamePart := parts[0] | |
| 327 | + | ||
| 328 | + | // Domain pattern: should contain at least one dot and valid domain characters | |
| 329 | + | domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`) | |
| 330 | + | ||
| 331 | + | if domainPattern.MatchString(imageNamePart) { | |
| 332 | + | return imageNamePart, true | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | return "", false | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // AddRoute adds a new route to the configuration and saves it | |
| 339 | + | func (c *Config) AddRoute(domain, image string) error { | |
| 340 | + | if c.Routes == nil { | |
| 341 | + | c.Routes = make(map[string]string) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | c.Routes[domain] = image | |
| 345 | + | ||
| 346 | + | // Read the current config file to preserve formatting | |
| 347 | + | configFile := viper.ConfigFileUsed() | |
| 348 | + | if configFile == "" { | |
| 349 | + | return fmt.Errorf("no config file path available") | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // Read existing content | |
| 353 | + | content, err := os.ReadFile(configFile) | |
| 354 | + | if err != nil { | |
| 355 | + | return fmt.Errorf("failed to read config file: %w", err) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // Parse and update only the routes section while preserving other formatting | |
| 359 | + | lines := strings.Split(string(content), "\n") | |
| 360 | + | var newLines []string | |
| 361 | + | inRoutesSection := false | |
| 362 | + | routeAdded := false | |
| 363 | + | ||
| 364 | + | for _, line := range lines { | |
| 365 | + | trimmed := strings.TrimSpace(line) | |
| 366 | + | ||
| 367 | + | // Check if we're entering or leaving the routes section | |
| 368 | + | if trimmed == "[routes]" { | |
| 369 | + | inRoutesSection = true | |
| 370 | + | newLines = append(newLines, line) | |
| 371 | + | continue | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // Check if we're entering a new section | |
| 375 | + | if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" { | |
| 376 | + | // If we were in routes section and haven't added the route yet, add it | |
| 377 | + | if inRoutesSection && !routeAdded { | |
| 378 | + | newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image)) | |
| 379 | + | routeAdded = true | |
| 380 | + | } | |
| 381 | + | inRoutesSection = false | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // If we're in routes section, check if this route already exists (check both quote styles) | |
| 385 | + | if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) { | |
| 386 | + | // Update existing route using double quotes for consistency | |
| 387 | + | newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image)) | |
| 388 | + | routeAdded = true | |
| 389 | + | } else { | |
| 390 | + | newLines = append(newLines, line) | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | // If we were in routes section at the end and haven't added the route yet, add it | |
| 395 | + | if inRoutesSection && !routeAdded { | |
| 396 | + | newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image)) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // Write the updated content back | |
| 400 | + | newContent := strings.Join(newLines, "\n") | |
| 401 | + | if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil { | |
| 402 | + | return fmt.Errorf("failed to write config file: %w", err) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | log.Info(). | |
| 406 | + | Str("domain", domain). | |
| 407 | + | Str("image", image). | |
| 408 | + | Msg("Auto-added route to configuration") | |
| 409 | + | ||
| 410 | + | // Create env file for the new route (if env loader is available) | |
| 411 | + | // This will be called from the auto-route handler which has access to the env loader | |
| 412 | + | ||
| 413 | + | return nil | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // UpdateRoute updates an existing route configuration | |
| 417 | + | func (c *Config) UpdateRoute(domain, image string) error { | |
| 418 | + | if c.Routes == nil { | |
| 419 | + | c.Routes = make(map[string]string) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // Check if route exists | |
| 423 | + | if _, exists := c.Routes[domain]; !exists { | |
| 424 | + | return fmt.Errorf("route %s does not exist", domain) | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | c.Routes[domain] = image | |
| 428 | + | ||
| 429 | + | // Read the current config file to preserve formatting | |
| 430 | + | configFile := viper.ConfigFileUsed() | |
| 431 | + | if configFile == "" { | |
| 432 | + | return fmt.Errorf("no config file path available") | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // Read existing content | |
| 436 | + | content, err := os.ReadFile(configFile) | |
| 437 | + | if err != nil { | |
| 438 | + | return fmt.Errorf("failed to read config file: %w", err) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // Parse and update only the routes section while preserving other formatting | |
| 442 | + | lines := strings.Split(string(content), "\n") | |
| 443 | + | var newLines []string | |
| 444 | + | inRoutesSection := false | |
| 445 | + | routeUpdated := false | |
| 446 | + | ||
| 447 | + | for _, line := range lines { | |
| 448 | + | trimmed := strings.TrimSpace(line) | |
| 449 | + | ||
| 450 | + | // Check if we're entering or leaving the routes section | |
| 451 | + | if trimmed == "[routes]" { | |
| 452 | + | inRoutesSection = true | |
| 453 | + | newLines = append(newLines, line) | |
| 454 | + | continue | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // Check if we're entering a new section | |
| 458 | + | if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" { | |
| 459 | + | inRoutesSection = false | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // If we're in routes section, check if this route matches (check both quote styles) | |
| 463 | + | if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) { | |
| 464 | + | // Update existing route using double quotes for consistency | |
| 465 | + | newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image)) | |
| 466 | + | routeUpdated = true | |
| 467 | + | } else { | |
| 468 | + | newLines = append(newLines, line) | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | if !routeUpdated { | |
| 473 | + | return fmt.Errorf("route %s not found in config file", domain) | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // Write the updated content back | |
| 477 | + | newContent := strings.Join(newLines, "\n") | |
| 478 | + | if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil { | |
| 479 | + | return fmt.Errorf("failed to write config file: %w", err) | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | log.Info(). | |
| 483 | + | Str("domain", domain). | |
| 484 | + | Str("image", image). | |
| 485 | + | Msg("Updated route configuration") | |
| 486 | + | ||
| 487 | + | return nil | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // validateEnvConfig validates the environment configuration | |
| 491 | + | func validateEnvConfig(envCfg *EnvConfig) error { | |
| 492 | + | // Validate env directory path | |
| 493 | + | if envCfg.Dir != "" { | |
| 494 | + | if filepath.IsAbs(envCfg.Dir) { | |
| 495 | + | // For absolute paths, check if the directory is accessible | |
| 496 | + | if info, err := os.Stat(envCfg.Dir); err != nil { | |
| 497 | + | if !os.IsNotExist(err) { | |
| 498 | + | return fmt.Errorf("env.dir path is not accessible: %w", err) | |
| 499 | + | } | |
| 500 | + | // Directory doesn't exist - that's okay, it will be created | |
| 501 | + | } else if !info.IsDir() { | |
| 502 | + | return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir) | |
| 503 | + | } | |
| 504 | + | } | |
| 505 | + | // For relative paths, we can't easily validate without changing working directory | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | // Validate secret providers configuration | |
| 509 | + | validProviders := []string{"pass", "sops"} | |
| 510 | + | for _, providerName := range envCfg.Providers { | |
| 511 | + | if providerName == "" { | |
| 512 | + | return fmt.Errorf("empty provider name in env.providers") | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | isValid := false | |
| 516 | + | for _, validType := range validProviders { | |
| 517 | + | if providerName == validType { | |
| 518 | + | isValid = true | |
| 519 | + | break | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | if !isValid { | |
| 523 | + | return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s", | |
| 524 | + | providerName, strings.Join(validProviders, ", ")) | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | return nil | |
| 529 | + | } | |
A internal/config/config_test.go +650 -0
| @@ -0,0 +1,650 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/spf13/viper" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "gordon/internal/testutils" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestConfig_Load_ValidBasicConfig(t *testing.T) { | |
| 16 | + | // Create temporary config file | |
| 17 | + | configContent := testutils.LoadFixtureConfig(t, "basic.toml") | |
| 18 | + | ||
| 19 | + | // Create a temporary directory for the config file | |
| 20 | + | tempDir := t.TempDir() | |
| 21 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 22 | + | ||
| 23 | + | // Write config to temporary file | |
| 24 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | ||
| 27 | + | // Set viper to use this config file | |
| 28 | + | viper.Reset() | |
| 29 | + | viper.SetConfigFile(configFile) | |
| 30 | + | err = viper.ReadInConfig() | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | ||
| 33 | + | // Load config | |
| 34 | + | cfg, err := Load() | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | require.NotNil(t, cfg) | |
| 37 | + | ||
| 38 | + | // Verify server config | |
| 39 | + | assert.Equal(t, 8080, cfg.Server.Port) | |
| 40 | + | assert.Equal(t, 5000, cfg.Server.RegistryPort) | |
| 41 | + | assert.Equal(t, "registry.example.com", cfg.Server.RegistryDomain) | |
| 42 | + | assert.Equal(t, "docker", cfg.Server.Runtime) | |
| 43 | + | ||
| 44 | + | // Verify registry auth config | |
| 45 | + | assert.True(t, cfg.RegistryAuth.Enabled) | |
| 46 | + | assert.Equal(t, "admin", cfg.RegistryAuth.Username) | |
| 47 | + | assert.Equal(t, "password123", cfg.RegistryAuth.Password) | |
| 48 | + | ||
| 49 | + | // Verify routes | |
| 50 | + | assert.Len(t, cfg.Routes, 2) | |
| 51 | + | assert.Equal(t, "nginx:latest", cfg.Routes["app.example.com"]) | |
| 52 | + | assert.Equal(t, "myapi:v1", cfg.Routes["api.example.com"]) | |
| 53 | + | ||
| 54 | + | // Verify volumes config | |
| 55 | + | assert.True(t, cfg.Volumes.AutoCreate) | |
| 56 | + | assert.Equal(t, "gordon", cfg.Volumes.Prefix) | |
| 57 | + | assert.True(t, cfg.Volumes.Preserve) | |
| 58 | + | ||
| 59 | + | // Verify env config | |
| 60 | + | assert.Equal(t, "/tmp/env", cfg.Env.Dir) | |
| 61 | + | assert.Contains(t, cfg.Env.Providers, "pass") | |
| 62 | + | assert.Contains(t, cfg.Env.Providers, "sops") | |
| 63 | + | ||
| 64 | + | // Verify logging config | |
| 65 | + | assert.True(t, cfg.Logging.Enabled) | |
| 66 | + | assert.Equal(t, "info", cfg.Logging.Level) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestConfig_Load_MinimalConfig(t *testing.T) { | |
| 70 | + | configContent := testutils.LoadFixtureConfig(t, "minimal.toml") | |
| 71 | + | ||
| 72 | + | tempDir := t.TempDir() | |
| 73 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 74 | + | ||
| 75 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | ||
| 78 | + | viper.Reset() | |
| 79 | + | viper.SetConfigFile(configFile) | |
| 80 | + | err = viper.ReadInConfig() | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | ||
| 83 | + | cfg, err := Load() | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | require.NotNil(t, cfg) | |
| 86 | + | ||
| 87 | + | // Should have defaults applied where not specified, but explicit values where set | |
| 88 | + | // Note: viper defaults may not apply if config structure is partially loaded | |
| 89 | + | assert.Equal(t, "registry.example.com", cfg.Server.RegistryDomain) | |
| 90 | + | assert.Equal(t, "docker", cfg.Server.Runtime) // explicitly set in minimal config | |
| 91 | + | assert.False(t, cfg.RegistryAuth.Enabled) // explicitly set in minimal config | |
| 92 | + | assert.True(t, cfg.Volumes.AutoCreate) // default | |
| 93 | + | assert.Equal(t, "gordon", cfg.Volumes.Prefix) // default | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestConfig_Load_InvalidTOMLSyntax(t *testing.T) { | |
| 97 | + | configContent := testutils.LoadFixtureConfig(t, "invalid.toml") | |
| 98 | + | ||
| 99 | + | tempDir := t.TempDir() | |
| 100 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 101 | + | ||
| 102 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | ||
| 105 | + | viper.Reset() | |
| 106 | + | viper.SetConfigFile(configFile) | |
| 107 | + | err = viper.ReadInConfig() | |
| 108 | + | require.Error(t, err) | |
| 109 | + | assert.Contains(t, err.Error(), "toml") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestConfig_Load_InvalidRuntime(t *testing.T) { | |
| 113 | + | configContent := `[server] | |
| 114 | + | registry_domain = "registry.example.com" | |
| 115 | + | runtime = "invalid-runtime"` | |
| 116 | + | ||
| 117 | + | tempDir := t.TempDir() | |
| 118 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 119 | + | ||
| 120 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | ||
| 123 | + | viper.Reset() | |
| 124 | + | viper.SetConfigFile(configFile) | |
| 125 | + | err = viper.ReadInConfig() | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | ||
| 128 | + | _, err = Load() | |
| 129 | + | require.Error(t, err) | |
| 130 | + | assert.Contains(t, err.Error(), "server.runtime must be one of") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestConfig_Load_InvalidRegistryDomain(t *testing.T) { | |
| 134 | + | testCases := []struct { | |
| 135 | + | name string | |
| 136 | + | domain string | |
| 137 | + | }{ | |
| 138 | + | {"with protocol", "https://registry.example.com"}, | |
| 139 | + | {"with path", "registry.example.com/path"}, | |
| 140 | + | {"with protocol and path", "https://registry.example.com/path"}, | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | for _, tc := range testCases { | |
| 144 | + | t.Run(tc.name, func(t *testing.T) { | |
| 145 | + | configContent := `[server] | |
| 146 | + | registry_domain = "` + tc.domain + `" | |
| 147 | + | runtime = "docker" | |
| 148 | + | ||
| 149 | + | [registry_auth] | |
| 150 | + | enabled = false` | |
| 151 | + | ||
| 152 | + | tempDir := t.TempDir() | |
| 153 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 154 | + | ||
| 155 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | ||
| 158 | + | viper.Reset() | |
| 159 | + | viper.SetConfigFile(configFile) | |
| 160 | + | err = viper.ReadInConfig() | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | ||
| 163 | + | _, err = Load() | |
| 164 | + | require.Error(t, err) | |
| 165 | + | assert.Contains(t, err.Error(), "registry_domain should be just the domain name") | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestConfig_Load_RegistryAuthValidation(t *testing.T) { | |
| 171 | + | configContent := `[server] | |
| 172 | + | registry_domain = "registry.example.com" | |
| 173 | + | runtime = "docker" | |
| 174 | + | ||
| 175 | + | [registry_auth] | |
| 176 | + | enabled = true | |
| 177 | + | username = "" | |
| 178 | + | password = ""` | |
| 179 | + | ||
| 180 | + | tempDir := t.TempDir() | |
| 181 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 182 | + | ||
| 183 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 184 | + | require.NoError(t, err) | |
| 185 | + | ||
| 186 | + | viper.Reset() | |
| 187 | + | viper.SetConfigFile(configFile) | |
| 188 | + | err = viper.ReadInConfig() | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | ||
| 191 | + | _, err = Load() | |
| 192 | + | require.Error(t, err) | |
| 193 | + | assert.Contains(t, err.Error(), "Registry auth enabled but username/password not provided") | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestConfig_GetRoutes(t *testing.T) { | |
| 197 | + | cfg := &Config{ | |
| 198 | + | Routes: map[string]string{ | |
| 199 | + | "app.example.com": "nginx:latest", | |
| 200 | + | "http://api.example.com": "api:v1", | |
| 201 | + | "secure.example.com": "secure:latest", | |
| 202 | + | }, | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | routes := cfg.GetRoutes() | |
| 206 | + | ||
| 207 | + | require.Len(t, routes, 3) | |
| 208 | + | ||
| 209 | + | // Find routes by domain | |
| 210 | + | var appRoute, apiRoute, secureRoute Route | |
| 211 | + | for _, route := range routes { | |
| 212 | + | switch route.Domain { | |
| 213 | + | case "app.example.com": | |
| 214 | + | appRoute = route | |
| 215 | + | case "api.example.com": | |
| 216 | + | apiRoute = route | |
| 217 | + | case "secure.example.com": | |
| 218 | + | secureRoute = route | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Verify HTTPS defaults | |
| 223 | + | assert.Equal(t, "nginx:latest", appRoute.Image) | |
| 224 | + | assert.True(t, appRoute.HTTPS) | |
| 225 | + | ||
| 226 | + | // Verify HTTP override | |
| 227 | + | assert.Equal(t, "api:v1", apiRoute.Image) | |
| 228 | + | assert.False(t, apiRoute.HTTPS) | |
| 229 | + | ||
| 230 | + | // Verify HTTPS default | |
| 231 | + | assert.Equal(t, "secure:latest", secureRoute.Image) | |
| 232 | + | assert.True(t, secureRoute.HTTPS) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestExtractDomainFromImageName(t *testing.T) { | |
| 236 | + | testCases := []struct { | |
| 237 | + | name string | |
| 238 | + | imageName string | |
| 239 | + | expectedDomain string | |
| 240 | + | shouldMatch bool | |
| 241 | + | }{ | |
| 242 | + | {"valid domain with tag", "app.example.com:latest", "app.example.com", true}, | |
| 243 | + | {"valid domain without tag", "api.example.com", "api.example.com", true}, | |
| 244 | + | {"subdomain", "staging.api.example.com:v1", "staging.api.example.com", true}, | |
| 245 | + | {"simple name", "nginx", "", false}, | |
| 246 | + | {"dockerhub format", "user/repo", "", false}, | |
| 247 | + | {"localhost", "localhost:5000", "", false}, | |
| 248 | + | {"ip address", "192.168.1.1:8080", "", false}, | |
| 249 | + | {"invalid domain", "app-.example.com", "", false}, | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | for _, tc := range testCases { | |
| 253 | + | t.Run(tc.name, func(t *testing.T) { | |
| 254 | + | domain, match := ExtractDomainFromImageName(tc.imageName) | |
| 255 | + | assert.Equal(t, tc.shouldMatch, match) | |
| 256 | + | assert.Equal(t, tc.expectedDomain, domain) | |
| 257 | + | }) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestConfig_AddRoute(t *testing.T) { | |
| 262 | + | // Create a config with existing routes | |
| 263 | + | configContent := `[server] | |
| 264 | + | registry_domain = "registry.example.com" | |
| 265 | + | runtime = "docker" | |
| 266 | + | ||
| 267 | + | [registry_auth] | |
| 268 | + | enabled = false | |
| 269 | + | ||
| 270 | + | [routes] | |
| 271 | + | "existing.example.com" = "existing:latest"` | |
| 272 | + | ||
| 273 | + | tempDir := t.TempDir() | |
| 274 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 275 | + | ||
| 276 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | ||
| 279 | + | viper.Reset() | |
| 280 | + | viper.SetConfigFile(configFile) | |
| 281 | + | err = viper.ReadInConfig() | |
| 282 | + | require.NoError(t, err) | |
| 283 | + | ||
| 284 | + | cfg, err := Load() | |
| 285 | + | require.NoError(t, err) | |
| 286 | + | ||
| 287 | + | // Add a new route | |
| 288 | + | err = cfg.AddRoute("new.example.com", "newapp:v1") | |
| 289 | + | require.NoError(t, err) | |
| 290 | + | ||
| 291 | + | // Verify route was added to memory | |
| 292 | + | assert.Equal(t, "newapp:v1", cfg.Routes["new.example.com"]) | |
| 293 | + | ||
| 294 | + | // Verify config file was updated | |
| 295 | + | updatedContent, err := os.ReadFile(configFile) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | assert.Contains(t, string(updatedContent), `"new.example.com" = "newapp:v1"`) | |
| 298 | + | assert.Contains(t, string(updatedContent), `"existing.example.com" = "existing:latest"`) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestConfig_UpdateRoute(t *testing.T) { | |
| 302 | + | configContent := `[server] | |
| 303 | + | registry_domain = "registry.example.com" | |
| 304 | + | runtime = "docker" | |
| 305 | + | ||
| 306 | + | [registry_auth] | |
| 307 | + | enabled = false | |
| 308 | + | ||
| 309 | + | [routes] | |
| 310 | + | "app.example.com" = "oldapp:v1"` | |
| 311 | + | ||
| 312 | + | tempDir := t.TempDir() | |
| 313 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 314 | + | ||
| 315 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 316 | + | require.NoError(t, err) | |
| 317 | + | ||
| 318 | + | viper.Reset() | |
| 319 | + | viper.SetConfigFile(configFile) | |
| 320 | + | err = viper.ReadInConfig() | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | ||
| 323 | + | cfg, err := Load() | |
| 324 | + | require.NoError(t, err) | |
| 325 | + | ||
| 326 | + | // Update existing route | |
| 327 | + | err = cfg.UpdateRoute("app.example.com", "newapp:v2") | |
| 328 | + | require.NoError(t, err) | |
| 329 | + | ||
| 330 | + | // Verify route was updated in memory | |
| 331 | + | assert.Equal(t, "newapp:v2", cfg.Routes["app.example.com"]) | |
| 332 | + | ||
| 333 | + | // Verify config file was updated | |
| 334 | + | updatedContent, err := os.ReadFile(configFile) | |
| 335 | + | require.NoError(t, err) | |
| 336 | + | assert.Contains(t, string(updatedContent), `"app.example.com" = "newapp:v2"`) | |
| 337 | + | assert.NotContains(t, string(updatedContent), "oldapp:v1") | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func TestConfig_UpdateRoute_NonExistent(t *testing.T) { | |
| 341 | + | configContent := `[server] | |
| 342 | + | registry_domain = "registry.example.com" | |
| 343 | + | runtime = "docker" | |
| 344 | + | ||
| 345 | + | [registry_auth] | |
| 346 | + | enabled = false | |
| 347 | + | ||
| 348 | + | [routes]` | |
| 349 | + | ||
| 350 | + | tempDir := t.TempDir() | |
| 351 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 352 | + | ||
| 353 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 354 | + | require.NoError(t, err) | |
| 355 | + | ||
| 356 | + | viper.Reset() | |
| 357 | + | viper.SetConfigFile(configFile) | |
| 358 | + | err = viper.ReadInConfig() | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | ||
| 361 | + | cfg, err := Load() | |
| 362 | + | require.NoError(t, err) | |
| 363 | + | ||
| 364 | + | // Try to update non-existent route | |
| 365 | + | err = cfg.UpdateRoute("nonexistent.example.com", "app:v1") | |
| 366 | + | require.Error(t, err) | |
| 367 | + | assert.Contains(t, err.Error(), "route nonexistent.example.com does not exist") | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func TestValidateEnvConfig(t *testing.T) { | |
| 371 | + | testCases := []struct { | |
| 372 | + | name string | |
| 373 | + | envConfig EnvConfig | |
| 374 | + | expectError bool | |
| 375 | + | errorText string | |
| 376 | + | }{ | |
| 377 | + | { | |
| 378 | + | name: "valid config", | |
| 379 | + | envConfig: EnvConfig{ | |
| 380 | + | Dir: "/tmp/env", | |
| 381 | + | Providers: []string{"pass", "sops"}, | |
| 382 | + | }, | |
| 383 | + | expectError: false, | |
| 384 | + | }, | |
| 385 | + | { | |
| 386 | + | name: "empty provider", | |
| 387 | + | envConfig: EnvConfig{ | |
| 388 | + | Dir: "/tmp/env", | |
| 389 | + | Providers: []string{"pass", "", "sops"}, | |
| 390 | + | }, | |
| 391 | + | expectError: true, | |
| 392 | + | errorText: "empty provider name", | |
| 393 | + | }, | |
| 394 | + | { | |
| 395 | + | name: "invalid provider", | |
| 396 | + | envConfig: EnvConfig{ | |
| 397 | + | Dir: "/tmp/env", | |
| 398 | + | Providers: []string{"pass", "invalid", "sops"}, | |
| 399 | + | }, | |
| 400 | + | expectError: true, | |
| 401 | + | errorText: "unsupported provider 'invalid'", | |
| 402 | + | }, | |
| 403 | + | { | |
| 404 | + | name: "non-existent directory file conflict", | |
| 405 | + | envConfig: EnvConfig{ | |
| 406 | + | Dir: "/dev/null", // This exists but is not a directory | |
| 407 | + | Providers: []string{"pass"}, | |
| 408 | + | }, | |
| 409 | + | expectError: true, | |
| 410 | + | errorText: "not a directory", | |
| 411 | + | }, | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | for _, tc := range testCases { | |
| 415 | + | t.Run(tc.name, func(t *testing.T) { | |
| 416 | + | err := validateEnvConfig(&tc.envConfig) | |
| 417 | + | if tc.expectError { | |
| 418 | + | require.Error(t, err) | |
| 419 | + | assert.Contains(t, err.Error(), tc.errorText) | |
| 420 | + | } else { | |
| 421 | + | require.NoError(t, err) | |
| 422 | + | } | |
| 423 | + | }) | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func TestGetDefaultDataDir(t *testing.T) { | |
| 428 | + | // Test should not fail regardless of environment | |
| 429 | + | dataDir := getDefaultDataDir() | |
| 430 | + | assert.NotEmpty(t, dataDir) | |
| 431 | + | ||
| 432 | + | // Should be either absolute path (for user home) or relative "./data" | |
| 433 | + | assert.True(t, filepath.IsAbs(dataDir) || dataDir == "./data") | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func TestConfig_Load_NetworkIsolation(t *testing.T) { | |
| 437 | + | configContent := `[server] | |
| 438 | + | registry_domain = "registry.example.com" | |
| 439 | + | runtime = "docker" | |
| 440 | + | ||
| 441 | + | [registry_auth] | |
| 442 | + | enabled = false | |
| 443 | + | ||
| 444 | + | [network_isolation] | |
| 445 | + | enabled = true | |
| 446 | + | network_prefix = "custom" | |
| 447 | + | dns_suffix = ".local"` | |
| 448 | + | ||
| 449 | + | tempDir := t.TempDir() | |
| 450 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 451 | + | ||
| 452 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 453 | + | require.NoError(t, err) | |
| 454 | + | ||
| 455 | + | viper.Reset() | |
| 456 | + | viper.SetConfigFile(configFile) | |
| 457 | + | err = viper.ReadInConfig() | |
| 458 | + | require.NoError(t, err) | |
| 459 | + | ||
| 460 | + | cfg, err := Load() | |
| 461 | + | require.NoError(t, err) | |
| 462 | + | ||
| 463 | + | assert.True(t, cfg.NetworkIsolation.Enabled) | |
| 464 | + | assert.Equal(t, "custom", cfg.NetworkIsolation.NetworkPrefix) | |
| 465 | + | assert.Equal(t, ".local", cfg.NetworkIsolation.DNSSuffix) | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | func TestConfig_Load_AttachmentsAndNetworkGroups(t *testing.T) { | |
| 469 | + | configContent := `[server] | |
| 470 | + | registry_domain = "registry.example.com" | |
| 471 | + | runtime = "docker" | |
| 472 | + | ||
| 473 | + | [registry_auth] | |
| 474 | + | enabled = false | |
| 475 | + | ||
| 476 | + | [attachments] | |
| 477 | + | "app.example.com" = ["postgres:14", "redis:6"] | |
| 478 | + | "backend" = ["postgres:14"] | |
| 479 | + | ||
| 480 | + | [network_groups] | |
| 481 | + | "backend" = ["api.example.com", "worker.example.com"]` | |
| 482 | + | ||
| 483 | + | tempDir := t.TempDir() | |
| 484 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 485 | + | ||
| 486 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 487 | + | require.NoError(t, err) | |
| 488 | + | ||
| 489 | + | viper.Reset() | |
| 490 | + | viper.SetConfigFile(configFile) | |
| 491 | + | err = viper.ReadInConfig() | |
| 492 | + | require.NoError(t, err) | |
| 493 | + | ||
| 494 | + | cfg, err := Load() | |
| 495 | + | require.NoError(t, err) | |
| 496 | + | ||
| 497 | + | // Verify attachments | |
| 498 | + | assert.Len(t, cfg.Attachments, 2) | |
| 499 | + | assert.Equal(t, []string{"postgres:14", "redis:6"}, cfg.Attachments["app.example.com"]) | |
| 500 | + | assert.Equal(t, []string{"postgres:14"}, cfg.Attachments["backend"]) | |
| 501 | + | ||
| 502 | + | // Verify network groups | |
| 503 | + | assert.Len(t, cfg.NetworkGroups, 1) | |
| 504 | + | assert.Equal(t, []string{"api.example.com", "worker.example.com"}, cfg.NetworkGroups["backend"]) | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func TestConfig_Load_EmptyConfiguration(t *testing.T) { | |
| 508 | + | // Test loading with completely empty config file but needs minimal valid config | |
| 509 | + | configContent := `[registry_auth] | |
| 510 | + | enabled = false` | |
| 511 | + | ||
| 512 | + | tempDir := t.TempDir() | |
| 513 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 514 | + | ||
| 515 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 516 | + | require.NoError(t, err) | |
| 517 | + | ||
| 518 | + | viper.Reset() | |
| 519 | + | viper.SetConfigFile(configFile) | |
| 520 | + | err = viper.ReadInConfig() | |
| 521 | + | require.NoError(t, err) | |
| 522 | + | ||
| 523 | + | cfg, err := Load() | |
| 524 | + | require.NoError(t, err) | |
| 525 | + | ||
| 526 | + | // Should have all defaults except what we explicitly set | |
| 527 | + | assert.Equal(t, 8080, cfg.Server.Port) | |
| 528 | + | assert.Equal(t, 5000, cfg.Server.RegistryPort) | |
| 529 | + | assert.Equal(t, "auto", cfg.Server.Runtime) | |
| 530 | + | assert.False(t, cfg.RegistryAuth.Enabled) // explicitly disabled | |
| 531 | + | assert.True(t, cfg.Volumes.AutoCreate) | |
| 532 | + | assert.Equal(t, "gordon", cfg.Volumes.Prefix) | |
| 533 | + | assert.True(t, cfg.Volumes.Preserve) | |
| 534 | + | assert.True(t, cfg.NetworkIsolation.Enabled) | |
| 535 | + | assert.Equal(t, "gordon", cfg.NetworkIsolation.NetworkPrefix) | |
| 536 | + | assert.Equal(t, ".internal", cfg.NetworkIsolation.DNSSuffix) | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func TestConfig_AddRoute_NoConfigFile(t *testing.T) { | |
| 540 | + | // Clear viper state to ensure no config file is set | |
| 541 | + | viper.Reset() | |
| 542 | + | ||
| 543 | + | cfg := &Config{ | |
| 544 | + | Routes: make(map[string]string), | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // Try to add route when no config file is available | |
| 548 | + | err := cfg.AddRoute("test.example.com", "test:latest") | |
| 549 | + | require.Error(t, err) | |
| 550 | + | assert.Contains(t, err.Error(), "no config file path available") | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | func TestConfig_AddRoute_FileReadError(t *testing.T) { | |
| 554 | + | // Create a config file in a non-existent directory | |
| 555 | + | configFile := "/nonexistent/directory/gordon.toml" | |
| 556 | + | ||
| 557 | + | viper.Reset() | |
| 558 | + | viper.SetConfigFile(configFile) | |
| 559 | + | ||
| 560 | + | cfg := &Config{ | |
| 561 | + | Routes: make(map[string]string), | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | err := cfg.AddRoute("test.example.com", "test:latest") | |
| 565 | + | require.Error(t, err) | |
| 566 | + | assert.Contains(t, err.Error(), "failed to read config file") | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | func TestConfig_UpdateRoute_FileReadError(t *testing.T) { | |
| 570 | + | // Create a config file in a non-existent directory | |
| 571 | + | configFile := "/nonexistent/directory/gordon.toml" | |
| 572 | + | ||
| 573 | + | viper.Reset() | |
| 574 | + | viper.SetConfigFile(configFile) | |
| 575 | + | ||
| 576 | + | cfg := &Config{ | |
| 577 | + | Routes: map[string]string{ | |
| 578 | + | "test.example.com": "test:v1", | |
| 579 | + | }, | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | err := cfg.UpdateRoute("test.example.com", "test:v2") | |
| 583 | + | require.Error(t, err) | |
| 584 | + | assert.Contains(t, err.Error(), "failed to read config file") | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func TestConfig_AddRoute_RouteAtEndOfFile(t *testing.T) { | |
| 588 | + | configContent := `[server] | |
| 589 | + | registry_domain = "registry.example.com" | |
| 590 | + | runtime = "docker" | |
| 591 | + | ||
| 592 | + | [registry_auth] | |
| 593 | + | enabled = false | |
| 594 | + | ||
| 595 | + | [routes] | |
| 596 | + | "existing.example.com" = "existing:latest"` | |
| 597 | + | ||
| 598 | + | tempDir := t.TempDir() | |
| 599 | + | configFile := filepath.Join(tempDir, "gordon.toml") | |
| 600 | + | ||
| 601 | + | err := os.WriteFile(configFile, []byte(configContent), 0644) | |
| 602 | + | require.NoError(t, err) | |
| 603 | + | ||
| 604 | + | viper.Reset() | |
| 605 | + | viper.SetConfigFile(configFile) | |
| 606 | + | err = viper.ReadInConfig() | |
| 607 | + | require.NoError(t, err) | |
| 608 | + | ||
| 609 | + | cfg, err := Load() | |
| 610 | + | require.NoError(t, err) | |
| 611 | + | ||
| 612 | + | // Add a new route at the end of the routes section | |
| 613 | + | err = cfg.AddRoute("end.example.com", "endapp:v1") | |
| 614 | + | require.NoError(t, err) | |
| 615 | + | ||
| 616 | + | // Verify route was added to memory | |
| 617 | + | assert.Equal(t, "endapp:v1", cfg.Routes["end.example.com"]) | |
| 618 | + | ||
| 619 | + | // Verify config file was updated | |
| 620 | + | updatedContent, err := os.ReadFile(configFile) | |
| 621 | + | require.NoError(t, err) | |
| 622 | + | assert.Contains(t, string(updatedContent), `"end.example.com" = "endapp:v1"`) | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | func TestValidateEnvConfig_RelativePath(t *testing.T) { | |
| 626 | + | envConfig := EnvConfig{ | |
| 627 | + | Dir: "./relative/path", // relative path should not cause error | |
| 628 | + | Providers: []string{"pass"}, | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | err := validateEnvConfig(&envConfig) | |
| 632 | + | require.NoError(t, err) | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | func TestValidateEnvConfig_EmptyDir(t *testing.T) { | |
| 636 | + | envConfig := EnvConfig{ | |
| 637 | + | Dir: "", // empty dir should not cause error | |
| 638 | + | Providers: []string{"pass"}, | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | err := validateEnvConfig(&envConfig) | |
| 642 | + | require.NoError(t, err) | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | func TestGetDefaultDataDir_Coverage(t *testing.T) { | |
| 646 | + | // This test mainly exists to ensure coverage of getDefaultDataDir branches | |
| 647 | + | // We can't easily test root vs non-root scenarios, but we can test the function | |
| 648 | + | dataDir := getDefaultDataDir() | |
| 649 | + | assert.NotEmpty(t, dataDir) | |
| 650 | + | }⏎̸ | |
A internal/container/docker.go +681 -0
| @@ -0,0 +1,681 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "gordon/pkg/runtime" | |
| 9 | + | "io" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "github.com/docker/docker/api/types/container" | |
| 14 | + | "github.com/docker/docker/api/types/image" | |
| 15 | + | "github.com/docker/docker/api/types/network" | |
| 16 | + | "github.com/docker/docker/api/types/registry" | |
| 17 | + | "github.com/docker/docker/api/types/volume" | |
| 18 | + | "github.com/docker/docker/client" | |
| 19 | + | "github.com/docker/docker/errdefs" | |
| 20 | + | "github.com/docker/go-connections/nat" | |
| 21 | + | "github.com/rs/zerolog/log" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // DockerRuntime implements the Runtime interface using Docker API | |
| 25 | + | type DockerRuntime struct { | |
| 26 | + | client *client.Client | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewDockerRuntime creates a new Docker runtime instance | |
| 30 | + | func NewDockerRuntime() (*DockerRuntime, error) { | |
| 31 | + | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| 32 | + | if err != nil { | |
| 33 | + | return nil, fmt.Errorf("failed to create Docker client: %w", err) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | return &DockerRuntime{ | |
| 37 | + | client: cli, | |
| 38 | + | }, nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // NewDockerRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing) | |
| 42 | + | func NewDockerRuntimeWithClient(cli *client.Client) *DockerRuntime { | |
| 43 | + | return &DockerRuntime{ | |
| 44 | + | client: cli, | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // CreateContainer creates a new container | |
| 49 | + | func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) { | |
| 50 | + | // Convert ports to Docker format | |
| 51 | + | exposedPorts := make(nat.PortSet) | |
| 52 | + | portBindings := make(nat.PortMap) | |
| 53 | + | ||
| 54 | + | for _, port := range config.Ports { | |
| 55 | + | containerPort := nat.Port(fmt.Sprintf("%d/tcp", port)) | |
| 56 | + | exposedPorts[containerPort] = struct{}{} | |
| 57 | + | ||
| 58 | + | // Bind to random available port on host | |
| 59 | + | portBindings[containerPort] = []nat.PortBinding{ | |
| 60 | + | { | |
| 61 | + | HostIP: "0.0.0.0", | |
| 62 | + | HostPort: "0", // Docker will assign a random available port | |
| 63 | + | }, | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Convert volumes to Docker format | |
| 68 | + | var binds []string | |
| 69 | + | if config.Volumes != nil { | |
| 70 | + | for containerPath, volumeName := range config.Volumes { | |
| 71 | + | bind := fmt.Sprintf("%s:%s", volumeName, containerPath) | |
| 72 | + | binds = append(binds, bind) | |
| 73 | + | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("Adding volume mount") | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Create container configuration | |
| 78 | + | containerConfig := &container.Config{ | |
| 79 | + | Image: config.Image, | |
| 80 | + | Env: config.Env, | |
| 81 | + | ExposedPorts: exposedPorts, | |
| 82 | + | WorkingDir: config.WorkingDir, | |
| 83 | + | Cmd: config.Cmd, | |
| 84 | + | Labels: config.Labels, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | hostConfig := &container.HostConfig{ | |
| 88 | + | PortBindings: portBindings, | |
| 89 | + | AutoRemove: config.AutoRemove, | |
| 90 | + | Binds: binds, | |
| 91 | + | NetworkMode: container.NetworkMode(config.NetworkMode), | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Create network configuration for container | |
| 95 | + | var networkConfig *network.NetworkingConfig | |
| 96 | + | if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" { | |
| 97 | + | networkConfig = &network.NetworkingConfig{ | |
| 98 | + | EndpointsConfig: map[string]*network.EndpointSettings{ | |
| 99 | + | config.NetworkMode: { | |
| 100 | + | Aliases: config.Aliases, | |
| 101 | + | }, | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | if config.Hostname != "" { | |
| 105 | + | networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Create the container | |
| 110 | + | resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name) | |
| 111 | + | if err != nil { | |
| 112 | + | return nil, fmt.Errorf("failed to create container: %w", err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created") | |
| 116 | + | ||
| 117 | + | // Inspect the created container to get full details | |
| 118 | + | return d.InspectContainer(ctx, resp.ID) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // StartContainer starts a container | |
| 122 | + | func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error { | |
| 123 | + | err := d.client.ContainerStart(ctx, containerID, container.StartOptions{}) | |
| 124 | + | if err != nil { | |
| 125 | + | return fmt.Errorf("failed to start container %s: %w", containerID, err) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | log.Info().Str("id", containerID).Msg("Container started") | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // StopContainer stops a container | |
| 133 | + | func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error { | |
| 134 | + | timeout := 30 // 30 seconds timeout | |
| 135 | + | err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) | |
| 136 | + | if err != nil { | |
| 137 | + | return fmt.Errorf("failed to stop container %s: %w", containerID, err) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | log.Info().Str("id", containerID).Msg("Container stopped") | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // RestartContainer restarts a container | |
| 145 | + | func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error { | |
| 146 | + | timeout := 30 // 30 seconds timeout | |
| 147 | + | err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout}) | |
| 148 | + | if err != nil { | |
| 149 | + | return fmt.Errorf("failed to restart container %s: %w", containerID, err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | log.Info().Str("id", containerID).Msg("Container restarted") | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // RemoveContainer removes a container | |
| 157 | + | func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error { | |
| 158 | + | err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force}) | |
| 159 | + | if err != nil { | |
| 160 | + | return fmt.Errorf("failed to remove container %s: %w", containerID, err) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed") | |
| 164 | + | return nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // ListContainers lists containers | |
| 168 | + | func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) { | |
| 169 | + | containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all}) | |
| 170 | + | if err != nil { | |
| 171 | + | return nil, fmt.Errorf("failed to list containers: %w", err) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | var result []*runtime.Container | |
| 175 | + | for _, c := range containers { | |
| 176 | + | // Extract ports | |
| 177 | + | var ports []int | |
| 178 | + | for _, port := range c.Ports { | |
| 179 | + | if port.PublicPort > 0 { | |
| 180 | + | ports = append(ports, int(port.PublicPort)) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // Get the primary name (remove leading slash) | |
| 185 | + | name := "" | |
| 186 | + | if len(c.Names) > 0 { | |
| 187 | + | name = strings.TrimPrefix(c.Names[0], "/") | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | result = append(result, &runtime.Container{ | |
| 191 | + | ID: c.ID, | |
| 192 | + | Image: c.Image, | |
| 193 | + | Name: name, | |
| 194 | + | Status: c.Status, | |
| 195 | + | Ports: ports, | |
| 196 | + | Labels: c.Labels, | |
| 197 | + | }) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | return result, nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // InspectContainer inspects a container | |
| 204 | + | func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) { | |
| 205 | + | resp, err := d.client.ContainerInspect(ctx, containerID) | |
| 206 | + | if err != nil { | |
| 207 | + | return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // Extract published ports | |
| 211 | + | var ports []int | |
| 212 | + | if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { | |
| 213 | + | for _, bindings := range resp.NetworkSettings.Ports { | |
| 214 | + | for _, binding := range bindings { | |
| 215 | + | if binding.HostPort != "" { | |
| 216 | + | if port, err := strconv.Atoi(binding.HostPort); err == nil { | |
| 217 | + | ports = append(ports, port) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // Get container name (remove leading slash) | |
| 225 | + | name := strings.TrimPrefix(resp.Name, "/") | |
| 226 | + | ||
| 227 | + | return &runtime.Container{ | |
| 228 | + | ID: resp.ID, | |
| 229 | + | Image: resp.Config.Image, | |
| 230 | + | Name: name, | |
| 231 | + | Status: resp.State.Status, | |
| 232 | + | Ports: ports, | |
| 233 | + | Labels: resp.Config.Labels, | |
| 234 | + | }, nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // GetContainerLogs gets container logs | |
| 238 | + | func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) { | |
| 239 | + | logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{ | |
| 240 | + | ShowStdout: true, | |
| 241 | + | ShowStderr: true, | |
| 242 | + | Follow: follow, | |
| 243 | + | Timestamps: true, | |
| 244 | + | }) | |
| 245 | + | if err != nil { | |
| 246 | + | return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | return logs, nil | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // PullImage pulls an image | |
| 253 | + | func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | |
| 254 | + | log.Info().Str("image", imageRef).Msg("Pulling image") | |
| 255 | + | ||
| 256 | + | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{}) | |
| 257 | + | if err != nil { | |
| 258 | + | return fmt.Errorf("failed to pull image %s: %w", imageRef, err) | |
| 259 | + | } | |
| 260 | + | defer reader.Close() | |
| 261 | + | ||
| 262 | + | // Read the response to completion (this is required for the pull to complete) | |
| 263 | + | _, err = io.Copy(io.Discard, reader) | |
| 264 | + | if err != nil { | |
| 265 | + | return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | log.Info().Str("image", imageRef).Msg("Image pulled successfully") | |
| 269 | + | return nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // PullImageWithAuth pulls an image with authentication | |
| 273 | + | func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error { | |
| 274 | + | log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication") | |
| 275 | + | ||
| 276 | + | // Create authentication configuration | |
| 277 | + | authConfig := registry.AuthConfig{ | |
| 278 | + | Username: username, | |
| 279 | + | Password: password, | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // Encode authentication to base64 JSON | |
| 283 | + | authConfigBytes, err := json.Marshal(authConfig) | |
| 284 | + | if err != nil { | |
| 285 | + | return fmt.Errorf("failed to marshal auth config: %w", err) | |
| 286 | + | } | |
| 287 | + | authStr := base64.URLEncoding.EncodeToString(authConfigBytes) | |
| 288 | + | ||
| 289 | + | // Pull with authentication | |
| 290 | + | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{ | |
| 291 | + | RegistryAuth: authStr, | |
| 292 | + | }) | |
| 293 | + | if err != nil { | |
| 294 | + | return fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err) | |
| 295 | + | } | |
| 296 | + | defer reader.Close() | |
| 297 | + | ||
| 298 | + | // Read the response to completion (this is required for the pull to complete) | |
| 299 | + | _, err = io.Copy(io.Discard, reader) | |
| 300 | + | if err != nil { | |
| 301 | + | return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication") | |
| 305 | + | return nil | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // RemoveImage removes an image | |
| 309 | + | func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error { | |
| 310 | + | _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force}) | |
| 311 | + | if err != nil { | |
| 312 | + | return fmt.Errorf("failed to remove image %s: %w", imageRef, err) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed") | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // ListImages lists images | |
| 320 | + | func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) { | |
| 321 | + | images, err := d.client.ImageList(ctx, image.ListOptions{}) | |
| 322 | + | if err != nil { | |
| 323 | + | return nil, fmt.Errorf("failed to list images: %w", err) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | var result []string | |
| 327 | + | for _, img := range images { | |
| 328 | + | for _, tag := range img.RepoTags { | |
| 329 | + | if tag != "<none>:<none>" { | |
| 330 | + | result = append(result, tag) | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | return result, nil | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // Ping checks if Docker is responsive | |
| 339 | + | func (d *DockerRuntime) Ping(ctx context.Context) error { | |
| 340 | + | _, err := d.client.Ping(ctx) | |
| 341 | + | if err != nil { | |
| 342 | + | return fmt.Errorf("Docker ping failed: %w", err) | |
| 343 | + | } | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // Version returns Docker version | |
| 348 | + | func (d *DockerRuntime) Version(ctx context.Context) (string, error) { | |
| 349 | + | version, err := d.client.ServerVersion(ctx) | |
| 350 | + | if err != nil { | |
| 351 | + | return "", fmt.Errorf("failed to get Docker version: %w", err) | |
| 352 | + | } | |
| 353 | + | return version.Version, nil | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // IsContainerRunning checks if a container is running | |
| 357 | + | func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) { | |
| 358 | + | container, err := d.InspectContainer(ctx, containerID) | |
| 359 | + | if err != nil { | |
| 360 | + | return false, err | |
| 361 | + | } | |
| 362 | + | return container.Status == "running", nil | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // GetContainerPort gets the host port for a container's internal port | |
| 366 | + | func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | |
| 367 | + | resp, err := d.client.ContainerInspect(ctx, containerID) | |
| 368 | + | if err != nil { | |
| 369 | + | return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil { | |
| 373 | + | return 0, fmt.Errorf("no port mappings found for container %s", containerID) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort)) | |
| 377 | + | bindings, exists := resp.NetworkSettings.Ports[containerPort] | |
| 378 | + | if !exists || len(bindings) == 0 { | |
| 379 | + | return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | hostPort, err := strconv.Atoi(bindings[0].HostPort) | |
| 383 | + | if err != nil { | |
| 384 | + | return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | return hostPort, nil | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // GetImageExposedPorts gets the exposed ports from an image | |
| 391 | + | func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) { | |
| 392 | + | imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef) | |
| 393 | + | if err != nil { | |
| 394 | + | return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | var ports []int | |
| 398 | + | if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil { | |
| 399 | + | for portSpec := range imageInspect.Config.ExposedPorts { | |
| 400 | + | // Parse port from format like "80/tcp" | |
| 401 | + | portStr := strings.Split(string(portSpec), "/")[0] | |
| 402 | + | if port, err := strconv.Atoi(portStr); err == nil { | |
| 403 | + | ports = append(ports, port) | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | if len(ports) == 0 { | |
| 409 | + | return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image") | |
| 413 | + | return ports, nil | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // GetContainerExposedPorts gets all exposed ports from a running container | |
| 417 | + | func (d *DockerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | |
| 418 | + | resp, err := d.client.ContainerInspect(ctx, containerID) | |
| 419 | + | if err != nil { | |
| 420 | + | return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | var ports []int | |
| 424 | + | if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { | |
| 425 | + | for portSpec := range resp.NetworkSettings.Ports { | |
| 426 | + | // Parse port from format like "80/tcp" | |
| 427 | + | portStr := strings.Split(string(portSpec), "/")[0] | |
| 428 | + | if port, err := strconv.Atoi(portStr); err == nil { | |
| 429 | + | ports = append(ports, port) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | return ports, nil | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use | |
| 438 | + | func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) { | |
| 439 | + | resp, err := d.client.ContainerInspect(ctx, containerID) | |
| 440 | + | if err != nil { | |
| 441 | + | return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err) | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | // Get container's internal IP address | |
| 445 | + | var containerIP string | |
| 446 | + | if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil { | |
| 447 | + | // Use the first available network (usually bridge or custom network) | |
| 448 | + | for _, network := range resp.NetworkSettings.Networks { | |
| 449 | + | if network.IPAddress != "" { | |
| 450 | + | containerIP = network.IPAddress | |
| 451 | + | break | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | if containerIP == "" { | |
| 457 | + | return "", 0, fmt.Errorf("no IP address found for container %s", containerID) | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // Get exposed ports from container | |
| 461 | + | var exposedPorts []int | |
| 462 | + | if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { | |
| 463 | + | for portSpec := range resp.NetworkSettings.Ports { | |
| 464 | + | // Parse port from format like "80/tcp" | |
| 465 | + | portStr := strings.Split(string(portSpec), "/")[0] | |
| 466 | + | if port, err := strconv.Atoi(portStr); err == nil { | |
| 467 | + | exposedPorts = append(exposedPorts, port) | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | if len(exposedPorts) == 0 { | |
| 473 | + | return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID) | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // Use the first exposed port | |
| 477 | + | selectedPort := exposedPorts[0] | |
| 478 | + | ||
| 479 | + | log.Info(). | |
| 480 | + | Str("container_id", containerID). | |
| 481 | + | Str("ip", containerIP). | |
| 482 | + | Int("selected_port", selectedPort). | |
| 483 | + | Ints("exposed_ports", exposedPorts). | |
| 484 | + | Msg("Container network info detected") | |
| 485 | + | ||
| 486 | + | return containerIP, selectedPort, nil | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // InspectImageVolumes gets the volume mount points declared in the image | |
| 490 | + | func (d *DockerRuntime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) { | |
| 491 | + | imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef) | |
| 492 | + | if err != nil { | |
| 493 | + | return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err) | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | var volumes []string | |
| 497 | + | if imageInspect.Config != nil && imageInspect.Config.Volumes != nil { | |
| 498 | + | for volumePath := range imageInspect.Config.Volumes { | |
| 499 | + | volumes = append(volumes, volumePath) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | if len(volumes) > 0 { | |
| 504 | + | log.Info().Str("image", imageRef).Strs("volumes", volumes).Msg("Found VOLUME directives in image") | |
| 505 | + | } else { | |
| 506 | + | log.Debug().Str("image", imageRef).Msg("No VOLUME directives found in image") | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | return volumes, nil | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // VolumeExists checks if a Docker volume exists | |
| 513 | + | func (d *DockerRuntime) VolumeExists(ctx context.Context, volumeName string) (bool, error) { | |
| 514 | + | _, err := d.client.VolumeInspect(ctx, volumeName) | |
| 515 | + | if err != nil { | |
| 516 | + | if errdefs.IsNotFound(err) { | |
| 517 | + | return false, nil | |
| 518 | + | } | |
| 519 | + | return false, fmt.Errorf("failed to inspect volume %s: %w", volumeName, err) | |
| 520 | + | } | |
| 521 | + | return true, nil | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | // CreateVolume creates a new Docker volume | |
| 525 | + | func (d *DockerRuntime) CreateVolume(ctx context.Context, volumeName string) error { | |
| 526 | + | _, err := d.client.VolumeCreate(ctx, volume.CreateOptions{ | |
| 527 | + | Name: volumeName, | |
| 528 | + | Labels: map[string]string{ | |
| 529 | + | "gordon.managed": "true", | |
| 530 | + | "gordon.created": "auto", | |
| 531 | + | }, | |
| 532 | + | }) | |
| 533 | + | if err != nil { | |
| 534 | + | return fmt.Errorf("failed to create volume %s: %w", volumeName, err) | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | log.Info().Str("volume", volumeName).Msg("Volume created") | |
| 538 | + | return nil | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | // RemoveVolume removes a Docker volume | |
| 542 | + | func (d *DockerRuntime) RemoveVolume(ctx context.Context, volumeName string, force bool) error { | |
| 543 | + | err := d.client.VolumeRemove(ctx, volumeName, force) | |
| 544 | + | if err != nil { | |
| 545 | + | if errdefs.IsNotFound(err) { | |
| 546 | + | log.Debug().Str("volume", volumeName).Msg("Volume not found, already removed") | |
| 547 | + | return nil | |
| 548 | + | } | |
| 549 | + | return fmt.Errorf("failed to remove volume %s: %w", volumeName, err) | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | log.Info().Str("volume", volumeName).Msg("Volume removed") | |
| 553 | + | return nil | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | // InspectImageEnv gets the environment variables declared in the image | |
| 557 | + | func (d *DockerRuntime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) { | |
| 558 | + | imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef) | |
| 559 | + | if err != nil { | |
| 560 | + | return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err) | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | var envVars []string | |
| 564 | + | if imageInspect.Config != nil && imageInspect.Config.Env != nil { | |
| 565 | + | envVars = imageInspect.Config.Env | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | if len(envVars) > 0 { | |
| 569 | + | log.Debug().Str("image", imageRef).Strs("env_vars", envVars).Msg("Found ENV directives in image") | |
| 570 | + | } else { | |
| 571 | + | log.Debug().Str("image", imageRef).Msg("No ENV directives found in image") | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | return envVars, nil | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | // CreateNetwork creates a new Docker network | |
| 578 | + | func (d *DockerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error { | |
| 579 | + | // Set default driver to bridge if not specified | |
| 580 | + | driver := "bridge" | |
| 581 | + | if driverOption, exists := options["driver"]; exists { | |
| 582 | + | driver = driverOption | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | createOptions := network.CreateOptions{ | |
| 586 | + | Driver: driver, | |
| 587 | + | Labels: map[string]string{ | |
| 588 | + | "gordon.managed": "true", | |
| 589 | + | }, | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | // Add any additional options to labels | |
| 593 | + | for key, value := range options { | |
| 594 | + | if key != "driver" { | |
| 595 | + | createOptions.Labels["gordon."+key] = value | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | _, err := d.client.NetworkCreate(ctx, name, createOptions) | |
| 600 | + | if err != nil { | |
| 601 | + | return fmt.Errorf("failed to create network %s: %w", name, err) | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | log.Info().Str("network", name).Str("driver", driver).Msg("Network created") | |
| 605 | + | return nil | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | // RemoveNetwork removes a Docker network | |
| 609 | + | func (d *DockerRuntime) RemoveNetwork(ctx context.Context, name string) error { | |
| 610 | + | err := d.client.NetworkRemove(ctx, name) | |
| 611 | + | if err != nil { | |
| 612 | + | if errdefs.IsNotFound(err) { | |
| 613 | + | log.Debug().Str("network", name).Msg("Network not found, already removed") | |
| 614 | + | return nil | |
| 615 | + | } | |
| 616 | + | return fmt.Errorf("failed to remove network %s: %w", name, err) | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | log.Info().Str("network", name).Msg("Network removed") | |
| 620 | + | return nil | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // ListNetworks lists all Docker networks | |
| 624 | + | func (d *DockerRuntime) ListNetworks(ctx context.Context) ([]*runtime.NetworkInfo, error) { | |
| 625 | + | networks, err := d.client.NetworkList(ctx, network.ListOptions{}) | |
| 626 | + | if err != nil { | |
| 627 | + | return nil, fmt.Errorf("failed to list networks: %w", err) | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | var result []*runtime.NetworkInfo | |
| 631 | + | for _, net := range networks { | |
| 632 | + | var containers []string | |
| 633 | + | for containerID := range net.Containers { | |
| 634 | + | containers = append(containers, containerID) | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | result = append(result, &runtime.NetworkInfo{ | |
| 638 | + | ID: net.ID, | |
| 639 | + | Name: net.Name, | |
| 640 | + | Driver: net.Driver, | |
| 641 | + | Containers: containers, | |
| 642 | + | Labels: net.Labels, | |
| 643 | + | }) | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | return result, nil | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | // NetworkExists checks if a Docker network exists | |
| 650 | + | func (d *DockerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) { | |
| 651 | + | _, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{}) | |
| 652 | + | if err != nil { | |
| 653 | + | if errdefs.IsNotFound(err) { | |
| 654 | + | return false, nil | |
| 655 | + | } | |
| 656 | + | return false, fmt.Errorf("failed to inspect network %s: %w", name, err) | |
| 657 | + | } | |
| 658 | + | return true, nil | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | // ConnectContainerToNetwork connects a container to a network | |
| 662 | + | func (d *DockerRuntime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error { | |
| 663 | + | err := d.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{}) | |
| 664 | + | if err != nil { | |
| 665 | + | return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err) | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | log.Info().Str("container", containerName).Str("network", networkName).Msg("Container connected to network") | |
| 669 | + | return nil | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | // DisconnectContainerFromNetwork disconnects a container from a network | |
| 673 | + | func (d *DockerRuntime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error { | |
| 674 | + | err := d.client.NetworkDisconnect(ctx, networkName, containerName, false) | |
| 675 | + | if err != nil { | |
| 676 | + | return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err) | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | log.Info().Str("container", containerName).Str("network", networkName).Msg("Container disconnected from network") | |
| 680 | + | return nil | |
| 681 | + | } | |
A internal/container/factory.go +263 -0
| @@ -0,0 +1,263 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/user" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/docker/docker/client" | |
| 11 | + | "github.com/rs/zerolog/log" | |
| 12 | + | ||
| 13 | + | "gordon/internal/config" | |
| 14 | + | "gordon/pkg/runtime" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // RuntimeType represents the container runtime type | |
| 18 | + | type RuntimeType string | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | RuntimeAuto RuntimeType = "auto" | |
| 22 | + | RuntimeDocker RuntimeType = "docker" | |
| 23 | + | RuntimePodman RuntimeType = "podman" | |
| 24 | + | RuntimePodmanRootless RuntimeType = "podman-rootless" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // RuntimeInfo contains information about a detected runtime | |
| 28 | + | type RuntimeInfo struct { | |
| 29 | + | Type RuntimeType | |
| 30 | + | SocketPath string | |
| 31 | + | Version string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // CreateRuntime creates a container runtime based on configuration | |
| 35 | + | func CreateRuntime(ctx context.Context, cfg *config.Config) (runtime.Runtime, error) { | |
| 36 | + | runtimeType := RuntimeType(cfg.Server.Runtime) | |
| 37 | + | ||
| 38 | + | // Get socket path from config or environment | |
| 39 | + | socketPath := getSocketPath(cfg.Server.SocketPath) | |
| 40 | + | ||
| 41 | + | switch runtimeType { | |
| 42 | + | case RuntimeAuto: | |
| 43 | + | return createAutoRuntime(ctx, socketPath) | |
| 44 | + | case RuntimeDocker: | |
| 45 | + | return createDockerRuntime(ctx, socketPath) | |
| 46 | + | case RuntimePodman, RuntimePodmanRootless: | |
| 47 | + | return createPodmanRuntime(ctx, socketPath, runtimeType == RuntimePodmanRootless) | |
| 48 | + | default: | |
| 49 | + | return nil, fmt.Errorf("unsupported runtime: %s", runtimeType) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // getSocketPath determines the socket path to use | |
| 54 | + | func getSocketPath(configPath string) string { | |
| 55 | + | // 1. Use config if provided | |
| 56 | + | if configPath != "" { | |
| 57 | + | log.Debug().Str("path", configPath).Msg("Using socket path from config") | |
| 58 | + | return configPath | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // 2. Check CONTAINER_HOST environment variable | |
| 62 | + | if envPath := os.Getenv("CONTAINER_HOST"); envPath != "" { | |
| 63 | + | log.Debug().Str("path", envPath).Msg("Using socket path from CONTAINER_HOST") | |
| 64 | + | return envPath | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // 3. Return empty string for auto-detection | |
| 68 | + | return "" | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // createAutoRuntime automatically detects and creates the best available runtime | |
| 72 | + | func createAutoRuntime(ctx context.Context, customSocketPath string) (runtime.Runtime, error) { | |
| 73 | + | var candidatePaths []string | |
| 74 | + | ||
| 75 | + | if customSocketPath != "" { | |
| 76 | + | // If custom path provided, try it first | |
| 77 | + | candidatePaths = []string{customSocketPath} | |
| 78 | + | } else { | |
| 79 | + | // Auto-detect socket paths | |
| 80 | + | candidatePaths = getDefaultSocketPaths() | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Try each socket path | |
| 84 | + | for _, socketPath := range candidatePaths { | |
| 85 | + | if rt, info, err := tryCreateRuntime(ctx, socketPath); err == nil { | |
| 86 | + | log.Info(). | |
| 87 | + | Str("runtime", string(info.Type)). | |
| 88 | + | Str("socket", info.SocketPath). | |
| 89 | + | Str("version", info.Version). | |
| 90 | + | Msg("Container runtime auto-detected") | |
| 91 | + | return rt, nil | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | return nil, fmt.Errorf("no container runtime found. Tried paths: %v", candidatePaths) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // createDockerRuntime creates a Docker runtime with optional custom socket | |
| 99 | + | func createDockerRuntime(ctx context.Context, socketPath string) (runtime.Runtime, error) { | |
| 100 | + | var opts []client.Opt | |
| 101 | + | opts = append(opts, client.FromEnv, client.WithAPIVersionNegotiation()) | |
| 102 | + | ||
| 103 | + | if socketPath != "" { | |
| 104 | + | opts = append(opts, client.WithHost(socketPath)) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | cli, err := client.NewClientWithOpts(opts...) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, fmt.Errorf("failed to create Docker client: %w", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Test connectivity | |
| 113 | + | if _, err := cli.Ping(ctx); err != nil { | |
| 114 | + | return nil, fmt.Errorf("Docker ping failed: %w", err) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | return &DockerRuntime{client: cli}, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // createPodmanRuntime creates a Podman runtime with optional custom socket | |
| 121 | + | func createPodmanRuntime(ctx context.Context, socketPath string, rootless bool) (runtime.Runtime, error) { | |
| 122 | + | if socketPath == "" { | |
| 123 | + | socketPath = getDefaultPodmanSocket(rootless) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | return createPodmanRuntimeWithSocket(ctx, socketPath) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // tryCreateRuntime attempts to create a runtime for a given socket path | |
| 130 | + | func tryCreateRuntime(ctx context.Context, socketPath string) (runtime.Runtime, *RuntimeInfo, error) { | |
| 131 | + | // Try to create client | |
| 132 | + | opts := []client.Opt{ | |
| 133 | + | client.WithHost(socketPath), | |
| 134 | + | client.WithAPIVersionNegotiation(), | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | cli, err := client.NewClientWithOpts(opts...) | |
| 138 | + | if err != nil { | |
| 139 | + | return nil, nil, fmt.Errorf("failed to create client: %w", err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Test connectivity and get version | |
| 143 | + | ping, err := cli.Ping(ctx) | |
| 144 | + | if err != nil { | |
| 145 | + | return nil, nil, fmt.Errorf("ping failed: %w", err) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Determine runtime type based on socket path and server info | |
| 149 | + | runtimeType := detectRuntimeType(socketPath) | |
| 150 | + | ||
| 151 | + | // Since Podman uses Docker-compatible API, we can use DockerRuntime for both | |
| 152 | + | rt := &DockerRuntime{client: cli} | |
| 153 | + | ||
| 154 | + | info := &RuntimeInfo{ | |
| 155 | + | Type: runtimeType, | |
| 156 | + | SocketPath: socketPath, | |
| 157 | + | Version: ping.APIVersion, | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | return rt, info, nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // detectRuntimeType determines if the socket is Docker or Podman | |
| 164 | + | func detectRuntimeType(socketPath string) RuntimeType { | |
| 165 | + | // Check socket path patterns | |
| 166 | + | if strings.Contains(socketPath, "podman") { | |
| 167 | + | if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") { | |
| 168 | + | return RuntimePodmanRootless | |
| 169 | + | } | |
| 170 | + | return RuntimePodman | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | if strings.Contains(socketPath, "docker") { | |
| 174 | + | return RuntimeDocker | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Default socket paths | |
| 178 | + | switch socketPath { | |
| 179 | + | case "/var/run/docker.sock", "unix:///var/run/docker.sock": | |
| 180 | + | return RuntimeDocker | |
| 181 | + | case "/run/podman/podman.sock", "unix:///run/podman/podman.sock": | |
| 182 | + | return RuntimePodman | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Check if it's in user runtime directory (rootless) | |
| 186 | + | if strings.Contains(socketPath, "/run/user/") { | |
| 187 | + | return RuntimePodmanRootless | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Default to Docker for unknown paths | |
| 191 | + | return RuntimeDocker | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // getDefaultSocketPaths returns the default socket paths to try | |
| 195 | + | func getDefaultSocketPaths() []string { | |
| 196 | + | paths := []string{ | |
| 197 | + | "unix:///var/run/docker.sock", // Docker | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Add Podman rootless socket first (preferred) | |
| 201 | + | if rootlessSocket := getDefaultPodmanSocket(true); rootlessSocket != "" { | |
| 202 | + | paths = append(paths, rootlessSocket) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // Add Podman root socket last | |
| 206 | + | paths = append(paths, "unix:///run/podman/podman.sock") | |
| 207 | + | ||
| 208 | + | return paths | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // getDefaultPodmanSocket returns the default Podman socket path | |
| 212 | + | func getDefaultPodmanSocket(rootless bool) string { | |
| 213 | + | if !rootless { | |
| 214 | + | return "unix:///run/podman/podman.sock" | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Get user runtime directory for rootless Podman | |
| 218 | + | if xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR"); xdgRuntimeDir != "" { | |
| 219 | + | return fmt.Sprintf("unix://%s/podman/podman.sock", xdgRuntimeDir) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Fallback: construct from user ID | |
| 223 | + | if currentUser, err := user.Current(); err == nil { | |
| 224 | + | return fmt.Sprintf("unix:///run/user/%s/podman/podman.sock", currentUser.Uid) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | log.Warn().Msg("Could not determine rootless Podman socket path") | |
| 228 | + | return "" | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // createPodmanRuntimeWithSocket creates a Podman runtime for a specific socket | |
| 232 | + | func createPodmanRuntimeWithSocket(ctx context.Context, socketPath string) (runtime.Runtime, error) { | |
| 233 | + | opts := []client.Opt{ | |
| 234 | + | client.WithHost(socketPath), | |
| 235 | + | client.WithAPIVersionNegotiation(), | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | cli, err := client.NewClientWithOpts(opts...) | |
| 239 | + | if err != nil { | |
| 240 | + | return nil, fmt.Errorf("failed to create Podman client: %w", err) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Test connectivity | |
| 244 | + | if _, err := cli.Ping(ctx); err != nil { | |
| 245 | + | return nil, fmt.Errorf("Podman ping failed: %w", err) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | return &DockerRuntime{client: cli}, nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // GetAvailableRuntimes returns information about all available runtimes | |
| 252 | + | func GetAvailableRuntimes() []RuntimeInfo { | |
| 253 | + | var runtimes []RuntimeInfo | |
| 254 | + | ctx := context.Background() | |
| 255 | + | ||
| 256 | + | for _, socketPath := range getDefaultSocketPaths() { | |
| 257 | + | if _, info, err := tryCreateRuntime(ctx, socketPath); err == nil { | |
| 258 | + | runtimes = append(runtimes, *info) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | return runtimes | |
| 263 | + | } | |
A internal/container/factory_test.go +337 -0
| @@ -0,0 +1,337 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "gordon/internal/config" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestCreateRuntime(t *testing.T) { | |
| 17 | + | ctx := context.Background() | |
| 18 | + | ||
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | config *config.Config | |
| 22 | + | expectError bool | |
| 23 | + | expectedError string | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "docker runtime", | |
| 27 | + | config: &config.Config{ | |
| 28 | + | Server: config.ServerConfig{ | |
| 29 | + | Runtime: "docker", | |
| 30 | + | SocketPath: "/var/run/docker.sock", | |
| 31 | + | }, | |
| 32 | + | }, | |
| 33 | + | expectError: true, // Will fail because Docker isn't running in test | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "podman runtime", | |
| 37 | + | config: &config.Config{ | |
| 38 | + | Server: config.ServerConfig{ | |
| 39 | + | Runtime: "podman", | |
| 40 | + | SocketPath: "/run/user/1000/podman/podman.sock", | |
| 41 | + | }, | |
| 42 | + | }, | |
| 43 | + | expectError: true, // Will fail because Podman isn't running in test | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "auto runtime", | |
| 47 | + | config: &config.Config{ | |
| 48 | + | Server: config.ServerConfig{ | |
| 49 | + | Runtime: "auto", | |
| 50 | + | }, | |
| 51 | + | }, | |
| 52 | + | expectError: false, // May succeed if runtime is available | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "invalid runtime", | |
| 56 | + | config: &config.Config{ | |
| 57 | + | Server: config.ServerConfig{ | |
| 58 | + | Runtime: "invalid", | |
| 59 | + | }, | |
| 60 | + | }, | |
| 61 | + | expectError: true, | |
| 62 | + | expectedError: "unsupported runtime", | |
| 63 | + | }, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | runtime, err := CreateRuntime(ctx, tt.config) | |
| 69 | + | ||
| 70 | + | if tt.name == "auto runtime" { | |
| 71 | + | // Auto runtime may succeed or fail depending on environment | |
| 72 | + | if err != nil { | |
| 73 | + | assert.Nil(t, runtime) | |
| 74 | + | } else { | |
| 75 | + | assert.NotNil(t, runtime) | |
| 76 | + | } | |
| 77 | + | } else if tt.expectError { | |
| 78 | + | assert.Error(t, err) | |
| 79 | + | if tt.expectedError != "" { | |
| 80 | + | assert.Contains(t, err.Error(), tt.expectedError) | |
| 81 | + | } | |
| 82 | + | assert.Nil(t, runtime) | |
| 83 | + | } else { | |
| 84 | + | assert.NoError(t, err) | |
| 85 | + | assert.NotNil(t, runtime) | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestGetSocketPath(t *testing.T) { | |
| 92 | + | tests := []struct { | |
| 93 | + | name string | |
| 94 | + | configPath string | |
| 95 | + | setupEnv func() | |
| 96 | + | cleanupEnv func() | |
| 97 | + | expected string | |
| 98 | + | }{ | |
| 99 | + | { | |
| 100 | + | name: "with provided config path", | |
| 101 | + | configPath: "/custom/docker.sock", | |
| 102 | + | setupEnv: func() {}, | |
| 103 | + | cleanupEnv: func() {}, | |
| 104 | + | expected: "/custom/docker.sock", | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "with CONTAINER_HOST env var", | |
| 108 | + | configPath: "", | |
| 109 | + | setupEnv: func() { | |
| 110 | + | os.Setenv("CONTAINER_HOST", "/env/container.sock") | |
| 111 | + | }, | |
| 112 | + | cleanupEnv: func() { | |
| 113 | + | os.Unsetenv("CONTAINER_HOST") | |
| 114 | + | }, | |
| 115 | + | expected: "/env/container.sock", | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "no config or env - returns empty", | |
| 119 | + | configPath: "", | |
| 120 | + | setupEnv: func() { | |
| 121 | + | os.Unsetenv("CONTAINER_HOST") | |
| 122 | + | }, | |
| 123 | + | cleanupEnv: func() {}, | |
| 124 | + | expected: "", | |
| 125 | + | }, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | tt.setupEnv() | |
| 131 | + | defer tt.cleanupEnv() | |
| 132 | + | ||
| 133 | + | result := getSocketPath(tt.configPath) | |
| 134 | + | assert.Equal(t, tt.expected, result) | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestCreateAutoRuntime(t *testing.T) { | |
| 140 | + | ctx := context.Background() | |
| 141 | + | ||
| 142 | + | // Test that createAutoRuntime tries different runtimes | |
| 143 | + | runtime, err := createAutoRuntime(ctx, "") | |
| 144 | + | // In test environment, this may succeed or fail depending on available runtimes | |
| 145 | + | if err != nil { | |
| 146 | + | // If it fails, should be because no runtime is available | |
| 147 | + | assert.Nil(t, runtime) | |
| 148 | + | assert.Contains(t, err.Error(), "no container runtime available") | |
| 149 | + | } else { | |
| 150 | + | // If it succeeds, should return a runtime | |
| 151 | + | assert.NotNil(t, runtime) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestCreateDockerRuntime(t *testing.T) { | |
| 156 | + | ctx := context.Background() | |
| 157 | + | ||
| 158 | + | tests := []struct { | |
| 159 | + | name string | |
| 160 | + | socketPath string | |
| 161 | + | expectError bool | |
| 162 | + | }{ | |
| 163 | + | { | |
| 164 | + | name: "default docker socket", | |
| 165 | + | socketPath: "/var/run/docker.sock", | |
| 166 | + | expectError: true, // Will fail because Docker isn't running | |
| 167 | + | }, | |
| 168 | + | { | |
| 169 | + | name: "custom socket path", | |
| 170 | + | socketPath: "/custom/docker.sock", | |
| 171 | + | expectError: true, // Will fail because socket doesn't exist | |
| 172 | + | }, | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | for _, tt := range tests { | |
| 176 | + | t.Run(tt.name, func(t *testing.T) { | |
| 177 | + | runtime, err := createDockerRuntime(ctx, tt.socketPath) | |
| 178 | + | ||
| 179 | + | if tt.expectError { | |
| 180 | + | assert.Error(t, err) | |
| 181 | + | assert.Nil(t, runtime) | |
| 182 | + | } else { | |
| 183 | + | assert.NoError(t, err) | |
| 184 | + | assert.NotNil(t, runtime) | |
| 185 | + | } | |
| 186 | + | }) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestCreatePodmanRuntime(t *testing.T) { | |
| 191 | + | ctx := context.Background() | |
| 192 | + | ||
| 193 | + | tests := []struct { | |
| 194 | + | name string | |
| 195 | + | socketPath string | |
| 196 | + | rootless bool | |
| 197 | + | expectError bool | |
| 198 | + | }{ | |
| 199 | + | { | |
| 200 | + | name: "custom podman socket", | |
| 201 | + | socketPath: "/custom/podman.sock", | |
| 202 | + | rootless: false, | |
| 203 | + | expectError: true, // Will fail because socket doesn't exist | |
| 204 | + | }, | |
| 205 | + | { | |
| 206 | + | name: "rootless podman socket", | |
| 207 | + | socketPath: "/custom/podman.sock", | |
| 208 | + | rootless: true, | |
| 209 | + | expectError: true, // Will fail because socket doesn't exist | |
| 210 | + | }, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | for _, tt := range tests { | |
| 214 | + | t.Run(tt.name, func(t *testing.T) { | |
| 215 | + | runtime, err := createPodmanRuntime(ctx, tt.socketPath, tt.rootless) | |
| 216 | + | ||
| 217 | + | if tt.expectError { | |
| 218 | + | assert.Error(t, err) | |
| 219 | + | assert.Nil(t, runtime) | |
| 220 | + | } else { | |
| 221 | + | assert.NoError(t, err) | |
| 222 | + | assert.NotNil(t, runtime) | |
| 223 | + | } | |
| 224 | + | }) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestDetectRuntimeType(t *testing.T) { | |
| 229 | + | tests := []struct { | |
| 230 | + | name string | |
| 231 | + | socketPath string | |
| 232 | + | expected RuntimeType | |
| 233 | + | }{ | |
| 234 | + | { | |
| 235 | + | name: "docker socket", | |
| 236 | + | socketPath: "/var/run/docker.sock", | |
| 237 | + | expected: RuntimeDocker, | |
| 238 | + | }, | |
| 239 | + | { | |
| 240 | + | name: "docker unix socket", | |
| 241 | + | socketPath: "unix:///var/run/docker.sock", | |
| 242 | + | expected: RuntimeDocker, | |
| 243 | + | }, | |
| 244 | + | { | |
| 245 | + | name: "podman socket", | |
| 246 | + | socketPath: "/run/podman/podman.sock", | |
| 247 | + | expected: RuntimePodman, | |
| 248 | + | }, | |
| 249 | + | { | |
| 250 | + | name: "rootless podman socket", | |
| 251 | + | socketPath: "/run/user/1000/podman/podman.sock", | |
| 252 | + | expected: RuntimePodmanRootless, | |
| 253 | + | }, | |
| 254 | + | { | |
| 255 | + | name: "unknown socket defaults to docker", | |
| 256 | + | socketPath: "/unknown/socket.sock", | |
| 257 | + | expected: RuntimeDocker, | |
| 258 | + | }, | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | for _, tt := range tests { | |
| 262 | + | t.Run(tt.name, func(t *testing.T) { | |
| 263 | + | result := detectRuntimeType(tt.socketPath) | |
| 264 | + | assert.Equal(t, tt.expected, result) | |
| 265 | + | }) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestGetDefaultSocketPaths(t *testing.T) { | |
| 270 | + | paths := getDefaultSocketPaths() | |
| 271 | + | ||
| 272 | + | // Should return common socket paths | |
| 273 | + | assert.True(t, len(paths) > 0) | |
| 274 | + | ||
| 275 | + | // Should contain at least one Docker socket path | |
| 276 | + | found := false | |
| 277 | + | for _, path := range paths { | |
| 278 | + | if strings.Contains(path, "docker") { | |
| 279 | + | found = true | |
| 280 | + | break | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | assert.True(t, found, "Should contain a Docker socket path") | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestGetDefaultPodmanSocket(t *testing.T) { | |
| 287 | + | socket := getDefaultPodmanSocket(false) // Regular mode | |
| 288 | + | ||
| 289 | + | // Should return a valid path | |
| 290 | + | assert.NotEmpty(t, socket) | |
| 291 | + | ||
| 292 | + | // Should contain "podman" in the path | |
| 293 | + | assert.Contains(t, socket, "podman") | |
| 294 | + | ||
| 295 | + | // Test rootless mode | |
| 296 | + | rootlessSocket := getDefaultPodmanSocket(true) | |
| 297 | + | assert.NotEmpty(t, rootlessSocket) | |
| 298 | + | assert.Contains(t, rootlessSocket, "podman") | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestGetAvailableRuntimes(t *testing.T) { | |
| 302 | + | runtimes := GetAvailableRuntimes() | |
| 303 | + | ||
| 304 | + | // Should return a slice of runtime info | |
| 305 | + | assert.IsType(t, []RuntimeInfo{}, runtimes) | |
| 306 | + | ||
| 307 | + | // Should not be empty | |
| 308 | + | assert.True(t, len(runtimes) > 0) | |
| 309 | + | ||
| 310 | + | // Each runtime should have basic info | |
| 311 | + | for _, rt := range runtimes { | |
| 312 | + | assert.NotEmpty(t, rt.Type) | |
| 313 | + | assert.NotEmpty(t, rt.SocketPath) | |
| 314 | + | // Version may be empty if runtime not actually available | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // Test socket existence checking (private function behavior via public interface) | |
| 319 | + | func TestSocketExistenceChecking(t *testing.T) { | |
| 320 | + | ctx := context.Background() | |
| 321 | + | ||
| 322 | + | // Create a temporary socket file | |
| 323 | + | tmpDir := t.TempDir() | |
| 324 | + | socketPath := filepath.Join(tmpDir, "test.sock") | |
| 325 | + | ||
| 326 | + | // Create an empty file to simulate a socket | |
| 327 | + | file, err := os.Create(socketPath) | |
| 328 | + | require.NoError(t, err) | |
| 329 | + | file.Close() | |
| 330 | + | ||
| 331 | + | // Test with existing file (won't be a real socket, but tests file existence) | |
| 332 | + | _, err = createDockerRuntime(ctx, socketPath) | |
| 333 | + | // This will still fail because it's not a real Docker socket, but for a different reason | |
| 334 | + | assert.Error(t, err) | |
| 335 | + | // Error should not be about file not existing | |
| 336 | + | assert.NotContains(t, err.Error(), "no such file") | |
| 337 | + | }⏎̸ | |
A internal/container/helpers.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "fmt" | |
| 6 | + | "regexp" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // generateVolumeName creates a deterministic volume name for a domain and path | |
| 11 | + | func generateVolumeName(prefix, domain, path string) string { | |
| 12 | + | // Sanitize domain name for use in volume name | |
| 13 | + | sanitizedDomain := sanitizeDomainForVolume(domain) | |
| 14 | + | ||
| 15 | + | // Create a short hash of the path to handle long paths and special characters | |
| 16 | + | hasher := sha256.New() | |
| 17 | + | hasher.Write([]byte(path)) | |
| 18 | + | pathHash := fmt.Sprintf("%x", hasher.Sum(nil))[:8] | |
| 19 | + | ||
| 20 | + | return fmt.Sprintf("%s-%s-%s", prefix, sanitizedDomain, pathHash) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // sanitizeDomainForVolume converts domain to volume-safe format | |
| 24 | + | func sanitizeDomainForVolume(domain string) string { | |
| 25 | + | // Replace dots and other special characters with hyphens | |
| 26 | + | reg := regexp.MustCompile(`[^a-zA-Z0-9\-]`) | |
| 27 | + | sanitized := reg.ReplaceAllString(domain, "-") | |
| 28 | + | ||
| 29 | + | // Remove leading/trailing hyphens and convert to lowercase | |
| 30 | + | sanitized = strings.Trim(sanitized, "-") | |
| 31 | + | sanitized = strings.ToLower(sanitized) | |
| 32 | + | ||
| 33 | + | return sanitized | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // normalizeImageRef normalizes image references for comparison | |
| 37 | + | func normalizeImageRef(imageRef string) string { | |
| 38 | + | // Split image and tag | |
| 39 | + | parts := strings.Split(imageRef, ":") | |
| 40 | + | image := parts[0] | |
| 41 | + | tag := "latest" | |
| 42 | + | if len(parts) > 1 { | |
| 43 | + | tag = parts[1] | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Normalize Docker Hub references | |
| 47 | + | if !strings.Contains(image, "/") { | |
| 48 | + | // Official library image (e.g., "nginx" -> "docker.io/library/nginx") | |
| 49 | + | image = "docker.io/library/" + image | |
| 50 | + | } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") { | |
| 51 | + | // User repository (e.g., "user/repo" -> "docker.io/user/repo") | |
| 52 | + | image = "docker.io/" + image | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | return image + ":" + tag | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // mergeEnvironmentVariables merges Dockerfile ENV directives with user-provided env vars | |
| 59 | + | // User-provided env vars take precedence over Dockerfile ENV directives | |
| 60 | + | func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |
| 61 | + | // Create a map to store environment variables by key | |
| 62 | + | envMap := make(map[string]string) | |
| 63 | + | ||
| 64 | + | // First, add Dockerfile ENV directives | |
| 65 | + | for _, env := range dockerfileEnv { | |
| 66 | + | parts := strings.SplitN(env, "=", 2) | |
| 67 | + | if len(parts) == 2 { | |
| 68 | + | envMap[parts[0]] = parts[1] | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Then, add user-provided env vars (these override Dockerfile ENV) | |
| 73 | + | for _, env := range userEnv { | |
| 74 | + | parts := strings.SplitN(env, "=", 2) | |
| 75 | + | if len(parts) == 2 { | |
| 76 | + | envMap[parts[0]] = parts[1] | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Convert back to slice format | |
| 81 | + | var result []string | |
| 82 | + | for key, value := range envMap { | |
| 83 | + | result = append(result, fmt.Sprintf("%s=%s", key, value)) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | return result | |
| 87 | + | } | |
A internal/container/helpers_test.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestNormalizeImageRef(t *testing.T) { | |
| 11 | + | testCases := []struct { | |
| 12 | + | name string | |
| 13 | + | input string | |
| 14 | + | expected string | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "simple image with tag", | |
| 18 | + | input: "nginx:latest", | |
| 19 | + | expected: "docker.io/library/nginx:latest", | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | name: "image without tag", | |
| 23 | + | input: "nginx", | |
| 24 | + | expected: "docker.io/library/nginx:latest", | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "registry with image and tag", | |
| 28 | + | input: "registry.example.com/nginx:v1.0", | |
| 29 | + | expected: "registry.example.com/nginx:v1.0", | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "dockerhub image with user", | |
| 33 | + | input: "user/repo:tag", | |
| 34 | + | expected: "docker.io/user/repo:tag", | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "official library image without tag", | |
| 38 | + | input: "postgres", | |
| 39 | + | expected: "docker.io/library/postgres:latest", | |
| 40 | + | }, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | for _, tc := range testCases { | |
| 44 | + | t.Run(tc.name, func(t *testing.T) { | |
| 45 | + | result := normalizeImageRef(tc.input) | |
| 46 | + | assert.Equal(t, tc.expected, result) | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestGenerateVolumeName(t *testing.T) { | |
| 52 | + | testCases := []struct { | |
| 53 | + | name string | |
| 54 | + | prefix string | |
| 55 | + | domain string | |
| 56 | + | path string | |
| 57 | + | }{ | |
| 58 | + | { | |
| 59 | + | name: "basic volume name", | |
| 60 | + | prefix: "gordon", | |
| 61 | + | domain: "app.example.com", | |
| 62 | + | path: "/data", | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "complex path", | |
| 66 | + | prefix: "test", | |
| 67 | + | domain: "my-app.domain.com", | |
| 68 | + | path: "/var/www/html/uploads", | |
| 69 | + | }, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | for _, tc := range testCases { | |
| 73 | + | t.Run(tc.name, func(t *testing.T) { | |
| 74 | + | result := generateVolumeName(tc.prefix, tc.domain, tc.path) | |
| 75 | + | ||
| 76 | + | // Check that it starts with prefix | |
| 77 | + | assert.True(t, strings.HasPrefix(result, tc.prefix+"-")) | |
| 78 | + | ||
| 79 | + | // Check that it contains sanitized domain | |
| 80 | + | sanitizedDomain := strings.ReplaceAll(strings.ToLower(tc.domain), ".", "-") | |
| 81 | + | assert.Contains(t, result, sanitizedDomain) | |
| 82 | + | ||
| 83 | + | // Volume names should be consistent for same inputs | |
| 84 | + | result2 := generateVolumeName(tc.prefix, tc.domain, tc.path) | |
| 85 | + | assert.Equal(t, result, result2) | |
| 86 | + | ||
| 87 | + | // Volume names should be different for different paths | |
| 88 | + | result3 := generateVolumeName(tc.prefix, tc.domain, tc.path+"/different") | |
| 89 | + | assert.NotEqual(t, result, result3) | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestMergeEnvironmentVariables(t *testing.T) { | |
| 95 | + | testCases := []struct { | |
| 96 | + | name string | |
| 97 | + | dockerfileEnv []string | |
| 98 | + | userEnv []string | |
| 99 | + | expected []string | |
| 100 | + | }{ | |
| 101 | + | { | |
| 102 | + | name: "user env overrides dockerfile env", | |
| 103 | + | dockerfileEnv: []string{"NODE_ENV=development", "PORT=3000"}, | |
| 104 | + | userEnv: []string{"NODE_ENV=production", "API_KEY=secret"}, | |
| 105 | + | expected: []string{"NODE_ENV=production", "PORT=3000", "API_KEY=secret"}, | |
| 106 | + | }, | |
| 107 | + | { | |
| 108 | + | name: "only dockerfile env", | |
| 109 | + | dockerfileEnv: []string{"NODE_ENV=development", "PORT=3000"}, | |
| 110 | + | userEnv: []string{}, | |
| 111 | + | expected: []string{"NODE_ENV=development", "PORT=3000"}, | |
| 112 | + | }, | |
| 113 | + | { | |
| 114 | + | name: "only user env", | |
| 115 | + | dockerfileEnv: []string{}, | |
| 116 | + | userEnv: []string{"API_KEY=secret", "DEBUG=true"}, | |
| 117 | + | expected: []string{"API_KEY=secret", "DEBUG=true"}, | |
| 118 | + | }, | |
| 119 | + | { | |
| 120 | + | name: "empty environment", | |
| 121 | + | dockerfileEnv: []string{}, | |
| 122 | + | userEnv: []string{}, | |
| 123 | + | expected: []string{}, | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | for _, tc := range testCases { | |
| 128 | + | t.Run(tc.name, func(t *testing.T) { | |
| 129 | + | result := mergeEnvironmentVariables(tc.dockerfileEnv, tc.userEnv) | |
| 130 | + | assert.Equal(t, len(tc.expected), len(result)) | |
| 131 | + | ||
| 132 | + | // Convert to map for easier comparison | |
| 133 | + | resultMap := make(map[string]string) | |
| 134 | + | for _, env := range result { | |
| 135 | + | parts := splitEnvVar(env) | |
| 136 | + | if len(parts) == 2 { | |
| 137 | + | resultMap[parts[0]] = parts[1] | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | expectedMap := make(map[string]string) | |
| 142 | + | for _, env := range tc.expected { | |
| 143 | + | parts := splitEnvVar(env) | |
| 144 | + | if len(parts) == 2 { | |
| 145 | + | expectedMap[parts[0]] = parts[1] | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | assert.Equal(t, expectedMap, resultMap) | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Helper function to split environment variable string | |
| 155 | + | func splitEnvVar(env string) []string { | |
| 156 | + | for i, c := range env { | |
| 157 | + | if c == '=' { | |
| 158 | + | return []string{env[:i], env[i+1:]} | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return []string{env} | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestSanitizeDomainForVolume(t *testing.T) { | |
| 165 | + | testCases := []struct { | |
| 166 | + | name string | |
| 167 | + | input string | |
| 168 | + | expected string | |
| 169 | + | }{ | |
| 170 | + | { | |
| 171 | + | name: "basic domain", | |
| 172 | + | input: "app.example.com", | |
| 173 | + | expected: "app-example-com", | |
| 174 | + | }, | |
| 175 | + | { | |
| 176 | + | name: "domain with port", | |
| 177 | + | input: "localhost:3000", | |
| 178 | + | expected: "localhost-3000", | |
| 179 | + | }, | |
| 180 | + | { | |
| 181 | + | name: "domain with special chars", | |
| 182 | + | input: "my_app@example.com", | |
| 183 | + | expected: "my-app-example-com", | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | name: "uppercase domain", | |
| 187 | + | input: "API.EXAMPLE.COM", | |
| 188 | + | expected: "api-example-com", | |
| 189 | + | }, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | for _, tc := range testCases { | |
| 193 | + | t.Run(tc.name, func(t *testing.T) { | |
| 194 | + | result := sanitizeDomainForVolume(tc.input) | |
| 195 | + | assert.Equal(t, tc.expected, result) | |
| 196 | + | }) | |
| 197 | + | } | |
| 198 | + | }⏎̸ | |
A internal/container/interfaces.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "gordon/internal/config" | |
| 7 | + | "gordon/pkg/runtime" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ManagerInterface defines the interface for container management operations | |
| 11 | + | // This interface is used to enable testing with mocks | |
| 12 | + | type ManagerInterface interface { | |
| 13 | + | DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) | |
| 14 | + | StopContainer(ctx context.Context, containerID string) error | |
| 15 | + | RemoveContainer(ctx context.Context, containerID string, force bool) error | |
| 16 | + | ListContainers() map[string]*runtime.Container | |
| 17 | + | GetContainer(domain string) (*runtime.Container, bool) | |
| 18 | + | StopContainerByDomain(ctx context.Context, domain string) error | |
| 19 | + | Runtime() runtime.Runtime | |
| 20 | + | HealthCheck(ctx context.Context) map[string]bool | |
| 21 | + | SyncContainers(ctx context.Context) error | |
| 22 | + | GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) | |
| 23 | + | GetNetworkForApp(domain string) string | |
| 24 | + | AutoStartContainers(ctx context.Context) error | |
| 25 | + | StopAllManagedContainers(ctx context.Context) error | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Ensure Manager implements the interface | |
| 29 | + | var _ ManagerInterface = (*Manager)(nil)⏎̸ | |
A internal/container/manager.go +1106 -0
| @@ -0,0 +1,1106 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "github.com/rs/zerolog/log" | |
| 10 | + | ||
| 11 | + | "gordon/internal/config" | |
| 12 | + | "gordon/internal/env" | |
| 13 | + | "gordon/internal/logging" | |
| 14 | + | "gordon/pkg/runtime" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Manager handles container lifecycle and management | |
| 18 | + | type Manager struct { | |
| 19 | + | runtime runtime.Runtime | |
| 20 | + | config *config.Config | |
| 21 | + | containers map[string]*runtime.Container // map[domain] -> container | |
| 22 | + | envLoader *env.Loader | |
| 23 | + | logManager *logging.LogManager | |
| 24 | + | mu sync.RWMutex | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // NewManager creates a new container manager | |
| 28 | + | func NewManager(cfg *config.Config) (*Manager, error) { | |
| 29 | + | // Test runtime connectivity with background context | |
| 30 | + | ctx := context.Background() | |
| 31 | + | ||
| 32 | + | // Create runtime using the factory | |
| 33 | + | rt, err := CreateRuntime(ctx, cfg) | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, fmt.Errorf("failed to create container runtime: %w", err) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Test runtime connectivity | |
| 39 | + | if err := rt.Ping(ctx); err != nil { | |
| 40 | + | return nil, fmt.Errorf("runtime not available: %w", err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | version, err := rt.Version(ctx) | |
| 44 | + | if err != nil { | |
| 45 | + | log.Warn().Err(err).Msg("Could not get runtime version") | |
| 46 | + | } else { | |
| 47 | + | log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Create environment loader | |
| 51 | + | envLoader := env.NewLoader(cfg) | |
| 52 | + | ||
| 53 | + | // Register secret providers based on config | |
| 54 | + | for _, providerName := range cfg.Env.Providers { | |
| 55 | + | switch providerName { | |
| 56 | + | case "pass": | |
| 57 | + | envLoader.RegisterSecretProvider("pass", env.NewPassProvider()) | |
| 58 | + | case "sops": | |
| 59 | + | envLoader.RegisterSecretProvider("sops", env.NewSopsProvider()) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Ensure env directory exists | |
| 64 | + | if err := envLoader.EnsureEnvDir(); err != nil { | |
| 65 | + | log.Warn().Err(err).Msg("Failed to ensure env directory exists") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Create empty env files for all configured routes (if they don't exist) | |
| 69 | + | if err := envLoader.CreateEnvFilesForRoutes(); err != nil { | |
| 70 | + | log.Warn().Err(err).Msg("Failed to create env files for routes") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Create log manager | |
| 74 | + | logManager := logging.NewLogManager(cfg, rt) | |
| 75 | + | ||
| 76 | + | return &Manager{ | |
| 77 | + | runtime: rt, | |
| 78 | + | config: cfg, | |
| 79 | + | containers: make(map[string]*runtime.Container), | |
| 80 | + | envLoader: envLoader, | |
| 81 | + | logManager: logManager, | |
| 82 | + | }, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // DeployContainer deploys a container for a specific route | |
| 86 | + | func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) { | |
| 87 | + | m.mu.Lock() | |
| 88 | + | defer m.mu.Unlock() | |
| 89 | + | ||
| 90 | + | // Check if container already exists for this domain | |
| 91 | + | if existing, exists := m.containers[route.Domain]; exists { | |
| 92 | + | log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting") | |
| 93 | + | ||
| 94 | + | // Stop and remove existing container | |
| 95 | + | if err := m.runtime.StopContainer(ctx, existing.ID); err != nil { | |
| 96 | + | log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |
| 100 | + | log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container") | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | delete(m.containers, route.Domain) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // Also check for orphaned containers with the same name in the runtime | |
| 107 | + | expectedContainerName := fmt.Sprintf("gordon-%s", route.Domain) | |
| 108 | + | allContainers, err := m.runtime.ListContainers(ctx, true) // include stopped containers | |
| 109 | + | if err != nil { | |
| 110 | + | log.Warn().Err(err).Msg("Failed to list all containers, proceeding with deployment") | |
| 111 | + | } else { | |
| 112 | + | for _, container := range allContainers { | |
| 113 | + | if container.Name == expectedContainerName { | |
| 114 | + | log.Info(). | |
| 115 | + | Str("domain", route.Domain). | |
| 116 | + | Str("container", container.ID). | |
| 117 | + | Str("status", container.Status). | |
| 118 | + | Msg("Found orphaned container with same name, removing") | |
| 119 | + | ||
| 120 | + | // Stop if running | |
| 121 | + | if err := m.runtime.StopContainer(ctx, container.ID); err != nil { | |
| 122 | + | log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop orphaned container") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Force remove | |
| 126 | + | if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil { | |
| 127 | + | log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove orphaned container") | |
| 128 | + | } else { | |
| 129 | + | log.Info().Str("container", container.ID).Msg("Successfully removed orphaned container") | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Construct the full image reference | |
| 136 | + | imageRef := route.Image | |
| 137 | + | ||
| 138 | + | // If registry auth is enabled and image doesn't already start with our registry domain, prepend it | |
| 139 | + | if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" { | |
| 140 | + | repoPart := strings.Split(imageRef, ":")[0] | |
| 141 | + | // Don't prepend if: | |
| 142 | + | // 1. Image already starts with our registry domain | |
| 143 | + | // 2. Image looks like an external registry (contains both dots and slashes, indicating registry.com/repo format) | |
| 144 | + | if !strings.HasPrefix(repoPart, m.config.Server.RegistryDomain+"/") && | |
| 145 | + | !(strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/")) { | |
| 146 | + | imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Check if image exists locally first | |
| 151 | + | imageAvailable := false | |
| 152 | + | localImages, err := m.runtime.ListImages(ctx) | |
| 153 | + | if err != nil { | |
| 154 | + | log.Warn().Err(err).Msg("Failed to list local images, will attempt to pull from registry") | |
| 155 | + | } else { | |
| 156 | + | // Normalize image references for comparison | |
| 157 | + | normalizedImageRef := normalizeImageRef(imageRef) | |
| 158 | + | ||
| 159 | + | for _, localImage := range localImages { | |
| 160 | + | if normalizeImageRef(localImage) == normalizedImageRef { | |
| 161 | + | imageAvailable = true | |
| 162 | + | log.Info(). | |
| 163 | + | Str("image", imageRef). | |
| 164 | + | Msg("Image found locally, skipping pull") | |
| 165 | + | break | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Pull the image only if not available locally | |
| 171 | + | if !imageAvailable { | |
| 172 | + | log.Info().Str("image", imageRef).Msg("Image not found locally, pulling from registry") | |
| 173 | + | ||
| 174 | + | // Use authenticated pull if registry auth is configured | |
| 175 | + | if m.config.RegistryAuth.Enabled { | |
| 176 | + | log.Info(). | |
| 177 | + | Str("image", imageRef). | |
| 178 | + | Str("username", m.config.RegistryAuth.Username). | |
| 179 | + | Msg("Pulling image with authentication") | |
| 180 | + | ||
| 181 | + | err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password) | |
| 182 | + | if err != nil { | |
| 183 | + | // Provide better error message with context | |
| 184 | + | availableImages := make([]string, 0, len(localImages)) | |
| 185 | + | for _, img := range localImages { | |
| 186 | + | availableImages = append(availableImages, img) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+ | |
| 190 | + | "Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+ | |
| 191 | + | "Available local images: %v. Error: %w", | |
| 192 | + | imageRef, m.config.Server.RegistryDomain, availableImages, err) | |
| 193 | + | } | |
| 194 | + | log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication") | |
| 195 | + | } else { | |
| 196 | + | log.Info().Str("image", imageRef).Msg("Pulling image without authentication") | |
| 197 | + | ||
| 198 | + | err := m.runtime.PullImage(ctx, imageRef) | |
| 199 | + | if err != nil { | |
| 200 | + | // Provide better error message with context | |
| 201 | + | availableImages := make([]string, 0, len(localImages)) | |
| 202 | + | for _, img := range localImages { | |
| 203 | + | availableImages = append(availableImages, img) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+ | |
| 207 | + | "Please check: 1) Image name spelling, 2) Image exists in registry. "+ | |
| 208 | + | "Available local images: %v. Error: %w", | |
| 209 | + | imageRef, availableImages, err) | |
| 210 | + | } | |
| 211 | + | log.Info().Str("image", imageRef).Msg("Image pulled successfully") | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // Get exposed ports from the image | |
| 216 | + | exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, imageRef) | |
| 217 | + | if err != nil { | |
| 218 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to get exposed ports from image, using defaults") | |
| 219 | + | exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Load environment variables for this route | |
| 223 | + | userEnvVars, err := m.envLoader.LoadEnvForRoute(route.Domain) | |
| 224 | + | if err != nil { | |
| 225 | + | log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to load environment variables for route") | |
| 226 | + | return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // Get environment variables from image ENV directives | |
| 230 | + | dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, imageRef) | |
| 231 | + | if err != nil { | |
| 232 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image environment variables, proceeding without them") | |
| 233 | + | dockerfileEnvVars = []string{} | |
| 234 | + | } else if len(dockerfileEnvVars) > 0 { | |
| 235 | + | log.Info().Str("image", imageRef).Strs("dockerfile_env", dockerfileEnvVars).Msg("Found ENV directives in image") | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // Merge Dockerfile ENV with user-provided env vars (user env takes precedence) | |
| 239 | + | envVars := mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars) | |
| 240 | + | ||
| 241 | + | // Handle volume auto-creation if enabled | |
| 242 | + | volumes := make(map[string]string) | |
| 243 | + | if m.config.Volumes.AutoCreate { | |
| 244 | + | // Get volume paths from image VOLUME directives | |
| 245 | + | volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef) | |
| 246 | + | if err != nil { | |
| 247 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes, proceeding without volumes") | |
| 248 | + | } else if len(volumePaths) > 0 { | |
| 249 | + | log.Info().Str("image", imageRef).Strs("volume_paths", volumePaths).Msg("Found VOLUME directives in image") | |
| 250 | + | ||
| 251 | + | for _, volumePath := range volumePaths { | |
| 252 | + | volumeName := generateVolumeName(m.config.Volumes.Prefix, route.Domain, volumePath) | |
| 253 | + | ||
| 254 | + | // Check if volume exists, create if it doesn't | |
| 255 | + | exists, err := m.runtime.VolumeExists(ctx, volumeName) | |
| 256 | + | if err != nil { | |
| 257 | + | log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists") | |
| 258 | + | continue | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | if !exists { | |
| 262 | + | if err := m.runtime.CreateVolume(ctx, volumeName); err != nil { | |
| 263 | + | log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create volume") | |
| 264 | + | continue | |
| 265 | + | } | |
| 266 | + | log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for container") | |
| 267 | + | } else { | |
| 268 | + | log.Debug().Str("volume", volumeName).Str("path", volumePath).Msg("Volume already exists, reusing") | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | volumes[volumePath] = volumeName | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // Determine which network to use for this app | |
| 277 | + | networkName := m.GetNetworkForApp(route.Domain) | |
| 278 | + | ||
| 279 | + | // Create network if it doesn't exist | |
| 280 | + | if err := m.CreateNetworkIfNeeded(ctx, networkName); err != nil { | |
| 281 | + | return nil, fmt.Errorf("failed to create network for %s: %w", route.Domain, err) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // Create container configuration | |
| 285 | + | containerConfig := &runtime.ContainerConfig{ | |
| 286 | + | Image: imageRef, | |
| 287 | + | Name: fmt.Sprintf("gordon-%s", route.Domain), | |
| 288 | + | Ports: exposedPorts, | |
| 289 | + | Env: envVars, | |
| 290 | + | Volumes: volumes, | |
| 291 | + | NetworkMode: networkName, | |
| 292 | + | Hostname: route.Domain, | |
| 293 | + | Labels: map[string]string{ | |
| 294 | + | "gordon.domain": route.Domain, | |
| 295 | + | "gordon.image": route.Image, | |
| 296 | + | "gordon.managed": "true", | |
| 297 | + | "gordon.route": route.Domain, | |
| 298 | + | }, | |
| 299 | + | AutoRemove: false, // Keep containers for inspection | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // Create the container | |
| 303 | + | container, err := m.runtime.CreateContainer(ctx, containerConfig) | |
| 304 | + | if err != nil { | |
| 305 | + | return nil, fmt.Errorf("failed to create container for %s: %w", route.Domain, err) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // Start the container | |
| 309 | + | if err := m.runtime.StartContainer(ctx, container.ID); err != nil { | |
| 310 | + | // Clean up the created container | |
| 311 | + | m.runtime.RemoveContainer(ctx, container.ID, true) | |
| 312 | + | return nil, fmt.Errorf("failed to start container for %s: %w", route.Domain, err) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // Re-inspect to get updated port information | |
| 316 | + | container, err = m.runtime.InspectContainer(ctx, container.ID) | |
| 317 | + | if err != nil { | |
| 318 | + | return nil, fmt.Errorf("failed to inspect started container: %w", err) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // Store the container mapping | |
| 322 | + | m.containers[route.Domain] = container | |
| 323 | + | ||
| 324 | + | // Start log collection for the container | |
| 325 | + | containerName := container.Name | |
| 326 | + | if containerName == "" { | |
| 327 | + | containerName = route.Domain | |
| 328 | + | } | |
| 329 | + | if err := m.logManager.StartCollection(container.ID, containerName); err != nil { | |
| 330 | + | log.Warn(). | |
| 331 | + | Err(err). | |
| 332 | + | Str("container", container.ID). | |
| 333 | + | Str("domain", route.Domain). | |
| 334 | + | Msg("Failed to start log collection for container") | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | log.Info(). | |
| 338 | + | Str("domain", route.Domain). | |
| 339 | + | Str("image", route.Image). | |
| 340 | + | Str("container", container.ID). | |
| 341 | + | Ints("ports", container.Ports). | |
| 342 | + | Str("network", networkName). | |
| 343 | + | Msg("Container deployed successfully") | |
| 344 | + | ||
| 345 | + | // Deploy attachments for this specific app | |
| 346 | + | if attachments, ok := m.config.Attachments[route.Domain]; ok { | |
| 347 | + | for _, serviceImage := range attachments { | |
| 348 | + | if err := m.DeployAttachedService(ctx, route.Domain, serviceImage); err != nil { | |
| 349 | + | log.Error().Err(err).Str("service", serviceImage).Str("domain", route.Domain).Msg("Failed to deploy attachment") | |
| 350 | + | // Don't fail the main deployment if attachment fails | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // Check if app is part of a network group with attachments | |
| 356 | + | for groupName, domains := range m.config.NetworkGroups { | |
| 357 | + | for _, d := range domains { | |
| 358 | + | if d == route.Domain { | |
| 359 | + | // Deploy group attachments if not already deployed | |
| 360 | + | if attachments, ok := m.config.Attachments[groupName]; ok { | |
| 361 | + | for _, serviceImage := range attachments { | |
| 362 | + | if err := m.DeployAttachedService(ctx, groupName, serviceImage); err != nil { | |
| 363 | + | log.Error().Err(err).Str("service", serviceImage).Str("group", groupName).Msg("Failed to deploy group attachment") | |
| 364 | + | // Don't fail the main deployment if attachment fails | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | break | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | return container, nil | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // GetContainer returns the container for a domain | |
| 377 | + | func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) { | |
| 378 | + | m.mu.RLock() | |
| 379 | + | defer m.mu.RUnlock() | |
| 380 | + | ||
| 381 | + | container, exists := m.containers[domain] | |
| 382 | + | return container, exists | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // GetContainerPort returns the host port for a domain's container | |
| 386 | + | func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) { | |
| 387 | + | m.mu.RLock() | |
| 388 | + | container, exists := m.containers[domain] | |
| 389 | + | m.mu.RUnlock() | |
| 390 | + | ||
| 391 | + | if !exists { | |
| 392 | + | return 0, fmt.Errorf("no container found for domain %s", domain) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | return m.runtime.GetContainerPort(ctx, container.ID, internalPort) | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // ListContainers returns all managed containers | |
| 399 | + | func (m *Manager) ListContainers() map[string]*runtime.Container { | |
| 400 | + | m.mu.RLock() | |
| 401 | + | defer m.mu.RUnlock() | |
| 402 | + | ||
| 403 | + | // Return a copy of the map to avoid race conditions | |
| 404 | + | result := make(map[string]*runtime.Container) | |
| 405 | + | for domain, container := range m.containers { | |
| 406 | + | result[domain] = container | |
| 407 | + | } | |
| 408 | + | return result | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // FindContainerByDomain returns the container ID for a domain | |
| 412 | + | func (m *Manager) FindContainerByDomain(domain string) (string, bool) { | |
| 413 | + | m.mu.RLock() | |
| 414 | + | defer m.mu.RUnlock() | |
| 415 | + | ||
| 416 | + | if container, exists := m.containers[domain]; exists { | |
| 417 | + | return container.ID, true | |
| 418 | + | } | |
| 419 | + | return "", false | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // FindDomainByContainerID returns the domain for a container ID | |
| 423 | + | func (m *Manager) FindDomainByContainerID(containerID string) (string, bool) { | |
| 424 | + | m.mu.RLock() | |
| 425 | + | defer m.mu.RUnlock() | |
| 426 | + | ||
| 427 | + | for domain, container := range m.containers { | |
| 428 | + | if container.ID == containerID { | |
| 429 | + | return domain, true | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | return "", false | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // StopContainer stops a container by ID | |
| 436 | + | func (m *Manager) StopContainer(ctx context.Context, containerID string) error { | |
| 437 | + | if err := m.runtime.StopContainer(ctx, containerID); err != nil { | |
| 438 | + | return fmt.Errorf("failed to stop container %s: %w", containerID, err) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // Stop log collection for the container | |
| 442 | + | m.logManager.StopCollection(containerID) | |
| 443 | + | ||
| 444 | + | log.Info().Str("container", containerID).Msg("Container stopped") | |
| 445 | + | return nil | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // StopContainerByDomain stops a container for a domain | |
| 449 | + | func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) error { | |
| 450 | + | m.mu.RLock() | |
| 451 | + | container, exists := m.containers[domain] | |
| 452 | + | m.mu.RUnlock() | |
| 453 | + | ||
| 454 | + | if !exists { | |
| 455 | + | return fmt.Errorf("no container found for domain %s", domain) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | return m.StopContainer(ctx, container.ID) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | // RemoveContainer removes a container by ID | |
| 462 | + | func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force bool) error { | |
| 463 | + | // Find the domain for this container before removal for volume cleanup | |
| 464 | + | var containerDomain string | |
| 465 | + | m.mu.RLock() | |
| 466 | + | for domain, container := range m.containers { | |
| 467 | + | if container.ID == containerID { | |
| 468 | + | containerDomain = domain | |
| 469 | + | break | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | m.mu.RUnlock() | |
| 473 | + | ||
| 474 | + | if err := m.runtime.RemoveContainer(ctx, containerID, force); err != nil { | |
| 475 | + | return fmt.Errorf("failed to remove container %s: %w", containerID, err) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // Stop log collection for the container | |
| 479 | + | m.logManager.StopCollection(containerID) | |
| 480 | + | ||
| 481 | + | // Clean up volumes if preserve is disabled and we found the domain | |
| 482 | + | if containerDomain != "" && !m.config.Volumes.Preserve { | |
| 483 | + | if err := m.cleanupVolumesForDomain(ctx, containerDomain); err != nil { | |
| 484 | + | log.Warn().Err(err).Str("domain", containerDomain).Msg("Failed to cleanup volumes during container removal") | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // Remove from our tracking map and cleanup network if needed | |
| 489 | + | m.mu.Lock() | |
| 490 | + | var removedDomain string | |
| 491 | + | for domain, container := range m.containers { | |
| 492 | + | if container.ID == containerID { | |
| 493 | + | delete(m.containers, domain) | |
| 494 | + | removedDomain = domain | |
| 495 | + | log.Info().Str("domain", domain).Str("container", containerID).Msg("Container removed") | |
| 496 | + | break | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | m.mu.Unlock() | |
| 500 | + | ||
| 501 | + | // If we removed an app container, check if we need to cleanup its network | |
| 502 | + | if removedDomain != "" && m.config.NetworkIsolation.Enabled { | |
| 503 | + | networkName := m.GetNetworkForApp(removedDomain) | |
| 504 | + | if err := m.cleanupNetworkIfEmpty(ctx, networkName); err != nil { | |
| 505 | + | log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup network after container removal") | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | return nil | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // RemoveContainerByDomain removes a container for a domain | |
| 513 | + | func (m *Manager) RemoveContainerByDomain(ctx context.Context, domain string, force bool) error { | |
| 514 | + | m.mu.RLock() | |
| 515 | + | container, exists := m.containers[domain] | |
| 516 | + | m.mu.RUnlock() | |
| 517 | + | ||
| 518 | + | if !exists { | |
| 519 | + | return fmt.Errorf("no container found for domain %s", domain) | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | return m.RemoveContainer(ctx, container.ID, force) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // SyncContainers synchronizes the internal state with the actual running containers | |
| 526 | + | func (m *Manager) SyncContainers(ctx context.Context) error { | |
| 527 | + | m.mu.Lock() | |
| 528 | + | defer m.mu.Unlock() | |
| 529 | + | ||
| 530 | + | // Get all containers from the runtime | |
| 531 | + | allContainers, err := m.runtime.ListContainers(ctx, false) // only running containers | |
| 532 | + | if err != nil { | |
| 533 | + | return fmt.Errorf("failed to list containers: %w", err) | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // Find containers managed by Gordon | |
| 537 | + | managedContainers := make(map[string]*runtime.Container) | |
| 538 | + | for _, container := range allContainers { | |
| 539 | + | if container.Labels != nil { | |
| 540 | + | if domain, exists := container.Labels["gordon.domain"]; exists && container.Labels["gordon.managed"] == "true" { | |
| 541 | + | managedContainers[domain] = container | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // Update our internal state | |
| 547 | + | m.containers = managedContainers | |
| 548 | + | ||
| 549 | + | log.Info().Int("count", len(managedContainers)).Msg("Container state synchronized") | |
| 550 | + | return nil | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | // AutoStartContainers automatically starts containers that match configured routes | |
| 554 | + | func (m *Manager) AutoStartContainers(ctx context.Context) error { | |
| 555 | + | routes := m.config.GetRoutes() | |
| 556 | + | if len(routes) == 0 { | |
| 557 | + | log.Info().Msg("No routes configured, skipping auto-start") | |
| 558 | + | return nil | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | // Get all containers (including stopped ones) to check for matches | |
| 562 | + | allContainers, err := m.runtime.ListContainers(ctx, true) | |
| 563 | + | if err != nil { | |
| 564 | + | return fmt.Errorf("failed to list all containers: %w", err) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | // Create maps for quick lookup | |
| 568 | + | gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container | |
| 569 | + | containersByImage := make(map[string][]*runtime.Container) // image -> containers | |
| 570 | + | ||
| 571 | + | for _, container := range allContainers { | |
| 572 | + | // Track Gordon-managed containers by domain | |
| 573 | + | if container.Labels != nil && container.Labels["gordon.managed"] == "true" { | |
| 574 | + | if domain, exists := container.Labels["gordon.domain"]; exists { | |
| 575 | + | gordonManagedContainers[domain] = container | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // Track all containers by image (normalize image name) | |
| 580 | + | normalizedImage := m.normalizeImageName(container.Image) | |
| 581 | + | containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container) | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | startedCount := 0 | |
| 585 | + | for _, route := range routes { | |
| 586 | + | // Build the expected image reference | |
| 587 | + | expectedImage := route.Image | |
| 588 | + | if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" { | |
| 589 | + | if !strings.Contains(strings.Split(expectedImage, ":")[0], ".") { | |
| 590 | + | expectedImage = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image) | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | // First, check if we already have a Gordon-managed container for this domain | |
| 595 | + | if existingContainer, exists := gordonManagedContainers[route.Domain]; exists { | |
| 596 | + | isRunning, err := m.runtime.IsContainerRunning(ctx, existingContainer.ID) | |
| 597 | + | if err != nil { | |
| 598 | + | log.Warn().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to check container status") | |
| 599 | + | continue | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | if isRunning { | |
| 603 | + | log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Gordon-managed container already running") | |
| 604 | + | m.mu.Lock() | |
| 605 | + | m.containers[route.Domain] = existingContainer | |
| 606 | + | m.mu.Unlock() | |
| 607 | + | continue | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | // Container exists but is stopped, start it | |
| 611 | + | log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Starting existing Gordon-managed container") | |
| 612 | + | if err := m.runtime.StartContainer(ctx, existingContainer.ID); err != nil { | |
| 613 | + | log.Error().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to start existing container") | |
| 614 | + | continue | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | // Re-inspect to get updated information | |
| 618 | + | container, err := m.runtime.InspectContainer(ctx, existingContainer.ID) | |
| 619 | + | if err != nil { | |
| 620 | + | log.Error().Err(err).Str("container", existingContainer.ID).Msg("Failed to inspect started container") | |
| 621 | + | continue | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | m.mu.Lock() | |
| 625 | + | m.containers[route.Domain] = container | |
| 626 | + | m.mu.Unlock() | |
| 627 | + | startedCount++ | |
| 628 | + | ||
| 629 | + | // Start log collection for the container | |
| 630 | + | containerName := container.Name | |
| 631 | + | if containerName == "" { | |
| 632 | + | containerName = route.Domain | |
| 633 | + | } | |
| 634 | + | if err := m.logManager.StartCollection(container.ID, containerName); err != nil { | |
| 635 | + | log.Warn(). | |
| 636 | + | Err(err). | |
| 637 | + | Str("container", container.ID). | |
| 638 | + | Str("domain", route.Domain). | |
| 639 | + | Msg("Failed to start log collection for auto-started container") | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully") | |
| 643 | + | continue | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | // Second, check if there are existing containers with the same image and clean them up | |
| 647 | + | normalizedExpectedImage := m.normalizeImageName(expectedImage) | |
| 648 | + | if containers, exists := containersByImage[normalizedExpectedImage]; exists { | |
| 649 | + | // Stop and remove existing containers with the same image to avoid conflicts | |
| 650 | + | for _, container := range containers { | |
| 651 | + | // Skip Gordon-managed containers (already handled above) | |
| 652 | + | if container.Labels != nil && container.Labels["gordon.managed"] == "true" { | |
| 653 | + | continue | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts") | |
| 657 | + | ||
| 658 | + | // Stop the container if it's running | |
| 659 | + | isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID) | |
| 660 | + | if err != nil { | |
| 661 | + | log.Warn().Err(err).Str("container", container.ID).Msg("Failed to check container status") | |
| 662 | + | continue | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | if isRunning { | |
| 666 | + | if err := m.runtime.StopContainer(ctx, container.ID); err != nil { | |
| 667 | + | log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop existing container") | |
| 668 | + | continue | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | // Remove the container to clean up | |
| 673 | + | if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil { | |
| 674 | + | log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove existing container") | |
| 675 | + | } else { | |
| 676 | + | log.Info().Str("container", container.ID).Msg("Removed existing container with same image") | |
| 677 | + | } | |
| 678 | + | } | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | // No existing container found, deploy a new one | |
| 682 | + | log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route") | |
| 683 | + | ||
| 684 | + | _, err := m.DeployContainer(ctx, route) | |
| 685 | + | if err != nil { | |
| 686 | + | log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route") | |
| 687 | + | continue | |
| 688 | + | } | |
| 689 | + | startedCount++ | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | log.Info().Int("started", startedCount).Int("total_routes", len(routes)).Msg("Auto-start completed") | |
| 693 | + | return nil | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | // normalizeImageName normalizes image names for comparison | |
| 697 | + | func (m *Manager) normalizeImageName(image string) string { | |
| 698 | + | // Remove tag if present, keep only repository name | |
| 699 | + | parts := strings.Split(image, ":") | |
| 700 | + | repo := parts[0] | |
| 701 | + | ||
| 702 | + | // If no registry domain and it's a simple name, it's from Docker Hub | |
| 703 | + | if !strings.Contains(repo, "/") { | |
| 704 | + | return "docker.io/library/" + repo | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | // If it has one slash and no domain, it's a user repo on Docker Hub | |
| 708 | + | if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") { | |
| 709 | + | return "docker.io/" + repo | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | return repo | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | // StopAllManagedContainers stops all containers managed by Gordon | |
| 716 | + | func (m *Manager) StopAllManagedContainers(ctx context.Context) error { | |
| 717 | + | m.mu.RLock() | |
| 718 | + | containers := make(map[string]*runtime.Container) | |
| 719 | + | for domain, container := range m.containers { | |
| 720 | + | containers[domain] = container | |
| 721 | + | } | |
| 722 | + | m.mu.RUnlock() | |
| 723 | + | ||
| 724 | + | if len(containers) == 0 { | |
| 725 | + | log.Info().Msg("No managed containers to stop") | |
| 726 | + | return nil | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | log.Info().Int("count", len(containers)).Msg("Stopping all managed containers...") | |
| 730 | + | ||
| 731 | + | var errors []error | |
| 732 | + | for domain, container := range containers { | |
| 733 | + | log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container") | |
| 734 | + | ||
| 735 | + | if err := m.runtime.StopContainer(ctx, container.ID); err != nil { | |
| 736 | + | log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container") | |
| 737 | + | errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err)) | |
| 738 | + | continue | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | // Stop log collection for the container | |
| 742 | + | m.logManager.StopCollection(container.ID) | |
| 743 | + | ||
| 744 | + | // Remove from tracking | |
| 745 | + | m.mu.Lock() | |
| 746 | + | delete(m.containers, domain) | |
| 747 | + | m.mu.Unlock() | |
| 748 | + | ||
| 749 | + | log.Info().Str("domain", domain).Str("container", container.ID).Msg("Managed container stopped successfully") | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | if len(errors) > 0 { | |
| 753 | + | return fmt.Errorf("failed to stop %d containers: %v", len(errors), errors) | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | log.Info().Msg("All managed containers stopped successfully") | |
| 757 | + | return nil | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | // HealthCheck checks the health of all managed containers | |
| 761 | + | func (m *Manager) HealthCheck(ctx context.Context) map[string]bool { | |
| 762 | + | m.mu.RLock() | |
| 763 | + | defer m.mu.RUnlock() | |
| 764 | + | ||
| 765 | + | health := make(map[string]bool) | |
| 766 | + | for domain, container := range m.containers { | |
| 767 | + | running, err := m.runtime.IsContainerRunning(ctx, container.ID) | |
| 768 | + | if err != nil { | |
| 769 | + | log.Warn().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to check container health") | |
| 770 | + | health[domain] = false | |
| 771 | + | } else { | |
| 772 | + | health[domain] = running | |
| 773 | + | } | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | return health | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | // Runtime returns the underlying runtime interface | |
| 780 | + | func (m *Manager) Runtime() runtime.Runtime { | |
| 781 | + | return m.runtime | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | // UpdateConfig updates the manager's configuration and creates env files for new routes | |
| 785 | + | func (m *Manager) UpdateConfig(cfg *config.Config) { | |
| 786 | + | m.config = cfg | |
| 787 | + | ||
| 788 | + | // Update env loader config | |
| 789 | + | m.envLoader.UpdateConfig(cfg) | |
| 790 | + | ||
| 791 | + | // Create env files for any new routes | |
| 792 | + | if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil { | |
| 793 | + | log.Warn().Err(err).Msg("Failed to create env files for new routes during config update") | |
| 794 | + | } | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | // Shutdown gracefully shuts down the container manager | |
| 798 | + | func (m *Manager) Shutdown(ctx context.Context) error { | |
| 799 | + | log.Info().Msg("Shutting down container manager...") | |
| 800 | + | ||
| 801 | + | // Stop all managed containers (this also stops log collection for each) | |
| 802 | + | if err := m.StopAllManagedContainers(ctx); err != nil { | |
| 803 | + | log.Error().Err(err).Msg("Failed to stop all managed containers during shutdown") | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | // Stop all remaining log collection | |
| 807 | + | m.logManager.StopAll() | |
| 808 | + | ||
| 809 | + | log.Info().Msg("Container manager shutdown complete") | |
| 810 | + | return nil | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | // cleanupVolumesForDomain removes all volumes associated with a domain | |
| 814 | + | func (m *Manager) cleanupVolumesForDomain(ctx context.Context, domain string) error { | |
| 815 | + | log.Info().Str("domain", domain).Msg("Cleaning up volumes for domain") | |
| 816 | + | ||
| 817 | + | // We need to get the route to know what image was used to determine volume paths | |
| 818 | + | var route *config.Route | |
| 819 | + | for _, r := range m.config.GetRoutes() { | |
| 820 | + | if r.Domain == domain { | |
| 821 | + | route = &r | |
| 822 | + | break | |
| 823 | + | } | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | if route == nil { | |
| 827 | + | log.Debug().Str("domain", domain).Msg("No route found for domain, skipping volume cleanup") | |
| 828 | + | return nil | |
| 829 | + | } | |
| 830 | + | ||
| 831 | + | // Construct the full image reference like we do in DeployContainer | |
| 832 | + | imageRef := route.Image | |
| 833 | + | if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" { | |
| 834 | + | if !strings.Contains(strings.Split(imageRef, ":")[0], ".") { | |
| 835 | + | imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image) | |
| 836 | + | } | |
| 837 | + | } | |
| 838 | + | ||
| 839 | + | // Get volume paths from image VOLUME directives | |
| 840 | + | volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef) | |
| 841 | + | if err != nil { | |
| 842 | + | log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes during cleanup") | |
| 843 | + | return err | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | if len(volumePaths) == 0 { | |
| 847 | + | log.Debug().Str("domain", domain).Msg("No volumes found for cleanup") | |
| 848 | + | return nil | |
| 849 | + | } | |
| 850 | + | ||
| 851 | + | var errors []error | |
| 852 | + | for _, volumePath := range volumePaths { | |
| 853 | + | volumeName := generateVolumeName(m.config.Volumes.Prefix, domain, volumePath) | |
| 854 | + | ||
| 855 | + | // Check if volume exists before trying to remove | |
| 856 | + | exists, err := m.runtime.VolumeExists(ctx, volumeName) | |
| 857 | + | if err != nil { | |
| 858 | + | log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists during cleanup") | |
| 859 | + | continue | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | if exists { | |
| 863 | + | if err := m.runtime.RemoveVolume(ctx, volumeName, true); err != nil { | |
| 864 | + | log.Error().Err(err).Str("volume", volumeName).Str("domain", domain).Msg("Failed to remove volume during cleanup") | |
| 865 | + | errors = append(errors, err) | |
| 866 | + | } else { | |
| 867 | + | log.Info().Str("volume", volumeName).Str("domain", domain).Msg("Volume cleaned up successfully") | |
| 868 | + | } | |
| 869 | + | } | |
| 870 | + | } | |
| 871 | + | ||
| 872 | + | if len(errors) > 0 { | |
| 873 | + | return fmt.Errorf("failed to cleanup %d volumes for domain %s", len(errors), domain) | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | return nil | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | // cleanupNetworkIfEmpty removes a network if it has no containers | |
| 880 | + | func (m *Manager) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error { | |
| 881 | + | if networkName == "bridge" || networkName == "default" { | |
| 882 | + | return nil // Don't try to remove default networks | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | networks, err := m.runtime.ListNetworks(ctx) | |
| 886 | + | if err != nil { | |
| 887 | + | return fmt.Errorf("failed to list networks: %w", err) | |
| 888 | + | } | |
| 889 | + | ||
| 890 | + | for _, network := range networks { | |
| 891 | + | if network.Name == networkName { | |
| 892 | + | // Check if network has any containers | |
| 893 | + | if len(network.Containers) == 0 { | |
| 894 | + | // Network is empty, remove it | |
| 895 | + | if err := m.runtime.RemoveNetwork(ctx, networkName); err != nil { | |
| 896 | + | log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup empty network") | |
| 897 | + | return err | |
| 898 | + | } | |
| 899 | + | log.Info().Str("network", networkName).Msg("Cleaned up empty network") | |
| 900 | + | } | |
| 901 | + | break | |
| 902 | + | } | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | return nil | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | // DeployAttachedService deploys a service attached to an app or network group | |
| 909 | + | func (m *Manager) DeployAttachedService(ctx context.Context, identifier, serviceImage string) error { | |
| 910 | + | networkName := m.generateNetworkName(identifier) | |
| 911 | + | ||
| 912 | + | // Extract service name from image for container naming | |
| 913 | + | serviceName := strings.Split(serviceImage, ":")[0] | |
| 914 | + | if strings.Contains(serviceName, "/") { | |
| 915 | + | parts := strings.Split(serviceName, "/") | |
| 916 | + | serviceName = parts[len(parts)-1] | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | // For network groups, we need a different container naming scheme | |
| 920 | + | apps := m.GetAppsForNetwork(identifier) | |
| 921 | + | var containerName string | |
| 922 | + | ||
| 923 | + | if len(apps) > 1 { | |
| 924 | + | // Shared service for network group | |
| 925 | + | containerName = fmt.Sprintf("%s-shared-%s", m.config.NetworkIsolation.NetworkPrefix, serviceName) | |
| 926 | + | } else { | |
| 927 | + | // App-specific service | |
| 928 | + | containerName = fmt.Sprintf("%s-%s", strings.ReplaceAll(identifier, ".", "-"), serviceName) | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | // Check if service is already running | |
| 932 | + | containers, err := m.runtime.ListContainers(ctx, false) | |
| 933 | + | if err != nil { | |
| 934 | + | return fmt.Errorf("failed to list containers: %w", err) | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | for _, container := range containers { | |
| 938 | + | if container.Name == containerName { | |
| 939 | + | log.Info().Str("service", containerName).Msg("Service already running") | |
| 940 | + | return nil | |
| 941 | + | } | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | // Get exposed ports from the service image | |
| 945 | + | exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, serviceImage) | |
| 946 | + | if err != nil { | |
| 947 | + | log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to get exposed ports from service image, using empty ports") | |
| 948 | + | exposedPorts = []int{} // Services don't need external ports by default | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | // Load environment for service (inherit from first app in group or specific app) | |
| 952 | + | var envVars []string | |
| 953 | + | if len(apps) > 0 { | |
| 954 | + | userEnvVars, err := m.envLoader.LoadEnvForRoute(apps[0]) | |
| 955 | + | if err != nil { | |
| 956 | + | log.Warn().Err(err).Str("domain", apps[0]).Msg("Failed to load environment for service, using empty env") | |
| 957 | + | userEnvVars = []string{} | |
| 958 | + | } | |
| 959 | + | ||
| 960 | + | // Get environment variables from image ENV directives | |
| 961 | + | dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, serviceImage) | |
| 962 | + | if err != nil { | |
| 963 | + | log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image environment variables") | |
| 964 | + | dockerfileEnvVars = []string{} | |
| 965 | + | } | |
| 966 | + | ||
| 967 | + | // Merge environments | |
| 968 | + | envVars = mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars) | |
| 969 | + | } | |
| 970 | + | ||
| 971 | + | // Handle volumes for the service | |
| 972 | + | volumes := make(map[string]string) | |
| 973 | + | if m.config.Volumes.AutoCreate { | |
| 974 | + | volumePaths, err := m.runtime.InspectImageVolumes(ctx, serviceImage) | |
| 975 | + | if err != nil { | |
| 976 | + | log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image volumes") | |
| 977 | + | } else if len(volumePaths) > 0 { | |
| 978 | + | for _, volumePath := range volumePaths { | |
| 979 | + | volumeName := fmt.Sprintf("%s-%s-%s", | |
| 980 | + | m.config.Volumes.Prefix, | |
| 981 | + | strings.ReplaceAll(identifier, ".", "-"), | |
| 982 | + | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |
| 983 | + | ||
| 984 | + | // Create volume if it doesn't exist | |
| 985 | + | exists, err := m.runtime.VolumeExists(ctx, volumeName) | |
| 986 | + | if err != nil { | |
| 987 | + | log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check service volume") | |
| 988 | + | continue | |
| 989 | + | } | |
| 990 | + | ||
| 991 | + | if !exists { | |
| 992 | + | if err := m.runtime.CreateVolume(ctx, volumeName); err != nil { | |
| 993 | + | log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create service volume") | |
| 994 | + | continue | |
| 995 | + | } | |
| 996 | + | log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for service") | |
| 997 | + | } | |
| 998 | + | ||
| 999 | + | volumes[volumePath] = volumeName | |
| 1000 | + | } | |
| 1001 | + | } | |
| 1002 | + | } | |
| 1003 | + | ||
| 1004 | + | // Create service container configuration | |
| 1005 | + | serviceConfig := &runtime.ContainerConfig{ | |
| 1006 | + | Image: serviceImage, | |
| 1007 | + | Name: containerName, | |
| 1008 | + | Ports: exposedPorts, | |
| 1009 | + | Env: envVars, | |
| 1010 | + | Volumes: volumes, | |
| 1011 | + | NetworkMode: networkName, | |
| 1012 | + | Hostname: serviceName, | |
| 1013 | + | Labels: map[string]string{ | |
| 1014 | + | "gordon.managed": "true", | |
| 1015 | + | "gordon.service": "true", | |
| 1016 | + | "gordon.attached": identifier, | |
| 1017 | + | "gordon.image": serviceImage, | |
| 1018 | + | }, | |
| 1019 | + | AutoRemove: false, | |
| 1020 | + | } | |
| 1021 | + | ||
| 1022 | + | // Create and start the service container | |
| 1023 | + | container, err := m.runtime.CreateContainer(ctx, serviceConfig) | |
| 1024 | + | if err != nil { | |
| 1025 | + | return fmt.Errorf("failed to create service container %s: %w", containerName, err) | |
| 1026 | + | } | |
| 1027 | + | ||
| 1028 | + | if err := m.runtime.StartContainer(ctx, container.ID); err != nil { | |
| 1029 | + | m.runtime.RemoveContainer(ctx, container.ID, true) | |
| 1030 | + | return fmt.Errorf("failed to start service container %s: %w", containerName, err) | |
| 1031 | + | } | |
| 1032 | + | ||
| 1033 | + | log.Info(). | |
| 1034 | + | Str("service", containerName). | |
| 1035 | + | Str("image", serviceImage). | |
| 1036 | + | Str("network", networkName). | |
| 1037 | + | Str("attached_to", identifier). | |
| 1038 | + | Msg("Service deployed successfully") | |
| 1039 | + | ||
| 1040 | + | return nil | |
| 1041 | + | } | |
| 1042 | + | ||
| 1043 | + | // GetNetworkForApp determines which network an app should use | |
| 1044 | + | func (m *Manager) GetNetworkForApp(domain string) string { | |
| 1045 | + | if !m.config.NetworkIsolation.Enabled { | |
| 1046 | + | return "bridge" // Use default Docker bridge network | |
| 1047 | + | } | |
| 1048 | + | ||
| 1049 | + | // Check if domain is part of a network group | |
| 1050 | + | for groupName, domains := range m.config.NetworkGroups { | |
| 1051 | + | for _, d := range domains { | |
| 1052 | + | if d == domain { | |
| 1053 | + | return m.generateNetworkName(groupName) | |
| 1054 | + | } | |
| 1055 | + | } | |
| 1056 | + | } | |
| 1057 | + | ||
| 1058 | + | // Default: app gets its own network | |
| 1059 | + | return m.generateNetworkName(domain) | |
| 1060 | + | } | |
| 1061 | + | ||
| 1062 | + | // generateNetworkName creates a network name from an identifier | |
| 1063 | + | func (m *Manager) generateNetworkName(identifier string) string { | |
| 1064 | + | // "myapp.example.com" -> "gordon-myapp-example-com" | |
| 1065 | + | // "backend" -> "gordon-backend" | |
| 1066 | + | return fmt.Sprintf("%s-%s", | |
| 1067 | + | m.config.NetworkIsolation.NetworkPrefix, | |
| 1068 | + | strings.ReplaceAll(identifier, ".", "-")) | |
| 1069 | + | } | |
| 1070 | + | ||
| 1071 | + | // GetAppsForNetwork returns all apps that should have access to a network | |
| 1072 | + | func (m *Manager) GetAppsForNetwork(identifier string) []string { | |
| 1073 | + | // Check if it's a network group | |
| 1074 | + | if apps, ok := m.config.NetworkGroups[identifier]; ok { | |
| 1075 | + | return apps | |
| 1076 | + | } | |
| 1077 | + | ||
| 1078 | + | // Single app network | |
| 1079 | + | return []string{identifier} | |
| 1080 | + | } | |
| 1081 | + | ||
| 1082 | + | // CreateNetworkIfNeeded creates a network if it doesn't exist | |
| 1083 | + | func (m *Manager) CreateNetworkIfNeeded(ctx context.Context, networkName string) error { | |
| 1084 | + | if networkName == "bridge" || networkName == "default" { | |
| 1085 | + | return nil // Don't try to create default networks | |
| 1086 | + | } | |
| 1087 | + | ||
| 1088 | + | exists, err := m.runtime.NetworkExists(ctx, networkName) | |
| 1089 | + | if err != nil { | |
| 1090 | + | return fmt.Errorf("failed to check if network exists: %w", err) | |
| 1091 | + | } | |
| 1092 | + | ||
| 1093 | + | if !exists { | |
| 1094 | + | options := map[string]string{ | |
| 1095 | + | "driver": "bridge", | |
| 1096 | + | } | |
| 1097 | + | ||
| 1098 | + | if err := m.runtime.CreateNetwork(ctx, networkName, options); err != nil { | |
| 1099 | + | return fmt.Errorf("failed to create network %s: %w", networkName, err) | |
| 1100 | + | } | |
| 1101 | + | ||
| 1102 | + | log.Info().Str("network", networkName).Msg("Created network for app isolation") | |
| 1103 | + | } | |
| 1104 | + | ||
| 1105 | + | return nil | |
| 1106 | + | } | |
A internal/container/manager_autostart_test.go +253 -0
| @@ -0,0 +1,253 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | "go.uber.org/mock/gomock" | |
| 9 | + | ||
| 10 | + | "gordon/internal/config" | |
| 11 | + | "gordon/internal/testutils" | |
| 12 | + | "gordon/internal/testutils/mocks" | |
| 13 | + | "gordon/pkg/runtime" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestManager_AutoStartContainers(t *testing.T) { | |
| 17 | + | ctrl := gomock.NewController(t) | |
| 18 | + | defer ctrl.Finish() | |
| 19 | + | ||
| 20 | + | ctx := testutils.TestContext(t) | |
| 21 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 22 | + | ||
| 23 | + | cfg := &config.Config{ | |
| 24 | + | Server: config.ServerConfig{ | |
| 25 | + | Runtime: "docker", | |
| 26 | + | }, | |
| 27 | + | Routes: map[string]string{ | |
| 28 | + | "app1.example.com": "nginx:latest", | |
| 29 | + | "app2.example.com": "apache:latest", | |
| 30 | + | }, | |
| 31 | + | Volumes: config.VolumeConfig{ | |
| 32 | + | AutoCreate: false, | |
| 33 | + | }, | |
| 34 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 35 | + | Enabled: false, | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | ||
| 42 | + | // Mock existing containers - one Gordon-managed running, one stopped | |
| 43 | + | existingContainers := []*runtime.Container{ | |
| 44 | + | { | |
| 45 | + | ID: "running-container", | |
| 46 | + | Name: "gordon-app1.example.com", | |
| 47 | + | Image: "nginx:latest", | |
| 48 | + | Labels: map[string]string{ | |
| 49 | + | "gordon.managed": "true", | |
| 50 | + | "gordon.domain": "app1.example.com", | |
| 51 | + | }, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | ID: "stopped-container", | |
| 55 | + | Name: "gordon-app2.example.com", | |
| 56 | + | Image: "apache:latest", | |
| 57 | + | Labels: map[string]string{ | |
| 58 | + | "gordon.managed": "true", | |
| 59 | + | "gordon.domain": "app2.example.com", | |
| 60 | + | }, | |
| 61 | + | }, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return(existingContainers, nil) | |
| 65 | + | ||
| 66 | + | // First container is already running | |
| 67 | + | mockRuntime.EXPECT().IsContainerRunning(ctx, "running-container").Return(true, nil) | |
| 68 | + | ||
| 69 | + | // Second container is stopped, should be started | |
| 70 | + | mockRuntime.EXPECT().IsContainerRunning(ctx, "stopped-container").Return(false, nil) | |
| 71 | + | mockRuntime.EXPECT().StartContainer(ctx, "stopped-container").Return(nil) | |
| 72 | + | ||
| 73 | + | // Mock inspection of restarted container | |
| 74 | + | startedContainer := &runtime.Container{ | |
| 75 | + | ID: "stopped-container", | |
| 76 | + | Name: "gordon-app2.example.com", | |
| 77 | + | Image: "apache:latest", | |
| 78 | + | Ports: []int{80}, | |
| 79 | + | } | |
| 80 | + | mockRuntime.EXPECT().InspectContainer(ctx, "stopped-container").Return(startedContainer, nil) | |
| 81 | + | ||
| 82 | + | err = manager.AutoStartContainers(ctx) | |
| 83 | + | ||
| 84 | + | assert.NoError(t, err) | |
| 85 | + | assert.Len(t, manager.containers, 2) | |
| 86 | + | assert.Contains(t, manager.containers, "app1.example.com") | |
| 87 | + | assert.Contains(t, manager.containers, "app2.example.com") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestManager_AutoStartContainers_DeployNew(t *testing.T) { | |
| 91 | + | ctrl := gomock.NewController(t) | |
| 92 | + | defer ctrl.Finish() | |
| 93 | + | ||
| 94 | + | ctx := testutils.TestContext(t) | |
| 95 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 96 | + | ||
| 97 | + | cfg := &config.Config{ | |
| 98 | + | Server: config.ServerConfig{ | |
| 99 | + | Runtime: "docker", | |
| 100 | + | }, | |
| 101 | + | Routes: map[string]string{ | |
| 102 | + | "new-app.example.com": "redis:latest", | |
| 103 | + | }, | |
| 104 | + | Volumes: config.VolumeConfig{ | |
| 105 | + | AutoCreate: false, | |
| 106 | + | }, | |
| 107 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 108 | + | Enabled: false, | |
| 109 | + | }, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | ||
| 115 | + | // No existing containers | |
| 116 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 117 | + | ||
| 118 | + | // Should deploy new container | |
| 119 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 120 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"redis:latest"}, nil) | |
| 121 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil) | |
| 122 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil) | |
| 123 | + | ||
| 124 | + | newContainer := &runtime.Container{ | |
| 125 | + | ID: "new-container", | |
| 126 | + | Name: "gordon-new-app.example.com", | |
| 127 | + | Image: "redis:latest", | |
| 128 | + | Ports: []int{6379}, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(newContainer, nil) | |
| 132 | + | mockRuntime.EXPECT().StartContainer(ctx, "new-container").Return(nil) | |
| 133 | + | mockRuntime.EXPECT().InspectContainer(ctx, "new-container").Return(newContainer, nil) | |
| 134 | + | ||
| 135 | + | err = manager.AutoStartContainers(ctx) | |
| 136 | + | ||
| 137 | + | assert.NoError(t, err) | |
| 138 | + | assert.Len(t, manager.containers, 1) | |
| 139 | + | assert.Contains(t, manager.containers, "new-app.example.com") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestManager_AutoStartContainers_CleanupConflicts(t *testing.T) { | |
| 143 | + | ctrl := gomock.NewController(t) | |
| 144 | + | defer ctrl.Finish() | |
| 145 | + | ||
| 146 | + | ctx := testutils.TestContext(t) | |
| 147 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 148 | + | ||
| 149 | + | cfg := &config.Config{ | |
| 150 | + | Server: config.ServerConfig{ | |
| 151 | + | Runtime: "docker", | |
| 152 | + | }, | |
| 153 | + | Routes: map[string]string{ | |
| 154 | + | "app.example.com": "nginx:latest", | |
| 155 | + | }, | |
| 156 | + | Volumes: config.VolumeConfig{ | |
| 157 | + | AutoCreate: false, | |
| 158 | + | }, | |
| 159 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 160 | + | Enabled: false, | |
| 161 | + | }, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | ||
| 167 | + | // Existing containers include a non-Gordon container with same image | |
| 168 | + | existingContainers := []*runtime.Container{ | |
| 169 | + | { | |
| 170 | + | ID: "unmanaged-container", | |
| 171 | + | Name: "some-nginx-container", | |
| 172 | + | Image: "nginx:latest", | |
| 173 | + | // No Gordon labels - not managed | |
| 174 | + | }, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return(existingContainers, nil) | |
| 178 | + | ||
| 179 | + | // Should stop and remove the conflicting container | |
| 180 | + | mockRuntime.EXPECT().IsContainerRunning(ctx, "unmanaged-container").Return(true, nil) | |
| 181 | + | mockRuntime.EXPECT().StopContainer(ctx, "unmanaged-container").Return(nil) | |
| 182 | + | mockRuntime.EXPECT().RemoveContainer(ctx, "unmanaged-container", true).Return(nil) | |
| 183 | + | ||
| 184 | + | // Then deploy new Gordon-managed container | |
| 185 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 186 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"nginx:latest"}, nil) | |
| 187 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "nginx:latest").Return([]int{80}, nil) | |
| 188 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "nginx:latest").Return([]string{}, nil) | |
| 189 | + | ||
| 190 | + | newContainer := &runtime.Container{ | |
| 191 | + | ID: "gordon-container", | |
| 192 | + | Name: "gordon-app.example.com", | |
| 193 | + | Image: "nginx:latest", | |
| 194 | + | Ports: []int{80}, | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(newContainer, nil) | |
| 198 | + | mockRuntime.EXPECT().StartContainer(ctx, "gordon-container").Return(nil) | |
| 199 | + | mockRuntime.EXPECT().InspectContainer(ctx, "gordon-container").Return(newContainer, nil) | |
| 200 | + | ||
| 201 | + | err = manager.AutoStartContainers(ctx) | |
| 202 | + | ||
| 203 | + | assert.NoError(t, err) | |
| 204 | + | assert.Len(t, manager.containers, 1) | |
| 205 | + | assert.Contains(t, manager.containers, "app.example.com") | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestManager_AutoStartContainers_NoRoutes(t *testing.T) { | |
| 209 | + | ctrl := gomock.NewController(t) | |
| 210 | + | defer ctrl.Finish() | |
| 211 | + | ||
| 212 | + | ctx := testutils.TestContext(t) | |
| 213 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 214 | + | ||
| 215 | + | cfg := &config.Config{ | |
| 216 | + | Server: config.ServerConfig{ | |
| 217 | + | Runtime: "docker", | |
| 218 | + | }, | |
| 219 | + | // No routes configured | |
| 220 | + | Routes: map[string]string{}, | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | ||
| 226 | + | err = manager.AutoStartContainers(ctx) | |
| 227 | + | ||
| 228 | + | assert.NoError(t, err) | |
| 229 | + | assert.Empty(t, manager.containers) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestManager_normalizeImageName(t *testing.T) { | |
| 233 | + | manager := &Manager{} | |
| 234 | + | ||
| 235 | + | tests := []struct { | |
| 236 | + | input string | |
| 237 | + | expected string | |
| 238 | + | }{ | |
| 239 | + | {"nginx", "docker.io/library/nginx"}, | |
| 240 | + | {"nginx:latest", "docker.io/library/nginx"}, | |
| 241 | + | {"user/repo", "docker.io/user/repo"}, | |
| 242 | + | {"user/repo:tag", "docker.io/user/repo"}, | |
| 243 | + | {"registry.com/user/repo", "registry.com/user/repo"}, | |
| 244 | + | {"registry.com/user/repo:tag", "registry.com/user/repo"}, | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | for _, tt := range tests { | |
| 248 | + | t.Run(tt.input, func(t *testing.T) { | |
| 249 | + | result := manager.normalizeImageName(tt.input) | |
| 250 | + | assert.Equal(t, tt.expected, result) | |
| 251 | + | }) | |
| 252 | + | } | |
| 253 | + | }⏎̸ | |
A internal/container/manager_deploy_test.go +394 -0
| @@ -0,0 +1,394 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | "go.uber.org/mock/gomock" | |
| 10 | + | ||
| 11 | + | "gordon/internal/config" | |
| 12 | + | "gordon/internal/testutils" | |
| 13 | + | "gordon/internal/testutils/mocks" | |
| 14 | + | "gordon/pkg/runtime" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestManager_DeployContainer_ImagePull(t *testing.T) { | |
| 18 | + | ctrl := gomock.NewController(t) | |
| 19 | + | defer ctrl.Finish() | |
| 20 | + | ||
| 21 | + | ctx := testutils.TestContext(t) | |
| 22 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 23 | + | ||
| 24 | + | cfg := &config.Config{ | |
| 25 | + | Server: config.ServerConfig{ | |
| 26 | + | Runtime: "docker", | |
| 27 | + | }, | |
| 28 | + | Volumes: config.VolumeConfig{ | |
| 29 | + | AutoCreate: false, | |
| 30 | + | }, | |
| 31 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 32 | + | Enabled: false, | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | ||
| 39 | + | route := config.Route{ | |
| 40 | + | Domain: "test.example.com", | |
| 41 | + | Image: "alpine:latest", | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Image not found locally, should pull | |
| 45 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 46 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{}, nil) | |
| 47 | + | mockRuntime.EXPECT().PullImage(ctx, "alpine:latest").Return(nil) | |
| 48 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "alpine:latest").Return([]int{}, nil) | |
| 49 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "alpine:latest").Return([]string{}, nil) | |
| 50 | + | ||
| 51 | + | expectedContainer := &runtime.Container{ | |
| 52 | + | ID: "container456", | |
| 53 | + | Name: "gordon-test.example.com", | |
| 54 | + | Image: "alpine:latest", | |
| 55 | + | Status: "running", | |
| 56 | + | Ports: []int{}, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 60 | + | mockRuntime.EXPECT().StartContainer(ctx, "container456").Return(nil) | |
| 61 | + | mockRuntime.EXPECT().InspectContainer(ctx, "container456").Return(expectedContainer, nil) | |
| 62 | + | ||
| 63 | + | container, err := manager.DeployContainer(ctx, route) | |
| 64 | + | ||
| 65 | + | assert.NoError(t, err) | |
| 66 | + | assert.Equal(t, expectedContainer, container) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestManager_DeployContainer_WithAuth(t *testing.T) { | |
| 70 | + | ctrl := gomock.NewController(t) | |
| 71 | + | defer ctrl.Finish() | |
| 72 | + | ||
| 73 | + | ctx := testutils.TestContext(t) | |
| 74 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 75 | + | ||
| 76 | + | cfg := &config.Config{ | |
| 77 | + | Server: config.ServerConfig{ | |
| 78 | + | Runtime: "docker", | |
| 79 | + | RegistryDomain: "registry.example.com", | |
| 80 | + | }, | |
| 81 | + | RegistryAuth: config.RegistryAuthConfig{ | |
| 82 | + | Enabled: true, | |
| 83 | + | Username: "testuser", | |
| 84 | + | Password: "testpass", | |
| 85 | + | }, | |
| 86 | + | Volumes: config.VolumeConfig{ | |
| 87 | + | AutoCreate: false, | |
| 88 | + | }, | |
| 89 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 90 | + | Enabled: false, | |
| 91 | + | }, | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | ||
| 97 | + | route := config.Route{ | |
| 98 | + | Domain: "test.example.com", | |
| 99 | + | Image: "myapp:latest", | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Should use authenticated pull with registry domain prefix | |
| 103 | + | expectedImageRef := "registry.example.com/myapp:latest" | |
| 104 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 105 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{}, nil) | |
| 106 | + | mockRuntime.EXPECT().PullImageWithAuth(ctx, expectedImageRef, "testuser", "testpass").Return(nil) | |
| 107 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, expectedImageRef).Return([]int{8080}, nil) | |
| 108 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, expectedImageRef).Return([]string{}, nil) | |
| 109 | + | ||
| 110 | + | expectedContainer := &runtime.Container{ | |
| 111 | + | ID: "container789", | |
| 112 | + | Name: "gordon-test.example.com", | |
| 113 | + | Image: expectedImageRef, | |
| 114 | + | Status: "running", | |
| 115 | + | Ports: []int{8080}, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 119 | + | mockRuntime.EXPECT().StartContainer(ctx, "container789").Return(nil) | |
| 120 | + | mockRuntime.EXPECT().InspectContainer(ctx, "container789").Return(expectedContainer, nil) | |
| 121 | + | ||
| 122 | + | container, err := manager.DeployContainer(ctx, route) | |
| 123 | + | ||
| 124 | + | assert.NoError(t, err) | |
| 125 | + | assert.Equal(t, expectedContainer, container) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestManager_DeployContainer_ReplaceExisting(t *testing.T) { | |
| 129 | + | ctrl := gomock.NewController(t) | |
| 130 | + | defer ctrl.Finish() | |
| 131 | + | ||
| 132 | + | ctx := testutils.TestContext(t) | |
| 133 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 134 | + | ||
| 135 | + | cfg := &config.Config{ | |
| 136 | + | Server: config.ServerConfig{ | |
| 137 | + | Runtime: "docker", | |
| 138 | + | }, | |
| 139 | + | Volumes: config.VolumeConfig{ | |
| 140 | + | AutoCreate: false, | |
| 141 | + | }, | |
| 142 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 143 | + | Enabled: false, | |
| 144 | + | }, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | ||
| 150 | + | // Add existing container | |
| 151 | + | existingContainer := &runtime.Container{ | |
| 152 | + | ID: "old-container", | |
| 153 | + | Name: "gordon-test.example.com", | |
| 154 | + | Status: "running", | |
| 155 | + | } | |
| 156 | + | manager.containers["test.example.com"] = existingContainer | |
| 157 | + | ||
| 158 | + | route := config.Route{ | |
| 159 | + | Domain: "test.example.com", | |
| 160 | + | Image: "nginx:latest", | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Should stop and remove existing container | |
| 164 | + | mockRuntime.EXPECT().StopContainer(ctx, "old-container").Return(nil) | |
| 165 | + | mockRuntime.EXPECT().RemoveContainer(ctx, "old-container", true).Return(nil) | |
| 166 | + | ||
| 167 | + | // Check for orphaned containers | |
| 168 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 169 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"nginx:latest"}, nil) | |
| 170 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "nginx:latest").Return([]int{80}, nil) | |
| 171 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "nginx:latest").Return([]string{}, nil) | |
| 172 | + | ||
| 173 | + | newContainer := &runtime.Container{ | |
| 174 | + | ID: "new-container", | |
| 175 | + | Name: "gordon-test.example.com", | |
| 176 | + | Image: "nginx:latest", | |
| 177 | + | Status: "running", | |
| 178 | + | Ports: []int{80}, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(newContainer, nil) | |
| 182 | + | mockRuntime.EXPECT().StartContainer(ctx, "new-container").Return(nil) | |
| 183 | + | mockRuntime.EXPECT().InspectContainer(ctx, "new-container").Return(newContainer, nil) | |
| 184 | + | ||
| 185 | + | container, err := manager.DeployContainer(ctx, route) | |
| 186 | + | ||
| 187 | + | assert.NoError(t, err) | |
| 188 | + | assert.Equal(t, newContainer, container) | |
| 189 | + | assert.Equal(t, newContainer, manager.containers["test.example.com"]) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestManager_DeployContainer_Errors(t *testing.T) { | |
| 193 | + | tests := []struct { | |
| 194 | + | name string | |
| 195 | + | setupMocks func(*mocks.MockRuntime) | |
| 196 | + | expectedError string | |
| 197 | + | }{ | |
| 198 | + | { | |
| 199 | + | name: "pull image error", | |
| 200 | + | setupMocks: func(m *mocks.MockRuntime) { | |
| 201 | + | m.EXPECT().ListContainers(gomock.Any(), true).Return([]*runtime.Container{}, nil) | |
| 202 | + | m.EXPECT().ListImages(gomock.Any()).Return([]string{}, nil) | |
| 203 | + | m.EXPECT().PullImage(gomock.Any(), "nginx:latest").Return(errors.New("pull failed")) | |
| 204 | + | }, | |
| 205 | + | expectedError: "failed to pull image", | |
| 206 | + | }, | |
| 207 | + | { | |
| 208 | + | name: "create container error", | |
| 209 | + | setupMocks: func(m *mocks.MockRuntime) { | |
| 210 | + | m.EXPECT().ListContainers(gomock.Any(), true).Return([]*runtime.Container{}, nil) | |
| 211 | + | m.EXPECT().ListImages(gomock.Any()).Return([]string{"nginx:latest"}, nil) | |
| 212 | + | m.EXPECT().GetImageExposedPorts(gomock.Any(), "nginx:latest").Return([]int{80}, nil) | |
| 213 | + | m.EXPECT().InspectImageEnv(gomock.Any(), "nginx:latest").Return([]string{}, nil) | |
| 214 | + | m.EXPECT().CreateContainer(gomock.Any(), gomock.Any()).Return(nil, errors.New("create failed")) | |
| 215 | + | }, | |
| 216 | + | expectedError: "failed to create container", | |
| 217 | + | }, | |
| 218 | + | { | |
| 219 | + | name: "start container error", | |
| 220 | + | setupMocks: func(m *mocks.MockRuntime) { | |
| 221 | + | m.EXPECT().ListContainers(gomock.Any(), true).Return([]*runtime.Container{}, nil) | |
| 222 | + | m.EXPECT().ListImages(gomock.Any()).Return([]string{"nginx:latest"}, nil) | |
| 223 | + | m.EXPECT().GetImageExposedPorts(gomock.Any(), "nginx:latest").Return([]int{80}, nil) | |
| 224 | + | m.EXPECT().InspectImageEnv(gomock.Any(), "nginx:latest").Return([]string{}, nil) | |
| 225 | + | container := &runtime.Container{ID: "test123"} | |
| 226 | + | m.EXPECT().CreateContainer(gomock.Any(), gomock.Any()).Return(container, nil) | |
| 227 | + | m.EXPECT().StartContainer(gomock.Any(), "test123").Return(errors.New("start failed")) | |
| 228 | + | m.EXPECT().RemoveContainer(gomock.Any(), "test123", true).Return(nil) | |
| 229 | + | }, | |
| 230 | + | expectedError: "failed to start container", | |
| 231 | + | }, | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | for _, tt := range tests { | |
| 235 | + | t.Run(tt.name, func(t *testing.T) { | |
| 236 | + | ctrl := gomock.NewController(t) | |
| 237 | + | defer ctrl.Finish() | |
| 238 | + | ||
| 239 | + | ctx := testutils.TestContext(t) | |
| 240 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 241 | + | ||
| 242 | + | cfg := &config.Config{ | |
| 243 | + | Server: config.ServerConfig{ | |
| 244 | + | Runtime: "docker", | |
| 245 | + | }, | |
| 246 | + | Volumes: config.VolumeConfig{ | |
| 247 | + | AutoCreate: false, | |
| 248 | + | }, | |
| 249 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 250 | + | Enabled: false, | |
| 251 | + | }, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 255 | + | require.NoError(t, err) | |
| 256 | + | ||
| 257 | + | route := config.Route{ | |
| 258 | + | Domain: "test.example.com", | |
| 259 | + | Image: "nginx:latest", | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | tt.setupMocks(mockRuntime) | |
| 263 | + | ||
| 264 | + | _, err = manager.DeployContainer(ctx, route) | |
| 265 | + | assert.Error(t, err) | |
| 266 | + | assert.Contains(t, err.Error(), tt.expectedError) | |
| 267 | + | }) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func TestManager_DeployContainer_WithNetworkAttachments(t *testing.T) { | |
| 272 | + | ctrl := gomock.NewController(t) | |
| 273 | + | defer ctrl.Finish() | |
| 274 | + | ||
| 275 | + | ctx := testutils.TestContext(t) | |
| 276 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 277 | + | ||
| 278 | + | cfg := &config.Config{ | |
| 279 | + | Server: config.ServerConfig{ | |
| 280 | + | Runtime: "docker", | |
| 281 | + | }, | |
| 282 | + | Volumes: config.VolumeConfig{ | |
| 283 | + | AutoCreate: false, | |
| 284 | + | }, | |
| 285 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 286 | + | Enabled: true, | |
| 287 | + | NetworkPrefix: "gordon", | |
| 288 | + | }, | |
| 289 | + | NetworkGroups: map[string][]string{ | |
| 290 | + | "backend": {"api.example.com", "worker.example.com"}, | |
| 291 | + | }, | |
| 292 | + | Attachments: map[string][]string{ | |
| 293 | + | "api.example.com": {"redis:latest"}, | |
| 294 | + | "backend": {"postgres:latest"}, | |
| 295 | + | }, | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | ||
| 301 | + | route := config.Route{ | |
| 302 | + | Domain: "api.example.com", | |
| 303 | + | Image: "myapi:latest", | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // Main container deployment expectations | |
| 307 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 308 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"myapi:latest"}, nil) | |
| 309 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "myapi:latest").Return([]int{8080}, nil) | |
| 310 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "myapi:latest").Return([]string{}, nil) | |
| 311 | + | mockRuntime.EXPECT().NetworkExists(ctx, "gordon-backend").Return(true, nil) | |
| 312 | + | ||
| 313 | + | expectedContainer := &runtime.Container{ | |
| 314 | + | ID: "main-container", | |
| 315 | + | Name: "gordon-api.example.com", | |
| 316 | + | Image: "myapi:latest", | |
| 317 | + | Status: "running", | |
| 318 | + | Ports: []int{8080}, | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 322 | + | mockRuntime.EXPECT().StartContainer(ctx, "main-container").Return(nil) | |
| 323 | + | mockRuntime.EXPECT().InspectContainer(ctx, "main-container").Return(expectedContainer, nil) | |
| 324 | + | ||
| 325 | + | // Attachment deployment expectations for app-specific service | |
| 326 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil) | |
| 327 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil) | |
| 328 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil) | |
| 329 | + | ||
| 330 | + | redisContainer := &runtime.Container{ | |
| 331 | + | ID: "redis-container", | |
| 332 | + | Name: "api-example-com-redis", | |
| 333 | + | } | |
| 334 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(redisContainer, nil) | |
| 335 | + | mockRuntime.EXPECT().StartContainer(ctx, "redis-container").Return(nil) | |
| 336 | + | ||
| 337 | + | // Attachment deployment expectations for network group service | |
| 338 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{redisContainer}, nil) | |
| 339 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil) | |
| 340 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil) | |
| 341 | + | ||
| 342 | + | pgContainer := &runtime.Container{ | |
| 343 | + | ID: "pg-container", | |
| 344 | + | Name: "gordon-shared-postgres", | |
| 345 | + | } | |
| 346 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(pgContainer, nil) | |
| 347 | + | mockRuntime.EXPECT().StartContainer(ctx, "pg-container").Return(nil) | |
| 348 | + | ||
| 349 | + | container, err := manager.DeployContainer(ctx, route) | |
| 350 | + | ||
| 351 | + | assert.NoError(t, err) | |
| 352 | + | assert.Equal(t, expectedContainer, container) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func TestManager_DeployAttachedService(t *testing.T) { | |
| 356 | + | ctrl := gomock.NewController(t) | |
| 357 | + | defer ctrl.Finish() | |
| 358 | + | ||
| 359 | + | ctx := testutils.TestContext(t) | |
| 360 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 361 | + | ||
| 362 | + | cfg := &config.Config{ | |
| 363 | + | Volumes: config.VolumeConfig{ | |
| 364 | + | AutoCreate: true, | |
| 365 | + | Prefix: "gordon", | |
| 366 | + | }, | |
| 367 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 368 | + | NetworkPrefix: "gordon", | |
| 369 | + | }, | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 373 | + | require.NoError(t, err) | |
| 374 | + | ||
| 375 | + | // Test deploying redis service for an app | |
| 376 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil) | |
| 377 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil) | |
| 378 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{"REDIS_VERSION=7.0"}, nil) | |
| 379 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "redis:latest").Return([]string{"/data"}, nil) | |
| 380 | + | mockRuntime.EXPECT().VolumeExists(ctx, "gordon-test-example-com-data").Return(false, nil) | |
| 381 | + | mockRuntime.EXPECT().CreateVolume(ctx, "gordon-test-example-com-data").Return(nil) | |
| 382 | + | ||
| 383 | + | expectedContainer := &runtime.Container{ | |
| 384 | + | ID: "redis-service", | |
| 385 | + | Name: "test-example-com-redis", | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 389 | + | mockRuntime.EXPECT().StartContainer(ctx, "redis-service").Return(nil) | |
| 390 | + | ||
| 391 | + | err = manager.DeployAttachedService(ctx, "test.example.com", "redis:latest") | |
| 392 | + | ||
| 393 | + | assert.NoError(t, err) | |
| 394 | + | }⏎̸ | |
A internal/container/manager_network_test.go +421 -0
| @@ -0,0 +1,421 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | "go.uber.org/mock/gomock" | |
| 10 | + | ||
| 11 | + | "gordon/internal/config" | |
| 12 | + | "gordon/internal/testutils" | |
| 13 | + | "gordon/internal/testutils/mocks" | |
| 14 | + | "gordon/pkg/runtime" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestManager_GetNetworkForApp_WithGroups(t *testing.T) { | |
| 18 | + | manager := &Manager{ | |
| 19 | + | config: &config.Config{ | |
| 20 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 21 | + | Enabled: true, | |
| 22 | + | NetworkPrefix: "gordon", | |
| 23 | + | }, | |
| 24 | + | NetworkGroups: map[string][]string{ | |
| 25 | + | "backend": {"api.example.com", "worker.example.com"}, | |
| 26 | + | "frontend": {"web.example.com", "cdn.example.com"}, | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Test app in backend group | |
| 32 | + | network := manager.GetNetworkForApp("api.example.com") | |
| 33 | + | assert.Equal(t, "gordon-backend", network) | |
| 34 | + | ||
| 35 | + | // Test app in frontend group | |
| 36 | + | network = manager.GetNetworkForApp("web.example.com") | |
| 37 | + | assert.Equal(t, "gordon-frontend", network) | |
| 38 | + | ||
| 39 | + | // Test standalone app | |
| 40 | + | network = manager.GetNetworkForApp("standalone.example.com") | |
| 41 | + | assert.Equal(t, "gordon-standalone-example-com", network) | |
| 42 | + | ||
| 43 | + | // Test disabled network isolation | |
| 44 | + | manager.config.NetworkIsolation.Enabled = false | |
| 45 | + | network = manager.GetNetworkForApp("test.example.com") | |
| 46 | + | assert.Equal(t, "bridge", network) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestManager_generateNetworkName(t *testing.T) { | |
| 50 | + | manager := &Manager{ | |
| 51 | + | config: &config.Config{ | |
| 52 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 53 | + | NetworkPrefix: "gordon", | |
| 54 | + | }, | |
| 55 | + | }, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | networkName := manager.generateNetworkName("test.example.com") | |
| 59 | + | assert.Equal(t, "gordon-test-example-com", networkName) | |
| 60 | + | ||
| 61 | + | networkName = manager.generateNetworkName("backend") | |
| 62 | + | assert.Equal(t, "gordon-backend", networkName) | |
| 63 | + | ||
| 64 | + | networkName = manager.generateNetworkName("complex-app.subdomain.example.com") | |
| 65 | + | assert.Equal(t, "gordon-complex-app-subdomain-example-com", networkName) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestManager_GetAppsForNetwork(t *testing.T) { | |
| 69 | + | manager := &Manager{ | |
| 70 | + | config: &config.Config{ | |
| 71 | + | NetworkGroups: map[string][]string{ | |
| 72 | + | "backend": {"api.example.com", "worker.example.com"}, | |
| 73 | + | "frontend": {"web.example.com"}, | |
| 74 | + | }, | |
| 75 | + | }, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Test network group | |
| 79 | + | apps := manager.GetAppsForNetwork("backend") | |
| 80 | + | assert.Equal(t, []string{"api.example.com", "worker.example.com"}, apps) | |
| 81 | + | ||
| 82 | + | // Test single app group | |
| 83 | + | apps = manager.GetAppsForNetwork("frontend") | |
| 84 | + | assert.Equal(t, []string{"web.example.com"}, apps) | |
| 85 | + | ||
| 86 | + | // Test single app (not in any group) | |
| 87 | + | apps = manager.GetAppsForNetwork("standalone.example.com") | |
| 88 | + | assert.Equal(t, []string{"standalone.example.com"}, apps) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestManager_CreateNetworkIfNeeded(t *testing.T) { | |
| 92 | + | ctrl := gomock.NewController(t) | |
| 93 | + | defer ctrl.Finish() | |
| 94 | + | ||
| 95 | + | ctx := testutils.TestContext(t) | |
| 96 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 97 | + | ||
| 98 | + | cfg := &config.Config{} | |
| 99 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | ||
| 102 | + | // Test bridge network (should not try to create) | |
| 103 | + | err = manager.CreateNetworkIfNeeded(ctx, "bridge") | |
| 104 | + | assert.NoError(t, err) | |
| 105 | + | ||
| 106 | + | // Test default network (should not try to create) | |
| 107 | + | err = manager.CreateNetworkIfNeeded(ctx, "default") | |
| 108 | + | assert.NoError(t, err) | |
| 109 | + | ||
| 110 | + | // Test custom network that doesn't exist | |
| 111 | + | mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test").Return(false, nil) | |
| 112 | + | mockRuntime.EXPECT().CreateNetwork(ctx, "gordon-test", map[string]string{"driver": "bridge"}).Return(nil) | |
| 113 | + | ||
| 114 | + | err = manager.CreateNetworkIfNeeded(ctx, "gordon-test") | |
| 115 | + | assert.NoError(t, err) | |
| 116 | + | ||
| 117 | + | // Test custom network that already exists | |
| 118 | + | mockRuntime.EXPECT().NetworkExists(ctx, "gordon-existing").Return(true, nil) | |
| 119 | + | ||
| 120 | + | err = manager.CreateNetworkIfNeeded(ctx, "gordon-existing") | |
| 121 | + | assert.NoError(t, err) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestManager_CreateNetworkIfNeeded_Errors(t *testing.T) { | |
| 125 | + | ctrl := gomock.NewController(t) | |
| 126 | + | defer ctrl.Finish() | |
| 127 | + | ||
| 128 | + | ctx := testutils.TestContext(t) | |
| 129 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 130 | + | ||
| 131 | + | cfg := &config.Config{} | |
| 132 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | ||
| 135 | + | // Test error checking if network exists | |
| 136 | + | mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test").Return(false, errors.New("check failed")) | |
| 137 | + | ||
| 138 | + | err = manager.CreateNetworkIfNeeded(ctx, "gordon-test") | |
| 139 | + | assert.Error(t, err) | |
| 140 | + | assert.Contains(t, err.Error(), "failed to check if network exists") | |
| 141 | + | ||
| 142 | + | // Test error creating network | |
| 143 | + | mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test").Return(false, nil) | |
| 144 | + | mockRuntime.EXPECT().CreateNetwork(ctx, "gordon-test", map[string]string{"driver": "bridge"}).Return(errors.New("create failed")) | |
| 145 | + | ||
| 146 | + | err = manager.CreateNetworkIfNeeded(ctx, "gordon-test") | |
| 147 | + | assert.Error(t, err) | |
| 148 | + | assert.Contains(t, err.Error(), "failed to create network") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestManager_cleanupNetworkIfEmpty(t *testing.T) { | |
| 152 | + | ctrl := gomock.NewController(t) | |
| 153 | + | defer ctrl.Finish() | |
| 154 | + | ||
| 155 | + | ctx := testutils.TestContext(t) | |
| 156 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 157 | + | ||
| 158 | + | cfg := &config.Config{} | |
| 159 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | ||
| 162 | + | // Test cleanup of empty network | |
| 163 | + | networks := []*runtime.NetworkInfo{ | |
| 164 | + | { | |
| 165 | + | Name: "gordon-test", | |
| 166 | + | Containers: []string{}, // Empty network | |
| 167 | + | }, | |
| 168 | + | { | |
| 169 | + | Name: "gordon-used", | |
| 170 | + | Containers: []string{"container1"}, // Has containers | |
| 171 | + | }, | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | mockRuntime.EXPECT().ListNetworks(ctx).Return(networks, nil) | |
| 175 | + | mockRuntime.EXPECT().RemoveNetwork(ctx, "gordon-test").Return(nil) | |
| 176 | + | ||
| 177 | + | err = manager.cleanupNetworkIfEmpty(ctx, "gordon-test") | |
| 178 | + | assert.NoError(t, err) | |
| 179 | + | ||
| 180 | + | // Test network with containers (should not be removed) | |
| 181 | + | mockRuntime.EXPECT().ListNetworks(ctx).Return(networks, nil) | |
| 182 | + | ||
| 183 | + | err = manager.cleanupNetworkIfEmpty(ctx, "gordon-used") | |
| 184 | + | assert.NoError(t, err) | |
| 185 | + | ||
| 186 | + | // Test bridge network (should not try to remove) | |
| 187 | + | err = manager.cleanupNetworkIfEmpty(ctx, "bridge") | |
| 188 | + | assert.NoError(t, err) | |
| 189 | + | ||
| 190 | + | // Test default network (should not try to remove) | |
| 191 | + | err = manager.cleanupNetworkIfEmpty(ctx, "default") | |
| 192 | + | assert.NoError(t, err) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestManager_cleanupNetworkIfEmpty_Errors(t *testing.T) { | |
| 196 | + | ctrl := gomock.NewController(t) | |
| 197 | + | defer ctrl.Finish() | |
| 198 | + | ||
| 199 | + | ctx := testutils.TestContext(t) | |
| 200 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 201 | + | ||
| 202 | + | cfg := &config.Config{} | |
| 203 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | ||
| 206 | + | // Test error listing networks | |
| 207 | + | mockRuntime.EXPECT().ListNetworks(ctx).Return(nil, errors.New("list failed")) | |
| 208 | + | ||
| 209 | + | err = manager.cleanupNetworkIfEmpty(ctx, "gordon-test") | |
| 210 | + | assert.Error(t, err) | |
| 211 | + | assert.Contains(t, err.Error(), "failed to list networks") | |
| 212 | + | ||
| 213 | + | // Test error removing network | |
| 214 | + | networks := []*runtime.NetworkInfo{ | |
| 215 | + | { | |
| 216 | + | Name: "gordon-test", | |
| 217 | + | Containers: []string{}, | |
| 218 | + | }, | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | mockRuntime.EXPECT().ListNetworks(ctx).Return(networks, nil) | |
| 222 | + | mockRuntime.EXPECT().RemoveNetwork(ctx, "gordon-test").Return(errors.New("remove failed")) | |
| 223 | + | ||
| 224 | + | err = manager.cleanupNetworkIfEmpty(ctx, "gordon-test") | |
| 225 | + | assert.Error(t, err) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestManager_DeployAttachedService_SharedService(t *testing.T) { | |
| 229 | + | ctrl := gomock.NewController(t) | |
| 230 | + | defer ctrl.Finish() | |
| 231 | + | ||
| 232 | + | ctx := testutils.TestContext(t) | |
| 233 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 234 | + | ||
| 235 | + | cfg := &config.Config{ | |
| 236 | + | Volumes: config.VolumeConfig{ | |
| 237 | + | AutoCreate: false, | |
| 238 | + | }, | |
| 239 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 240 | + | NetworkPrefix: "gordon", | |
| 241 | + | }, | |
| 242 | + | NetworkGroups: map[string][]string{ | |
| 243 | + | "backend": {"api.example.com", "worker.example.com"}, | |
| 244 | + | }, | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | ||
| 250 | + | // Test deploying shared service for network group | |
| 251 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil) | |
| 252 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil) | |
| 253 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{"POSTGRES_VERSION=15"}, nil) | |
| 254 | + | ||
| 255 | + | expectedContainer := &runtime.Container{ | |
| 256 | + | ID: "postgres-service", | |
| 257 | + | Name: "gordon-shared-postgres", | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 261 | + | mockRuntime.EXPECT().StartContainer(ctx, "postgres-service").Return(nil) | |
| 262 | + | ||
| 263 | + | err = manager.DeployAttachedService(ctx, "backend", "postgres:latest") | |
| 264 | + | ||
| 265 | + | assert.NoError(t, err) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func TestManager_DeployAttachedService_AppSpecific(t *testing.T) { | |
| 269 | + | ctrl := gomock.NewController(t) | |
| 270 | + | defer ctrl.Finish() | |
| 271 | + | ||
| 272 | + | ctx := testutils.TestContext(t) | |
| 273 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 274 | + | ||
| 275 | + | cfg := &config.Config{ | |
| 276 | + | Volumes: config.VolumeConfig{ | |
| 277 | + | AutoCreate: false, | |
| 278 | + | }, | |
| 279 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 280 | + | NetworkPrefix: "gordon", | |
| 281 | + | }, | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 285 | + | require.NoError(t, err) | |
| 286 | + | ||
| 287 | + | // Test deploying app-specific service | |
| 288 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil) | |
| 289 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil) | |
| 290 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil) | |
| 291 | + | ||
| 292 | + | expectedContainer := &runtime.Container{ | |
| 293 | + | ID: "redis-service", | |
| 294 | + | Name: "api-example-com-redis", | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 298 | + | mockRuntime.EXPECT().StartContainer(ctx, "redis-service").Return(nil) | |
| 299 | + | ||
| 300 | + | err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest") | |
| 301 | + | ||
| 302 | + | assert.NoError(t, err) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func TestManager_DeployAttachedService_AlreadyRunning(t *testing.T) { | |
| 306 | + | ctrl := gomock.NewController(t) | |
| 307 | + | defer ctrl.Finish() | |
| 308 | + | ||
| 309 | + | ctx := testutils.TestContext(t) | |
| 310 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 311 | + | ||
| 312 | + | cfg := &config.Config{ | |
| 313 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 314 | + | NetworkPrefix: "gordon", | |
| 315 | + | }, | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 319 | + | require.NoError(t, err) | |
| 320 | + | ||
| 321 | + | // Mock existing container with same name | |
| 322 | + | existingContainers := []*runtime.Container{ | |
| 323 | + | { | |
| 324 | + | ID: "existing-redis", | |
| 325 | + | Name: "api-example-com-redis", | |
| 326 | + | }, | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return(existingContainers, nil) | |
| 330 | + | ||
| 331 | + | err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest") | |
| 332 | + | ||
| 333 | + | assert.NoError(t, err) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func TestManager_DeployAttachedService_WithVolumes(t *testing.T) { | |
| 337 | + | ctrl := gomock.NewController(t) | |
| 338 | + | defer ctrl.Finish() | |
| 339 | + | ||
| 340 | + | ctx := testutils.TestContext(t) | |
| 341 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 342 | + | ||
| 343 | + | cfg := &config.Config{ | |
| 344 | + | Volumes: config.VolumeConfig{ | |
| 345 | + | AutoCreate: true, | |
| 346 | + | Prefix: "gordon", | |
| 347 | + | }, | |
| 348 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 349 | + | NetworkPrefix: "gordon", | |
| 350 | + | }, | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 354 | + | require.NoError(t, err) | |
| 355 | + | ||
| 356 | + | // Test service with volumes | |
| 357 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil) | |
| 358 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil) | |
| 359 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil) | |
| 360 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "postgres:latest").Return([]string{"/var/lib/postgresql/data"}, nil) | |
| 361 | + | ||
| 362 | + | // Volume creation expectations | |
| 363 | + | mockRuntime.EXPECT().VolumeExists(ctx, "gordon-api-example-com-var-lib-postgresql-data").Return(false, nil) | |
| 364 | + | mockRuntime.EXPECT().CreateVolume(ctx, "gordon-api-example-com-var-lib-postgresql-data").Return(nil) | |
| 365 | + | ||
| 366 | + | expectedContainer := &runtime.Container{ | |
| 367 | + | ID: "postgres-service", | |
| 368 | + | Name: "api-example-com-postgres", | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 372 | + | mockRuntime.EXPECT().StartContainer(ctx, "postgres-service").Return(nil) | |
| 373 | + | ||
| 374 | + | err = manager.DeployAttachedService(ctx, "api.example.com", "postgres:latest") | |
| 375 | + | ||
| 376 | + | assert.NoError(t, err) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func TestManager_DeployAttachedService_Errors(t *testing.T) { | |
| 380 | + | ctrl := gomock.NewController(t) | |
| 381 | + | defer ctrl.Finish() | |
| 382 | + | ||
| 383 | + | ctx := testutils.TestContext(t) | |
| 384 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 385 | + | ||
| 386 | + | cfg := &config.Config{ | |
| 387 | + | Volumes: config.VolumeConfig{ | |
| 388 | + | AutoCreate: false, | |
| 389 | + | }, | |
| 390 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 391 | + | NetworkPrefix: "gordon", | |
| 392 | + | }, | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 396 | + | require.NoError(t, err) | |
| 397 | + | ||
| 398 | + | // Test create container error | |
| 399 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil) | |
| 400 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil) | |
| 401 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil) | |
| 402 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(nil, errors.New("create failed")) | |
| 403 | + | ||
| 404 | + | err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest") | |
| 405 | + | assert.Error(t, err) | |
| 406 | + | assert.Contains(t, err.Error(), "failed to create service container") | |
| 407 | + | ||
| 408 | + | // Test start container error | |
| 409 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return([]*runtime.Container{}, nil) | |
| 410 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "redis:latest").Return([]int{6379}, nil) | |
| 411 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "redis:latest").Return([]string{}, nil) | |
| 412 | + | ||
| 413 | + | container := &runtime.Container{ID: "service123", Name: "api-example-com-redis"} | |
| 414 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(container, nil) | |
| 415 | + | mockRuntime.EXPECT().StartContainer(ctx, "service123").Return(errors.New("start failed")) | |
| 416 | + | mockRuntime.EXPECT().RemoveContainer(ctx, "service123", true).Return(nil) | |
| 417 | + | ||
| 418 | + | err = manager.DeployAttachedService(ctx, "api.example.com", "redis:latest") | |
| 419 | + | assert.Error(t, err) | |
| 420 | + | assert.Contains(t, err.Error(), "failed to start service container") | |
| 421 | + | }⏎̸ | |
A internal/container/manager_operations_test.go +517 -0
| @@ -0,0 +1,517 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | "go.uber.org/mock/gomock" | |
| 11 | + | ||
| 12 | + | "gordon/internal/config" | |
| 13 | + | "gordon/internal/testutils" | |
| 14 | + | "gordon/internal/testutils/mocks" | |
| 15 | + | "gordon/pkg/runtime" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestManager_SyncContainers(t *testing.T) { | |
| 19 | + | ctrl := gomock.NewController(t) | |
| 20 | + | defer ctrl.Finish() | |
| 21 | + | ||
| 22 | + | ctx := testutils.TestContext(t) | |
| 23 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 24 | + | ||
| 25 | + | cfg := &config.Config{} | |
| 26 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | ||
| 29 | + | // Add some containers to internal state that might be stale | |
| 30 | + | manager.containers["old.example.com"] = &runtime.Container{ID: "old-container"} | |
| 31 | + | ||
| 32 | + | // Mock runtime returning current containers | |
| 33 | + | currentContainers := []*runtime.Container{ | |
| 34 | + | { | |
| 35 | + | ID: "active-container-1", | |
| 36 | + | Name: "gordon-app1.example.com", | |
| 37 | + | Labels: map[string]string{ | |
| 38 | + | "gordon.managed": "true", | |
| 39 | + | "gordon.domain": "app1.example.com", | |
| 40 | + | }, | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | ID: "active-container-2", | |
| 44 | + | Name: "gordon-app2.example.com", | |
| 45 | + | Labels: map[string]string{ | |
| 46 | + | "gordon.managed": "true", | |
| 47 | + | "gordon.domain": "app2.example.com", | |
| 48 | + | }, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | ID: "unmanaged-container", | |
| 52 | + | Name: "some-other-container", | |
| 53 | + | Labels: map[string]string{ | |
| 54 | + | "other.label": "value", | |
| 55 | + | }, | |
| 56 | + | }, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return(currentContainers, nil) | |
| 60 | + | ||
| 61 | + | err = manager.SyncContainers(ctx) | |
| 62 | + | ||
| 63 | + | assert.NoError(t, err) | |
| 64 | + | assert.Len(t, manager.containers, 2) | |
| 65 | + | assert.Contains(t, manager.containers, "app1.example.com") | |
| 66 | + | assert.Contains(t, manager.containers, "app2.example.com") | |
| 67 | + | assert.NotContains(t, manager.containers, "old.example.com") | |
| 68 | + | assert.Equal(t, "active-container-1", manager.containers["app1.example.com"].ID) | |
| 69 | + | assert.Equal(t, "active-container-2", manager.containers["app2.example.com"].ID) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestManager_SyncContainers_Error(t *testing.T) { | |
| 73 | + | ctrl := gomock.NewController(t) | |
| 74 | + | defer ctrl.Finish() | |
| 75 | + | ||
| 76 | + | ctx := testutils.TestContext(t) | |
| 77 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 78 | + | ||
| 79 | + | cfg := &config.Config{} | |
| 80 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | ||
| 83 | + | mockRuntime.EXPECT().ListContainers(ctx, false).Return(nil, errors.New("runtime error")) | |
| 84 | + | ||
| 85 | + | err = manager.SyncContainers(ctx) | |
| 86 | + | ||
| 87 | + | assert.Error(t, err) | |
| 88 | + | assert.Contains(t, err.Error(), "failed to list containers") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestManager_StopContainer(t *testing.T) { | |
| 92 | + | ctrl := gomock.NewController(t) | |
| 93 | + | defer ctrl.Finish() | |
| 94 | + | ||
| 95 | + | ctx := testutils.TestContext(t) | |
| 96 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 97 | + | ||
| 98 | + | cfg := &config.Config{} | |
| 99 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | ||
| 102 | + | mockRuntime.EXPECT().StopContainer(ctx, "container123").Return(nil) | |
| 103 | + | ||
| 104 | + | err = manager.StopContainer(ctx, "container123") | |
| 105 | + | ||
| 106 | + | assert.NoError(t, err) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestManager_StopContainer_Error(t *testing.T) { | |
| 110 | + | ctrl := gomock.NewController(t) | |
| 111 | + | defer ctrl.Finish() | |
| 112 | + | ||
| 113 | + | ctx := testutils.TestContext(t) | |
| 114 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 115 | + | ||
| 116 | + | cfg := &config.Config{} | |
| 117 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | ||
| 120 | + | mockRuntime.EXPECT().StopContainer(ctx, "container123").Return(errors.New("stop failed")) | |
| 121 | + | ||
| 122 | + | err = manager.StopContainer(ctx, "container123") | |
| 123 | + | ||
| 124 | + | assert.Error(t, err) | |
| 125 | + | assert.Contains(t, err.Error(), "failed to stop container") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestManager_StopContainerByDomain(t *testing.T) { | |
| 129 | + | ctrl := gomock.NewController(t) | |
| 130 | + | defer ctrl.Finish() | |
| 131 | + | ||
| 132 | + | ctx := testutils.TestContext(t) | |
| 133 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 134 | + | ||
| 135 | + | cfg := &config.Config{} | |
| 136 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | ||
| 139 | + | container := &runtime.Container{ | |
| 140 | + | ID: "container123", | |
| 141 | + | } | |
| 142 | + | manager.containers["test.example.com"] = container | |
| 143 | + | ||
| 144 | + | mockRuntime.EXPECT().StopContainer(ctx, "container123").Return(nil) | |
| 145 | + | ||
| 146 | + | err = manager.StopContainerByDomain(ctx, "test.example.com") | |
| 147 | + | ||
| 148 | + | assert.NoError(t, err) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestManager_StopContainerByDomain_NotFound(t *testing.T) { | |
| 152 | + | cfg := &config.Config{} | |
| 153 | + | manager := &Manager{ | |
| 154 | + | config: cfg, | |
| 155 | + | containers: make(map[string]*runtime.Container), | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | ctx := testutils.TestContext(t) | |
| 159 | + | err := manager.StopContainerByDomain(ctx, "nonexistent.example.com") | |
| 160 | + | ||
| 161 | + | assert.Error(t, err) | |
| 162 | + | assert.Contains(t, err.Error(), "no container found for domain") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestManager_RemoveContainer(t *testing.T) { | |
| 166 | + | ctrl := gomock.NewController(t) | |
| 167 | + | defer ctrl.Finish() | |
| 168 | + | ||
| 169 | + | ctx := testutils.TestContext(t) | |
| 170 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 171 | + | ||
| 172 | + | cfg := &config.Config{ | |
| 173 | + | Volumes: config.VolumeConfig{ | |
| 174 | + | Preserve: true, // Don't cleanup volumes | |
| 175 | + | }, | |
| 176 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 177 | + | Enabled: false, | |
| 178 | + | }, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | ||
| 184 | + | container := &runtime.Container{ | |
| 185 | + | ID: "container123", | |
| 186 | + | } | |
| 187 | + | manager.containers["test.example.com"] = container | |
| 188 | + | ||
| 189 | + | mockRuntime.EXPECT().RemoveContainer(ctx, "container123", true).Return(nil) | |
| 190 | + | ||
| 191 | + | err = manager.RemoveContainer(ctx, "container123", true) | |
| 192 | + | ||
| 193 | + | assert.NoError(t, err) | |
| 194 | + | assert.NotContains(t, manager.containers, "test.example.com") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestManager_RemoveContainer_WithVolumeCleanup(t *testing.T) { | |
| 198 | + | ctrl := gomock.NewController(t) | |
| 199 | + | defer ctrl.Finish() | |
| 200 | + | ||
| 201 | + | ctx := testutils.TestContext(t) | |
| 202 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 203 | + | ||
| 204 | + | cfg := &config.Config{ | |
| 205 | + | Volumes: config.VolumeConfig{ | |
| 206 | + | Preserve: false, // Should cleanup volumes | |
| 207 | + | Prefix: "gordon", | |
| 208 | + | }, | |
| 209 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 210 | + | Enabled: false, | |
| 211 | + | }, | |
| 212 | + | Routes: map[string]string{ | |
| 213 | + | "test.example.com": "nginx:latest", | |
| 214 | + | }, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | ||
| 220 | + | container := &runtime.Container{ | |
| 221 | + | ID: "container123", | |
| 222 | + | } | |
| 223 | + | manager.containers["test.example.com"] = container | |
| 224 | + | ||
| 225 | + | mockRuntime.EXPECT().RemoveContainer(ctx, "container123", true).Return(nil) | |
| 226 | + | ||
| 227 | + | // Expect volume cleanup | |
| 228 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil) | |
| 229 | + | varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx") | |
| 230 | + | mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(true, nil) | |
| 231 | + | mockRuntime.EXPECT().RemoveVolume(ctx, varLogNginxVolume, true).Return(nil) | |
| 232 | + | ||
| 233 | + | err = manager.RemoveContainer(ctx, "container123", true) | |
| 234 | + | ||
| 235 | + | assert.NoError(t, err) | |
| 236 | + | assert.NotContains(t, manager.containers, "test.example.com") | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func TestManager_RemoveContainer_WithNetworkCleanup(t *testing.T) { | |
| 240 | + | ctrl := gomock.NewController(t) | |
| 241 | + | defer ctrl.Finish() | |
| 242 | + | ||
| 243 | + | ctx := testutils.TestContext(t) | |
| 244 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 245 | + | ||
| 246 | + | cfg := &config.Config{ | |
| 247 | + | Volumes: config.VolumeConfig{ | |
| 248 | + | Preserve: true, | |
| 249 | + | }, | |
| 250 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 251 | + | Enabled: true, | |
| 252 | + | NetworkPrefix: "gordon", | |
| 253 | + | }, | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 257 | + | require.NoError(t, err) | |
| 258 | + | ||
| 259 | + | container := &runtime.Container{ | |
| 260 | + | ID: "container123", | |
| 261 | + | } | |
| 262 | + | manager.containers["test.example.com"] = container | |
| 263 | + | ||
| 264 | + | mockRuntime.EXPECT().RemoveContainer(ctx, "container123", true).Return(nil) | |
| 265 | + | ||
| 266 | + | // Expect network cleanup check | |
| 267 | + | mockRuntime.EXPECT().ListNetworks(ctx).Return([]*runtime.NetworkInfo{ | |
| 268 | + | { | |
| 269 | + | Name: "gordon-test-example-com", | |
| 270 | + | Containers: []string{}, // Empty network | |
| 271 | + | }, | |
| 272 | + | }, nil) | |
| 273 | + | mockRuntime.EXPECT().RemoveNetwork(ctx, "gordon-test-example-com").Return(nil) | |
| 274 | + | ||
| 275 | + | err = manager.RemoveContainer(ctx, "container123", true) | |
| 276 | + | ||
| 277 | + | assert.NoError(t, err) | |
| 278 | + | assert.NotContains(t, manager.containers, "test.example.com") | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func TestManager_RemoveContainerByDomain(t *testing.T) { | |
| 282 | + | ctrl := gomock.NewController(t) | |
| 283 | + | defer ctrl.Finish() | |
| 284 | + | ||
| 285 | + | ctx := testutils.TestContext(t) | |
| 286 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 287 | + | ||
| 288 | + | cfg := &config.Config{ | |
| 289 | + | Volumes: config.VolumeConfig{ | |
| 290 | + | Preserve: true, | |
| 291 | + | }, | |
| 292 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 293 | + | Enabled: false, | |
| 294 | + | }, | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 298 | + | require.NoError(t, err) | |
| 299 | + | ||
| 300 | + | container := &runtime.Container{ | |
| 301 | + | ID: "container123", | |
| 302 | + | } | |
| 303 | + | manager.containers["test.example.com"] = container | |
| 304 | + | ||
| 305 | + | mockRuntime.EXPECT().RemoveContainer(ctx, "container123", false).Return(nil) | |
| 306 | + | ||
| 307 | + | err = manager.RemoveContainerByDomain(ctx, "test.example.com", false) | |
| 308 | + | ||
| 309 | + | assert.NoError(t, err) | |
| 310 | + | assert.NotContains(t, manager.containers, "test.example.com") | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func TestManager_RemoveContainerByDomain_NotFound(t *testing.T) { | |
| 314 | + | cfg := &config.Config{} | |
| 315 | + | manager := &Manager{ | |
| 316 | + | config: cfg, | |
| 317 | + | containers: make(map[string]*runtime.Container), | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | ctx := testutils.TestContext(t) | |
| 321 | + | err := manager.RemoveContainerByDomain(ctx, "nonexistent.example.com", false) | |
| 322 | + | ||
| 323 | + | assert.Error(t, err) | |
| 324 | + | assert.Contains(t, err.Error(), "no container found for domain") | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestManager_GetContainerPort(t *testing.T) { | |
| 328 | + | ctrl := gomock.NewController(t) | |
| 329 | + | defer ctrl.Finish() | |
| 330 | + | ||
| 331 | + | ctx := testutils.TestContext(t) | |
| 332 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 333 | + | ||
| 334 | + | cfg := &config.Config{} | |
| 335 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 336 | + | require.NoError(t, err) | |
| 337 | + | ||
| 338 | + | container := &runtime.Container{ | |
| 339 | + | ID: "container123", | |
| 340 | + | } | |
| 341 | + | manager.containers["test.example.com"] = container | |
| 342 | + | ||
| 343 | + | mockRuntime.EXPECT().GetContainerPort(ctx, "container123", 80).Return(8080, nil) | |
| 344 | + | ||
| 345 | + | port, err := manager.GetContainerPort(ctx, "test.example.com", 80) | |
| 346 | + | ||
| 347 | + | assert.NoError(t, err) | |
| 348 | + | assert.Equal(t, 8080, port) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestManager_GetContainerPort_NotFound(t *testing.T) { | |
| 352 | + | cfg := &config.Config{} | |
| 353 | + | manager := &Manager{ | |
| 354 | + | config: cfg, | |
| 355 | + | containers: make(map[string]*runtime.Container), | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | ctx := testutils.TestContext(t) | |
| 359 | + | _, err := manager.GetContainerPort(ctx, "nonexistent.example.com", 80) | |
| 360 | + | ||
| 361 | + | assert.Error(t, err) | |
| 362 | + | assert.Contains(t, err.Error(), "no container found for domain") | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func TestManager_FindContainerByDomain(t *testing.T) { | |
| 366 | + | manager := &Manager{ | |
| 367 | + | containers: map[string]*runtime.Container{ | |
| 368 | + | "test.example.com": {ID: "container123"}, | |
| 369 | + | }, | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | id, exists := manager.FindContainerByDomain("test.example.com") | |
| 373 | + | assert.True(t, exists) | |
| 374 | + | assert.Equal(t, "container123", id) | |
| 375 | + | ||
| 376 | + | id, exists = manager.FindContainerByDomain("nonexistent.example.com") | |
| 377 | + | assert.False(t, exists) | |
| 378 | + | assert.Empty(t, id) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestManager_FindDomainByContainerID(t *testing.T) { | |
| 382 | + | manager := &Manager{ | |
| 383 | + | containers: map[string]*runtime.Container{ | |
| 384 | + | "test.example.com": {ID: "container123"}, | |
| 385 | + | "app.example.com": {ID: "container456"}, | |
| 386 | + | }, | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | domain, exists := manager.FindDomainByContainerID("container123") | |
| 390 | + | assert.True(t, exists) | |
| 391 | + | assert.Equal(t, "test.example.com", domain) | |
| 392 | + | ||
| 393 | + | domain, exists = manager.FindDomainByContainerID("nonexistent") | |
| 394 | + | assert.False(t, exists) | |
| 395 | + | assert.Empty(t, domain) | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func TestManager_StopAllManagedContainers(t *testing.T) { | |
| 399 | + | ctrl := gomock.NewController(t) | |
| 400 | + | defer ctrl.Finish() | |
| 401 | + | ||
| 402 | + | ctx := testutils.TestContext(t) | |
| 403 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 404 | + | ||
| 405 | + | cfg := &config.Config{} | |
| 406 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 407 | + | require.NoError(t, err) | |
| 408 | + | ||
| 409 | + | manager.containers["app1.example.com"] = &runtime.Container{ID: "container1"} | |
| 410 | + | manager.containers["app2.example.com"] = &runtime.Container{ID: "container2"} | |
| 411 | + | ||
| 412 | + | mockRuntime.EXPECT().StopContainer(ctx, "container1").Return(nil) | |
| 413 | + | mockRuntime.EXPECT().StopContainer(ctx, "container2").Return(nil) | |
| 414 | + | ||
| 415 | + | err = manager.StopAllManagedContainers(ctx) | |
| 416 | + | ||
| 417 | + | assert.NoError(t, err) | |
| 418 | + | assert.Empty(t, manager.containers) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func TestManager_StopAllManagedContainers_WithErrors(t *testing.T) { | |
| 422 | + | ctrl := gomock.NewController(t) | |
| 423 | + | defer ctrl.Finish() | |
| 424 | + | ||
| 425 | + | ctx := testutils.TestContext(t) | |
| 426 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 427 | + | ||
| 428 | + | cfg := &config.Config{} | |
| 429 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 430 | + | require.NoError(t, err) | |
| 431 | + | ||
| 432 | + | manager.containers["app1.example.com"] = &runtime.Container{ID: "container1"} | |
| 433 | + | manager.containers["app2.example.com"] = &runtime.Container{ID: "container2"} | |
| 434 | + | ||
| 435 | + | mockRuntime.EXPECT().StopContainer(ctx, "container1").Return(nil) | |
| 436 | + | mockRuntime.EXPECT().StopContainer(ctx, "container2").Return(errors.New("stop failed")) | |
| 437 | + | ||
| 438 | + | err = manager.StopAllManagedContainers(ctx) | |
| 439 | + | ||
| 440 | + | assert.Error(t, err) | |
| 441 | + | assert.Contains(t, err.Error(), "failed to stop 1 containers") | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | func TestManager_StopAllManagedContainers_Empty(t *testing.T) { | |
| 445 | + | ctrl := gomock.NewController(t) | |
| 446 | + | defer ctrl.Finish() | |
| 447 | + | ||
| 448 | + | ctx := testutils.TestContext(t) | |
| 449 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 450 | + | ||
| 451 | + | cfg := &config.Config{} | |
| 452 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 453 | + | require.NoError(t, err) | |
| 454 | + | ||
| 455 | + | err = manager.StopAllManagedContainers(ctx) | |
| 456 | + | ||
| 457 | + | assert.NoError(t, err) | |
| 458 | + | assert.Empty(t, manager.containers) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func TestManager_UpdateConfig(t *testing.T) { | |
| 462 | + | ctrl := gomock.NewController(t) | |
| 463 | + | defer ctrl.Finish() | |
| 464 | + | ||
| 465 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 466 | + | ||
| 467 | + | originalCfg := &config.Config{ | |
| 468 | + | Server: config.ServerConfig{ | |
| 469 | + | Runtime: "docker", | |
| 470 | + | }, | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | manager, err := NewManagerWithRuntime(originalCfg, mockRuntime) | |
| 474 | + | require.NoError(t, err) | |
| 475 | + | ||
| 476 | + | newCfg := &config.Config{ | |
| 477 | + | Server: config.ServerConfig{ | |
| 478 | + | Runtime: "podman", | |
| 479 | + | }, | |
| 480 | + | Routes: map[string]string{ | |
| 481 | + | "new-app.example.com": "nginx:latest", | |
| 482 | + | }, | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | manager.UpdateConfig(newCfg) | |
| 486 | + | ||
| 487 | + | // Cleanup the env file created by UpdateConfig | |
| 488 | + | defer func() { | |
| 489 | + | if err := os.Remove("new-app_example_com.env"); err != nil && !os.IsNotExist(err) { | |
| 490 | + | t.Logf("Failed to cleanup env file: %v", err) | |
| 491 | + | } | |
| 492 | + | }() | |
| 493 | + | ||
| 494 | + | assert.Equal(t, newCfg, manager.config) | |
| 495 | + | assert.Equal(t, "podman", manager.config.Server.Runtime) | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | func TestManager_Shutdown(t *testing.T) { | |
| 499 | + | ctrl := gomock.NewController(t) | |
| 500 | + | defer ctrl.Finish() | |
| 501 | + | ||
| 502 | + | ctx := testutils.TestContext(t) | |
| 503 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 504 | + | ||
| 505 | + | cfg := &config.Config{} | |
| 506 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 507 | + | require.NoError(t, err) | |
| 508 | + | ||
| 509 | + | manager.containers["app1.example.com"] = &runtime.Container{ID: "container1"} | |
| 510 | + | ||
| 511 | + | mockRuntime.EXPECT().StopContainer(ctx, "container1").Return(nil) | |
| 512 | + | ||
| 513 | + | err = manager.Shutdown(ctx) | |
| 514 | + | ||
| 515 | + | assert.NoError(t, err) | |
| 516 | + | assert.Empty(t, manager.containers) | |
| 517 | + | }⏎̸ | |
A internal/container/manager_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | "go.uber.org/mock/gomock" | |
| 9 | + | ||
| 10 | + | "gordon/internal/config" | |
| 11 | + | "gordon/internal/testutils" | |
| 12 | + | "gordon/internal/testutils/mocks" | |
| 13 | + | "gordon/pkg/runtime" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestManager_GetContainer(t *testing.T) { | |
| 17 | + | container := &runtime.Container{ | |
| 18 | + | ID: "test123", | |
| 19 | + | Name: "test-container", | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | manager := &Manager{ | |
| 23 | + | containers: map[string]*runtime.Container{ | |
| 24 | + | "test.example.com": container, | |
| 25 | + | }, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Test existing container | |
| 29 | + | found, exists := manager.GetContainer("test.example.com") | |
| 30 | + | assert.True(t, exists) | |
| 31 | + | assert.Equal(t, container, found) | |
| 32 | + | ||
| 33 | + | // Test non-existent container | |
| 34 | + | notFound, exists := manager.GetContainer("nonexistent.example.com") | |
| 35 | + | assert.False(t, exists) | |
| 36 | + | assert.Nil(t, notFound) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestManager_ListContainers(t *testing.T) { | |
| 40 | + | container1 := &runtime.Container{ID: "test1", Name: "container1"} | |
| 41 | + | container2 := &runtime.Container{ID: "test2", Name: "container2"} | |
| 42 | + | ||
| 43 | + | manager := &Manager{ | |
| 44 | + | containers: map[string]*runtime.Container{ | |
| 45 | + | "app1.example.com": container1, | |
| 46 | + | "app2.example.com": container2, | |
| 47 | + | }, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | containers := manager.ListContainers() | |
| 51 | + | ||
| 52 | + | assert.Len(t, containers, 2) | |
| 53 | + | assert.Contains(t, containers, "app1.example.com") | |
| 54 | + | assert.Contains(t, containers, "app2.example.com") | |
| 55 | + | assert.Equal(t, container1, containers["app1.example.com"]) | |
| 56 | + | assert.Equal(t, container2, containers["app2.example.com"]) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestManager_HealthCheck(t *testing.T) { | |
| 60 | + | ctrl := gomock.NewController(t) | |
| 61 | + | defer ctrl.Finish() | |
| 62 | + | ||
| 63 | + | ctx := testutils.TestContext(t) | |
| 64 | + | ||
| 65 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 66 | + | mockRuntime.EXPECT().IsContainerRunning(ctx, "container1").Return(true, nil) | |
| 67 | + | mockRuntime.EXPECT().IsContainerRunning(ctx, "container2").Return(false, nil) | |
| 68 | + | ||
| 69 | + | manager := &Manager{ | |
| 70 | + | runtime: mockRuntime, | |
| 71 | + | containers: map[string]*runtime.Container{ | |
| 72 | + | "app1.example.com": {ID: "container1"}, | |
| 73 | + | "app2.example.com": {ID: "container2"}, | |
| 74 | + | }, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | health := manager.HealthCheck(ctx) | |
| 78 | + | ||
| 79 | + | assert.Len(t, health, 2) | |
| 80 | + | assert.True(t, health["app1.example.com"]) | |
| 81 | + | assert.False(t, health["app2.example.com"]) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestManager_GetNetworkForApp(t *testing.T) { | |
| 85 | + | manager := &Manager{ | |
| 86 | + | config: &config.Config{ | |
| 87 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 88 | + | Enabled: true, | |
| 89 | + | NetworkPrefix: "gordon", | |
| 90 | + | }, | |
| 91 | + | NetworkGroups: map[string][]string{ | |
| 92 | + | "backend": {"api.example.com", "worker.example.com"}, | |
| 93 | + | }, | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Test app in network group | |
| 98 | + | network := manager.GetNetworkForApp("api.example.com") | |
| 99 | + | assert.Equal(t, "gordon-backend", network) | |
| 100 | + | ||
| 101 | + | // Test standalone app | |
| 102 | + | network = manager.GetNetworkForApp("frontend.example.com") | |
| 103 | + | assert.Equal(t, "gordon-frontend-example-com", network) | |
| 104 | + | ||
| 105 | + | // Test disabled network isolation | |
| 106 | + | manager.config.NetworkIsolation.Enabled = false | |
| 107 | + | network = manager.GetNetworkForApp("test.example.com") | |
| 108 | + | assert.Equal(t, "bridge", network) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestManager_Runtime(t *testing.T) { | |
| 112 | + | ctrl := gomock.NewController(t) | |
| 113 | + | defer ctrl.Finish() | |
| 114 | + | ||
| 115 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 116 | + | manager := &Manager{ | |
| 117 | + | runtime: mockRuntime, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | result := manager.Runtime() | |
| 121 | + | assert.Equal(t, mockRuntime, result) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestManager_DeployContainer(t *testing.T) { | |
| 125 | + | ctrl := gomock.NewController(t) | |
| 126 | + | defer ctrl.Finish() | |
| 127 | + | ||
| 128 | + | ctx := testutils.TestContext(t) | |
| 129 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 130 | + | ||
| 131 | + | cfg := &config.Config{ | |
| 132 | + | Server: config.ServerConfig{ | |
| 133 | + | Runtime: "docker", | |
| 134 | + | }, | |
| 135 | + | Volumes: config.VolumeConfig{ | |
| 136 | + | AutoCreate: true, | |
| 137 | + | Prefix: "gordon", | |
| 138 | + | Preserve: false, | |
| 139 | + | }, | |
| 140 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 141 | + | Enabled: true, | |
| 142 | + | NetworkPrefix: "gordon", | |
| 143 | + | }, | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | ||
| 149 | + | route := config.Route{ | |
| 150 | + | Domain: "test.example.com", | |
| 151 | + | Image: "nginx:latest", | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Set up expectations for successful deployment | |
| 155 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 156 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"nginx:latest"}, nil) | |
| 157 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "nginx:latest").Return([]int{80}, nil) | |
| 158 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "nginx:latest").Return([]string{"PATH=/usr/local/sbin:/usr/local/bin"}, nil) | |
| 159 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil) | |
| 160 | + | varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx") | |
| 161 | + | mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(false, nil) | |
| 162 | + | mockRuntime.EXPECT().CreateVolume(ctx, varLogNginxVolume).Return(nil) | |
| 163 | + | mockRuntime.EXPECT().NetworkExists(ctx, "gordon-test-example-com").Return(false, nil) | |
| 164 | + | mockRuntime.EXPECT().CreateNetwork(ctx, "gordon-test-example-com", gomock.Any()).Return(nil) | |
| 165 | + | ||
| 166 | + | expectedContainer := &runtime.Container{ | |
| 167 | + | ID: "container123", | |
| 168 | + | Name: "gordon-test.example.com", | |
| 169 | + | Image: "nginx:latest", | |
| 170 | + | Status: "running", | |
| 171 | + | Ports: []int{80}, | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 175 | + | mockRuntime.EXPECT().StartContainer(ctx, "container123").Return(nil) | |
| 176 | + | mockRuntime.EXPECT().InspectContainer(ctx, "container123").Return(expectedContainer, nil) | |
| 177 | + | ||
| 178 | + | container, err := manager.DeployContainer(ctx, route) | |
| 179 | + | ||
| 180 | + | assert.NoError(t, err) | |
| 181 | + | assert.Equal(t, expectedContainer, container) | |
| 182 | + | assert.Equal(t, expectedContainer, manager.containers["test.example.com"]) | |
| 183 | + | } | |
A internal/container/manager_volume_test.go +388 -0
| @@ -0,0 +1,388 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | "go.uber.org/mock/gomock" | |
| 10 | + | ||
| 11 | + | "gordon/internal/config" | |
| 12 | + | "gordon/internal/testutils" | |
| 13 | + | "gordon/internal/testutils/mocks" | |
| 14 | + | "gordon/pkg/runtime" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestManager_cleanupVolumesForDomain(t *testing.T) { | |
| 18 | + | ctrl := gomock.NewController(t) | |
| 19 | + | defer ctrl.Finish() | |
| 20 | + | ||
| 21 | + | ctx := testutils.TestContext(t) | |
| 22 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 23 | + | ||
| 24 | + | cfg := &config.Config{ | |
| 25 | + | Volumes: config.VolumeConfig{ | |
| 26 | + | Prefix: "gordon", | |
| 27 | + | }, | |
| 28 | + | Routes: map[string]string{ | |
| 29 | + | "test.example.com": "nginx:latest", | |
| 30 | + | }, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | ||
| 36 | + | // Mock image inspection for volume paths | |
| 37 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx", "/etc/nginx"}, nil) | |
| 38 | + | ||
| 39 | + | // Mock volume existence checks and removal | |
| 40 | + | varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx") | |
| 41 | + | etcNginxVolume := generateVolumeName("gordon", "test.example.com", "/etc/nginx") | |
| 42 | + | ||
| 43 | + | mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(true, nil) | |
| 44 | + | mockRuntime.EXPECT().RemoveVolume(ctx, varLogNginxVolume, true).Return(nil) | |
| 45 | + | ||
| 46 | + | mockRuntime.EXPECT().VolumeExists(ctx, etcNginxVolume).Return(true, nil) | |
| 47 | + | mockRuntime.EXPECT().RemoveVolume(ctx, etcNginxVolume, true).Return(nil) | |
| 48 | + | ||
| 49 | + | err = manager.cleanupVolumesForDomain(ctx, "test.example.com") | |
| 50 | + | ||
| 51 | + | assert.NoError(t, err) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestManager_cleanupVolumesForDomain_NoRoute(t *testing.T) { | |
| 55 | + | ctrl := gomock.NewController(t) | |
| 56 | + | defer ctrl.Finish() | |
| 57 | + | ||
| 58 | + | ctx := testutils.TestContext(t) | |
| 59 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 60 | + | ||
| 61 | + | cfg := &config.Config{ | |
| 62 | + | Volumes: config.VolumeConfig{ | |
| 63 | + | Prefix: "gordon", | |
| 64 | + | }, | |
| 65 | + | Routes: map[string]string{}, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | // Should return nil without doing anything | |
| 72 | + | err = manager.cleanupVolumesForDomain(ctx, "nonexistent.example.com") | |
| 73 | + | ||
| 74 | + | assert.NoError(t, err) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestManager_cleanupVolumesForDomain_WithRegistryAuth(t *testing.T) { | |
| 78 | + | ctrl := gomock.NewController(t) | |
| 79 | + | defer ctrl.Finish() | |
| 80 | + | ||
| 81 | + | ctx := testutils.TestContext(t) | |
| 82 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 83 | + | ||
| 84 | + | cfg := &config.Config{ | |
| 85 | + | Server: config.ServerConfig{ | |
| 86 | + | RegistryDomain: "registry.example.com", | |
| 87 | + | }, | |
| 88 | + | RegistryAuth: config.RegistryAuthConfig{ | |
| 89 | + | Enabled: true, | |
| 90 | + | }, | |
| 91 | + | Volumes: config.VolumeConfig{ | |
| 92 | + | Prefix: "gordon", | |
| 93 | + | }, | |
| 94 | + | Routes: map[string]string{ | |
| 95 | + | "test.example.com": "myapp:latest", | |
| 96 | + | }, | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | ||
| 102 | + | // Should use prefixed image reference | |
| 103 | + | expectedImageRef := "registry.example.com/myapp:latest" | |
| 104 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, expectedImageRef).Return([]string{"/data"}, nil) | |
| 105 | + | ||
| 106 | + | dataVolume := generateVolumeName("gordon", "test.example.com", "/data") | |
| 107 | + | mockRuntime.EXPECT().VolumeExists(ctx, dataVolume).Return(true, nil) | |
| 108 | + | mockRuntime.EXPECT().RemoveVolume(ctx, dataVolume, true).Return(nil) | |
| 109 | + | ||
| 110 | + | err = manager.cleanupVolumesForDomain(ctx, "test.example.com") | |
| 111 | + | ||
| 112 | + | assert.NoError(t, err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestManager_cleanupVolumesForDomain_NoVolumes(t *testing.T) { | |
| 116 | + | ctrl := gomock.NewController(t) | |
| 117 | + | defer ctrl.Finish() | |
| 118 | + | ||
| 119 | + | ctx := testutils.TestContext(t) | |
| 120 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 121 | + | ||
| 122 | + | cfg := &config.Config{ | |
| 123 | + | Volumes: config.VolumeConfig{ | |
| 124 | + | Prefix: "gordon", | |
| 125 | + | }, | |
| 126 | + | Routes: map[string]string{ | |
| 127 | + | "test.example.com": "alpine:latest", | |
| 128 | + | }, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | ||
| 134 | + | // Image has no volumes | |
| 135 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "alpine:latest").Return([]string{}, nil) | |
| 136 | + | ||
| 137 | + | err = manager.cleanupVolumesForDomain(ctx, "test.example.com") | |
| 138 | + | ||
| 139 | + | assert.NoError(t, err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestManager_cleanupVolumesForDomain_VolumeNotExists(t *testing.T) { | |
| 143 | + | ctrl := gomock.NewController(t) | |
| 144 | + | defer ctrl.Finish() | |
| 145 | + | ||
| 146 | + | ctx := testutils.TestContext(t) | |
| 147 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 148 | + | ||
| 149 | + | cfg := &config.Config{ | |
| 150 | + | Volumes: config.VolumeConfig{ | |
| 151 | + | Prefix: "gordon", | |
| 152 | + | }, | |
| 153 | + | Routes: map[string]string{ | |
| 154 | + | "test.example.com": "nginx:latest", | |
| 155 | + | }, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | ||
| 161 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil) | |
| 162 | + | ||
| 163 | + | // Volume doesn't exist, should skip removal | |
| 164 | + | varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx") | |
| 165 | + | mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(false, nil) | |
| 166 | + | ||
| 167 | + | err = manager.cleanupVolumesForDomain(ctx, "test.example.com") | |
| 168 | + | ||
| 169 | + | assert.NoError(t, err) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestManager_cleanupVolumesForDomain_Errors(t *testing.T) { | |
| 173 | + | ctrl := gomock.NewController(t) | |
| 174 | + | defer ctrl.Finish() | |
| 175 | + | ||
| 176 | + | ctx := testutils.TestContext(t) | |
| 177 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 178 | + | ||
| 179 | + | cfg := &config.Config{ | |
| 180 | + | Volumes: config.VolumeConfig{ | |
| 181 | + | Prefix: "gordon", | |
| 182 | + | }, | |
| 183 | + | Routes: map[string]string{ | |
| 184 | + | "test.example.com": "nginx:latest", | |
| 185 | + | }, | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | ||
| 191 | + | // Test error inspecting image volumes | |
| 192 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return(nil, errors.New("inspect failed")) | |
| 193 | + | ||
| 194 | + | err = manager.cleanupVolumesForDomain(ctx, "test.example.com") | |
| 195 | + | assert.Error(t, err) | |
| 196 | + | ||
| 197 | + | // Test error checking volume existence | |
| 198 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil) | |
| 199 | + | varLogNginxVolume := generateVolumeName("gordon", "test.example.com", "/var/log/nginx") | |
| 200 | + | mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(false, errors.New("check failed")) | |
| 201 | + | ||
| 202 | + | err = manager.cleanupVolumesForDomain(ctx, "test.example.com") | |
| 203 | + | assert.NoError(t, err) // Should continue despite check error | |
| 204 | + | ||
| 205 | + | // Test error removing volume | |
| 206 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "nginx:latest").Return([]string{"/var/log/nginx"}, nil) | |
| 207 | + | mockRuntime.EXPECT().VolumeExists(ctx, varLogNginxVolume).Return(true, nil) | |
| 208 | + | mockRuntime.EXPECT().RemoveVolume(ctx, varLogNginxVolume, true).Return(errors.New("remove failed")) | |
| 209 | + | ||
| 210 | + | err = manager.cleanupVolumesForDomain(ctx, "test.example.com") | |
| 211 | + | assert.Error(t, err) | |
| 212 | + | assert.Contains(t, err.Error(), "failed to cleanup 1 volumes") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestManager_DeployContainer_VolumeAutoCreation(t *testing.T) { | |
| 216 | + | ctrl := gomock.NewController(t) | |
| 217 | + | defer ctrl.Finish() | |
| 218 | + | ||
| 219 | + | ctx := testutils.TestContext(t) | |
| 220 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 221 | + | ||
| 222 | + | cfg := &config.Config{ | |
| 223 | + | Server: config.ServerConfig{ | |
| 224 | + | Runtime: "docker", | |
| 225 | + | }, | |
| 226 | + | Volumes: config.VolumeConfig{ | |
| 227 | + | AutoCreate: true, | |
| 228 | + | Prefix: "gordon", | |
| 229 | + | Preserve: false, | |
| 230 | + | }, | |
| 231 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 232 | + | Enabled: false, | |
| 233 | + | }, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 237 | + | require.NoError(t, err) | |
| 238 | + | ||
| 239 | + | route := config.Route{ | |
| 240 | + | Domain: "test.example.com", | |
| 241 | + | Image: "postgres:latest", | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Set up expectations for deployment with volume creation | |
| 245 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 246 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"postgres:latest"}, nil) | |
| 247 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil) | |
| 248 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil) | |
| 249 | + | ||
| 250 | + | // Mock volume discovery and creation | |
| 251 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "postgres:latest").Return([]string{"/var/lib/postgresql/data", "/etc/postgresql"}, nil) | |
| 252 | + | ||
| 253 | + | // First volume | |
| 254 | + | dataVolume := generateVolumeName("gordon", "test.example.com", "/var/lib/postgresql/data") | |
| 255 | + | mockRuntime.EXPECT().VolumeExists(ctx, dataVolume).Return(false, nil) | |
| 256 | + | mockRuntime.EXPECT().CreateVolume(ctx, dataVolume).Return(nil) | |
| 257 | + | ||
| 258 | + | // Second volume already exists | |
| 259 | + | etcVolume := generateVolumeName("gordon", "test.example.com", "/etc/postgresql") | |
| 260 | + | mockRuntime.EXPECT().VolumeExists(ctx, etcVolume).Return(true, nil) | |
| 261 | + | ||
| 262 | + | expectedContainer := &runtime.Container{ | |
| 263 | + | ID: "postgres-container", | |
| 264 | + | Name: "gordon-test.example.com", | |
| 265 | + | Image: "postgres:latest", | |
| 266 | + | Status: "running", | |
| 267 | + | Ports: []int{5432}, | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 271 | + | mockRuntime.EXPECT().StartContainer(ctx, "postgres-container").Return(nil) | |
| 272 | + | mockRuntime.EXPECT().InspectContainer(ctx, "postgres-container").Return(expectedContainer, nil) | |
| 273 | + | ||
| 274 | + | container, err := manager.DeployContainer(ctx, route) | |
| 275 | + | ||
| 276 | + | assert.NoError(t, err) | |
| 277 | + | assert.Equal(t, expectedContainer, container) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func TestManager_DeployContainer_VolumeCreationError(t *testing.T) { | |
| 281 | + | ctrl := gomock.NewController(t) | |
| 282 | + | defer ctrl.Finish() | |
| 283 | + | ||
| 284 | + | ctx := testutils.TestContext(t) | |
| 285 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 286 | + | ||
| 287 | + | cfg := &config.Config{ | |
| 288 | + | Server: config.ServerConfig{ | |
| 289 | + | Runtime: "docker", | |
| 290 | + | }, | |
| 291 | + | Volumes: config.VolumeConfig{ | |
| 292 | + | AutoCreate: true, | |
| 293 | + | Prefix: "gordon", | |
| 294 | + | }, | |
| 295 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 296 | + | Enabled: false, | |
| 297 | + | }, | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 301 | + | require.NoError(t, err) | |
| 302 | + | ||
| 303 | + | route := config.Route{ | |
| 304 | + | Domain: "test.example.com", | |
| 305 | + | Image: "postgres:latest", | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 309 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"postgres:latest"}, nil) | |
| 310 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil) | |
| 311 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil) | |
| 312 | + | ||
| 313 | + | // Mock volume creation failure | |
| 314 | + | mockRuntime.EXPECT().InspectImageVolumes(ctx, "postgres:latest").Return([]string{"/var/lib/postgresql/data"}, nil) | |
| 315 | + | dataVolume := generateVolumeName("gordon", "test.example.com", "/var/lib/postgresql/data") | |
| 316 | + | mockRuntime.EXPECT().VolumeExists(ctx, dataVolume).Return(false, nil) | |
| 317 | + | mockRuntime.EXPECT().CreateVolume(ctx, dataVolume).Return(errors.New("create failed")) | |
| 318 | + | ||
| 319 | + | // Should still continue with container creation (volume creation is best-effort) | |
| 320 | + | expectedContainer := &runtime.Container{ | |
| 321 | + | ID: "postgres-container", | |
| 322 | + | Name: "gordon-test.example.com", | |
| 323 | + | Image: "postgres:latest", | |
| 324 | + | Status: "running", | |
| 325 | + | Ports: []int{5432}, | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 329 | + | mockRuntime.EXPECT().StartContainer(ctx, "postgres-container").Return(nil) | |
| 330 | + | mockRuntime.EXPECT().InspectContainer(ctx, "postgres-container").Return(expectedContainer, nil) | |
| 331 | + | ||
| 332 | + | container, err := manager.DeployContainer(ctx, route) | |
| 333 | + | ||
| 334 | + | assert.NoError(t, err) | |
| 335 | + | assert.Equal(t, expectedContainer, container) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func TestManager_DeployContainer_VolumeAutoCreationDisabled(t *testing.T) { | |
| 339 | + | ctrl := gomock.NewController(t) | |
| 340 | + | defer ctrl.Finish() | |
| 341 | + | ||
| 342 | + | ctx := testutils.TestContext(t) | |
| 343 | + | mockRuntime := mocks.NewMockRuntime(ctrl) | |
| 344 | + | ||
| 345 | + | cfg := &config.Config{ | |
| 346 | + | Server: config.ServerConfig{ | |
| 347 | + | Runtime: "docker", | |
| 348 | + | }, | |
| 349 | + | Volumes: config.VolumeConfig{ | |
| 350 | + | AutoCreate: false, // Disabled | |
| 351 | + | }, | |
| 352 | + | NetworkIsolation: config.NetworkIsolationConfig{ | |
| 353 | + | Enabled: false, | |
| 354 | + | }, | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | manager, err := NewManagerWithRuntime(cfg, mockRuntime) | |
| 358 | + | require.NoError(t, err) | |
| 359 | + | ||
| 360 | + | route := config.Route{ | |
| 361 | + | Domain: "test.example.com", | |
| 362 | + | Image: "postgres:latest", | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | mockRuntime.EXPECT().ListContainers(ctx, true).Return([]*runtime.Container{}, nil) | |
| 366 | + | mockRuntime.EXPECT().ListImages(ctx).Return([]string{"postgres:latest"}, nil) | |
| 367 | + | mockRuntime.EXPECT().GetImageExposedPorts(ctx, "postgres:latest").Return([]int{5432}, nil) | |
| 368 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "postgres:latest").Return([]string{}, nil) | |
| 369 | + | ||
| 370 | + | // Should NOT inspect image volumes when auto-creation is disabled | |
| 371 | + | ||
| 372 | + | expectedContainer := &runtime.Container{ | |
| 373 | + | ID: "postgres-container", | |
| 374 | + | Name: "gordon-test.example.com", | |
| 375 | + | Image: "postgres:latest", | |
| 376 | + | Status: "running", | |
| 377 | + | Ports: []int{5432}, | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | mockRuntime.EXPECT().CreateContainer(ctx, gomock.Any()).Return(expectedContainer, nil) | |
| 381 | + | mockRuntime.EXPECT().StartContainer(ctx, "postgres-container").Return(nil) | |
| 382 | + | mockRuntime.EXPECT().InspectContainer(ctx, "postgres-container").Return(expectedContainer, nil) | |
| 383 | + | ||
| 384 | + | container, err := manager.DeployContainer(ctx, route) | |
| 385 | + | ||
| 386 | + | assert.NoError(t, err) | |
| 387 | + | assert.Equal(t, expectedContainer, container) | |
| 388 | + | }⏎̸ | |
A internal/container/test_manager.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "gordon/internal/config" | |
| 5 | + | "gordon/internal/env" | |
| 6 | + | "gordon/internal/logging" | |
| 7 | + | "gordon/pkg/runtime" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // NewManagerWithRuntime creates a manager with an injected runtime for testing | |
| 11 | + | func NewManagerWithRuntime(cfg *config.Config, rt runtime.Runtime) (*Manager, error) { | |
| 12 | + | // Create environment loader | |
| 13 | + | envLoader := env.NewLoader(cfg) | |
| 14 | + | ||
| 15 | + | // Register secret providers based on config | |
| 16 | + | for _, providerName := range cfg.Env.Providers { | |
| 17 | + | switch providerName { | |
| 18 | + | case "pass": | |
| 19 | + | envLoader.RegisterSecretProvider("pass", env.NewPassProvider()) | |
| 20 | + | case "sops": | |
| 21 | + | envLoader.RegisterSecretProvider("sops", env.NewSopsProvider()) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Create log manager | |
| 26 | + | logManager := logging.NewLogManager(cfg, rt) | |
| 27 | + | ||
| 28 | + | return &Manager{ | |
| 29 | + | runtime: rt, | |
| 30 | + | config: cfg, | |
| 31 | + | containers: make(map[string]*runtime.Container), | |
| 32 | + | envLoader: envLoader, | |
| 33 | + | logManager: logManager, | |
| 34 | + | }, nil | |
| 35 | + | }⏎̸ | |
D internal/db/db_struct.go +0 -44
| @@ -1,44 +0,0 @@ | |||
| 1 | - | package db | |
| 2 | - | ||
| 3 | - | type User struct { | |
| 4 | - | ID string `sql:"id, primary_key"` | |
| 5 | - | Name string `sql:"name"` | |
| 6 | - | Email string `sql:"email"` | |
| 7 | - | Account Account | |
| 8 | - | } | |
| 9 | - | ||
| 10 | - | type Account struct { | |
| 11 | - | ID string `sql:"id, primary_key"` | |
| 12 | - | UserID string `sql:"user_id, foreign_key=user.id"` | |
| 13 | - | Sessions []Sessions | |
| 14 | - | Providers []Provider | |
| 15 | - | Clients []Clients | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | type Sessions struct { | |
| 19 | - | ID string `sql:"id, primary_key"` | |
| 20 | - | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 21 | - | BrowserInfo string `sql:"browser_info"` | |
| 22 | - | AccessToken string `sql:"access_token"` | |
| 23 | - | Expires string `sql:"expires"` | |
| 24 | - | IsOnline bool `sql:"is_online"` | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | type Provider struct { | |
| 28 | - | ID string `sql:"id, primary_key"` | |
| 29 | - | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 30 | - | Name string `sql:"name"` | |
| 31 | - | Login string `sql:"login"` | |
| 32 | - | AvatarURL string `sql:"avatar_url"` | |
| 33 | - | ProfileURL string `sql:"profile_url"` | |
| 34 | - | Email string `sql:"email"` | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | type Clients struct { | |
| 38 | - | ID string `sql:"id, primary_key"` | |
| 39 | - | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 40 | - | OS string `sql:"os"` | |
| 41 | - | IP string `sql:"ip"` | |
| 42 | - | Hostname string `sql:"hostname"` | |
| 43 | - | Expires string `sql:"expires"` | |
| 44 | - | } | |
D internal/db/queries/account.go +0 -47
| @@ -1,47 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/db" | |
| 5 | - | "github.com/bnema/gordon/internal/server" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func createDBAccount(a *server.App) (*db.Account, error) { | |
| 9 | - | account := &db.Account{ | |
| 10 | - | ID: generateUUID(), | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | _, err := a.DB.Exec( | |
| 14 | - | "INSERT INTO account (id, user_id) VALUES (?, ?)", | |
| 15 | - | account.ID, a.DBTables.User.ID, | |
| 16 | - | ) | |
| 17 | - | return account, err | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func CheckDBAccountExists(a *server.App, accountID string) (bool, error) { | |
| 21 | - | var exists bool | |
| 22 | - | err := a.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)", accountID).Scan(&exists) | |
| 23 | - | if err != nil { | |
| 24 | - | return false, err | |
| 25 | - | } | |
| 26 | - | return exists, nil | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func PopulateAccountFromDB(a *server.App) error { | |
| 30 | - | rows, err := a.DB.Query("SELECT id, user_id FROM account") | |
| 31 | - | if err != nil { | |
| 32 | - | return err | |
| 33 | - | } | |
| 34 | - | defer rows.Close() | |
| 35 | - | ||
| 36 | - | for rows.Next() { | |
| 37 | - | account := db.Account{} | |
| 38 | - | err := rows.Scan(&account.ID, &account.UserID) | |
| 39 | - | if err != nil { | |
| 40 | - | return err | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | a.DBTables.Account = account | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | return rows.Err() | |
| 47 | - | } | |
D internal/db/queries/clients.go +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | package queries | |
D internal/db/queries/inspectdb.go +0 -75
| @@ -1,75 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/server" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // InspectInMemoryDB inspects the in-memory database. (for debug purpose) | |
| 10 | - | func InspectInMemoryDB(a *server.App) error { | |
| 11 | - | memDb := a.DB | |
| 12 | - | // Query the sqlite_master table to get a list of all tables | |
| 13 | - | rows, err := memDb.Query("SELECT name FROM sqlite_master WHERE type='table'") | |
| 14 | - | fmt.Println("rows", rows) | |
| 15 | - | if err != nil { | |
| 16 | - | return err | |
| 17 | - | } | |
| 18 | - | defer rows.Close() | |
| 19 | - | ||
| 20 | - | var tables []string | |
| 21 | - | for rows.Next() { | |
| 22 | - | var tableName string | |
| 23 | - | if err := rows.Scan(&tableName); err != nil { | |
| 24 | - | return err | |
| 25 | - | } | |
| 26 | - | tables = append(tables, tableName) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | if err := rows.Err(); err != nil { | |
| 30 | - | return err | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // Print the tables | |
| 34 | - | fmt.Println("Tables in the in-memory database:") | |
| 35 | - | for _, table := range tables { | |
| 36 | - | fmt.Println(table) | |
| 37 | - | } | |
| 38 | - | // Print the content of the users table | |
| 39 | - | userRows, err := memDb.Query("SELECT * FROM user") | |
| 40 | - | if err != nil { | |
| 41 | - | return err | |
| 42 | - | } | |
| 43 | - | defer userRows.Close() | |
| 44 | - | ||
| 45 | - | fmt.Println("\nContent of the 'user' table:") | |
| 46 | - | for userRows.Next() { | |
| 47 | - | // Assuming users table has columns: id, name | |
| 48 | - | var id int | |
| 49 | - | var name string | |
| 50 | - | if err := userRows.Scan(&id, &name); err != nil { | |
| 51 | - | return err | |
| 52 | - | } | |
| 53 | - | fmt.Printf("ID: %d, Name: %s\n", id, name) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // Print the content of accounts table | |
| 57 | - | accountRows, err := memDb.Query("SELECT * FROM account") | |
| 58 | - | if err != nil { | |
| 59 | - | return err | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | fmt.Println("\nContent of the 'account' table:") | |
| 63 | - | for accountRows.Next() { | |
| 64 | - | // Assuming accounts table has columns: id, name, user_id | |
| 65 | - | var id int | |
| 66 | - | var name string | |
| 67 | - | var userID int | |
| 68 | - | if err := accountRows.Scan(&id, &name, &userID); err != nil { | |
| 69 | - | return err | |
| 70 | - | } | |
| 71 | - | fmt.Printf("ID: %d, Name: %s, UserID: %d\n", id, name, userID) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | return nil | |
| 75 | - | } | |
D internal/db/queries/provider.go +0 -41
| @@ -1,41 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/db" | |
| 5 | - | "github.com/bnema/gordon/internal/server" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func createDBGitHubProvider(a *server.App, userInfo *GithubUserInfo) error { | |
| 9 | - | provider := &db.Provider{ | |
| 10 | - | ID: generateUUID(), | |
| 11 | - | Email: userInfo.Emails[0], | |
| 12 | - | Login: userInfo.Login, | |
| 13 | - | AvatarURL: userInfo.AvatarURL, | |
| 14 | - | ProfileURL: userInfo.ProfileURL, | |
| 15 | - | Name: "GitHub", | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | query := "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)" | |
| 19 | - | _, err := a.DB.Exec(query, provider.ID, a.DBTables.Account.ID, provider.Name, provider.Login, provider.AvatarURL, provider.ProfileURL, provider.Email) | |
| 20 | - | return err | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | func PopulateProviderFromDB(a *server.App) error { | |
| 24 | - | rows, err := a.DB.Query("SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider") | |
| 25 | - | if err != nil { | |
| 26 | - | return err | |
| 27 | - | } | |
| 28 | - | defer rows.Close() | |
| 29 | - | ||
| 30 | - | for rows.Next() { | |
| 31 | - | provider := db.Provider{} | |
| 32 | - | err := rows.Scan(&provider.ID, &provider.AccountID, &provider.Name, &provider.Login, &provider.AvatarURL, &provider.ProfileURL, &provider.Email) | |
| 33 | - | if err != nil { | |
| 34 | - | return err | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | a.DBTables.Provider = provider | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return rows.Err() | |
| 41 | - | } | |
D internal/db/queries/sessions.go +0 -143
| @@ -1,143 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/db" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // createSession creates a session for the user. | |
| 13 | - | func createDBSession(a *server.App, browserInfo string, accessToken string, accountID string) error { | |
| 14 | - | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 15 | - | sessions := &db.Sessions{ | |
| 16 | - | ID: generateUUID(), | |
| 17 | - | AccessToken: accessToken, | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | query := "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)" | |
| 21 | - | _, err := a.DB.Exec(query, sessions.ID, accountID, accessToken, browserInfo, expireTime, true) | |
| 22 | - | if err != nil { | |
| 23 | - | return err | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // Update global state here if really necessary | |
| 27 | - | a.DBTables.Sessions.ID = sessions.ID | |
| 28 | - | a.DBTables.Sessions.AccessToken = accessToken | |
| 29 | - | a.DBTables.Sessions.Expires = expireTime | |
| 30 | - | ||
| 31 | - | return nil | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // GetDBUserSession gets the user session from the database based on the access token and browser info. | |
| 35 | - | func GetDBUserSession(a *server.App, accessToken string, browserInfo string) (*db.Sessions, error) { | |
| 36 | - | query := "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?" | |
| 37 | - | err := a.DB.QueryRow(query, accessToken, browserInfo).Scan(&a.DBTables.Sessions.ID, &a.DBTables.Sessions.AccountID, &a.DBTables.Sessions.AccessToken, &a.DBTables.Sessions.BrowserInfo, &a.DBTables.Sessions.Expires, &a.DBTables.Sessions.IsOnline) | |
| 38 | - | if err != nil { | |
| 39 | - | if err == sql.ErrNoRows { | |
| 40 | - | return nil, nil | |
| 41 | - | } | |
| 42 | - | return nil, fmt.Errorf("error getting session: %w", err) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return &a.DBTables.Sessions, nil | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func updateDBSession(a *server.App, accessToken string, browserInfo string) error { | |
| 49 | - | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 50 | - | query := "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?" | |
| 51 | - | _, err := a.DB.Exec(query, accessToken, expireTime, a.DBTables.Sessions.ID) | |
| 52 | - | if err != nil { | |
| 53 | - | return err | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | return nil | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | func CreateOrUpdateDBSession(a *server.App, accessToken string, browserInfo string) error { | |
| 60 | - | // Check if the session exists using the access token and browser info | |
| 61 | - | session, err := GetDBUserSession(a, accessToken, browserInfo) | |
| 62 | - | if err != nil && err != sql.ErrNoRows { | |
| 63 | - | return fmt.Errorf("error checking for session: %w", err) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // If the session exists, update the session | |
| 67 | - | if session != nil { | |
| 68 | - | err := updateDBSession(a, accessToken, browserInfo) | |
| 69 | - | if err != nil { | |
| 70 | - | return fmt.Errorf("error updating session: %w", err) | |
| 71 | - | } | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // If the session does not exist, create the session | |
| 75 | - | if session == nil { | |
| 76 | - | err := createDBSession(a, browserInfo, accessToken, a.DBTables.Account.ID) | |
| 77 | - | if err != nil { | |
| 78 | - | return fmt.Errorf("error creating session: %w", err) | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | return nil | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // DeleteSession deletes the session from the database. | |
| 86 | - | func DeleteDBSession(a *server.App, accountID string, sessionID string) error { | |
| 87 | - | query := "DELETE FROM sessions WHERE account_id = ? AND id = ?" | |
| 88 | - | _, err := a.DB.Exec(query, accountID, sessionID) | |
| 89 | - | if err != nil { | |
| 90 | - | return err | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | return nil | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | func GetSessionExpiration(a *server.App, accountID string, sessionID string, currentTime time.Time) (time.Time, error) { | |
| 97 | - | var expirationTime string | |
| 98 | - | // with userID and sessionID we can get the expiration time | |
| 99 | - | query := "SELECT expires FROM sessions WHERE account_id = ? AND id = ?" | |
| 100 | - | err := a.DB.QueryRow(query, accountID, sessionID).Scan(&expirationTime) | |
| 101 | - | if err != nil { | |
| 102 | - | return currentTime, err | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // convert the expiration time to a time.Time | |
| 106 | - | expirationTimeParsed, err := time.Parse(time.RFC3339, expirationTime) | |
| 107 | - | if err != nil { | |
| 108 | - | return currentTime, err | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | return expirationTimeParsed, nil | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | func ExtendSessionExpiration(a *server.App, accountID string, sessionID string, newExpirationTime time.Time) error { | |
| 115 | - | query := "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?" | |
| 116 | - | _, err := a.DB.Exec(query, newExpirationTime.Format(time.RFC3339), accountID, sessionID) | |
| 117 | - | if err != nil { | |
| 118 | - | return err | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | return nil | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func CheckDBSessionExists(a *server.App, sessionID string) (bool, error) { | |
| 125 | - | var sessionExists bool | |
| 126 | - | query := "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)" | |
| 127 | - | err := a.DB.QueryRow(query, sessionID).Scan(&sessionExists) | |
| 128 | - | if err != nil { | |
| 129 | - | return false, err | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | return sessionExists, nil | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | func InvalidateDBSession(a *server.App, sessionID string) error { | |
| 136 | - | query := "UPDATE sessions SET is_online = ? WHERE id = ?" | |
| 137 | - | _, err := a.DB.Exec(query, false, sessionID) | |
| 138 | - | if err != nil { | |
| 139 | - | return err | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | return nil | |
| 143 | - | } | |
D internal/db/queries/user.go +0 -138
| @@ -1,138 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/db" | |
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/google/uuid" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func generateUUID() string { | |
| 13 | - | return uuid.New().String() | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // UserInfo holds the essential information for a Github user. | |
| 17 | - | type GithubUserInfo struct { | |
| 18 | - | Login string `json:"login"` | |
| 19 | - | AvatarURL string `json:"avatar_url"` | |
| 20 | - | ProfileURL string `json:"html_url"` | |
| 21 | - | Emails []string | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // CreateUser creates a new user along with the associated account, provider, and session. | |
| 25 | - | func CreateUser(a *server.App, accessToken string, browserInfo string, userInfo *GithubUserInfo) error { | |
| 26 | - | // Check if a user already exists. If so, return an error. | |
| 27 | - | if exists, err := CheckDBUserExists(a); err != nil || exists { | |
| 28 | - | return fmt.Errorf("error checking user or user already exists: %v", err) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | user, err := createDBUser(a, userInfo) | |
| 32 | - | if err != nil { | |
| 33 | - | return err | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // update the global state | |
| 37 | - | a.DBTables.User.ID = user.ID | |
| 38 | - | a.DBTables.User.Name = user.Name | |
| 39 | - | a.DBTables.User.Email = user.Email | |
| 40 | - | ||
| 41 | - | account, err := createDBAccount(a) | |
| 42 | - | if err != nil { | |
| 43 | - | return err | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // update the global state | |
| 47 | - | a.DBTables.Account.ID = account.ID | |
| 48 | - | a.DBTables.Account.UserID = account.UserID | |
| 49 | - | ||
| 50 | - | err = createDBGitHubProvider(a, userInfo) | |
| 51 | - | if err != nil { | |
| 52 | - | return err | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | return createDBSession(a, browserInfo, accessToken, account.ID) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func createDBUser(a *server.App, userInfo *GithubUserInfo) (*db.User, error) { | |
| 59 | - | user := &db.User{ | |
| 60 | - | ID: generateUUID(), | |
| 61 | - | Name: userInfo.Login, | |
| 62 | - | Email: userInfo.Emails[0], | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | _, err := a.DB.Exec( | |
| 66 | - | "INSERT INTO user (id, name, email) VALUES (?, ?, ?)", | |
| 67 | - | user.ID, user.Name, user.Email, | |
| 68 | - | ) | |
| 69 | - | return user, err | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func CheckDBUserExists(a *server.App) (bool, error) { | |
| 73 | - | var userID string | |
| 74 | - | err := a.DB.QueryRow("SELECT id FROM user").Scan(&userID) | |
| 75 | - | if err != nil { | |
| 76 | - | if err == sql.ErrNoRows { | |
| 77 | - | return false, nil | |
| 78 | - | } | |
| 79 | - | return false, err | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | if userID != "" { | |
| 83 | - | return true, nil | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | return false, nil | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // If there is already a user, we compare the login and email to see if it is the same user | |
| 90 | - | func CheckDBUserIsGood(a *server.App, userInfo *GithubUserInfo) (bool, error) { | |
| 91 | - | login := userInfo.Login | |
| 92 | - | email := userInfo.Emails[0] | |
| 93 | - | ||
| 94 | - | // Check if the user exists based on github login and email | |
| 95 | - | query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?" | |
| 96 | - | err := a.DB.QueryRow(query, login, email).Scan(&a.DBTables.User.ID, &a.DBTables.User.Name, &a.DBTables.User.Email) | |
| 97 | - | if err != nil { | |
| 98 | - | if err == sql.ErrNoRows { | |
| 99 | - | return false, nil | |
| 100 | - | } | |
| 101 | - | return false, err | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | if a.DBTables.User.ID != "" { | |
| 105 | - | return true, nil | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | return false, nil | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func UpdateUser(a *server.App, accessToken string, browserInfo string, userInfo *GithubUserInfo) (*db.User, error) { | |
| 112 | - | ||
| 113 | - | err := CreateOrUpdateDBSession(a, accessToken, browserInfo) | |
| 114 | - | if err != nil { | |
| 115 | - | return nil, fmt.Errorf("could not create or update session: %w", err) | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | user := a.DBTables.User | |
| 119 | - | ||
| 120 | - | query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?" | |
| 121 | - | err = a.DB.QueryRow(query, userInfo.Login, userInfo.Emails[0]).Scan(&user.ID, &user.Name, &user.Email) | |
| 122 | - | if err != nil { | |
| 123 | - | return nil, err | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | return &user, nil | |
| 127 | - | ||
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func GetAccountCount(a *server.App) (int, error) { | |
| 131 | - | var count int | |
| 132 | - | err := a.DB.QueryRow("SELECT COUNT(*) FROM account").Scan(&count) | |
| 133 | - | if err != nil { | |
| 134 | - | return 0, err | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | return count, nil | |
| 138 | - | } | |
A internal/env/loader.go +294 -0
| @@ -0,0 +1,294 @@ | |||
| 1 | + | package env | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "gordon/internal/config" | |
| 11 | + | ||
| 12 | + | "github.com/rs/zerolog/log" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type Loader struct { | |
| 16 | + | cfg *config.Config | |
| 17 | + | secretProviders map[string]SecretProvider | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func NewLoader(cfg *config.Config) *Loader { | |
| 21 | + | return &Loader{ | |
| 22 | + | cfg: cfg, | |
| 23 | + | secretProviders: make(map[string]SecretProvider), | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (l *Loader) RegisterSecretProvider(name string, provider SecretProvider) { | |
| 28 | + | l.secretProviders[name] = provider | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (l *Loader) LoadEnvForRoute(domain string) ([]string, error) { | |
| 32 | + | envVars := []string{} | |
| 33 | + | ||
| 34 | + | // Create domain-safe filename (replace dots and other chars with underscores) | |
| 35 | + | safeDomain := strings.ReplaceAll(domain, ".", "_") | |
| 36 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 37 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |
| 38 | + | ||
| 39 | + | envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env") | |
| 40 | + | ||
| 41 | + | // Check if env file exists | |
| 42 | + | if _, err := os.Stat(envFile); os.IsNotExist(err) { | |
| 43 | + | log.Debug(). | |
| 44 | + | Str("domain", domain). | |
| 45 | + | Str("env_file", envFile). | |
| 46 | + | Msg("No env file found for route") | |
| 47 | + | return envVars, nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | log.Debug(). | |
| 51 | + | Str("domain", domain). | |
| 52 | + | Str("env_file", envFile). | |
| 53 | + | Msg("Loading env file for route") | |
| 54 | + | ||
| 55 | + | file, err := os.Open(envFile) | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, fmt.Errorf("failed to open env file %s: %w", envFile, err) | |
| 58 | + | } | |
| 59 | + | defer file.Close() | |
| 60 | + | ||
| 61 | + | scanner := bufio.NewScanner(file) | |
| 62 | + | lineNum := 0 | |
| 63 | + | ||
| 64 | + | for scanner.Scan() { | |
| 65 | + | lineNum++ | |
| 66 | + | line := strings.TrimSpace(scanner.Text()) | |
| 67 | + | ||
| 68 | + | // Skip empty lines and comments | |
| 69 | + | if line == "" || strings.HasPrefix(line, "#") { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Parse KEY=VALUE format | |
| 74 | + | parts := strings.SplitN(line, "=", 2) | |
| 75 | + | if len(parts) != 2 { | |
| 76 | + | log.Warn(). | |
| 77 | + | Str("file", envFile). | |
| 78 | + | Int("line", lineNum). | |
| 79 | + | Str("content", line). | |
| 80 | + | Msg("Invalid env file line format, skipping") | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | key := strings.TrimSpace(parts[0]) | |
| 85 | + | value := strings.TrimSpace(parts[1]) | |
| 86 | + | ||
| 87 | + | // Remove quotes if present | |
| 88 | + | if len(value) >= 2 { | |
| 89 | + | if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || | |
| 90 | + | (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { | |
| 91 | + | value = value[1 : len(value)-1] | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Resolve secrets if value contains secret syntax | |
| 96 | + | resolvedValue, err := l.resolveSecrets(value) | |
| 97 | + | if err != nil { | |
| 98 | + | log.Error(). | |
| 99 | + | Err(err). | |
| 100 | + | Str("key", key). | |
| 101 | + | Str("file", envFile). | |
| 102 | + | Int("line", lineNum). | |
| 103 | + | Msg("Failed to resolve secret") | |
| 104 | + | return nil, fmt.Errorf("failed to resolve secret for %s in %s line %d: %w", key, envFile, lineNum, err) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue)) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | if err := scanner.Err(); err != nil { | |
| 111 | + | return nil, fmt.Errorf("error reading env file %s: %w", envFile, err) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | log.Info(). | |
| 115 | + | Str("domain", domain). | |
| 116 | + | Int("count", len(envVars)). | |
| 117 | + | Msg("Loaded environment variables for route") | |
| 118 | + | ||
| 119 | + | return envVars, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (l *Loader) resolveSecrets(value string) (string, error) { | |
| 123 | + | // Check if value contains secret syntax: ${provider:path} | |
| 124 | + | if !strings.Contains(value, "${") { | |
| 125 | + | return value, nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | result := value | |
| 129 | + | for { | |
| 130 | + | start := strings.Index(result, "${") | |
| 131 | + | if start == -1 { | |
| 132 | + | break | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | end := strings.Index(result[start:], "}") | |
| 136 | + | if end == -1 { | |
| 137 | + | return "", fmt.Errorf("unclosed secret syntax in value: %s", value) | |
| 138 | + | } | |
| 139 | + | end += start | |
| 140 | + | ||
| 141 | + | secretRef := result[start+2 : end] | |
| 142 | + | parts := strings.SplitN(secretRef, ":", 2) | |
| 143 | + | if len(parts) != 2 { | |
| 144 | + | return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | providerName := parts[0] | |
| 148 | + | secretPath := parts[1] | |
| 149 | + | ||
| 150 | + | provider, exists := l.secretProviders[providerName] | |
| 151 | + | if !exists { | |
| 152 | + | return "", fmt.Errorf("unknown secret provider: %s", providerName) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | secretValue, err := provider.GetSecret(secretPath) | |
| 156 | + | if err != nil { | |
| 157 | + | return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // Replace the secret reference with the actual value | |
| 161 | + | result = result[:start] + secretValue + result[end+1:] | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | return result, nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (l *Loader) EnsureEnvDir() error { | |
| 168 | + | if err := os.MkdirAll(l.cfg.Env.Dir, 0700); err != nil { | |
| 169 | + | return fmt.Errorf("failed to create env directory %s: %w", l.cfg.Env.Dir, err) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | log.Debug(). | |
| 173 | + | Str("env_dir", l.cfg.Env.Dir). | |
| 174 | + | Msg("Ensured env directory exists") | |
| 175 | + | ||
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (l *Loader) CreateEnvFilesForRoutes() error { | |
| 180 | + | routes := l.cfg.GetRoutes() | |
| 181 | + | if len(routes) == 0 { | |
| 182 | + | log.Debug().Msg("No routes configured, skipping env file creation") | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | createdCount := 0 | |
| 187 | + | for _, route := range routes { | |
| 188 | + | // Create domain-safe filename (same logic as LoadEnvForRoute) | |
| 189 | + | safeDomain := strings.ReplaceAll(route.Domain, ".", "_") | |
| 190 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 191 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |
| 192 | + | ||
| 193 | + | envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env") | |
| 194 | + | ||
| 195 | + | // Check if file already exists | |
| 196 | + | if _, err := os.Stat(envFile); err == nil { | |
| 197 | + | log.Debug(). | |
| 198 | + | Str("domain", route.Domain). | |
| 199 | + | Str("env_file", envFile). | |
| 200 | + | Msg("Env file already exists, skipping creation") | |
| 201 | + | continue | |
| 202 | + | } else if !os.IsNotExist(err) { | |
| 203 | + | log.Warn(). | |
| 204 | + | Err(err). | |
| 205 | + | Str("env_file", envFile). | |
| 206 | + | Msg("Error checking env file, skipping creation") | |
| 207 | + | continue | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // Create empty env file with helpful comments | |
| 211 | + | content := fmt.Sprintf(`# Environment variables for route: %s | |
| 212 | + | # Image: %s | |
| 213 | + | ||
| 214 | + | `, route.Domain, route.Image) | |
| 215 | + | ||
| 216 | + | if err := os.WriteFile(envFile, []byte(content), 0600); err != nil { | |
| 217 | + | log.Error(). | |
| 218 | + | Err(err). | |
| 219 | + | Str("domain", route.Domain). | |
| 220 | + | Str("env_file", envFile). | |
| 221 | + | Msg("Failed to create env file") | |
| 222 | + | continue | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | createdCount++ | |
| 226 | + | log.Info(). | |
| 227 | + | Str("domain", route.Domain). | |
| 228 | + | Str("env_file", envFile). | |
| 229 | + | Msg("Created empty env file for route") | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | if createdCount > 0 { | |
| 233 | + | log.Info(). | |
| 234 | + | Int("created", createdCount). | |
| 235 | + | Int("total_routes", len(routes)). | |
| 236 | + | Str("env_dir", l.cfg.Env.Dir). | |
| 237 | + | Msg("Created env files for routes") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | return nil | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func (l *Loader) CreateEnvFileForRoute(domain, image string) error { | |
| 244 | + | // Create domain-safe filename (same logic as LoadEnvForRoute) | |
| 245 | + | safeDomain := strings.ReplaceAll(domain, ".", "_") | |
| 246 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 247 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |
| 248 | + | ||
| 249 | + | envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env") | |
| 250 | + | ||
| 251 | + | // Check if file already exists | |
| 252 | + | if _, err := os.Stat(envFile); err == nil { | |
| 253 | + | log.Debug(). | |
| 254 | + | Str("domain", domain). | |
| 255 | + | Str("env_file", envFile). | |
| 256 | + | Msg("Env file already exists, skipping creation") | |
| 257 | + | return nil | |
| 258 | + | } else if !os.IsNotExist(err) { | |
| 259 | + | return fmt.Errorf("error checking env file %s: %w", envFile, err) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // Ensure directory exists | |
| 263 | + | if err := l.EnsureEnvDir(); err != nil { | |
| 264 | + | return err | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // Create empty env file with helpful comments | |
| 268 | + | content := fmt.Sprintf(`# Environment variables for route: %s | |
| 269 | + | # Image: %s | |
| 270 | + | ||
| 271 | + | `, domain, image) | |
| 272 | + | ||
| 273 | + | if err := os.WriteFile(envFile, []byte(content), 0600); err != nil { | |
| 274 | + | return fmt.Errorf("failed to create env file %s: %w", envFile, err) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | log.Info(). | |
| 278 | + | Str("domain", domain). | |
| 279 | + | Str("env_file", envFile). | |
| 280 | + | Msg("Created empty env file for new route") | |
| 281 | + | ||
| 282 | + | return nil | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func (l *Loader) UpdateConfig(cfg *config.Config) { | |
| 286 | + | l.cfg = cfg | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (l *Loader) GetEnvFilePath(domain string) string { | |
| 290 | + | safeDomain := strings.ReplaceAll(domain, ".", "_") | |
| 291 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 292 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |
| 293 | + | return filepath.Join(l.cfg.Env.Dir, safeDomain+".env") | |
| 294 | + | }⏎̸ | |
A internal/env/loader_test.go +488 -0
| @@ -0,0 +1,488 @@ | |||
| 1 | + | package env | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "gordon/internal/config" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestNewLoader(t *testing.T) { | |
| 15 | + | cfg := &config.Config{ | |
| 16 | + | Env: config.EnvConfig{ | |
| 17 | + | Dir: "/tmp/env", | |
| 18 | + | }, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | loader := NewLoader(cfg) | |
| 22 | + | ||
| 23 | + | assert.NotNil(t, loader) | |
| 24 | + | assert.Equal(t, cfg, loader.cfg) | |
| 25 | + | assert.NotNil(t, loader.secretProviders) | |
| 26 | + | assert.Empty(t, loader.secretProviders) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestLoader_RegisterSecretProvider(t *testing.T) { | |
| 30 | + | cfg := &config.Config{} | |
| 31 | + | loader := NewLoader(cfg) | |
| 32 | + | ||
| 33 | + | mockProvider := &MockSecretProvider{name: "test"} | |
| 34 | + | loader.RegisterSecretProvider("test", mockProvider) | |
| 35 | + | ||
| 36 | + | assert.Contains(t, loader.secretProviders, "test") | |
| 37 | + | assert.Equal(t, mockProvider, loader.secretProviders["test"]) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestLoader_LoadEnvForRoute(t *testing.T) { | |
| 41 | + | tests := []struct { | |
| 42 | + | name string | |
| 43 | + | domain string | |
| 44 | + | fileContent string | |
| 45 | + | expectedVars []string | |
| 46 | + | expectedError bool | |
| 47 | + | setupProvider bool | |
| 48 | + | providerSecret string | |
| 49 | + | }{ | |
| 50 | + | { | |
| 51 | + | name: "no env file exists", | |
| 52 | + | domain: "app.example.com", | |
| 53 | + | expectedVars: []string{}, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "basic env file", | |
| 57 | + | domain: "app.example.com", | |
| 58 | + | fileContent: "KEY1=value1\nKEY2=value2\n", | |
| 59 | + | expectedVars: []string{ | |
| 60 | + | "KEY1=value1", | |
| 61 | + | "KEY2=value2", | |
| 62 | + | }, | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "env file with comments and empty lines", | |
| 66 | + | domain: "app.example.com", | |
| 67 | + | fileContent: `# This is a comment | |
| 68 | + | KEY1=value1 | |
| 69 | + | ||
| 70 | + | # Another comment | |
| 71 | + | KEY2=value2 | |
| 72 | + | `, | |
| 73 | + | expectedVars: []string{ | |
| 74 | + | "KEY1=value1", | |
| 75 | + | "KEY2=value2", | |
| 76 | + | }, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "env file with quoted values", | |
| 80 | + | domain: "app.example.com", | |
| 81 | + | fileContent: `KEY1="quoted value"` + "\n" + `KEY2='single quoted'` + "\n", | |
| 82 | + | expectedVars: []string{ | |
| 83 | + | "KEY1=quoted value", | |
| 84 | + | "KEY2=single quoted", | |
| 85 | + | }, | |
| 86 | + | }, | |
| 87 | + | { | |
| 88 | + | name: "env file with secret resolution", | |
| 89 | + | domain: "app.example.com", | |
| 90 | + | fileContent: "SECRET_KEY=${test:secret/path}\nNORMAL_KEY=value\n", | |
| 91 | + | setupProvider: true, | |
| 92 | + | providerSecret: "secret-value", | |
| 93 | + | expectedVars: []string{ | |
| 94 | + | "SECRET_KEY=secret-value", | |
| 95 | + | "NORMAL_KEY=value", | |
| 96 | + | }, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "env file with invalid format", | |
| 100 | + | domain: "app.example.com", | |
| 101 | + | fileContent: "INVALID_LINE_NO_EQUALS\nVALID_KEY=value\n", | |
| 102 | + | expectedVars: []string{ | |
| 103 | + | "VALID_KEY=value", | |
| 104 | + | }, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "env file with unknown secret provider", | |
| 108 | + | domain: "app.example.com", | |
| 109 | + | fileContent: "SECRET_KEY=${unknown:secret/path}\n", | |
| 110 | + | expectedError: true, | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | name: "env file with malformed secret syntax", | |
| 114 | + | domain: "app.example.com", | |
| 115 | + | fileContent: "SECRET_KEY=${malformed\n", | |
| 116 | + | expectedError: true, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "env file with invalid secret format", | |
| 120 | + | domain: "app.example.com", | |
| 121 | + | fileContent: "SECRET_KEY=${invalid-format}\n", | |
| 122 | + | expectedError: true, | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "domain with special characters", | |
| 126 | + | domain: "sub.app.example.com:8080", | |
| 127 | + | fileContent: "KEY1=value1\n", | |
| 128 | + | expectedVars: []string{ | |
| 129 | + | "KEY1=value1", | |
| 130 | + | }, | |
| 131 | + | }, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | for _, tt := range tests { | |
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |
| 136 | + | envDir := "/tmp/env" | |
| 137 | + | cfg := &config.Config{ | |
| 138 | + | Env: config.EnvConfig{ | |
| 139 | + | Dir: envDir, | |
| 140 | + | }, | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | loader := NewLoader(cfg) | |
| 144 | + | ||
| 145 | + | // Setup secret provider if needed | |
| 146 | + | if tt.setupProvider { | |
| 147 | + | mockProvider := &MockSecretProvider{ | |
| 148 | + | name: "test", | |
| 149 | + | secret: tt.providerSecret, | |
| 150 | + | } | |
| 151 | + | loader.RegisterSecretProvider("test", mockProvider) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Create env file if content is provided | |
| 155 | + | if tt.fileContent != "" { | |
| 156 | + | // Create safe domain name for file | |
| 157 | + | safeDomain := "app_example_com" | |
| 158 | + | if tt.domain == "sub.app.example.com:8080" { | |
| 159 | + | safeDomain = "sub_app_example_com_8080" | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // envFile would be: filepath.Join(envDir, safeDomain+".env") | |
| 163 | + | ||
| 164 | + | // Create directory and file using real filesystem for this test | |
| 165 | + | realEnvDir := filepath.Join(os.TempDir(), "gordon-test-env") | |
| 166 | + | defer os.RemoveAll(realEnvDir) | |
| 167 | + | ||
| 168 | + | err := os.MkdirAll(realEnvDir, 0755) | |
| 169 | + | require.NoError(t, err) | |
| 170 | + | ||
| 171 | + | realEnvFile := filepath.Join(realEnvDir, safeDomain+".env") | |
| 172 | + | err = os.WriteFile(realEnvFile, []byte(tt.fileContent), 0644) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | ||
| 175 | + | // Update config to use real directory | |
| 176 | + | cfg.Env.Dir = realEnvDir | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // Execute test | |
| 180 | + | vars, err := loader.LoadEnvForRoute(tt.domain) | |
| 181 | + | ||
| 182 | + | // Verify results | |
| 183 | + | if tt.expectedError { | |
| 184 | + | assert.Error(t, err) | |
| 185 | + | } else { | |
| 186 | + | assert.NoError(t, err) | |
| 187 | + | assert.Equal(t, tt.expectedVars, vars) | |
| 188 | + | } | |
| 189 | + | }) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestLoader_ResolveSecrets(t *testing.T) { | |
| 194 | + | cfg := &config.Config{} | |
| 195 | + | loader := NewLoader(cfg) | |
| 196 | + | ||
| 197 | + | // Register mock secret provider | |
| 198 | + | mockProvider := &MockSecretProvider{ | |
| 199 | + | name: "test", | |
| 200 | + | secret: "secret-value", | |
| 201 | + | } | |
| 202 | + | loader.RegisterSecretProvider("test", mockProvider) | |
| 203 | + | ||
| 204 | + | tests := []struct { | |
| 205 | + | name string | |
| 206 | + | input string | |
| 207 | + | expected string | |
| 208 | + | expectedError bool | |
| 209 | + | }{ | |
| 210 | + | { | |
| 211 | + | name: "no secret syntax", | |
| 212 | + | input: "plain-value", | |
| 213 | + | expected: "plain-value", | |
| 214 | + | }, | |
| 215 | + | { | |
| 216 | + | name: "single secret replacement", | |
| 217 | + | input: "${test:secret/path}", | |
| 218 | + | expected: "secret-value", | |
| 219 | + | }, | |
| 220 | + | { | |
| 221 | + | name: "secret in middle of string", | |
| 222 | + | input: "prefix-${test:secret/path}-suffix", | |
| 223 | + | expected: "prefix-secret-value-suffix", | |
| 224 | + | }, | |
| 225 | + | { | |
| 226 | + | name: "multiple secrets", | |
| 227 | + | input: "${test:secret1}-${test:secret2}", | |
| 228 | + | expected: "secret-value-secret-value", | |
| 229 | + | }, | |
| 230 | + | { | |
| 231 | + | name: "unclosed secret syntax", | |
| 232 | + | input: "${test:secret", | |
| 233 | + | expectedError: true, | |
| 234 | + | }, | |
| 235 | + | { | |
| 236 | + | name: "invalid secret format", | |
| 237 | + | input: "${invalid-format}", | |
| 238 | + | expectedError: true, | |
| 239 | + | }, | |
| 240 | + | { | |
| 241 | + | name: "unknown provider", | |
| 242 | + | input: "${unknown:secret/path}", | |
| 243 | + | expectedError: true, | |
| 244 | + | }, | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | for _, tt := range tests { | |
| 248 | + | t.Run(tt.name, func(t *testing.T) { | |
| 249 | + | result, err := loader.resolveSecrets(tt.input) | |
| 250 | + | ||
| 251 | + | if tt.expectedError { | |
| 252 | + | assert.Error(t, err) | |
| 253 | + | } else { | |
| 254 | + | assert.NoError(t, err) | |
| 255 | + | assert.Equal(t, tt.expected, result) | |
| 256 | + | } | |
| 257 | + | }) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestLoader_EnsureEnvDir(t *testing.T) { | |
| 262 | + | tempDir := os.TempDir() | |
| 263 | + | envDir := filepath.Join(tempDir, "gordon-test-env-ensure") | |
| 264 | + | defer os.RemoveAll(envDir) | |
| 265 | + | ||
| 266 | + | cfg := &config.Config{ | |
| 267 | + | Env: config.EnvConfig{ | |
| 268 | + | Dir: envDir, | |
| 269 | + | }, | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | loader := NewLoader(cfg) | |
| 273 | + | ||
| 274 | + | // Directory should not exist initially | |
| 275 | + | _, err := os.Stat(envDir) | |
| 276 | + | assert.True(t, os.IsNotExist(err)) | |
| 277 | + | ||
| 278 | + | // Ensure directory | |
| 279 | + | err = loader.EnsureEnvDir() | |
| 280 | + | assert.NoError(t, err) | |
| 281 | + | ||
| 282 | + | // Directory should now exist | |
| 283 | + | info, err := os.Stat(envDir) | |
| 284 | + | assert.NoError(t, err) | |
| 285 | + | assert.True(t, info.IsDir()) | |
| 286 | + | assert.Equal(t, os.FileMode(0700), info.Mode().Perm()) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func TestLoader_CreateEnvFilesForRoutes(t *testing.T) { | |
| 290 | + | tempDir := os.TempDir() | |
| 291 | + | envDir := filepath.Join(tempDir, "gordon-test-env-create") | |
| 292 | + | defer os.RemoveAll(envDir) | |
| 293 | + | ||
| 294 | + | cfg := &config.Config{ | |
| 295 | + | Env: config.EnvConfig{ | |
| 296 | + | Dir: envDir, | |
| 297 | + | }, | |
| 298 | + | Routes: map[string]string{ | |
| 299 | + | "app1.example.com": "nginx:latest", | |
| 300 | + | "app2.example.com": "redis:alpine", | |
| 301 | + | "api.example.com:8080": "myapi:v1", | |
| 302 | + | }, | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | loader := NewLoader(cfg) | |
| 306 | + | ||
| 307 | + | // Ensure the env directory exists | |
| 308 | + | err := os.MkdirAll(envDir, 0755) | |
| 309 | + | require.NoError(t, err) | |
| 310 | + | ||
| 311 | + | err = loader.CreateEnvFilesForRoutes() | |
| 312 | + | assert.NoError(t, err) | |
| 313 | + | ||
| 314 | + | // Check that files were created | |
| 315 | + | expectedFiles := map[string]struct{domain, image string}{ | |
| 316 | + | "app1_example_com.env": {domain: "app1.example.com", image: "nginx:latest"}, | |
| 317 | + | "app2_example_com.env": {domain: "app2.example.com", image: "redis:alpine"}, | |
| 318 | + | "api_example_com_8080.env": {domain: "api.example.com:8080", image: "myapi:v1"}, | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | for fileName, expected := range expectedFiles { | |
| 322 | + | filePath := filepath.Join(envDir, fileName) | |
| 323 | + | assert.FileExists(t, filePath) | |
| 324 | + | ||
| 325 | + | // Check file content | |
| 326 | + | content, err := os.ReadFile(filePath) | |
| 327 | + | assert.NoError(t, err) | |
| 328 | + | ||
| 329 | + | expectedContent := "# Environment variables for route: " + expected.domain + "\n# Image: " + expected.image + "\n\n" | |
| 330 | + | assert.Contains(t, string(content), expectedContent) | |
| 331 | + | ||
| 332 | + | // Check file permissions | |
| 333 | + | info, err := os.Stat(filePath) | |
| 334 | + | assert.NoError(t, err) | |
| 335 | + | assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func TestLoader_CreateEnvFilesForRoutes_NoRoutes(t *testing.T) { | |
| 340 | + | cfg := &config.Config{ | |
| 341 | + | Env: config.EnvConfig{ | |
| 342 | + | Dir: "/tmp/env", | |
| 343 | + | }, | |
| 344 | + | Routes: map[string]string{}, | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | loader := NewLoader(cfg) | |
| 348 | + | ||
| 349 | + | err := loader.CreateEnvFilesForRoutes() | |
| 350 | + | assert.NoError(t, err) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestLoader_CreateEnvFilesForRoutes_ExistingFile(t *testing.T) { | |
| 354 | + | tempDir := os.TempDir() | |
| 355 | + | envDir := filepath.Join(tempDir, "gordon-test-env-existing") | |
| 356 | + | defer os.RemoveAll(envDir) | |
| 357 | + | ||
| 358 | + | err := os.MkdirAll(envDir, 0755) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | ||
| 361 | + | // Create existing file | |
| 362 | + | existingFile := filepath.Join(envDir, "app_example_com.env") | |
| 363 | + | err = os.WriteFile(existingFile, []byte("EXISTING=value\n"), 0644) | |
| 364 | + | require.NoError(t, err) | |
| 365 | + | ||
| 366 | + | cfg := &config.Config{ | |
| 367 | + | Env: config.EnvConfig{ | |
| 368 | + | Dir: envDir, | |
| 369 | + | }, | |
| 370 | + | Routes: map[string]string{ | |
| 371 | + | "app.example.com": "nginx:latest", | |
| 372 | + | }, | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | loader := NewLoader(cfg) | |
| 376 | + | ||
| 377 | + | err = loader.CreateEnvFilesForRoutes() | |
| 378 | + | assert.NoError(t, err) | |
| 379 | + | ||
| 380 | + | // File should still have original content | |
| 381 | + | content, err := os.ReadFile(existingFile) | |
| 382 | + | assert.NoError(t, err) | |
| 383 | + | assert.Equal(t, "EXISTING=value\n", string(content)) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func TestLoader_CreateEnvFileForRoute(t *testing.T) { | |
| 387 | + | tempDir := os.TempDir() | |
| 388 | + | envDir := filepath.Join(tempDir, "gordon-test-env-single") | |
| 389 | + | defer os.RemoveAll(envDir) | |
| 390 | + | ||
| 391 | + | cfg := &config.Config{ | |
| 392 | + | Env: config.EnvConfig{ | |
| 393 | + | Dir: envDir, | |
| 394 | + | }, | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | loader := NewLoader(cfg) | |
| 398 | + | ||
| 399 | + | domain := "test.example.com" | |
| 400 | + | image := "test:latest" | |
| 401 | + | ||
| 402 | + | err := loader.CreateEnvFileForRoute(domain, image) | |
| 403 | + | assert.NoError(t, err) | |
| 404 | + | ||
| 405 | + | // Check file was created | |
| 406 | + | expectedFile := filepath.Join(envDir, "test_example_com.env") | |
| 407 | + | assert.FileExists(t, expectedFile) | |
| 408 | + | ||
| 409 | + | // Check content | |
| 410 | + | content, err := os.ReadFile(expectedFile) | |
| 411 | + | assert.NoError(t, err) | |
| 412 | + | ||
| 413 | + | expectedContent := "# Environment variables for route: test.example.com\n# Image: test:latest\n\n" | |
| 414 | + | assert.Equal(t, expectedContent, string(content)) | |
| 415 | + | ||
| 416 | + | // Test creating the same file again (should skip) | |
| 417 | + | err = loader.CreateEnvFileForRoute(domain, image) | |
| 418 | + | assert.NoError(t, err) | |
| 419 | + | ||
| 420 | + | // Content should be unchanged | |
| 421 | + | content2, err := os.ReadFile(expectedFile) | |
| 422 | + | assert.NoError(t, err) | |
| 423 | + | assert.Equal(t, string(content), string(content2)) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func TestLoader_UpdateConfig(t *testing.T) { | |
| 427 | + | cfg1 := &config.Config{Env: config.EnvConfig{Dir: "/tmp/env1"}} | |
| 428 | + | cfg2 := &config.Config{Env: config.EnvConfig{Dir: "/tmp/env2"}} | |
| 429 | + | ||
| 430 | + | loader := NewLoader(cfg1) | |
| 431 | + | assert.Equal(t, cfg1, loader.cfg) | |
| 432 | + | ||
| 433 | + | loader.UpdateConfig(cfg2) | |
| 434 | + | assert.Equal(t, cfg2, loader.cfg) | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func TestLoader_GetEnvFilePath(t *testing.T) { | |
| 438 | + | cfg := &config.Config{ | |
| 439 | + | Env: config.EnvConfig{ | |
| 440 | + | Dir: "/tmp/env", | |
| 441 | + | }, | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | loader := NewLoader(cfg) | |
| 445 | + | ||
| 446 | + | tests := []struct { | |
| 447 | + | domain string | |
| 448 | + | expected string | |
| 449 | + | }{ | |
| 450 | + | { | |
| 451 | + | domain: "app.example.com", | |
| 452 | + | expected: "/tmp/env/app_example_com.env", | |
| 453 | + | }, | |
| 454 | + | { | |
| 455 | + | domain: "api.example.com:8080", | |
| 456 | + | expected: "/tmp/env/api_example_com_8080.env", | |
| 457 | + | }, | |
| 458 | + | { | |
| 459 | + | domain: "sub.domain.example.com/path", | |
| 460 | + | expected: "/tmp/env/sub_domain_example_com_path.env", | |
| 461 | + | }, | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | for _, tt := range tests { | |
| 465 | + | t.Run(tt.domain, func(t *testing.T) { | |
| 466 | + | result := loader.GetEnvFilePath(tt.domain) | |
| 467 | + | assert.Equal(t, tt.expected, result) | |
| 468 | + | }) | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // MockSecretProvider is a mock implementation of SecretProvider for testing | |
| 473 | + | type MockSecretProvider struct { | |
| 474 | + | name string | |
| 475 | + | secret string | |
| 476 | + | err error | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | func (m *MockSecretProvider) Name() string { | |
| 480 | + | return m.name | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func (m *MockSecretProvider) GetSecret(path string) (string, error) { | |
| 484 | + | if m.err != nil { | |
| 485 | + | return "", m.err | |
| 486 | + | } | |
| 487 | + | return m.secret, nil | |
| 488 | + | }⏎̸ | |
A internal/env/providers.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package env | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os/exec" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/rs/zerolog/log" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type SecretProvider interface { | |
| 14 | + | GetSecret(path string) (string, error) | |
| 15 | + | Name() string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type PassProvider struct { | |
| 19 | + | timeout time.Duration | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func NewPassProvider() *PassProvider { | |
| 23 | + | return &PassProvider{ | |
| 24 | + | timeout: 10 * time.Second, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (p *PassProvider) Name() string { | |
| 29 | + | return "pass" | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (p *PassProvider) GetSecret(path string) (string, error) { | |
| 33 | + | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) | |
| 34 | + | defer cancel() | |
| 35 | + | ||
| 36 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) | |
| 37 | + | output, err := cmd.Output() | |
| 38 | + | if err != nil { | |
| 39 | + | if exitError, ok := err.(*exec.ExitError); ok { | |
| 40 | + | return "", fmt.Errorf("pass command failed: %s", string(exitError.Stderr)) | |
| 41 | + | } | |
| 42 | + | return "", fmt.Errorf("failed to execute pass command: %w", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | secret := strings.TrimSpace(string(output)) | |
| 46 | + | if secret == "" { | |
| 47 | + | return "", fmt.Errorf("empty secret returned from pass for path: %s", path) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | log.Debug(). | |
| 51 | + | Str("path", path). | |
| 52 | + | Msg("Successfully retrieved secret from pass") | |
| 53 | + | ||
| 54 | + | return secret, nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type SopsProvider struct { | |
| 58 | + | timeout time.Duration | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func NewSopsProvider() *SopsProvider { | |
| 62 | + | return &SopsProvider{ | |
| 63 | + | timeout: 10 * time.Second, | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (s *SopsProvider) Name() string { | |
| 68 | + | return "sops" | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (s *SopsProvider) GetSecret(path string) (string, error) { | |
| 72 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 73 | + | defer cancel() | |
| 74 | + | ||
| 75 | + | // Extract key from path format: file.yaml:key.nested.path | |
| 76 | + | parts := strings.SplitN(path, ":", 2) | |
| 77 | + | if len(parts) != 2 { | |
| 78 | + | return "", fmt.Errorf("sops path must be in format 'file:key', got: %s", path) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | filePath := parts[0] | |
| 82 | + | keyPath := parts[1] | |
| 83 | + | ||
| 84 | + | // Use sops -d --extract to get specific key | |
| 85 | + | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), filePath) | |
| 86 | + | output, err := cmd.Output() | |
| 87 | + | if err != nil { | |
| 88 | + | if exitError, ok := err.(*exec.ExitError); ok { | |
| 89 | + | return "", fmt.Errorf("sops command failed: %s", string(exitError.Stderr)) | |
| 90 | + | } | |
| 91 | + | return "", fmt.Errorf("failed to execute sops command: %w", err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | secret := strings.TrimSpace(string(output)) | |
| 95 | + | if secret == "" { | |
| 96 | + | return "", fmt.Errorf("empty secret returned from sops for path: %s", path) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | log.Debug(). | |
| 100 | + | Str("path", path). | |
| 101 | + | Msg("Successfully retrieved secret from sops") | |
| 102 | + | ||
| 103 | + | return secret, nil | |
| 104 | + | }⏎̸ | |
A internal/env/providers_test.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | package env | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os/exec" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestNewPassProvider(t *testing.T) { | |
| 13 | + | provider := NewPassProvider() | |
| 14 | + | assert.NotNil(t, provider) | |
| 15 | + | assert.Equal(t, "pass", provider.Name()) | |
| 16 | + | assert.Equal(t, 10*time.Second, provider.timeout) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestPassProvider_Name(t *testing.T) { | |
| 20 | + | provider := NewPassProvider() | |
| 21 | + | assert.Equal(t, "pass", provider.Name()) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestPassProvider_GetSecret(t *testing.T) { | |
| 25 | + | provider := NewPassProvider() | |
| 26 | + | ||
| 27 | + | tests := []struct { | |
| 28 | + | name string | |
| 29 | + | path string | |
| 30 | + | mockCommand bool | |
| 31 | + | mockOutput string | |
| 32 | + | mockError bool | |
| 33 | + | expectedSecret string | |
| 34 | + | expectedError bool | |
| 35 | + | }{ | |
| 36 | + | { | |
| 37 | + | name: "successful secret retrieval", | |
| 38 | + | path: "test/secret", | |
| 39 | + | mockCommand: true, | |
| 40 | + | mockOutput: "secret-value\n", | |
| 41 | + | expectedSecret: "secret-value", | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "command execution fails", | |
| 45 | + | path: "test/nonexistent", | |
| 46 | + | mockCommand: true, | |
| 47 | + | mockError: true, | |
| 48 | + | expectedError: true, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "empty secret returned", | |
| 52 | + | path: "test/empty", | |
| 53 | + | mockCommand: true, | |
| 54 | + | mockOutput: "\n", | |
| 55 | + | expectedError: true, | |
| 56 | + | }, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | for _, tt := range tests { | |
| 60 | + | t.Run(tt.name, func(t *testing.T) { | |
| 61 | + | // Check if pass command is available | |
| 62 | + | _, err := exec.LookPath("pass") | |
| 63 | + | if err != nil { | |
| 64 | + | t.Skip("pass command not available, skipping test") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Since we can't easily mock exec.Command in this context, | |
| 68 | + | // we'll test the interface and structure, but skip actual execution | |
| 69 | + | // unless we have a real pass installation | |
| 70 | + | ||
| 71 | + | // Test the provider structure | |
| 72 | + | assert.Equal(t, "pass", provider.Name()) | |
| 73 | + | assert.NotZero(t, provider.timeout) | |
| 74 | + | ||
| 75 | + | // For actual testing, we would need a more sophisticated mocking approach | |
| 76 | + | // or integration tests with a real pass setup | |
| 77 | + | if !tt.mockCommand { | |
| 78 | + | secret, err := provider.GetSecret(tt.path) | |
| 79 | + | if tt.expectedError { | |
| 80 | + | assert.Error(t, err) | |
| 81 | + | } else { | |
| 82 | + | assert.NoError(t, err) | |
| 83 | + | assert.Equal(t, tt.expectedSecret, secret) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestNewSopsProvider(t *testing.T) { | |
| 91 | + | provider := NewSopsProvider() | |
| 92 | + | assert.NotNil(t, provider) | |
| 93 | + | assert.Equal(t, "sops", provider.Name()) | |
| 94 | + | assert.Equal(t, 10*time.Second, provider.timeout) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestSopsProvider_Name(t *testing.T) { | |
| 98 | + | provider := NewSopsProvider() | |
| 99 | + | assert.Equal(t, "sops", provider.Name()) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestSopsProvider_GetSecret(t *testing.T) { | |
| 103 | + | provider := NewSopsProvider() | |
| 104 | + | ||
| 105 | + | tests := []struct { | |
| 106 | + | name string | |
| 107 | + | path string | |
| 108 | + | expectedSecret string | |
| 109 | + | expectedError bool | |
| 110 | + | }{ | |
| 111 | + | { | |
| 112 | + | name: "invalid path format - no colon", | |
| 113 | + | path: "invalid-path", | |
| 114 | + | expectedError: true, | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | + | name: "invalid path format - empty key", | |
| 118 | + | path: "file.yaml:", | |
| 119 | + | expectedError: true, | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | name: "invalid path format - empty file", | |
| 123 | + | path: ":key.path", | |
| 124 | + | expectedError: true, | |
| 125 | + | }, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | // Check if sops command is available | |
| 131 | + | _, err := exec.LookPath("sops") | |
| 132 | + | if err != nil { | |
| 133 | + | t.Skip("sops command not available, skipping test") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Test the provider structure | |
| 137 | + | assert.Equal(t, "sops", provider.Name()) | |
| 138 | + | assert.NotZero(t, provider.timeout) | |
| 139 | + | ||
| 140 | + | // Test path validation | |
| 141 | + | secret, err := provider.GetSecret(tt.path) | |
| 142 | + | if tt.expectedError { | |
| 143 | + | assert.Error(t, err) | |
| 144 | + | assert.Empty(t, secret) | |
| 145 | + | } else { | |
| 146 | + | // For successful cases, we'd need actual sops files | |
| 147 | + | // which is complex to set up in unit tests | |
| 148 | + | // This would be better tested in integration tests | |
| 149 | + | } | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestSopsProvider_GetSecret_ValidFormat(t *testing.T) { | |
| 155 | + | provider := NewSopsProvider() | |
| 156 | + | ||
| 157 | + | // Check if sops command is available | |
| 158 | + | _, err := exec.LookPath("sops") | |
| 159 | + | if err != nil { | |
| 160 | + | t.Skip("sops command not available, skipping test") | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Test with valid format but non-existent file (should fail gracefully) | |
| 164 | + | _, err = provider.GetSecret("nonexistent.yaml:some.key") | |
| 165 | + | assert.Error(t, err) | |
| 166 | + | assert.Contains(t, err.Error(), "sops command failed") | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestSecretProvider_Interface(t *testing.T) { | |
| 170 | + | // Test that our providers implement the SecretProvider interface | |
| 171 | + | var _ SecretProvider = NewPassProvider() | |
| 172 | + | var _ SecretProvider = NewSopsProvider() | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestProviders_Timeout(t *testing.T) { | |
| 176 | + | // Test that providers have reasonable timeouts | |
| 177 | + | passProvider := NewPassProvider() | |
| 178 | + | sopsProvider := NewSopsProvider() | |
| 179 | + | ||
| 180 | + | assert.Equal(t, 10*time.Second, passProvider.timeout) | |
| 181 | + | assert.Equal(t, 10*time.Second, sopsProvider.timeout) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestProviders_ContextCancellation(t *testing.T) { | |
| 185 | + | // Test that providers respect context cancellation | |
| 186 | + | passProvider := NewPassProvider() | |
| 187 | + | sopsProvider := NewSopsProvider() | |
| 188 | + | ||
| 189 | + | // Create a context that's already cancelled | |
| 190 | + | _, cancel := context.WithCancel(context.Background()) | |
| 191 | + | cancel() | |
| 192 | + | ||
| 193 | + | // Providers should handle cancelled contexts gracefully | |
| 194 | + | // (This is more of a behavioral test - the actual implementation | |
| 195 | + | // uses exec.CommandContext which should respect cancellation) | |
| 196 | + | assert.NotNil(t, passProvider) | |
| 197 | + | assert.NotNil(t, sopsProvider) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Mock implementations for more controlled testing | |
| 201 | + | type MockPassProvider struct { | |
| 202 | + | output string | |
| 203 | + | err error | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (m *MockPassProvider) Name() string { | |
| 207 | + | return "pass" | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (m *MockPassProvider) GetSecret(path string) (string, error) { | |
| 211 | + | if m.err != nil { | |
| 212 | + | return "", m.err | |
| 213 | + | } | |
| 214 | + | return m.output, nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | type MockSopsProvider struct { | |
| 218 | + | output string | |
| 219 | + | err error | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (m *MockSopsProvider) Name() string { | |
| 223 | + | return "sops" | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (m *MockSopsProvider) GetSecret(path string) (string, error) { | |
| 227 | + | if m.err != nil { | |
| 228 | + | return "", m.err | |
| 229 | + | } | |
| 230 | + | return m.output, nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestMockProviders(t *testing.T) { | |
| 234 | + | // Test our mock implementations | |
| 235 | + | mockPass := &MockPassProvider{output: "test-secret", err: nil} | |
| 236 | + | mockSops := &MockSopsProvider{output: "sops-secret", err: nil} | |
| 237 | + | ||
| 238 | + | assert.Equal(t, "pass", mockPass.Name()) | |
| 239 | + | assert.Equal(t, "sops", mockSops.Name()) | |
| 240 | + | ||
| 241 | + | secret, err := mockPass.GetSecret("test/path") | |
| 242 | + | assert.NoError(t, err) | |
| 243 | + | assert.Equal(t, "test-secret", secret) | |
| 244 | + | ||
| 245 | + | secret, err = mockSops.GetSecret("file.yaml:key") | |
| 246 | + | assert.NoError(t, err) | |
| 247 | + | assert.Equal(t, "sops-secret", secret) | |
| 248 | + | }⏎̸ | |
A internal/events/bus.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package events | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/google/uuid" | |
| 10 | + | "github.com/rs/zerolog/log" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type InMemoryEventBus struct { | |
| 14 | + | handlers []EventHandler | |
| 15 | + | eventChan chan Event | |
| 16 | + | done chan struct{} | |
| 17 | + | mu sync.RWMutex | |
| 18 | + | ctx context.Context | |
| 19 | + | cancel context.CancelFunc | |
| 20 | + | bufferSize int | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus { | |
| 24 | + | if bufferSize <= 0 { | |
| 25 | + | bufferSize = 100 | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 29 | + | ||
| 30 | + | return &InMemoryEventBus{ | |
| 31 | + | handlers: make([]EventHandler, 0), | |
| 32 | + | eventChan: make(chan Event, bufferSize), | |
| 33 | + | done: make(chan struct{}), | |
| 34 | + | ctx: ctx, | |
| 35 | + | cancel: cancel, | |
| 36 | + | bufferSize: bufferSize, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (bus *InMemoryEventBus) Publish(eventType EventType, payload interface{}) error { | |
| 41 | + | event := Event{ | |
| 42 | + | ID: uuid.New().String(), | |
| 43 | + | Type: eventType, | |
| 44 | + | Timestamp: time.Now(), | |
| 45 | + | Data: payload, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Add specific fields for known event types | |
| 49 | + | switch p := payload.(type) { | |
| 50 | + | case ImagePushedPayload: | |
| 51 | + | event.ImageName = p.Name | |
| 52 | + | event.Tag = p.Reference | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | select { | |
| 56 | + | case bus.eventChan <- event: | |
| 57 | + | log.Debug(). | |
| 58 | + | Str("event_id", event.ID). | |
| 59 | + | Str("event_type", string(event.Type)). | |
| 60 | + | Str("image_name", event.ImageName). | |
| 61 | + | Str("tag", event.Tag). | |
| 62 | + | Msg("Event published") | |
| 63 | + | return nil | |
| 64 | + | case <-bus.ctx.Done(): | |
| 65 | + | return fmt.Errorf("event bus is stopped") | |
| 66 | + | default: | |
| 67 | + | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (bus *InMemoryEventBus) Subscribe(handler EventHandler) error { | |
| 72 | + | bus.mu.Lock() | |
| 73 | + | defer bus.mu.Unlock() | |
| 74 | + | ||
| 75 | + | bus.handlers = append(bus.handlers, handler) | |
| 76 | + | log.Debug(). | |
| 77 | + | Str("handler_type", fmt.Sprintf("%T", handler)). | |
| 78 | + | Int("total_handlers", len(bus.handlers)). | |
| 79 | + | Msg("Event handler subscribed") | |
| 80 | + | ||
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error { | |
| 85 | + | bus.mu.Lock() | |
| 86 | + | defer bus.mu.Unlock() | |
| 87 | + | ||
| 88 | + | for i, h := range bus.handlers { | |
| 89 | + | if h == handler { | |
| 90 | + | bus.handlers = append(bus.handlers[:i], bus.handlers[i+1:]...) | |
| 91 | + | log.Debug(). | |
| 92 | + | Str("handler_type", fmt.Sprintf("%T", handler)). | |
| 93 | + | Int("total_handlers", len(bus.handlers)). | |
| 94 | + | Msg("Event handler unsubscribed") | |
| 95 | + | return nil | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | return fmt.Errorf("handler not found") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (bus *InMemoryEventBus) Start() error { | |
| 103 | + | log.Info(). | |
| 104 | + | Int("buffer_size", bus.bufferSize). | |
| 105 | + | Msg("Starting event bus") | |
| 106 | + | ||
| 107 | + | go bus.processEvents() | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (bus *InMemoryEventBus) Stop() error { | |
| 112 | + | log.Info().Msg("Stopping event bus") | |
| 113 | + | ||
| 114 | + | bus.cancel() | |
| 115 | + | ||
| 116 | + | select { | |
| 117 | + | case <-bus.done: | |
| 118 | + | log.Info().Msg("Event bus stopped") | |
| 119 | + | return nil | |
| 120 | + | case <-time.After(5 * time.Second): | |
| 121 | + | log.Warn().Msg("Event bus stop timeout") | |
| 122 | + | return fmt.Errorf("timeout waiting for event bus to stop") | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (bus *InMemoryEventBus) processEvents() { | |
| 127 | + | defer close(bus.done) | |
| 128 | + | ||
| 129 | + | for { | |
| 130 | + | select { | |
| 131 | + | case event := <-bus.eventChan: | |
| 132 | + | bus.handleEvent(event) | |
| 133 | + | case <-bus.ctx.Done(): | |
| 134 | + | log.Debug().Msg("Event bus processing stopped") | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (bus *InMemoryEventBus) handleEvent(event Event) { | |
| 141 | + | bus.mu.RLock() | |
| 142 | + | handlers := make([]EventHandler, len(bus.handlers)) | |
| 143 | + | copy(handlers, bus.handlers) | |
| 144 | + | bus.mu.RUnlock() | |
| 145 | + | ||
| 146 | + | for _, handler := range handlers { | |
| 147 | + | if handler.CanHandle(event.Type) { | |
| 148 | + | go func(h EventHandler) { | |
| 149 | + | start := time.Now() | |
| 150 | + | ||
| 151 | + | if err := h.Handle(event); err != nil { | |
| 152 | + | log.Error(). | |
| 153 | + | Err(err). | |
| 154 | + | Str("event_id", event.ID). | |
| 155 | + | Str("event_type", string(event.Type)). | |
| 156 | + | Str("handler_type", fmt.Sprintf("%T", h)). | |
| 157 | + | Msg("Error handling event") | |
| 158 | + | } else { | |
| 159 | + | log.Debug(). | |
| 160 | + | Str("event_id", event.ID). | |
| 161 | + | Str("event_type", string(event.Type)). | |
| 162 | + | Str("handler_type", fmt.Sprintf("%T", h)). | |
| 163 | + | Dur("duration", time.Since(start)). | |
| 164 | + | Msg("Event handled successfully") | |
| 165 | + | } | |
| 166 | + | }(handler) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
A internal/events/bus_test.go +337 -0
| @@ -0,0 +1,337 @@ | |||
| 1 | + | package events | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // MockEventHandler is a mock implementation of EventHandler | |
| 14 | + | type MockEventHandler struct { | |
| 15 | + | mock.Mock | |
| 16 | + | mu sync.Mutex | |
| 17 | + | events []Event | |
| 18 | + | canHandle bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (m *MockEventHandler) Handle(event Event) error { | |
| 22 | + | m.mu.Lock() | |
| 23 | + | defer m.mu.Unlock() | |
| 24 | + | m.events = append(m.events, event) | |
| 25 | + | args := m.Called(event) | |
| 26 | + | return args.Error(0) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (m *MockEventHandler) CanHandle(eventType EventType) bool { | |
| 30 | + | args := m.Called(eventType) | |
| 31 | + | return args.Bool(0) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (m *MockEventHandler) GetReceivedEvents() []Event { | |
| 35 | + | m.mu.Lock() | |
| 36 | + | defer m.mu.Unlock() | |
| 37 | + | return append([]Event{}, m.events...) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestInMemoryEventBus_NewInMemoryEventBus(t *testing.T) { | |
| 41 | + | bus := NewInMemoryEventBus(50) | |
| 42 | + | ||
| 43 | + | assert.NotNil(t, bus) | |
| 44 | + | assert.Equal(t, 50, cap(bus.eventChan)) | |
| 45 | + | assert.Empty(t, bus.handlers) | |
| 46 | + | assert.Equal(t, 50, bus.bufferSize) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestInMemoryEventBus_Subscribe(t *testing.T) { | |
| 50 | + | bus := NewInMemoryEventBus(10) | |
| 51 | + | handler := &MockEventHandler{} | |
| 52 | + | ||
| 53 | + | bus.Subscribe(handler) | |
| 54 | + | ||
| 55 | + | assert.Len(t, bus.handlers, 1) | |
| 56 | + | assert.Contains(t, bus.handlers, handler) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestInMemoryEventBus_Subscribe_DuplicateHandler(t *testing.T) { | |
| 60 | + | bus := NewInMemoryEventBus(10) | |
| 61 | + | handler := &MockEventHandler{} | |
| 62 | + | ||
| 63 | + | bus.Subscribe(handler) | |
| 64 | + | bus.Subscribe(handler) // Subscribe same handler twice | |
| 65 | + | ||
| 66 | + | // Implementation allows duplicates (handlers are appended to slice) | |
| 67 | + | assert.Len(t, bus.handlers, 2) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestInMemoryEventBus_Unsubscribe(t *testing.T) { | |
| 71 | + | bus := NewInMemoryEventBus(10) | |
| 72 | + | handler1 := &MockEventHandler{} | |
| 73 | + | handler2 := &MockEventHandler{} | |
| 74 | + | ||
| 75 | + | bus.Subscribe(handler1) | |
| 76 | + | bus.Subscribe(handler2) | |
| 77 | + | assert.Len(t, bus.handlers, 2) | |
| 78 | + | ||
| 79 | + | err := bus.Unsubscribe(handler1) | |
| 80 | + | ||
| 81 | + | assert.NoError(t, err) | |
| 82 | + | assert.Len(t, bus.handlers, 1) | |
| 83 | + | // Check that handler2 is still in the list (compare by position) | |
| 84 | + | assert.Equal(t, handler2, bus.handlers[0]) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestInMemoryEventBus_Unsubscribe_NonExistentHandler(t *testing.T) { | |
| 88 | + | bus := NewInMemoryEventBus(10) | |
| 89 | + | handler1 := &MockEventHandler{} | |
| 90 | + | handler2 := &MockEventHandler{} | |
| 91 | + | ||
| 92 | + | bus.Subscribe(handler1) | |
| 93 | + | err := bus.Unsubscribe(handler2) // Try to unsubscribe handler that wasn't subscribed | |
| 94 | + | ||
| 95 | + | assert.Error(t, err) | |
| 96 | + | assert.Contains(t, err.Error(), "handler not found") | |
| 97 | + | assert.Len(t, bus.handlers, 1) | |
| 98 | + | assert.Equal(t, handler1, bus.handlers[0]) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestInMemoryEventBus_StartStop(t *testing.T) { | |
| 102 | + | bus := NewInMemoryEventBus(10) | |
| 103 | + | ||
| 104 | + | // Start the bus | |
| 105 | + | err := bus.Start() | |
| 106 | + | assert.NoError(t, err) | |
| 107 | + | ||
| 108 | + | // Stop the bus | |
| 109 | + | err = bus.Stop() | |
| 110 | + | assert.NoError(t, err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestInMemoryEventBus_Publish_Running(t *testing.T) { | |
| 114 | + | bus := NewInMemoryEventBus(10) | |
| 115 | + | ||
| 116 | + | handler := &MockEventHandler{} | |
| 117 | + | handler.On("CanHandle", ImagePushed).Return(true) | |
| 118 | + | handler.On("Handle", mock.AnythingOfType("Event")).Return(nil) | |
| 119 | + | ||
| 120 | + | bus.Subscribe(handler) | |
| 121 | + | bus.Start() | |
| 122 | + | defer bus.Stop() | |
| 123 | + | ||
| 124 | + | payload := ImagePushedPayload{ | |
| 125 | + | Name: "nginx:latest", | |
| 126 | + | Reference: "latest", | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | bus.Publish(ImagePushed, payload) | |
| 130 | + | ||
| 131 | + | // Give some time for async processing | |
| 132 | + | time.Sleep(50 * time.Millisecond) | |
| 133 | + | ||
| 134 | + | handler.AssertExpectations(t) | |
| 135 | + | events := handler.GetReceivedEvents() | |
| 136 | + | require.Len(t, events, 1) | |
| 137 | + | assert.Equal(t, ImagePushed, events[0].Type) | |
| 138 | + | assert.Equal(t, "nginx:latest", events[0].ImageName) | |
| 139 | + | assert.Equal(t, "latest", events[0].Tag) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestInMemoryEventBus_Publish_NotRunning(t *testing.T) { | |
| 143 | + | bus := NewInMemoryEventBus(10) | |
| 144 | + | ||
| 145 | + | handler := &MockEventHandler{} | |
| 146 | + | bus.Subscribe(handler) | |
| 147 | + | ||
| 148 | + | payload := ImagePushedPayload{ | |
| 149 | + | Name: "nginx:latest", | |
| 150 | + | Reference: "latest", | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Publish when bus is not running | |
| 154 | + | bus.Publish(ImagePushed, payload) | |
| 155 | + | ||
| 156 | + | // Give some time in case it processes (it shouldn't) | |
| 157 | + | time.Sleep(10 * time.Millisecond) | |
| 158 | + | ||
| 159 | + | // Handler should not have received any events | |
| 160 | + | events := handler.GetReceivedEvents() | |
| 161 | + | assert.Empty(t, events) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestInMemoryEventBus_HandlerFiltering(t *testing.T) { | |
| 165 | + | bus := NewInMemoryEventBus(10) | |
| 166 | + | ||
| 167 | + | // Handler that only handles ImagePushed events | |
| 168 | + | imagePushedHandler := &MockEventHandler{} | |
| 169 | + | imagePushedHandler.On("CanHandle", ImagePushed).Return(true) | |
| 170 | + | imagePushedHandler.On("CanHandle", ConfigReload).Return(false) | |
| 171 | + | imagePushedHandler.On("Handle", mock.AnythingOfType("Event")).Return(nil) | |
| 172 | + | ||
| 173 | + | // Handler that only handles ConfigReload events | |
| 174 | + | configReloadHandler := &MockEventHandler{} | |
| 175 | + | configReloadHandler.On("CanHandle", ImagePushed).Return(false) | |
| 176 | + | configReloadHandler.On("CanHandle", ConfigReload).Return(true) | |
| 177 | + | configReloadHandler.On("Handle", mock.AnythingOfType("Event")).Return(nil) | |
| 178 | + | ||
| 179 | + | bus.Subscribe(imagePushedHandler) | |
| 180 | + | bus.Subscribe(configReloadHandler) | |
| 181 | + | bus.Start() | |
| 182 | + | defer bus.Stop() | |
| 183 | + | ||
| 184 | + | // Publish ImagePushed event | |
| 185 | + | bus.Publish(ImagePushed, ImagePushedPayload{ | |
| 186 | + | Name: "nginx:latest", | |
| 187 | + | Reference: "latest", | |
| 188 | + | }) | |
| 189 | + | ||
| 190 | + | // Publish ConfigReload event | |
| 191 | + | bus.Publish(ConfigReload, nil) | |
| 192 | + | ||
| 193 | + | // Give time for processing | |
| 194 | + | time.Sleep(20 * time.Millisecond) | |
| 195 | + | ||
| 196 | + | // Check that each handler only received appropriate events | |
| 197 | + | imagePushedEvents := imagePushedHandler.GetReceivedEvents() | |
| 198 | + | configReloadEvents := configReloadHandler.GetReceivedEvents() | |
| 199 | + | ||
| 200 | + | require.Len(t, imagePushedEvents, 1) | |
| 201 | + | assert.Equal(t, ImagePushed, imagePushedEvents[0].Type) | |
| 202 | + | ||
| 203 | + | require.Len(t, configReloadEvents, 1) | |
| 204 | + | assert.Equal(t, ConfigReload, configReloadEvents[0].Type) | |
| 205 | + | ||
| 206 | + | imagePushedHandler.AssertExpectations(t) | |
| 207 | + | configReloadHandler.AssertExpectations(t) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestInMemoryEventBus_HandlerError(t *testing.T) { | |
| 211 | + | bus := NewInMemoryEventBus(10) | |
| 212 | + | ||
| 213 | + | // Handler that returns an error | |
| 214 | + | errorHandler := &MockEventHandler{} | |
| 215 | + | errorHandler.On("CanHandle", ImagePushed).Return(true) | |
| 216 | + | errorHandler.On("Handle", mock.AnythingOfType("Event")).Return(assert.AnError) | |
| 217 | + | ||
| 218 | + | // Handler that succeeds | |
| 219 | + | successHandler := &MockEventHandler{} | |
| 220 | + | successHandler.On("CanHandle", ImagePushed).Return(true) | |
| 221 | + | successHandler.On("Handle", mock.AnythingOfType("Event")).Return(nil) | |
| 222 | + | ||
| 223 | + | bus.Subscribe(errorHandler) | |
| 224 | + | bus.Subscribe(successHandler) | |
| 225 | + | bus.Start() | |
| 226 | + | defer bus.Stop() | |
| 227 | + | ||
| 228 | + | payload := ImagePushedPayload{ | |
| 229 | + | Name: "nginx:latest", | |
| 230 | + | Reference: "latest", | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | bus.Publish(ImagePushed, payload) | |
| 234 | + | ||
| 235 | + | // Give time for processing | |
| 236 | + | time.Sleep(20 * time.Millisecond) | |
| 237 | + | ||
| 238 | + | // Both handlers should have been called despite error | |
| 239 | + | errorHandler.AssertExpectations(t) | |
| 240 | + | successHandler.AssertExpectations(t) | |
| 241 | + | ||
| 242 | + | // Both should have received the event | |
| 243 | + | errorEvents := errorHandler.GetReceivedEvents() | |
| 244 | + | successEvents := successHandler.GetReceivedEvents() | |
| 245 | + | ||
| 246 | + | assert.Len(t, errorEvents, 1) | |
| 247 | + | assert.Len(t, successEvents, 1) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestInMemoryEventBus_ContextCancellation(t *testing.T) { | |
| 251 | + | bus := NewInMemoryEventBus(10) | |
| 252 | + | ||
| 253 | + | handler := &MockEventHandler{} | |
| 254 | + | handler.On("CanHandle", ImagePushed).Return(true) | |
| 255 | + | handler.On("Handle", mock.AnythingOfType("Event")).Return(nil) | |
| 256 | + | ||
| 257 | + | bus.Subscribe(handler) | |
| 258 | + | bus.Start() | |
| 259 | + | ||
| 260 | + | // Stop the bus to trigger shutdown | |
| 261 | + | err := bus.Stop() | |
| 262 | + | assert.NoError(t, err) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestInMemoryEventBus_FullChannel(t *testing.T) { | |
| 266 | + | // Create bus with very small buffer | |
| 267 | + | bus := NewInMemoryEventBus(1) | |
| 268 | + | ||
| 269 | + | handler := &MockEventHandler{} | |
| 270 | + | handler.On("CanHandle", ImagePushed).Return(true) | |
| 271 | + | // Make handler slow to process events | |
| 272 | + | handler.On("Handle", mock.AnythingOfType("Event")).Return(nil).Run(func(args mock.Arguments) { | |
| 273 | + | time.Sleep(50 * time.Millisecond) | |
| 274 | + | }) | |
| 275 | + | ||
| 276 | + | bus.Subscribe(handler) | |
| 277 | + | bus.Start() | |
| 278 | + | defer bus.Stop() | |
| 279 | + | ||
| 280 | + | payload := ImagePushedPayload{ | |
| 281 | + | Name: "nginx:latest", | |
| 282 | + | Reference: "latest", | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // Publish multiple events quickly to potentially fill the channel | |
| 286 | + | for i := 0; i < 5; i++ { | |
| 287 | + | bus.Publish(ImagePushed, payload) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // Give time for processing | |
| 291 | + | time.Sleep(200 * time.Millisecond) | |
| 292 | + | ||
| 293 | + | // Should still work, events might be dropped but no crash | |
| 294 | + | events := handler.GetReceivedEvents() | |
| 295 | + | assert.True(t, len(events) >= 1) // At least some events should be processed | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestInMemoryEventBus_ConcurrentPublish(t *testing.T) { | |
| 299 | + | bus := NewInMemoryEventBus(100) | |
| 300 | + | ||
| 301 | + | handler := &MockEventHandler{} | |
| 302 | + | handler.On("CanHandle", ImagePushed).Return(true) | |
| 303 | + | handler.On("Handle", mock.AnythingOfType("Event")).Return(nil) | |
| 304 | + | ||
| 305 | + | bus.Subscribe(handler) | |
| 306 | + | bus.Start() | |
| 307 | + | defer bus.Stop() | |
| 308 | + | ||
| 309 | + | payload := ImagePushedPayload{ | |
| 310 | + | Name: "nginx:latest", | |
| 311 | + | Reference: "latest", | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // Publish events concurrently from multiple goroutines | |
| 315 | + | numGoroutines := 10 | |
| 316 | + | eventsPerGoroutine := 5 | |
| 317 | + | var wg sync.WaitGroup | |
| 318 | + | ||
| 319 | + | for i := 0; i < numGoroutines; i++ { | |
| 320 | + | wg.Add(1) | |
| 321 | + | go func() { | |
| 322 | + | defer wg.Done() | |
| 323 | + | for j := 0; j < eventsPerGoroutine; j++ { | |
| 324 | + | bus.Publish(ImagePushed, payload) | |
| 325 | + | } | |
| 326 | + | }() | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | wg.Wait() | |
| 330 | + | ||
| 331 | + | // Give time for processing | |
| 332 | + | time.Sleep(100 * time.Millisecond) | |
| 333 | + | ||
| 334 | + | events := handler.GetReceivedEvents() | |
| 335 | + | expectedEvents := numGoroutines * eventsPerGoroutine | |
| 336 | + | assert.Equal(t, expectedEvents, len(events)) | |
| 337 | + | }⏎̸ | |
A internal/events/handlers.go +393 -0
| @@ -0,0 +1,393 @@ | |||
| 1 | + | package events | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/rs/zerolog/log" | |
| 9 | + | "gordon/internal/config" | |
| 10 | + | "gordon/internal/container" | |
| 11 | + | "gordon/pkg/runtime" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type ContainerEventHandler struct { | |
| 15 | + | manager container.ManagerInterface | |
| 16 | + | config *config.Config | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func NewContainerEventHandler(manager container.ManagerInterface, config *config.Config) *ContainerEventHandler { | |
| 20 | + | return &ContainerEventHandler{ | |
| 21 | + | manager: manager, | |
| 22 | + | config: config, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (h *ContainerEventHandler) CanHandle(eventType EventType) bool { | |
| 27 | + | switch eventType { | |
| 28 | + | case ImagePushed, ConfigReload, ManualReload, ContainerStop, ContainerStart: | |
| 29 | + | return true | |
| 30 | + | default: | |
| 31 | + | return false | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (h *ContainerEventHandler) Handle(event Event) error { | |
| 36 | + | switch event.Type { | |
| 37 | + | case ImagePushed: | |
| 38 | + | return h.handleImagePushed(event) | |
| 39 | + | case ConfigReload: | |
| 40 | + | return h.handleConfigReload(event) | |
| 41 | + | case ManualReload: | |
| 42 | + | return h.handleManualReload(event) | |
| 43 | + | case ContainerStop: | |
| 44 | + | return h.handleContainerStop(event) | |
| 45 | + | case ContainerStart: | |
| 46 | + | return h.handleContainerStart(event) | |
| 47 | + | default: | |
| 48 | + | return fmt.Errorf("unsupported event type: %s", event.Type) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (h *ContainerEventHandler) handleImagePushed(event Event) error { | |
| 53 | + | imageName := event.ImageName | |
| 54 | + | tag := event.Tag | |
| 55 | + | ||
| 56 | + | if imageName == "" { | |
| 57 | + | return fmt.Errorf("image name is required for ImagePushed event") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | if tag == "" { | |
| 61 | + | tag = "latest" | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fullImageName := fmt.Sprintf("%s:%s", imageName, tag) | |
| 65 | + | ||
| 66 | + | log.Info(). | |
| 67 | + | Str("image", fullImageName). | |
| 68 | + | Str("event_id", event.ID). | |
| 69 | + | Msg("Processing image push event") | |
| 70 | + | ||
| 71 | + | routes := h.findRoutesForImage(fullImageName) | |
| 72 | + | if len(routes) == 0 { | |
| 73 | + | log.Debug(). | |
| 74 | + | Str("image", fullImageName). | |
| 75 | + | Msg("No routes configured for pushed image") | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | for _, route := range routes { | |
| 80 | + | if err := h.deployContainerForRoute(route, fullImageName); err != nil { | |
| 81 | + | log.Error(). | |
| 82 | + | Err(err). | |
| 83 | + | Str("route", route). | |
| 84 | + | Str("image", fullImageName). | |
| 85 | + | Msg("Failed to deploy container for route") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | return nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (h *ContainerEventHandler) handleConfigReload(event Event) error { | |
| 93 | + | log.Info(). | |
| 94 | + | Str("event_id", event.ID). | |
| 95 | + | Msg("Processing configuration reload event") | |
| 96 | + | ||
| 97 | + | ctx := context.Background() | |
| 98 | + | ||
| 99 | + | // Get current containers managed by Gordon | |
| 100 | + | currentContainers := h.manager.ListContainers() | |
| 101 | + | ||
| 102 | + | // Track which routes are currently active | |
| 103 | + | activeRoutes := make(map[string]*runtime.Container) | |
| 104 | + | for _, container := range currentContainers { | |
| 105 | + | if route, exists := container.Labels["gordon.route"]; exists { | |
| 106 | + | activeRoutes[route] = container | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Process each configured route | |
| 111 | + | for route, imageName := range h.config.Routes { | |
| 112 | + | if container, exists := activeRoutes[route]; exists { | |
| 113 | + | // Route exists - check if image has changed | |
| 114 | + | currentImage := container.Labels["gordon.image"] | |
| 115 | + | if currentImage != imageName { | |
| 116 | + | log.Info(). | |
| 117 | + | Str("route", route). | |
| 118 | + | Str("old_image", currentImage). | |
| 119 | + | Str("new_image", imageName). | |
| 120 | + | Msg("Image changed for route, redeploying") | |
| 121 | + | ||
| 122 | + | if err := h.deployContainerForRoute(route, imageName); err != nil { | |
| 123 | + | log.Error(). | |
| 124 | + | Err(err). | |
| 125 | + | Str("route", route). | |
| 126 | + | Msg("Failed to redeploy container for route") | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | // Remove from active routes so we know it's been processed | |
| 130 | + | delete(activeRoutes, route) | |
| 131 | + | } else { | |
| 132 | + | // New route - deploy container | |
| 133 | + | log.Info(). | |
| 134 | + | Str("route", route). | |
| 135 | + | Str("image", imageName). | |
| 136 | + | Msg("New route detected, deploying container") | |
| 137 | + | ||
| 138 | + | if err := h.deployContainerForRoute(route, imageName); err != nil { | |
| 139 | + | log.Error(). | |
| 140 | + | Err(err). | |
| 141 | + | Str("route", route). | |
| 142 | + | Msg("Failed to deploy container for new route") | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Stop containers for routes that are no longer configured | |
| 148 | + | for route, container := range activeRoutes { | |
| 149 | + | log.Info(). | |
| 150 | + | Str("route", route). | |
| 151 | + | Str("container_id", container.ID). | |
| 152 | + | Msg("Route no longer configured, stopping container") | |
| 153 | + | ||
| 154 | + | if err := h.manager.StopContainer(ctx, container.ID); err != nil { | |
| 155 | + | log.Error(). | |
| 156 | + | Err(err). | |
| 157 | + | Str("container_id", container.ID). | |
| 158 | + | Msg("Failed to stop container for removed route") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | if err := h.manager.RemoveContainer(ctx, container.ID, true); err != nil { | |
| 162 | + | log.Error(). | |
| 163 | + | Err(err). | |
| 164 | + | Str("container_id", container.ID). | |
| 165 | + | Msg("Failed to remove container for removed route") | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | return nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (h *ContainerEventHandler) handleManualReload(event Event) error { | |
| 173 | + | log.Info(). | |
| 174 | + | Str("event_id", event.ID). | |
| 175 | + | Msg("Processing manual reload event - redeploying all containers with updated environment variables") | |
| 176 | + | ||
| 177 | + | ctx := context.Background() | |
| 178 | + | routes := h.config.GetRoutes() | |
| 179 | + | redeployedCount := 0 | |
| 180 | + | var errors []error | |
| 181 | + | ||
| 182 | + | // Redeploy each container to pick up new environment variables | |
| 183 | + | for _, route := range routes { | |
| 184 | + | // Check if container exists for this route | |
| 185 | + | if _, exists := h.manager.GetContainer(route.Domain); !exists { | |
| 186 | + | log.Debug().Str("domain", route.Domain).Msg("No container found for route, skipping") | |
| 187 | + | continue | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Redeploying container with updated environment variables") | |
| 191 | + | ||
| 192 | + | // Deploy container (this will stop the old one and start a new one with updated env vars) | |
| 193 | + | _, err := h.manager.DeployContainer(ctx, route) | |
| 194 | + | if err != nil { | |
| 195 | + | log.Error().Str("domain", route.Domain).Err(err).Msg("Failed to redeploy container during manual reload") | |
| 196 | + | errors = append(errors, fmt.Errorf("failed to redeploy container for %s: %w", route.Domain, err)) | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | redeployedCount++ | |
| 201 | + | log.Info().Str("domain", route.Domain).Msg("Container redeployed successfully with updated environment variables") | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | if len(errors) > 0 { | |
| 205 | + | log.Warn().Int("redeployed", redeployedCount).Int("errors", len(errors)).Msg("Manual reload completed with some errors") | |
| 206 | + | return fmt.Errorf("manual reload completed with %d errors: %v", len(errors), errors) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | log.Info().Int("redeployed", redeployedCount).Int("total", len(routes)).Msg("Manual reload completed successfully") | |
| 210 | + | return nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (h *ContainerEventHandler) handleContainerStop(event Event) error { | |
| 214 | + | containerID := event.ContainerID | |
| 215 | + | if containerID == "" { | |
| 216 | + | return fmt.Errorf("container ID is required for ContainerStop event") | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | log.Info(). | |
| 220 | + | Str("container_id", containerID). | |
| 221 | + | Str("event_id", event.ID). | |
| 222 | + | Msg("Processing container stop event") | |
| 223 | + | ||
| 224 | + | ctx := context.Background() | |
| 225 | + | if err := h.manager.StopContainer(ctx, containerID); err != nil { | |
| 226 | + | return fmt.Errorf("failed to stop container %s: %w", containerID, err) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | return nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func (h *ContainerEventHandler) handleContainerStart(event Event) error { | |
| 233 | + | containerID := event.ContainerID | |
| 234 | + | if containerID == "" { | |
| 235 | + | return fmt.Errorf("container ID is required for ContainerStart event") | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | log.Info(). | |
| 239 | + | Str("container_id", containerID). | |
| 240 | + | Str("event_id", event.ID). | |
| 241 | + | Msg("Processing container start event") | |
| 242 | + | ||
| 243 | + | // StartContainer method doesn't exist on Manager - containers are started via DeployContainer | |
| 244 | + | // For manual start, we'd need to add this method or use the runtime directly | |
| 245 | + | return fmt.Errorf("container start not implemented - use automatic deployment instead") | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (h *ContainerEventHandler) findRoutesForImage(imageName string) []string { | |
| 249 | + | var routes []string | |
| 250 | + | ||
| 251 | + | for route, configuredImage := range h.config.Routes { | |
| 252 | + | if strings.EqualFold(configuredImage, imageName) { | |
| 253 | + | routes = append(routes, route) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | return routes | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (h *ContainerEventHandler) deployContainerForRoute(route, imageName string) error { | |
| 261 | + | ctx := context.Background() | |
| 262 | + | ||
| 263 | + | containers := h.manager.ListContainers() | |
| 264 | + | ||
| 265 | + | containerName := fmt.Sprintf("gordon-%s", strings.ReplaceAll(route, ".", "-")) | |
| 266 | + | ||
| 267 | + | // Stop and remove existing container if it exists | |
| 268 | + | for _, container := range containers { | |
| 269 | + | if container.Name == containerName { | |
| 270 | + | log.Info(). | |
| 271 | + | Str("container_name", containerName). | |
| 272 | + | Str("old_container_id", container.ID). | |
| 273 | + | Msg("Stopping existing container for route") | |
| 274 | + | ||
| 275 | + | if err := h.manager.StopContainer(ctx, container.ID); err != nil { | |
| 276 | + | log.Warn(). | |
| 277 | + | Err(err). | |
| 278 | + | Str("container_id", container.ID). | |
| 279 | + | Msg("Failed to stop existing container") | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | if err := h.manager.RemoveContainer(ctx, container.ID, true); err != nil { | |
| 283 | + | log.Warn(). | |
| 284 | + | Err(err). | |
| 285 | + | Str("container_id", container.ID). | |
| 286 | + | Msg("Failed to remove existing container") | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Use DeployContainer which handles creation and starting | |
| 292 | + | routeConfig := config.Route{ | |
| 293 | + | Domain: route, | |
| 294 | + | Image: imageName, | |
| 295 | + | HTTPS: true, | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | container, err := h.manager.DeployContainer(ctx, routeConfig) | |
| 299 | + | if err != nil { | |
| 300 | + | return fmt.Errorf("failed to deploy container for route %s: %w", route, err) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | log.Info(). | |
| 304 | + | Str("route", route). | |
| 305 | + | Str("image", imageName). | |
| 306 | + | Str("container_id", container.ID). | |
| 307 | + | Str("container_name", containerName). | |
| 308 | + | Msg("Successfully deployed container for route") | |
| 309 | + | ||
| 310 | + | return nil | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | type AutoRouteHandler struct { | |
| 314 | + | config *config.Config | |
| 315 | + | manager container.ManagerInterface | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func NewAutoRouteHandler(cfg *config.Config, manager container.ManagerInterface) *AutoRouteHandler { | |
| 319 | + | return &AutoRouteHandler{ | |
| 320 | + | config: cfg, | |
| 321 | + | manager: manager, | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func (h *AutoRouteHandler) CanHandle(eventType EventType) bool { | |
| 326 | + | return eventType == ImagePushed | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (h *AutoRouteHandler) Handle(event Event) error { | |
| 330 | + | if !h.config.AutoRoute.Enabled { | |
| 331 | + | return nil | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | if event.Type != ImagePushed { | |
| 335 | + | return nil | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | payload, ok := event.Data.(ImagePushedPayload) | |
| 339 | + | if !ok { | |
| 340 | + | return fmt.Errorf("invalid payload type for ImagePushed event") | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // Log annotations if present | |
| 344 | + | if len(payload.Annotations) > 0 { | |
| 345 | + | log.Debug(). | |
| 346 | + | Str("image", payload.Name). | |
| 347 | + | Str("reference", payload.Reference). | |
| 348 | + | Interface("annotations", payload.Annotations). | |
| 349 | + | Msg("Image pushed with annotations - auto-route processing") | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | imageName := payload.Name | |
| 353 | + | reference := payload.Reference | |
| 354 | + | fullImageName := fmt.Sprintf("%s:%s", imageName, reference) | |
| 355 | + | ||
| 356 | + | // Build the full registry image path for deployment | |
| 357 | + | registryImageName := fullImageName | |
| 358 | + | if h.config.Server.RegistryDomain != "" { | |
| 359 | + | registryImageName = fmt.Sprintf("%s/%s", h.config.Server.RegistryDomain, fullImageName) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // Try to extract domain from the image name part (before the colon) | |
| 363 | + | domain, isDomain := config.ExtractDomainFromImageName(imageName) | |
| 364 | + | if !isDomain { | |
| 365 | + | log.Debug(). | |
| 366 | + | Str("image", imageName). | |
| 367 | + | Msg("Image name does not contain a valid domain, skipping auto-route creation") | |
| 368 | + | return nil | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // Check if route already exists | |
| 372 | + | if _, exists := h.config.Routes[domain]; exists { | |
| 373 | + | log.Debug(). | |
| 374 | + | Str("domain", domain). | |
| 375 | + | Str("existing_image", h.config.Routes[domain]). | |
| 376 | + | Str("new_image", fullImageName). | |
| 377 | + | Msg("Route already exists for domain, skipping auto-route creation") | |
| 378 | + | return nil | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // Add the route to config with the full registry image path | |
| 382 | + | if err := h.config.AddRoute(domain, registryImageName); err != nil { | |
| 383 | + | return fmt.Errorf("failed to add auto-route for domain %s: %w", domain, err) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | log.Info(). | |
| 387 | + | Str("domain", domain). | |
| 388 | + | Str("image", registryImageName). | |
| 389 | + | Msg("Successfully auto-created route - deployment will be handled by config reload event") | |
| 390 | + | ||
| 391 | + | return nil | |
| 392 | + | } | |
| 393 | + | ||
A internal/events/handlers_test.go +852 -0
| @@ -0,0 +1,852 @@ | |||
| 1 | + | package events | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/spf13/viper" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "gordon/internal/config" | |
| 15 | + | "gordon/pkg/runtime" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // MockContainerManager is a mock implementation of container.ManagerInterface | |
| 19 | + | type MockContainerManager struct { | |
| 20 | + | mock.Mock | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (m *MockContainerManager) DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) { | |
| 24 | + | args := m.Called(ctx, route) | |
| 25 | + | if args.Get(0) == nil { | |
| 26 | + | return nil, args.Error(1) | |
| 27 | + | } | |
| 28 | + | return args.Get(0).(*runtime.Container), args.Error(1) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (m *MockContainerManager) StopContainer(ctx context.Context, containerID string) error { | |
| 32 | + | args := m.Called(ctx, containerID) | |
| 33 | + | return args.Error(0) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (m *MockContainerManager) RemoveContainer(ctx context.Context, containerID string, force bool) error { | |
| 37 | + | args := m.Called(ctx, containerID, force) | |
| 38 | + | return args.Error(0) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (m *MockContainerManager) ListContainers() map[string]*runtime.Container { | |
| 42 | + | args := m.Called() | |
| 43 | + | if args.Get(0) == nil { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | return args.Get(0).(map[string]*runtime.Container) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (m *MockContainerManager) GetContainer(domain string) (*runtime.Container, bool) { | |
| 50 | + | args := m.Called(domain) | |
| 51 | + | if args.Get(0) == nil { | |
| 52 | + | return nil, args.Bool(1) | |
| 53 | + | } | |
| 54 | + | return args.Get(0).(*runtime.Container), args.Bool(1) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (m *MockContainerManager) StopContainerByDomain(ctx context.Context, domain string) error { | |
| 58 | + | args := m.Called(ctx, domain) | |
| 59 | + | return args.Error(0) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (m *MockContainerManager) Runtime() runtime.Runtime { | |
| 63 | + | args := m.Called() | |
| 64 | + | return args.Get(0).(runtime.Runtime) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (m *MockContainerManager) HealthCheck(ctx context.Context) map[string]bool { | |
| 68 | + | args := m.Called(ctx) | |
| 69 | + | return args.Get(0).(map[string]bool) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (m *MockContainerManager) SyncContainers(ctx context.Context) error { | |
| 73 | + | args := m.Called(ctx) | |
| 74 | + | return args.Error(0) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (m *MockContainerManager) GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) { | |
| 78 | + | args := m.Called(ctx, domain, internalPort) | |
| 79 | + | return args.Int(0), args.Error(1) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (m *MockContainerManager) GetNetworkForApp(domain string) string { | |
| 83 | + | args := m.Called(domain) | |
| 84 | + | return args.String(0) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (m *MockContainerManager) AutoStartContainers(ctx context.Context) error { | |
| 88 | + | args := m.Called(ctx) | |
| 89 | + | return args.Error(0) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (m *MockContainerManager) StopAllManagedContainers(ctx context.Context) error { | |
| 93 | + | args := m.Called(ctx) | |
| 94 | + | return args.Error(0) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestContainerEventHandler_CanHandle(t *testing.T) { | |
| 98 | + | handler := &ContainerEventHandler{} | |
| 99 | + | ||
| 100 | + | testCases := []struct { | |
| 101 | + | eventType EventType | |
| 102 | + | expected bool | |
| 103 | + | }{ | |
| 104 | + | {ImagePushed, true}, | |
| 105 | + | {ConfigReload, true}, | |
| 106 | + | {ManualReload, true}, | |
| 107 | + | {ContainerStop, true}, | |
| 108 | + | {ContainerStart, true}, | |
| 109 | + | {ImageDeleted, false}, | |
| 110 | + | {ContainerHealthCheck, false}, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | for _, tc := range testCases { | |
| 114 | + | t.Run(string(tc.eventType), func(t *testing.T) { | |
| 115 | + | result := handler.CanHandle(tc.eventType) | |
| 116 | + | assert.Equal(t, tc.expected, result) | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestContainerEventHandler_Handle_ImagePushed(t *testing.T) { | |
| 122 | + | mockManager := &MockContainerManager{} | |
| 123 | + | mockConfig := &config.Config{ | |
| 124 | + | Routes: map[string]string{ | |
| 125 | + | "app.example.com": "nginx:latest", | |
| 126 | + | }, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | handler := &ContainerEventHandler{ | |
| 130 | + | manager: mockManager, | |
| 131 | + | config: mockConfig, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Mock container listing (empty list initially) | |
| 135 | + | mockManager.On("ListContainers").Return(map[string]*runtime.Container{}) | |
| 136 | + | ||
| 137 | + | // Mock successful deployment | |
| 138 | + | expectedRoute := config.Route{ | |
| 139 | + | Domain: "app.example.com", | |
| 140 | + | Image: "nginx:latest", | |
| 141 | + | HTTPS: true, | |
| 142 | + | } | |
| 143 | + | mockContainer := &runtime.Container{ | |
| 144 | + | ID: "container123", | |
| 145 | + | Name: "gordon-app-example-com", | |
| 146 | + | } | |
| 147 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil) | |
| 148 | + | ||
| 149 | + | event := Event{ | |
| 150 | + | Type: ImagePushed, | |
| 151 | + | ImageName: "nginx", | |
| 152 | + | Tag: "latest", | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | err := handler.Handle(event) | |
| 156 | + | ||
| 157 | + | assert.NoError(t, err) | |
| 158 | + | mockManager.AssertExpectations(t) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestContainerEventHandler_Handle_ImagePushed_NoMatchingRoutes(t *testing.T) { | |
| 162 | + | mockManager := &MockContainerManager{} | |
| 163 | + | mockConfig := &config.Config{ | |
| 164 | + | Routes: map[string]string{ | |
| 165 | + | "app.example.com": "postgres:14", // Different image | |
| 166 | + | }, | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | handler := &ContainerEventHandler{ | |
| 170 | + | manager: mockManager, | |
| 171 | + | config: mockConfig, | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | event := Event{ | |
| 175 | + | Type: ImagePushed, | |
| 176 | + | ImageName: "nginx", | |
| 177 | + | Tag: "latest", | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | err := handler.Handle(event) | |
| 181 | + | ||
| 182 | + | // Should succeed but not deploy anything | |
| 183 | + | assert.NoError(t, err) | |
| 184 | + | mockManager.AssertExpectations(t) // No calls expected | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func TestContainerEventHandler_Handle_ImagePushed_MultipleRoutes(t *testing.T) { | |
| 188 | + | mockManager := &MockContainerManager{} | |
| 189 | + | mockConfig := &config.Config{ | |
| 190 | + | Routes: map[string]string{ | |
| 191 | + | "app1.example.com": "nginx:latest", | |
| 192 | + | "app2.example.com": "nginx:latest", | |
| 193 | + | "app3.example.com": "postgres:14", // Different image | |
| 194 | + | }, | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | handler := &ContainerEventHandler{ | |
| 198 | + | manager: mockManager, | |
| 199 | + | config: mockConfig, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Mock successful deployments for matching routes | |
| 203 | + | mockManager.On("ListContainers").Return(map[string]*runtime.Container{}).Times(2) | |
| 204 | + | ||
| 205 | + | expectedRoute1 := config.Route{Domain: "app1.example.com", Image: "nginx:latest", HTTPS: true} | |
| 206 | + | expectedRoute2 := config.Route{Domain: "app2.example.com", Image: "nginx:latest", HTTPS: true} | |
| 207 | + | ||
| 208 | + | mockContainer1 := &runtime.Container{ID: "container1", Name: "gordon-app1-example-com"} | |
| 209 | + | mockContainer2 := &runtime.Container{ID: "container2", Name: "gordon-app2-example-com"} | |
| 210 | + | ||
| 211 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute1).Return(mockContainer1, nil) | |
| 212 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute2).Return(mockContainer2, nil) | |
| 213 | + | ||
| 214 | + | event := Event{ | |
| 215 | + | Type: ImagePushed, | |
| 216 | + | ImageName: "nginx", | |
| 217 | + | Tag: "latest", | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | err := handler.Handle(event) | |
| 221 | + | ||
| 222 | + | assert.NoError(t, err) | |
| 223 | + | mockManager.AssertExpectations(t) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestContainerEventHandler_Handle_ConfigReload(t *testing.T) { | |
| 227 | + | mockManager := &MockContainerManager{} | |
| 228 | + | ||
| 229 | + | // Mock existing containers | |
| 230 | + | existingContainers := map[string]*runtime.Container{ | |
| 231 | + | "old.example.com": { | |
| 232 | + | ID: "container1", | |
| 233 | + | Name: "old-container", | |
| 234 | + | Labels: map[string]string{ | |
| 235 | + | "gordon.route": "old.example.com", | |
| 236 | + | "gordon.image": "old:image", | |
| 237 | + | }, | |
| 238 | + | }, | |
| 239 | + | "app.example.com": { | |
| 240 | + | ID: "container2", | |
| 241 | + | Name: "app-container", | |
| 242 | + | Labels: map[string]string{ | |
| 243 | + | "gordon.route": "app.example.com", | |
| 244 | + | "gordon.image": "nginx:latest", | |
| 245 | + | }, | |
| 246 | + | }, | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // New config with different routes | |
| 250 | + | newConfig := &config.Config{ | |
| 251 | + | Routes: map[string]string{ | |
| 252 | + | "app.example.com": "nginx:latest", // Keep this one | |
| 253 | + | "new.example.com": "postgres:14", // Add new one | |
| 254 | + | // Remove old.example.com | |
| 255 | + | }, | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | handler := &ContainerEventHandler{ | |
| 259 | + | manager: mockManager, | |
| 260 | + | config: newConfig, | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // Mock manager calls | |
| 264 | + | mockManager.On("ListContainers").Return(existingContainers).Once() // First call in handleConfigReload | |
| 265 | + | mockManager.On("StopContainer", mock.Anything, "container1").Return(nil) // Stop removed container | |
| 266 | + | mockManager.On("RemoveContainer", mock.Anything, "container1", true).Return(nil) // Remove removed container | |
| 267 | + | ||
| 268 | + | // Mock deployment for new route | |
| 269 | + | expectedRoute := config.Route{Domain: "new.example.com", Image: "postgres:14", HTTPS: true} | |
| 270 | + | mockContainer := &runtime.Container{ID: "container3", Name: "gordon-new-example-com"} | |
| 271 | + | mockManager.On("ListContainers").Return(map[string]*runtime.Container{}).Once() // Second call in deployContainerForRoute | |
| 272 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil) | |
| 273 | + | ||
| 274 | + | event := Event{ | |
| 275 | + | Type: ConfigReload, | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | err := handler.Handle(event) | |
| 279 | + | ||
| 280 | + | assert.NoError(t, err) | |
| 281 | + | mockManager.AssertExpectations(t) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestContainerEventHandler_Handle_ManualReload(t *testing.T) { | |
| 285 | + | mockManager := &MockContainerManager{} | |
| 286 | + | mockConfig := &config.Config{ | |
| 287 | + | Routes: map[string]string{ | |
| 288 | + | "app.example.com": "nginx:latest", | |
| 289 | + | }, | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | handler := &ContainerEventHandler{ | |
| 293 | + | manager: mockManager, | |
| 294 | + | config: mockConfig, | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // Mock GetContainer to return true (container exists) | |
| 298 | + | mockContainer := &runtime.Container{ID: "container1", Name: "app-container"} | |
| 299 | + | mockManager.On("GetContainer", "app.example.com").Return(mockContainer, true) | |
| 300 | + | ||
| 301 | + | // Mock deployment | |
| 302 | + | expectedRoute := config.Route{Domain: "app.example.com", Image: "nginx:latest", HTTPS: true} | |
| 303 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil) | |
| 304 | + | ||
| 305 | + | event := Event{ | |
| 306 | + | Type: ManualReload, | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | err := handler.Handle(event) | |
| 310 | + | ||
| 311 | + | assert.NoError(t, err) | |
| 312 | + | mockManager.AssertExpectations(t) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func TestContainerEventHandler_Handle_ContainerStop(t *testing.T) { | |
| 316 | + | mockManager := &MockContainerManager{} | |
| 317 | + | ||
| 318 | + | handler := &ContainerEventHandler{ | |
| 319 | + | manager: mockManager, | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Mock container stop | |
| 323 | + | mockManager.On("StopContainer", mock.Anything, "container123").Return(nil) | |
| 324 | + | ||
| 325 | + | event := Event{ | |
| 326 | + | Type: ContainerStop, | |
| 327 | + | ContainerID: "container123", | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | err := handler.Handle(event) | |
| 331 | + | ||
| 332 | + | assert.NoError(t, err) | |
| 333 | + | mockManager.AssertExpectations(t) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func TestContainerEventHandler_Handle_ContainerStart(t *testing.T) { | |
| 337 | + | handler := &ContainerEventHandler{} | |
| 338 | + | ||
| 339 | + | event := Event{ | |
| 340 | + | Type: ContainerStart, | |
| 341 | + | ContainerID: "container123", | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | err := handler.Handle(event) | |
| 345 | + | ||
| 346 | + | // Should return error as start is not implemented | |
| 347 | + | assert.Error(t, err) | |
| 348 | + | assert.Contains(t, err.Error(), "container start not implemented") | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestContainerEventHandler_Handle_UnsupportedEvent(t *testing.T) { | |
| 352 | + | handler := &ContainerEventHandler{} | |
| 353 | + | ||
| 354 | + | event := Event{ | |
| 355 | + | Type: ImageDeleted, // Unsupported event type | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | err := handler.Handle(event) | |
| 359 | + | ||
| 360 | + | assert.Error(t, err) | |
| 361 | + | assert.Contains(t, err.Error(), "unsupported event type") | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func TestAutoRouteHandler_CanHandle(t *testing.T) { | |
| 365 | + | handler := &AutoRouteHandler{} | |
| 366 | + | ||
| 367 | + | testCases := []struct { | |
| 368 | + | eventType EventType | |
| 369 | + | expected bool | |
| 370 | + | }{ | |
| 371 | + | {ImagePushed, true}, | |
| 372 | + | {ImageDeleted, false}, | |
| 373 | + | {ConfigReload, false}, | |
| 374 | + | {ManualReload, false}, | |
| 375 | + | {ContainerStop, false}, | |
| 376 | + | {ContainerStart, false}, | |
| 377 | + | {ContainerHealthCheck, false}, | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | for _, tc := range testCases { | |
| 381 | + | t.Run(string(tc.eventType), func(t *testing.T) { | |
| 382 | + | result := handler.CanHandle(tc.eventType) | |
| 383 | + | assert.Equal(t, tc.expected, result) | |
| 384 | + | }) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func TestAutoRouteHandler_Handle_AutoRouteEnabled(t *testing.T) { | |
| 389 | + | mockManager := &MockContainerManager{} | |
| 390 | + | ||
| 391 | + | // Create a temporary config file for testing | |
| 392 | + | tempConfigFile := "/tmp/test-gordon-config.toml" | |
| 393 | + | configContent := `[server] | |
| 394 | + | registry_domain = "registry.example.com" | |
| 395 | + | ||
| 396 | + | [auto_route] | |
| 397 | + | enabled = true | |
| 398 | + | ||
| 399 | + | [routes] | |
| 400 | + | ` | |
| 401 | + | err := os.WriteFile(tempConfigFile, []byte(configContent), 0644) | |
| 402 | + | require.NoError(t, err) | |
| 403 | + | defer os.Remove(tempConfigFile) | |
| 404 | + | ||
| 405 | + | // Load config from temp file to set viper config file path | |
| 406 | + | viper.SetConfigFile(tempConfigFile) | |
| 407 | + | err = viper.ReadInConfig() | |
| 408 | + | require.NoError(t, err) | |
| 409 | + | ||
| 410 | + | mockConfig := &config.Config{ | |
| 411 | + | AutoRoute: config.AutoRouteConfig{ | |
| 412 | + | Enabled: true, | |
| 413 | + | }, | |
| 414 | + | Routes: make(map[string]string), | |
| 415 | + | Server: config.ServerConfig{ | |
| 416 | + | RegistryDomain: "registry.example.com", | |
| 417 | + | }, | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | handler := &AutoRouteHandler{ | |
| 421 | + | config: mockConfig, | |
| 422 | + | manager: mockManager, | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | payload := ImagePushedPayload{ | |
| 426 | + | Name: "myapp.example.com", | |
| 427 | + | Reference: "latest", | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | event := Event{ | |
| 431 | + | Type: ImagePushed, | |
| 432 | + | Data: payload, | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | err = handler.Handle(event) | |
| 436 | + | ||
| 437 | + | assert.NoError(t, err) | |
| 438 | + | // Check that route was added to config | |
| 439 | + | assert.Equal(t, "registry.example.com/myapp.example.com:latest", mockConfig.Routes["myapp.example.com"]) | |
| 440 | + | mockManager.AssertExpectations(t) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func TestAutoRouteHandler_Handle_AutoRouteDisabled(t *testing.T) { | |
| 444 | + | mockManager := &MockContainerManager{} | |
| 445 | + | mockConfig := &config.Config{ | |
| 446 | + | AutoRoute: config.AutoRouteConfig{ | |
| 447 | + | Enabled: false, | |
| 448 | + | }, | |
| 449 | + | Routes: make(map[string]string), | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | handler := &AutoRouteHandler{ | |
| 453 | + | config: mockConfig, | |
| 454 | + | manager: mockManager, | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | payload := ImagePushedPayload{ | |
| 458 | + | Name: "myapp.example.com", | |
| 459 | + | Reference: "latest", | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | event := Event{ | |
| 463 | + | Type: ImagePushed, | |
| 464 | + | Data: payload, | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | err := handler.Handle(event) | |
| 468 | + | ||
| 469 | + | // Should succeed but not create route | |
| 470 | + | assert.NoError(t, err) | |
| 471 | + | assert.Empty(t, mockConfig.Routes) | |
| 472 | + | mockManager.AssertExpectations(t) // No calls expected | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func TestAutoRouteHandler_Handle_RouteAlreadyExists(t *testing.T) { | |
| 476 | + | mockManager := &MockContainerManager{} | |
| 477 | + | mockConfig := &config.Config{ | |
| 478 | + | AutoRoute: config.AutoRouteConfig{ | |
| 479 | + | Enabled: true, | |
| 480 | + | }, | |
| 481 | + | Routes: map[string]string{ | |
| 482 | + | "myapp.example.com": "existing:image", | |
| 483 | + | }, | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | handler := &AutoRouteHandler{ | |
| 487 | + | config: mockConfig, | |
| 488 | + | manager: mockManager, | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | payload := ImagePushedPayload{ | |
| 492 | + | Name: "myapp.example.com", | |
| 493 | + | Reference: "latest", | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | event := Event{ | |
| 497 | + | Type: ImagePushed, | |
| 498 | + | Data: payload, | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | err := handler.Handle(event) | |
| 502 | + | ||
| 503 | + | // Should succeed but not modify existing route | |
| 504 | + | assert.NoError(t, err) | |
| 505 | + | assert.Equal(t, "existing:image", mockConfig.Routes["myapp.example.com"]) | |
| 506 | + | mockManager.AssertExpectations(t) // No calls expected | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func TestAutoRouteHandler_Handle_InvalidImageName(t *testing.T) { | |
| 510 | + | mockManager := &MockContainerManager{} | |
| 511 | + | mockConfig := &config.Config{ | |
| 512 | + | AutoRoute: config.AutoRouteConfig{ | |
| 513 | + | Enabled: true, | |
| 514 | + | }, | |
| 515 | + | Routes: make(map[string]string), | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | handler := &AutoRouteHandler{ | |
| 519 | + | config: mockConfig, | |
| 520 | + | manager: mockManager, | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | payload := ImagePushedPayload{ | |
| 524 | + | Name: "nginx", // No domain extractable | |
| 525 | + | Reference: "latest", | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | event := Event{ | |
| 529 | + | Type: ImagePushed, | |
| 530 | + | Data: payload, | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | err := handler.Handle(event) | |
| 534 | + | ||
| 535 | + | // Should succeed but not create route | |
| 536 | + | assert.NoError(t, err) | |
| 537 | + | assert.Empty(t, mockConfig.Routes) | |
| 538 | + | mockManager.AssertExpectations(t) // No calls expected | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | func TestNewContainerEventHandler(t *testing.T) { | |
| 542 | + | mockManager := &MockContainerManager{} | |
| 543 | + | mockConfig := &config.Config{} | |
| 544 | + | ||
| 545 | + | handler := NewContainerEventHandler(mockManager, mockConfig) | |
| 546 | + | ||
| 547 | + | require.NotNil(t, handler) | |
| 548 | + | assert.Equal(t, mockManager, handler.manager) | |
| 549 | + | assert.Equal(t, mockConfig, handler.config) | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func TestNewAutoRouteHandler(t *testing.T) { | |
| 553 | + | mockManager := &MockContainerManager{} | |
| 554 | + | mockConfig := &config.Config{} | |
| 555 | + | ||
| 556 | + | handler := NewAutoRouteHandler(mockConfig, mockManager) | |
| 557 | + | ||
| 558 | + | require.NotNil(t, handler) | |
| 559 | + | assert.Equal(t, mockManager, handler.manager) | |
| 560 | + | assert.Equal(t, mockConfig, handler.config) | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | func TestVersionHandler_CanHandle(t *testing.T) { | |
| 564 | + | handler := &VersionHandler{} | |
| 565 | + | ||
| 566 | + | testCases := []struct { | |
| 567 | + | eventType EventType | |
| 568 | + | expected bool | |
| 569 | + | }{ | |
| 570 | + | {ImagePushed, true}, | |
| 571 | + | {ImageDeleted, false}, | |
| 572 | + | {ConfigReload, false}, | |
| 573 | + | {ManualReload, false}, | |
| 574 | + | {ContainerStop, false}, | |
| 575 | + | {ContainerStart, false}, | |
| 576 | + | {ContainerHealthCheck, false}, | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | for _, tc := range testCases { | |
| 580 | + | t.Run(string(tc.eventType), func(t *testing.T) { | |
| 581 | + | result := handler.CanHandle(tc.eventType) | |
| 582 | + | assert.Equal(t, tc.expected, result) | |
| 583 | + | }) | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func TestVersionHandler_Handle_VersionedDeployment(t *testing.T) { | |
| 588 | + | // Create a temporary config file for testing | |
| 589 | + | tempConfigFile := "/tmp/test-gordon-version-config.toml" | |
| 590 | + | configContent := `[server] | |
| 591 | + | registry_domain = "registry.example.com" | |
| 592 | + | ||
| 593 | + | [routes] | |
| 594 | + | "app.example.com" = "myapp:v1.0" | |
| 595 | + | ` | |
| 596 | + | err := os.WriteFile(tempConfigFile, []byte(configContent), 0644) | |
| 597 | + | require.NoError(t, err) | |
| 598 | + | defer os.Remove(tempConfigFile) | |
| 599 | + | ||
| 600 | + | // Load config from temp file to set viper config file path | |
| 601 | + | viper.SetConfigFile(tempConfigFile) | |
| 602 | + | err = viper.ReadInConfig() | |
| 603 | + | require.NoError(t, err) | |
| 604 | + | ||
| 605 | + | mockManager := &MockContainerManager{} | |
| 606 | + | mockConfig := &config.Config{ | |
| 607 | + | Routes: map[string]string{ | |
| 608 | + | "app.example.com": "myapp:v2.0", // Match the payload reference so it finds routes | |
| 609 | + | }, | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | handler := &VersionHandler{ | |
| 613 | + | manager: mockManager, | |
| 614 | + | config: mockConfig, | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | // Mock deployment | |
| 618 | + | expectedRoute := config.Route{Domain: "app.example.com", Image: "myapp:v2.0", HTTPS: true} | |
| 619 | + | mockContainer := &runtime.Container{ID: "container1", Name: "app-container"} | |
| 620 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil) | |
| 621 | + | ||
| 622 | + | payload := ImagePushedPayload{ | |
| 623 | + | Name: "myapp", | |
| 624 | + | Reference: "v2.0", | |
| 625 | + | Annotations: map[string]string{ | |
| 626 | + | "version": "v2.0", | |
| 627 | + | }, | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | event := Event{ | |
| 631 | + | Type: ImagePushed, | |
| 632 | + | Data: payload, | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | err = handler.Handle(event) | |
| 636 | + | ||
| 637 | + | assert.NoError(t, err) | |
| 638 | + | // Check that route was updated to new version | |
| 639 | + | assert.Equal(t, "myapp:v2.0", mockConfig.Routes["app.example.com"]) | |
| 640 | + | mockManager.AssertExpectations(t) | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func TestVersionHandler_Handle_NonVersionedDeployment(t *testing.T) { | |
| 644 | + | mockManager := &MockContainerManager{} | |
| 645 | + | mockConfig := &config.Config{ | |
| 646 | + | Routes: make(map[string]string), | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | handler := &VersionHandler{ | |
| 650 | + | manager: mockManager, | |
| 651 | + | config: mockConfig, | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | payload := ImagePushedPayload{ | |
| 655 | + | Name: "myapp", | |
| 656 | + | Reference: "latest", | |
| 657 | + | Annotations: map[string]string{}, // No version annotations | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | event := Event{ | |
| 661 | + | Type: ImagePushed, | |
| 662 | + | Data: payload, | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | err := handler.Handle(event) | |
| 666 | + | ||
| 667 | + | // Should succeed but not process anything | |
| 668 | + | assert.NoError(t, err) | |
| 669 | + | assert.Empty(t, mockConfig.Routes) | |
| 670 | + | mockManager.AssertExpectations(t) // No calls expected | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | func TestVersionHandler_Handle_NoMatchingRoutes(t *testing.T) { | |
| 674 | + | mockManager := &MockContainerManager{} | |
| 675 | + | mockConfig := &config.Config{ | |
| 676 | + | Routes: map[string]string{ | |
| 677 | + | "app.example.com": "nginx:latest", // Different image | |
| 678 | + | }, | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | handler := &VersionHandler{ | |
| 682 | + | manager: mockManager, | |
| 683 | + | config: mockConfig, | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | payload := ImagePushedPayload{ | |
| 687 | + | Name: "myapp", | |
| 688 | + | Reference: "v2.0", | |
| 689 | + | Annotations: map[string]string{ | |
| 690 | + | "version": "v2.0", | |
| 691 | + | }, | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | event := Event{ | |
| 695 | + | Type: ImagePushed, | |
| 696 | + | Data: payload, | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | err := handler.Handle(event) | |
| 700 | + | ||
| 701 | + | // Should succeed but not deploy anything | |
| 702 | + | assert.NoError(t, err) | |
| 703 | + | assert.Equal(t, "nginx:latest", mockConfig.Routes["app.example.com"]) | |
| 704 | + | mockManager.AssertExpectations(t) // No calls expected | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | func TestVersionHandler_Handle_InvalidPayload(t *testing.T) { | |
| 708 | + | handler := &VersionHandler{} | |
| 709 | + | ||
| 710 | + | event := Event{ | |
| 711 | + | Type: ImagePushed, | |
| 712 | + | Data: "invalid payload", | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | err := handler.Handle(event) | |
| 716 | + | ||
| 717 | + | assert.Error(t, err) | |
| 718 | + | assert.Contains(t, err.Error(), "invalid payload type") | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | func TestVersionHandler_Handle_WrongEventType(t *testing.T) { | |
| 722 | + | handler := &VersionHandler{} | |
| 723 | + | ||
| 724 | + | event := Event{ | |
| 725 | + | Type: ConfigReload, | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | err := handler.Handle(event) | |
| 729 | + | ||
| 730 | + | assert.NoError(t, err) // Should succeed but do nothing | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | func TestVersionHandler_Handle_DeploymentFailure(t *testing.T) { | |
| 734 | + | // Create a temporary config file for testing | |
| 735 | + | tempConfigFile := "/tmp/test-gordon-version-fail-config.toml" | |
| 736 | + | configContent := `[server] | |
| 737 | + | registry_domain = "registry.example.com" | |
| 738 | + | ||
| 739 | + | [routes] | |
| 740 | + | "app.example.com" = "myapp:v1.0" | |
| 741 | + | ` | |
| 742 | + | err := os.WriteFile(tempConfigFile, []byte(configContent), 0644) | |
| 743 | + | require.NoError(t, err) | |
| 744 | + | defer os.Remove(tempConfigFile) | |
| 745 | + | ||
| 746 | + | // Load config from temp file to set viper config file path | |
| 747 | + | viper.SetConfigFile(tempConfigFile) | |
| 748 | + | err = viper.ReadInConfig() | |
| 749 | + | require.NoError(t, err) | |
| 750 | + | ||
| 751 | + | mockManager := &MockContainerManager{} | |
| 752 | + | mockConfig := &config.Config{ | |
| 753 | + | Routes: map[string]string{ | |
| 754 | + | "app.example.com": "myapp:v2.0", // Match the payload reference so it finds routes | |
| 755 | + | }, | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | handler := &VersionHandler{ | |
| 759 | + | manager: mockManager, | |
| 760 | + | config: mockConfig, | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | // Mock deployment failure | |
| 764 | + | expectedRoute := config.Route{Domain: "app.example.com", Image: "myapp:v2.0", HTTPS: true} | |
| 765 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(nil, fmt.Errorf("deployment failed")) | |
| 766 | + | ||
| 767 | + | payload := ImagePushedPayload{ | |
| 768 | + | Name: "myapp", | |
| 769 | + | Reference: "v2.0", | |
| 770 | + | Annotations: map[string]string{ | |
| 771 | + | "version": "v2.0", | |
| 772 | + | }, | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | event := Event{ | |
| 776 | + | Type: ImagePushed, | |
| 777 | + | Data: payload, | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | err = handler.Handle(event) | |
| 781 | + | ||
| 782 | + | assert.Error(t, err) | |
| 783 | + | assert.Contains(t, err.Error(), "version deployment completed with") | |
| 784 | + | mockManager.AssertExpectations(t) | |
| 785 | + | } | |
| 786 | + | ||
| 787 | + | func TestVersionHandler_Handle_FullImageReference(t *testing.T) { | |
| 788 | + | // Create a temporary config file for testing | |
| 789 | + | tempConfigFile := "/tmp/test-gordon-version-full-config.toml" | |
| 790 | + | configContent := `[server] | |
| 791 | + | registry_domain = "registry.example.com" | |
| 792 | + | ||
| 793 | + | [routes] | |
| 794 | + | "app.example.com" = "myapp:v1.0" | |
| 795 | + | ` | |
| 796 | + | err := os.WriteFile(tempConfigFile, []byte(configContent), 0644) | |
| 797 | + | require.NoError(t, err) | |
| 798 | + | defer os.Remove(tempConfigFile) | |
| 799 | + | ||
| 800 | + | // Load config from temp file to set viper config file path | |
| 801 | + | viper.SetConfigFile(tempConfigFile) | |
| 802 | + | err = viper.ReadInConfig() | |
| 803 | + | require.NoError(t, err) | |
| 804 | + | ||
| 805 | + | mockManager := &MockContainerManager{} | |
| 806 | + | mockConfig := &config.Config{ | |
| 807 | + | Routes: map[string]string{ | |
| 808 | + | "app.example.com": "myapp:v2.0", // Match the payload reference so it finds routes | |
| 809 | + | }, | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | handler := &VersionHandler{ | |
| 813 | + | manager: mockManager, | |
| 814 | + | config: mockConfig, | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | // Mock deployment with full image reference | |
| 818 | + | expectedRoute := config.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:v2.0", HTTPS: true} | |
| 819 | + | mockContainer := &runtime.Container{ID: "container1", Name: "app-container"} | |
| 820 | + | mockManager.On("DeployContainer", mock.Anything, expectedRoute).Return(mockContainer, nil) | |
| 821 | + | ||
| 822 | + | payload := ImagePushedPayload{ | |
| 823 | + | Name: "myapp", | |
| 824 | + | Reference: "v2.0", | |
| 825 | + | Annotations: map[string]string{ | |
| 826 | + | "version": "registry.example.com/myapp:v2.0", // Full image reference | |
| 827 | + | }, | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | event := Event{ | |
| 831 | + | Type: ImagePushed, | |
| 832 | + | Data: payload, | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | err = handler.Handle(event) | |
| 836 | + | ||
| 837 | + | assert.NoError(t, err) | |
| 838 | + | // Check that route was updated to full image reference | |
| 839 | + | assert.Equal(t, "registry.example.com/myapp:v2.0", mockConfig.Routes["app.example.com"]) | |
| 840 | + | mockManager.AssertExpectations(t) | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | func TestNewVersionHandler(t *testing.T) { | |
| 844 | + | mockManager := &MockContainerManager{} | |
| 845 | + | mockConfig := &config.Config{} | |
| 846 | + | ||
| 847 | + | handler := NewVersionHandler(mockManager, mockConfig) | |
| 848 | + | ||
| 849 | + | require.NotNil(t, handler) | |
| 850 | + | assert.Equal(t, mockManager, handler.manager) | |
| 851 | + | assert.Equal(t, mockConfig, handler.config) | |
| 852 | + | }⏎̸ | |
A internal/events/rollback_handler.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | package events | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/rs/zerolog/log" | |
| 9 | + | "gordon/internal/config" | |
| 10 | + | "gordon/internal/container" | |
| 11 | + | "gordon/pkg/manifest" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // VersionHandler handles version deployments based on manifest annotations | |
| 15 | + | type VersionHandler struct { | |
| 16 | + | manager container.ManagerInterface | |
| 17 | + | config *config.Config | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // NewVersionHandler creates a new version handler | |
| 21 | + | func NewVersionHandler(manager container.ManagerInterface, config *config.Config) *VersionHandler { | |
| 22 | + | return &VersionHandler{ | |
| 23 | + | manager: manager, | |
| 24 | + | config: config, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // CanHandle returns true for ImagePushed events | |
| 29 | + | func (h *VersionHandler) CanHandle(eventType EventType) bool { | |
| 30 | + | return eventType == ImagePushed | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Handle processes ImagePushed events and performs version deployments if necessary | |
| 34 | + | func (h *VersionHandler) Handle(event Event) error { | |
| 35 | + | if event.Type != ImagePushed { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | payload, ok := event.Data.(ImagePushedPayload) | |
| 40 | + | if !ok { | |
| 41 | + | return fmt.Errorf("invalid payload type for ImagePushed event") | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Skip if no annotations | |
| 45 | + | if len(payload.Annotations) == 0 { | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | imageName := payload.Name | |
| 50 | + | reference := payload.Reference | |
| 51 | + | annotations := payload.Annotations | |
| 52 | + | ||
| 53 | + | log.Debug(). | |
| 54 | + | Str("image", imageName). | |
| 55 | + | Str("reference", reference). | |
| 56 | + | Interface("annotations", annotations). | |
| 57 | + | Msg("Processing annotations for version deployment") | |
| 58 | + | ||
| 59 | + | // Check if this is a versioned deployment | |
| 60 | + | if manifest.IsVersionedDeployment(annotations) { | |
| 61 | + | return h.handleVersionedDeployment(imageName, reference, annotations) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // handleVersionedDeployment handles a versioned deployment | |
| 68 | + | func (h *VersionHandler) handleVersionedDeployment(imageName, reference string, annotations map[string]string) error { | |
| 69 | + | version := manifest.GetDeploymentVersion(annotations) | |
| 70 | + | if version == "" { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | log.Info(). | |
| 75 | + | Str("image", imageName). | |
| 76 | + | Str("reference", reference). | |
| 77 | + | Str("version", version). | |
| 78 | + | Msg("Deploying version via manifest annotation") | |
| 79 | + | ||
| 80 | + | // Deploy the versioned image - treat this as a deployment to the specified version | |
| 81 | + | return h.performVersionedDeployment(imageName, reference, version, annotations) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // performVersionedDeployment deploys a specific version based on manifest annotations | |
| 85 | + | func (h *VersionHandler) performVersionedDeployment(imageName, reference, version string, annotations map[string]string) error { | |
| 86 | + | ctx := context.Background() | |
| 87 | + | ||
| 88 | + | // Find routes that use this image | |
| 89 | + | routes := h.findRoutesForImage(fmt.Sprintf("%s:%s", imageName, reference)) | |
| 90 | + | if len(routes) == 0 { | |
| 91 | + | log.Debug(). | |
| 92 | + | Str("image", imageName). | |
| 93 | + | Str("reference", reference). | |
| 94 | + | Msg("No routes configured for versioned deployment image") | |
| 95 | + | return nil | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | var errors []error | |
| 99 | + | for _, route := range routes { | |
| 100 | + | if err := h.deployVersion(ctx, route, imageName, version, annotations); err != nil { | |
| 101 | + | log.Error(). | |
| 102 | + | Err(err). | |
| 103 | + | Str("route", route). | |
| 104 | + | Str("version", version). | |
| 105 | + | Msg("Failed to deploy version to route") | |
| 106 | + | errors = append(errors, fmt.Errorf("version deployment failed for route %s: %w", route, err)) | |
| 107 | + | } else { | |
| 108 | + | log.Info(). | |
| 109 | + | Str("route", route). | |
| 110 | + | Str("version", version). | |
| 111 | + | Msg("Version deployment completed successfully") | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | if len(errors) > 0 { | |
| 116 | + | return fmt.Errorf("version deployment completed with %d errors: %v", len(errors), errors) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | return nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | ||
| 123 | + | // findRoutesForImage finds all routes that match the given image | |
| 124 | + | func (h *VersionHandler) findRoutesForImage(imageName string) []string { | |
| 125 | + | var routes []string | |
| 126 | + | ||
| 127 | + | for route, configuredImage := range h.config.Routes { | |
| 128 | + | if strings.EqualFold(configuredImage, imageName) { | |
| 129 | + | routes = append(routes, route) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | return routes | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // deployVersion deploys a specific version to a route | |
| 137 | + | func (h *VersionHandler) deployVersion(ctx context.Context, route, imageName, version string, annotations map[string]string) error { | |
| 138 | + | // Construct the versioned image name | |
| 139 | + | var versionedImage string | |
| 140 | + | ||
| 141 | + | if strings.Contains(version, ":") { | |
| 142 | + | // Full image reference provided | |
| 143 | + | versionedImage = version | |
| 144 | + | } else { | |
| 145 | + | // Just a version/tag provided, construct full image reference | |
| 146 | + | versionedImage = fmt.Sprintf("%s:%s", imageName, version) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | log.Info(). | |
| 150 | + | Str("route", route). | |
| 151 | + | Str("current_image", h.config.Routes[route]). | |
| 152 | + | Str("versioned_image", versionedImage). | |
| 153 | + | Msg("Performing version deployment") | |
| 154 | + | ||
| 155 | + | // Update the route configuration to the versioned image | |
| 156 | + | if err := h.config.UpdateRoute(route, versionedImage); err != nil { | |
| 157 | + | return fmt.Errorf("failed to update route configuration for version deployment: %w", err) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // Deploy the versioned image | |
| 161 | + | routeConfig := config.Route{ | |
| 162 | + | Domain: route, | |
| 163 | + | Image: versionedImage, | |
| 164 | + | HTTPS: true, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | _, err := h.manager.DeployContainer(ctx, routeConfig) | |
| 168 | + | if err != nil { | |
| 169 | + | // Attempt to restore original configuration | |
| 170 | + | if originalImage := h.config.Routes[route]; originalImage != versionedImage { | |
| 171 | + | log.Warn(). | |
| 172 | + | Str("route", route). | |
| 173 | + | Str("original_image", originalImage). | |
| 174 | + | Msg("Version deployment failed, attempting to restore original configuration") | |
| 175 | + | _ = h.config.UpdateRoute(route, originalImage) | |
| 176 | + | } | |
| 177 | + | return fmt.Errorf("failed to deploy versioned container: %w", err) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Log successful deployment | |
| 181 | + | log.Info(). | |
| 182 | + | Str("route", route). | |
| 183 | + | Str("versioned_image", versionedImage). | |
| 184 | + | Interface("version_annotations", annotations). | |
| 185 | + | Msg("Version deployment completed successfully") | |
| 186 | + | ||
| 187 | + | return nil | |
| 188 | + | }⏎̸ | |
A internal/events/types.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package events | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | type EventType string | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | ImagePushed EventType = "image.pushed" | |
| 11 | + | ImageDeleted EventType = "image.deleted" | |
| 12 | + | ConfigReload EventType = "config.reload" | |
| 13 | + | ManualReload EventType = "manual.reload" | |
| 14 | + | ContainerStop EventType = "container.stop" | |
| 15 | + | ContainerStart EventType = "container.start" | |
| 16 | + | ContainerHealthCheck EventType = "container.health_check" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type Event struct { | |
| 20 | + | ID string `json:"id"` | |
| 21 | + | Type EventType `json:"type"` | |
| 22 | + | Timestamp time.Time `json:"timestamp"` | |
| 23 | + | ImageName string `json:"image_name,omitempty"` | |
| 24 | + | Tag string `json:"tag,omitempty"` | |
| 25 | + | Route string `json:"route,omitempty"` | |
| 26 | + | ContainerID string `json:"container_id,omitempty"` | |
| 27 | + | Data interface{} `json:"data,omitempty"` | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type ImagePushedPayload struct { | |
| 31 | + | Name string `json:"name"` | |
| 32 | + | Reference string `json:"reference"` | |
| 33 | + | Manifest []byte `json:"-"` | |
| 34 | + | Annotations map[string]string `json:"annotations,omitempty"` | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type EventHandler interface { | |
| 38 | + | Handle(event Event) error | |
| 39 | + | CanHandle(eventType EventType) bool | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type EventBus interface { | |
| 43 | + | Publish(eventType EventType, payload interface{}) error | |
| 44 | + | Subscribe(handler EventHandler) error | |
| 45 | + | Unsubscribe(handler EventHandler) error | |
| 46 | + | Start() error | |
| 47 | + | Stop() error | |
| 48 | + | } | |
D internal/httpserve/handlers/admin_view.go +0 -66
| @@ -1,66 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "net/http" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 10 | - | "github.com/labstack/echo/v4" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | var ( | |
| 14 | - | mainPath = "html/admin" | |
| 15 | - | fragmentsPath = "html/fragments" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // GetLocalizedData returns the data for the localization | |
| 19 | - | func GetLocalizedData(c echo.Context, a *server.App) (map[string]interface{}, error) { | |
| 20 | - | lang := c.Get("LangKey") | |
| 21 | - | if lang == nil { | |
| 22 | - | return nil, fmt.Errorf("LangKey not found in context") | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | yamlData, err := render.GetLocalization(lang.(string), a) | |
| 26 | - | if err != nil { | |
| 27 | - | return nil, fmt.Errorf("failed to get localization: %w", err) | |
| 28 | - | } | |
| 29 | - | return map[string]interface{}{ | |
| 30 | - | "Lang": yamlData, | |
| 31 | - | "LogoutURL": a.Config.Admin.Path + "/logout", | |
| 32 | - | "AdminPath": a.Config.Admin.Path, | |
| 33 | - | }, nil | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // renderAdminPage is a generalized function to render admin pages | |
| 37 | - | func renderAdminPage(c echo.Context, a *server.App, templateName string) error { | |
| 38 | - | data, err := GetLocalizedData(c, a) | |
| 39 | - | if err != nil { | |
| 40 | - | return err | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | data["AdminPath"] = a.Config.Admin.Path | |
| 44 | - | ||
| 45 | - | rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath) | |
| 46 | - | if err != nil { | |
| 47 | - | return err | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 51 | - | if err != nil { | |
| 52 | - | return err | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | return c.HTML(http.StatusOK, renderedHTML) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // AdminRoute is the route for the admin panel | |
| 59 | - | func AdminRoute(c echo.Context, a *server.App) error { | |
| 60 | - | return renderAdminPage(c, a, "index.gohtml") | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | // AdminManagerRoute is the route of the manager page | |
| 64 | - | func AdminManagerRoute(c echo.Context, a *server.App) error { | |
| 65 | - | return renderAdminPage(c, a, "manager.gohtml") | |
| 66 | - | } | |
D internal/httpserve/handlers/container_crud.go +0 -175
| @@ -1,175 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "log" | |
| 6 | - | "net/http" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 10 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 11 | - | "github.com/bnema/gordon/pkg/docker" | |
| 12 | - | "github.com/bnema/gordon/pkg/sanitize" | |
| 13 | - | "github.com/labstack/echo/v4" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // FromShortIDToImageID converts a short image ID to a full image ID | |
| 17 | - | func FromShortIDToImageID(ShortID string) (string, error) { | |
| 18 | - | imageID, exists := safelyInteractWithIDMap(Fetch, ShortID) | |
| 19 | - | if !exists { | |
| 20 | - | log.Printf("No mapping found for short ID: %s", ShortID) | |
| 21 | - | return "", fmt.Errorf("image ID not found") | |
| 22 | - | } | |
| 23 | - | return imageID, nil | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // CreateContainerRoute is the route for creating a new container | |
| 27 | - | func CreateContainerGET(c echo.Context, a *server.App) error { | |
| 28 | - | log.Printf("Full request URL: %s", c.Request().URL.String()) | |
| 29 | - | // Retrieve the ShortID of the image from the URL | |
| 30 | - | shortID := c.Param("ID") | |
| 31 | - | log.Printf("Received ShortID: %s", shortID) | |
| 32 | - | ||
| 33 | - | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | |
| 34 | - | if !exists { | |
| 35 | - | log.Printf("No mapping found for short ID: %s", shortID) | |
| 36 | - | return c.String(http.StatusNotFound, "Image ID not found") | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | log.Printf("Found mapping: %s -> %s", shortID, fullID) | |
| 40 | - | ||
| 41 | - | // Get the image info | |
| 42 | - | imageInfo, err := docker.GetImageInfo(fullID) | |
| 43 | - | if err != nil { | |
| 44 | - | return sendError(c, err) | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // Extract image name | |
| 48 | - | var imageName string | |
| 49 | - | if len(imageInfo.RepoTags) > 0 { | |
| 50 | - | imageName = imageInfo.RepoTags[0] | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | data := map[string]interface{}{ | |
| 54 | - | "Title": "Create a new container", | |
| 55 | - | "ShortID": shortID, | |
| 56 | - | "ImageID": fullID, | |
| 57 | - | "ImageName": imageName, | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Render the create container page | |
| 61 | - | rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a) | |
| 62 | - | ||
| 63 | - | if err != nil { | |
| 64 | - | return sendError(c, err) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 68 | - | ||
| 69 | - | if err != nil { | |
| 70 | - | return sendError(c, err) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | return c.HTML(200, renderedHTML) | |
| 74 | - | ||
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // CreateContainerFullGET handles the full HTML page for creating a new container | |
| 78 | - | func CreateContainerFullGET(c echo.Context, a *server.App) error { | |
| 79 | - | // Retrieve the ShortID of the image from the URL | |
| 80 | - | shortID := c.Param("ID") | |
| 81 | - | log.Printf("Received ShortID: %s", shortID) | |
| 82 | - | ||
| 83 | - | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | |
| 84 | - | if !exists { | |
| 85 | - | log.Printf("No mapping found for short ID: %s", shortID) | |
| 86 | - | return c.String(http.StatusNotFound, "Image ID not found") | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | log.Printf("Found mapping: %s -> %s", shortID, fullID) | |
| 90 | - | ||
| 91 | - | // Get the image info | |
| 92 | - | imageInfo, err := docker.GetImageInfo(fullID) | |
| 93 | - | if err != nil { | |
| 94 | - | return sendError(c, err) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // Extract image name | |
| 98 | - | var imageName string | |
| 99 | - | if len(imageInfo.RepoTags) > 0 { | |
| 100 | - | imageName = imageInfo.RepoTags[0] | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | data := map[string]interface{}{ | |
| 104 | - | "Title": "Create a new container", | |
| 105 | - | "ShortID": shortID, | |
| 106 | - | "ImageID": fullID, | |
| 107 | - | "ImageName": imageName, | |
| 108 | - | "AdminPath": a.Config.Admin.Path, | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // Specify both admin and fragments directories | |
| 112 | - | rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments") | |
| 113 | - | if err != nil { | |
| 114 | - | return sendError(c, err) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 118 | - | if err != nil { | |
| 119 | - | log.Printf("Error rendering template: %v", err) | |
| 120 | - | return sendError(c, err) | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | return c.HTML(200, renderedHTML) | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | // CreateContainerPOST handles the create container form submission | |
| 127 | - | func CreateContainerPOST(c echo.Context, a *server.App) error { | |
| 128 | - | // Retreive the ShortID of the image from the URL | |
| 129 | - | ShortID := c.Param("ID") | |
| 130 | - | ||
| 131 | - | // Convert the ShortID to a full image ID | |
| 132 | - | imageID, err := FromShortIDToImageID(ShortID) | |
| 133 | - | if err != nil { | |
| 134 | - | return sendError(c, err) | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // Get the image info | |
| 138 | - | _, err = docker.GetImageInfo(imageID) | |
| 139 | - | if err != nil { | |
| 140 | - | return sendError(c, err) | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | // Retrieve all form parameters | |
| 144 | - | formParams, err := c.FormParams() | |
| 145 | - | if err != nil { | |
| 146 | - | return sendError(c, err) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // Initialize a map to store sanitized values | |
| 150 | - | sanitizedInputs := make(map[string]string) | |
| 151 | - | ||
| 152 | - | // Iterate over all form parameters and sanitize them | |
| 153 | - | for k, v := range formParams { | |
| 154 | - | if len(v) > 0 { | |
| 155 | - | sanitized, err := sanitize.SanitizeHTML(v[0]) | |
| 156 | - | if err != nil { | |
| 157 | - | return sendError(c, err) | |
| 158 | - | } | |
| 159 | - | sanitizedInputs[k] = sanitized | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | cmdParams, err := cmdparams.FromInputsToCmdParams(sanitizedInputs, a) | |
| 164 | - | if err != nil { | |
| 165 | - | return sendError(c, err) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Create the container | |
| 169 | - | _, err = docker.CreateContainer(cmdParams) | |
| 170 | - | if err != nil { | |
| 171 | - | return sendError(c, err) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 175 | - | } | |
D internal/httpserve/handlers/container_management.go +0 -251
| @@ -1,251 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/http" | |
| 6 | - | "strings" | |
| 7 | - | "sync" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/server" | |
| 11 | - | "github.com/bnema/gordon/internal/templating/load" | |
| 12 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 13 | - | "github.com/bnema/gordon/pkg/docker" | |
| 14 | - | "github.com/bnema/gordon/pkg/humanize" | |
| 15 | - | "github.com/docker/docker/api/types" | |
| 16 | - | "github.com/labstack/echo/v4" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | type MapOperation string | |
| 20 | - | ||
| 21 | - | var ( | |
| 22 | - | idMap = make(map[string]string) | |
| 23 | - | idMapMutex sync.Mutex | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | const ( | |
| 27 | - | Fetch MapOperation = "Fetch" | |
| 28 | - | Update MapOperation = "Update" | |
| 29 | - | Delete MapOperation = "Delete" | |
| 30 | - | ) | |
| 31 | - | ||
| 32 | - | func safelyInteractWithIDMap(op MapOperation, key string, value ...string) (string, bool) { | |
| 33 | - | idMapMutex.Lock() | |
| 34 | - | defer idMapMutex.Unlock() | |
| 35 | - | ||
| 36 | - | switch op { | |
| 37 | - | case Fetch: | |
| 38 | - | val, exists := idMap[key] | |
| 39 | - | return val, exists | |
| 40 | - | case Update: | |
| 41 | - | if len(value) > 0 { | |
| 42 | - | idMap[key] = value[0] | |
| 43 | - | } | |
| 44 | - | return "", true | |
| 45 | - | case Delete: | |
| 46 | - | delete(idMap, key) | |
| 47 | - | return "", true | |
| 48 | - | default: | |
| 49 | - | return "", false | |
| 50 | - | } | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | type HumanReadableContainerImage struct { | |
| 54 | - | ID string | |
| 55 | - | Name string | |
| 56 | - | ShortID string | |
| 57 | - | CreatedStr string | |
| 58 | - | SizeStr string | |
| 59 | - | RepoDigests []string | |
| 60 | - | RepoTags []string | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | type HumanReadableContainer struct { | |
| 64 | - | *types.Container | |
| 65 | - | Name string | |
| 66 | - | Ports []string | |
| 67 | - | ShortID string | |
| 68 | - | CreatedStr string | |
| 69 | - | SizeStr string | |
| 70 | - | UpSince string | |
| 71 | - | StateColor string | |
| 72 | - | TraefikEntryPoint string | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // renderHTML is a generalized function to render HTML | |
| 76 | - | func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error { | |
| 77 | - | rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a) | |
| 78 | - | if err != nil { | |
| 79 | - | return err | |
| 80 | - | } | |
| 81 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 82 | - | if err != nil { | |
| 83 | - | return err | |
| 84 | - | } | |
| 85 | - | return c.HTML(http.StatusOK, renderedHTML) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | // ActionSuccess returns the success HTML fragment | |
| 89 | - | func ActionSuccess(a *server.App) string { | |
| 90 | - | successFragment, err := load.Fragment(a, "success") | |
| 91 | - | if err != nil { | |
| 92 | - | fmt.Println(err) | |
| 93 | - | return "" | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | return successFragment | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // ImageManagerComponent handles the /image-manager route (HTMX route) | |
| 100 | - | func ImageManagerComponent(c echo.Context, a *server.App) error { | |
| 101 | - | images, err := docker.ListContainerImages() | |
| 102 | - | if err != nil { | |
| 103 | - | return sendError(c, err) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | var humanReadableImages []HumanReadableContainerImage | |
| 107 | - | ||
| 108 | - | for _, img := range images { | |
| 109 | - | shortID := img.ID[7:19] | |
| 110 | - | safelyInteractWithIDMap(Update, shortID, img.ID) | |
| 111 | - | createdTime := time.Unix(img.Created, 0) | |
| 112 | - | createdStr := humanize.TimeAgo(createdTime) | |
| 113 | - | sizeStr := humanize.BytesToReadableSize(img.Size) | |
| 114 | - | ||
| 115 | - | for _, repoTag := range img.RepoTags { | |
| 116 | - | humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{ | |
| 117 | - | ID: img.ID, | |
| 118 | - | ShortID: shortID, | |
| 119 | - | CreatedStr: createdStr, | |
| 120 | - | SizeStr: sizeStr, | |
| 121 | - | Name: repoTag, | |
| 122 | - | RepoDigests: img.RepoDigests, | |
| 123 | - | RepoTags: img.RepoTags, | |
| 124 | - | }) | |
| 125 | - | } | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | data := map[string]interface{}{ | |
| 129 | - | "Images": humanReadableImages, | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // ImageManagerDelete handles the /image-manager/delete route | |
| 136 | - | func ImageManagerDelete(c echo.Context, a *server.App) error { | |
| 137 | - | ShortID := c.Param("ID") | |
| 138 | - | ||
| 139 | - | imageID, exists := safelyInteractWithIDMap(Fetch, ShortID) | |
| 140 | - | if !exists { | |
| 141 | - | return c.String(http.StatusBadRequest, "Invalid ShortImgID") | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | err := docker.DeleteContainerImage(imageID) | |
| 145 | - | if err != nil { | |
| 146 | - | c.Response().Header().Set("X-Error-Type", "image") | |
| 147 | - | return c.String(http.StatusInternalServerError, err.Error()) | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | safelyInteractWithIDMap(Delete, ShortID) | |
| 151 | - | ||
| 152 | - | return c.String(http.StatusOK, ActionSuccess(a)) | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // ContainerManagerComponent handles the /container-manager route | |
| 156 | - | func ContainerManagerComponent(c echo.Context, a *server.App) error { | |
| 157 | - | containers, err := docker.ListRunningContainers() | |
| 158 | - | if err != nil { | |
| 159 | - | return sendError(c, err) | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | var humanReadableContainers []HumanReadableContainer | |
| 163 | - | ||
| 164 | - | for _, container := range containers { | |
| 165 | - | localContainer := container // Make a local copy | |
| 166 | - | sizeStr := humanize.BytesToReadableSize(container.SizeRw) | |
| 167 | - | stateColor := "blue" | |
| 168 | - | ||
| 169 | - | if container.State == "running" { | |
| 170 | - | stateColor = "green" | |
| 171 | - | } else if container.State == "exited" { | |
| 172 | - | stateColor = "red" | |
| 173 | - | } | |
| 174 | - | var ports []string | |
| 175 | - | for _, port := range container.Ports { | |
| 176 | - | portStr := fmt.Sprintf("%d:%d", port.PublicPort, port.PrivatePort) | |
| 177 | - | ports = append(ports, portStr) | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | for _, name := range container.Names { | |
| 181 | - | name = name[1:] | |
| 182 | - | humanReadableContainers = append(humanReadableContainers, HumanReadableContainer{ | |
| 183 | - | Container: &localContainer, | |
| 184 | - | SizeStr: sizeStr, | |
| 185 | - | UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)), | |
| 186 | - | StateColor: stateColor, | |
| 187 | - | Name: name, | |
| 188 | - | Ports: ports, | |
| 189 | - | TraefikEntryPoint: extractTraefikEntryPoint(container.Labels), | |
| 190 | - | CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)), | |
| 191 | - | }) | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | yamlData, err := GetLocalizedData(c, a) | |
| 195 | - | if err != nil { | |
| 196 | - | return err | |
| 197 | - | } | |
| 198 | - | data := map[string]interface{}{ | |
| 199 | - | "Lang": yamlData["Lang"], | |
| 200 | - | "containers": humanReadableContainers, | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | func extractTraefikEntryPoint(labels map[string]string) string { | |
| 207 | - | for key, value := range labels { | |
| 208 | - | if strings.HasSuffix(key, ".loadbalancer.server.port") { | |
| 209 | - | return value | |
| 210 | - | } | |
| 211 | - | } | |
| 212 | - | return "" | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | // ContainerManagerDelete handles the /container-manager/delete route | |
| 216 | - | func ContainerManagerDelete(c echo.Context, a *server.App) error { | |
| 217 | - | err := docker.RemoveContainer(c.Param("ID")) | |
| 218 | - | if err != nil { | |
| 219 | - | return sendError(c, err) | |
| 220 | - | } | |
| 221 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | // ContainerManagerStop handles the /container-manager/stop route | |
| 225 | - | func ContainerManagerStop(c echo.Context, a *server.App) error { | |
| 226 | - | // Stop the container gracefully with a timeout | |
| 227 | - | stopped, err := docker.StopContainerGracefully(c.Param("ID"), 3*time.Second) | |
| 228 | - | if err != nil { | |
| 229 | - | return sendError(c, err) | |
| 230 | - | } | |
| 231 | - | // if stopped is true, the container was stopped gracefully | |
| 232 | - | if stopped { | |
| 233 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 234 | - | } | |
| 235 | - | // If stopped is false, the container was not stopped gracefully we force stop it | |
| 236 | - | err = docker.StopContainerRagefully(c.Param("ID")) | |
| 237 | - | if err != nil { | |
| 238 | - | return sendError(c, err) | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | // ContainerManagerStart handles the /container-manager/start route | |
| 245 | - | func ContainerManagerStart(c echo.Context, a *server.App) error { | |
| 246 | - | err := docker.StartContainer(c.Param("ID")) | |
| 247 | - | if err != nil { | |
| 248 | - | return sendError(c, err) | |
| 249 | - | } | |
| 250 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 251 | - | } | |
D internal/httpserve/handlers/container_transactions.go +0 -303
| @@ -1,303 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "log" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 10 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 11 | - | "github.com/bnema/gordon/pkg/docker" | |
| 12 | - | "github.com/labstack/echo/v4" | |
| 13 | - | "gopkg.in/yaml.v3" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func generateOrderedYAML(infoMap map[string]interface{}) (string, error) { | |
| 17 | - | orderedKeys := []string{"Name", "Image", "Hostname", "Ports", "Environment", "Labels", "Network", "Volumes", "Restart"} | |
| 18 | - | ||
| 19 | - | var yamlString string | |
| 20 | - | for _, key := range orderedKeys { | |
| 21 | - | value, exists := infoMap[key] | |
| 22 | - | if !exists { | |
| 23 | - | continue | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | yamlBytes, err := yaml.Marshal(map[string]interface{}{key: value}) | |
| 27 | - | if err != nil { | |
| 28 | - | return "", err | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | yamlString += string(yamlBytes) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | return yamlString, nil | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // ContainerManagerEditGET displays the edit container view | |
| 38 | - | func ContainerManagerEditGET(c echo.Context, a *server.App) error { | |
| 39 | - | containerID := c.Param("ID") | |
| 40 | - | ||
| 41 | - | // Get the container info | |
| 42 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 43 | - | if err != nil { | |
| 44 | - | return sendError(c, err) | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // Trim the slash from the container name | |
| 48 | - | containerInfo.Name = strings.TrimPrefix(containerInfo.Name, "/") | |
| 49 | - | // Get the network names | |
| 50 | - | networkNames := make([]string, 0, len(containerInfo.NetworkSettings.Networks)) | |
| 51 | - | for networkName := range containerInfo.NetworkSettings.Networks { | |
| 52 | - | networkNames = append(networkNames, networkName) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // Prepare Ports | |
| 56 | - | portMappings := make([]string, 0) | |
| 57 | - | for port, bindings := range containerInfo.HostConfig.PortBindings { | |
| 58 | - | for _, binding := range bindings { | |
| 59 | - | portMappings = append(portMappings, fmt.Sprintf("%s:%s/%s", binding.HostPort, port.Port(), port.Proto())) | |
| 60 | - | } | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | // Prepare Volumes (Mounts) | |
| 64 | - | volumeMappings := make([]string, 0) | |
| 65 | - | for _, mount := range containerInfo.Mounts { | |
| 66 | - | volumeMappings = append(volumeMappings, fmt.Sprintf("%s:%s", mount.Source, mount.Destination)) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Initialize a map to store the information from the container | |
| 70 | - | infoMap := make(map[string]interface{}) | |
| 71 | - | ||
| 72 | - | // Populate the map with container details | |
| 73 | - | infoMap["Name"] = containerInfo.Name | |
| 74 | - | infoMap["Image"] = containerInfo.Config.Image | |
| 75 | - | infoMap["Hostname"] = containerInfo.Config.Hostname | |
| 76 | - | infoMap["Ports"] = portMappings | |
| 77 | - | infoMap["Volumes"] = volumeMappings | |
| 78 | - | infoMap["Environment"] = containerInfo.Config.Env | |
| 79 | - | infoMap["Labels"] = containerInfo.Config.Labels | |
| 80 | - | infoMap["Network"] = networkNames | |
| 81 | - | infoMap["Restart"] = containerInfo.HostConfig.RestartPolicy.Name | |
| 82 | - | ||
| 83 | - | // Convert the infoMap to YAML format | |
| 84 | - | yamlString, err := generateOrderedYAML(infoMap) | |
| 85 | - | if err != nil { | |
| 86 | - | return sendError(c, err) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // Pass the containerInfo to the data map | |
| 90 | - | data := map[string]interface{}{ | |
| 91 | - | "Title": "Edit container", | |
| 92 | - | "ID": containerID, | |
| 93 | - | "ContainerInfo": yamlString, | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | rendererData, err := render.GetHTMLRenderer("html/fragments", "editcontainer.gohtml", a.TemplateFS, a) | |
| 97 | - | ||
| 98 | - | if err != nil { | |
| 99 | - | return sendError(c, err) | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 103 | - | ||
| 104 | - | if err != nil { | |
| 105 | - | return sendError(c, err) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | return c.HTML(200, renderedHTML) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | type TransactionStep func() error | |
| 112 | - | type RollbackStep func() error | |
| 113 | - | ||
| 114 | - | type TransactionQueue struct { | |
| 115 | - | NewContainerID string | |
| 116 | - | NewContainerName string | |
| 117 | - | TempContainerName string | |
| 118 | - | OldContainerID string | |
| 119 | - | OldContainerName string | |
| 120 | - | steps []TransactionStep | |
| 121 | - | rollbacks []RollbackStep | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func (tq *TransactionQueue) Add(step TransactionStep, rollback RollbackStep) { | |
| 125 | - | tq.steps = append(tq.steps, step) | |
| 126 | - | tq.rollbacks = append(tq.rollbacks, rollback) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func (tq *TransactionQueue) Execute() error { | |
| 130 | - | var transactionErrors []error | |
| 131 | - | var rollbackErrors []error | |
| 132 | - | for i, step := range tq.steps { | |
| 133 | - | err := step() | |
| 134 | - | if err != nil { | |
| 135 | - | log.Printf("Error occurred at step index %d: %v", i, err) | |
| 136 | - | transactionErrors = append(transactionErrors, err) | |
| 137 | - | for j := i - 1; j >= 0; j-- { | |
| 138 | - | rollback := tq.rollbacks[j] | |
| 139 | - | if rollback != nil { | |
| 140 | - | if rollbackErr := rollback(); rollbackErr != nil { | |
| 141 | - | log.Printf("Failed to execute rollback at index %d: %v", j, rollbackErr) | |
| 142 | - | rollbackErrors = append(rollbackErrors, rollbackErr) | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | return fmt.Errorf("transaction failed: %v; rollback errors: %v", transactionErrors, rollbackErrors) | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | return nil | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // ContainerManagerEditPOST handles the edit container form submission | |
| 153 | - | func ContainerManagerEditPOST(c echo.Context, a *server.App) error { | |
| 154 | - | tq := &TransactionQueue{} | |
| 155 | - | oldContainerID := c.Param("ID") | |
| 156 | - | ||
| 157 | - | oldContainerInfo, err := docker.GetContainerInfo(oldContainerID) | |
| 158 | - | if err != nil { | |
| 159 | - | return sendError(c, fmt.Errorf("getting old container info failed: %w", err)) | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | containerConfig := c.FormValue("container_config") | |
| 163 | - | var containerParams cmdparams.YAMLContainerParams | |
| 164 | - | err = yaml.Unmarshal([]byte(containerConfig), &containerParams) | |
| 165 | - | if err != nil { | |
| 166 | - | return sendError(c, fmt.Errorf("YAML unmarshal failed: %w", err)) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | // Use render.FromYAMLStructToCmdParams to convert YAMLContainerParams to ContainerCommandParams | |
| 170 | - | cmdParams, err := cmdparams.FromYAMLStructToCmdParams(containerParams) | |
| 171 | - | if err != nil { | |
| 172 | - | return fmt.Errorf("failed to convert YAML: %w", err) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | tq.OldContainerID = oldContainerID | |
| 176 | - | tq.OldContainerName = strings.TrimPrefix(oldContainerInfo.Name, "/") | |
| 177 | - | tq.NewContainerName = cmdParams.ContainerName | |
| 178 | - | tq.TempContainerName = fmt.Sprintf("%s-temp", tq.NewContainerName) | |
| 179 | - | ||
| 180 | - | cmdParams.ContainerName = tq.TempContainerName | |
| 181 | - | ||
| 182 | - | // 1. Stop the old container | |
| 183 | - | tq.Add(StopOldContainerStep(tq), StartOldContainerRollback(tq)) | |
| 184 | - | ||
| 185 | - | // 2. Create the new container | |
| 186 | - | tq.Add(CreateNewContainerStep(tq, cmdParams), RemoveNewContainerRollback(tq)) | |
| 187 | - | ||
| 188 | - | // 3. Start the new container | |
| 189 | - | tq.Add(StartNewContainerStep(tq), StopNewContainerRollback(tq)) | |
| 190 | - | ||
| 191 | - | // 4. Rename the new container to the original name | |
| 192 | - | tq.Add(RemoveOldContainerStep(tq), nil) | |
| 193 | - | ||
| 194 | - | // 5. Remove the old container | |
| 195 | - | tq.Add(RenameNewContainerStep(tq), RenameNewContainerRollback(tq)) | |
| 196 | - | ||
| 197 | - | err = tq.Execute() | |
| 198 | - | if err != nil { | |
| 199 | - | return sendError(c, fmt.Errorf("transaction failed: %w", err)) | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | return c.HTML(200, ActionSuccess(a)) | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // CreateNewContainerStep creates a new container with the given parameters | |
| 206 | - | func CreateNewContainerStep(tq *TransactionQueue, cmdParams docker.ContainerCommandParams) TransactionStep { | |
| 207 | - | return func() error { | |
| 208 | - | var err error | |
| 209 | - | tq.NewContainerID, err = docker.CreateContainer(cmdParams) | |
| 210 | - | if err != nil { | |
| 211 | - | return fmt.Errorf("creating new container failed: %w", err) | |
| 212 | - | } | |
| 213 | - | return nil | |
| 214 | - | } | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // StartNewContainerStep starts the new container | |
| 218 | - | func StartNewContainerStep(tq *TransactionQueue) TransactionStep { | |
| 219 | - | return func() error { | |
| 220 | - | err := docker.StartContainer(tq.NewContainerID) | |
| 221 | - | if err != nil { | |
| 222 | - | return fmt.Errorf("starting new container failed: %w", err) | |
| 223 | - | } | |
| 224 | - | return nil | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | // StopOldContainerStep stops the old container | |
| 229 | - | func StopOldContainerStep(tq *TransactionQueue) TransactionStep { | |
| 230 | - | return func() error { | |
| 231 | - | err := docker.StopContainer(tq.OldContainerID) | |
| 232 | - | if err != nil { | |
| 233 | - | return fmt.Errorf("stopping old container failed: %w", err) | |
| 234 | - | } | |
| 235 | - | return nil | |
| 236 | - | } | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | // RemoveOldContainerStep removes the old container | |
| 240 | - | func RemoveOldContainerStep(tq *TransactionQueue) TransactionStep { | |
| 241 | - | return func() error { | |
| 242 | - | err := docker.RemoveContainer(tq.OldContainerID) | |
| 243 | - | if err != nil { | |
| 244 | - | return fmt.Errorf("removing old container failed: %w", err) | |
| 245 | - | } | |
| 246 | - | return nil | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // RemoveNewContainerRollback removes the new container if the transaction fails | |
| 251 | - | func RemoveNewContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 252 | - | return func() error { | |
| 253 | - | err := docker.RemoveContainer(tq.NewContainerID) | |
| 254 | - | if err != nil { | |
| 255 | - | return fmt.Errorf("rollback: removing new container failed: %w", err) | |
| 256 | - | } | |
| 257 | - | return nil | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // StopNewContainerRollback stops the new container if the transaction fails | |
| 262 | - | func StopNewContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 263 | - | return func() error { | |
| 264 | - | err := docker.StopContainer(tq.NewContainerID) | |
| 265 | - | if err != nil { | |
| 266 | - | return fmt.Errorf("rollback: stopping new container failed: %w", err) | |
| 267 | - | } | |
| 268 | - | return nil | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // StartOldContainerRollback starts the old container if the transaction fails | |
| 273 | - | func StartOldContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 274 | - | return func() error { | |
| 275 | - | err := docker.StartContainer(tq.OldContainerID) | |
| 276 | - | if err != nil { | |
| 277 | - | return fmt.Errorf("rollback: starting old container failed: %w", err) | |
| 278 | - | } | |
| 279 | - | return nil | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | // RenameNewContainerStep renames the new temporary container to the original name | |
| 284 | - | func RenameNewContainerStep(tq *TransactionQueue) TransactionStep { | |
| 285 | - | return func() error { | |
| 286 | - | err := docker.RenameContainer(tq.NewContainerID, tq.NewContainerName) | |
| 287 | - | if err != nil { | |
| 288 | - | return fmt.Errorf("renaming new container failed: %w", err) | |
| 289 | - | } | |
| 290 | - | return nil | |
| 291 | - | } | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | // RenameNewContainerRollback renames the new container back to the temporary name if it fails | |
| 295 | - | func RenameNewContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 296 | - | return func() error { | |
| 297 | - | err := docker.RenameContainer(tq.NewContainerID, tq.TempContainerName) | |
| 298 | - | if err != nil { | |
| 299 | - | return fmt.Errorf("rollback: renaming new container back failed: %w", err) | |
| 300 | - | } | |
| 301 | - | return nil | |
| 302 | - | } | |
| 303 | - | } | |
D internal/httpserve/handlers/endpoint_deploy.go +0 -373
| @@ -1,373 +0,0 @@ | |||
| 1 | - | // endpoint_deploy.go | |
| 2 | - | package handlers | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "encoding/json" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "net/http" | |
| 9 | - | "os" | |
| 10 | - | "path/filepath" | |
| 11 | - | "strings" | |
| 12 | - | ||
| 13 | - | authToken "github.com/bnema/gordon/internal/cli/auth" | |
| 14 | - | "github.com/bnema/gordon/internal/common" | |
| 15 | - | "github.com/bnema/gordon/internal/server" | |
| 16 | - | "github.com/bnema/gordon/pkg/docker" | |
| 17 | - | "github.com/charmbracelet/log" | |
| 18 | - | "github.com/labstack/echo/v4" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | // PostDeploy handles the container deployment request | |
| 22 | - | func PostDeploy(c echo.Context, a *server.App) error { | |
| 23 | - | payload, err := validateAndPrepareDeployPayload(c) | |
| 24 | - | if err != nil { | |
| 25 | - | // Debug | |
| 26 | - | log.Error("Failed to validate and prepare payload", "error", err) | |
| 27 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 28 | - | Success: false, | |
| 29 | - | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| 30 | - | StatusCode: http.StatusBadRequest, | |
| 31 | - | }) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | _, err = saveAndImportImage(c, a, payload) | |
| 35 | - | if err != nil { | |
| 36 | - | // Debug | |
| 37 | - | log.Error("Failed to save and import image", "error", err) | |
| 38 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 39 | - | Success: false, | |
| 40 | - | Message: fmt.Sprintf("Failed to save or import image: %v", err), | |
| 41 | - | StatusCode: http.StatusInternalServerError, | |
| 42 | - | }) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | containerID, containerName, err := createAndStartContainer(a, payload) | |
| 46 | - | if err != nil { | |
| 47 | - | if strings.Contains(err.Error(), "is already in use") { | |
| 48 | - | log.Warn("Container already exists", "error", err) | |
| 49 | - | existingContainerID := extractContainerID(err.Error()) | |
| 50 | - | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 51 | - | // Remove the / from the container name | |
| 52 | - | existingContainerName = strings.TrimLeft(existingContainerName, "/") | |
| 53 | - | ||
| 54 | - | if existingContainerID == "" || existingContainerName == "" { | |
| 55 | - | log.Error("Failed to extract container ID or Name from error message") | |
| 56 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 57 | - | Success: false, | |
| 58 | - | Message: "A container for this deploy already exists but could not be identified.", | |
| 59 | - | StatusCode: http.StatusConflict, | |
| 60 | - | }) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 64 | - | Success: false, | |
| 65 | - | Message: "A container for this deploy already exists.", | |
| 66 | - | ContainerID: existingContainerID, | |
| 67 | - | ContainerName: existingContainerName, | |
| 68 | - | StatusCode: http.StatusConflict, | |
| 69 | - | }) | |
| 70 | - | } | |
| 71 | - | log.Error("Failed to create or start container", "error", err) | |
| 72 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 73 | - | Success: false, | |
| 74 | - | Message: fmt.Sprintf("Failed to create or start container: %v", err), | |
| 75 | - | StatusCode: http.StatusInternalServerError, | |
| 76 | - | }) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // Arrived here means deployment was successful | |
| 80 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 81 | - | Success: true, | |
| 82 | - | Message: "Deployment successful", | |
| 83 | - | Domain: payload.TargetDomain, | |
| 84 | - | ContainerID: containerID, | |
| 85 | - | ContainerName: containerName, | |
| 86 | - | }) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // PostDeployChunked handles the chunked container deployment request | |
| 90 | - | func PostDeployChunked(c echo.Context, a *server.App) error { | |
| 91 | - | return handleChunkedDeploy(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) error { | |
| 95 | - | var metadata ChunkMetadata | |
| 96 | - | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |
| 97 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 98 | - | Success: false, | |
| 99 | - | Message: "Invalid chunk metadata", | |
| 100 | - | StatusCode: http.StatusBadRequest, | |
| 101 | - | }) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | payload, err := validateAndPrepareDeployPayload(c) | |
| 105 | - | if err != nil { | |
| 106 | - | log.Error("Failed to validate and prepare payload", "error", err) | |
| 107 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 108 | - | Success: false, | |
| 109 | - | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| 110 | - | StatusCode: http.StatusBadRequest, | |
| 111 | - | }) | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | // Auth verification steps | |
| 115 | - | ||
| 116 | - | auth := c.Request().Header.Get("Authorization") | |
| 117 | - | if auth == "" { | |
| 118 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 119 | - | Success: false, | |
| 120 | - | Message: "Authentication required", | |
| 121 | - | StatusCode: http.StatusUnauthorized, | |
| 122 | - | }) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Validate the token through GitHub API | |
| 126 | - | token := strings.TrimPrefix(auth, "Bearer ") | |
| 127 | - | if !authToken.IsValidGitHubToken(token) { | |
| 128 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 129 | - | Success: false, | |
| 130 | - | Message: "Invalid or expired token", | |
| 131 | - | StatusCode: http.StatusUnauthorized, | |
| 132 | - | }) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // TODO: Here we should probably check that the github user is the same as the one in DB | |
| 136 | - | ||
| 137 | - | if metadata.TotalChunks == 1 { | |
| 138 | - | chunkData, err := io.ReadAll(c.Request().Body) | |
| 139 | - | if err != nil { | |
| 140 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 141 | - | Success: false, | |
| 142 | - | Message: "Failed to read chunk data", | |
| 143 | - | StatusCode: http.StatusInternalServerError, | |
| 144 | - | }) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // Store the data directly | |
| 148 | - | chunkStore.mu.Lock() | |
| 149 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 150 | - | chunkStore.chunks[metadata.TransferID][0] = chunkData | |
| 151 | - | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 152 | - | chunkStore.mu.Unlock() | |
| 153 | - | ||
| 154 | - | // Process the complete transfer immediately | |
| 155 | - | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | // Store chunk data | |
| 159 | - | chunkStore.mu.Lock() | |
| 160 | - | if chunkStore.chunks[metadata.TransferID] == nil { | |
| 161 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 162 | - | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | chunkData, err := io.ReadAll(c.Request().Body) | |
| 166 | - | if err != nil { | |
| 167 | - | chunkStore.mu.Unlock() | |
| 168 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 169 | - | Success: false, | |
| 170 | - | Message: "Failed to read chunk data", | |
| 171 | - | StatusCode: http.StatusInternalServerError, | |
| 172 | - | }) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // Chunks validation steps | |
| 176 | - | // | |
| 177 | - | if len(chunkData) == 0 { | |
| 178 | - | log.Error("Received empty chunk") | |
| 179 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 180 | - | Success: false, | |
| 181 | - | Message: "Received empty chunk", | |
| 182 | - | StatusCode: http.StatusBadRequest, | |
| 183 | - | }) | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // Verify chunk size matches metadata | |
| 187 | - | if int64(len(chunkData)) != metadata.ChunkSize { | |
| 188 | - | log.Error("Chunk size mismatch", | |
| 189 | - | "expected", metadata.ChunkSize, | |
| 190 | - | "received", len(chunkData)) | |
| 191 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 192 | - | Success: false, | |
| 193 | - | Message: fmt.Sprintf("Chunk size mismatch: expected %d, got %d", | |
| 194 | - | metadata.ChunkSize, len(chunkData)), | |
| 195 | - | StatusCode: http.StatusBadRequest, | |
| 196 | - | }) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // Store the complete chunk into our store | |
| 200 | - | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | |
| 201 | - | chunkStore.mu.Unlock() | |
| 202 | - | ||
| 203 | - | // If this is the last chunk, process the complete transfer | |
| 204 | - | if isTransferComplete(metadata.TransferID) { | |
| 205 | - | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | // Include domain information in intermediate chunk responses | |
| 209 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 210 | - | Success: true, | |
| 211 | - | Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks), | |
| 212 | - | Domain: payload.TargetDomain, | |
| 213 | - | StatusCode: http.StatusOK, | |
| 214 | - | }) | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error { | |
| 218 | - | log.Info("Starting complete deploy transfer processing", | |
| 219 | - | "transferID", transferID) | |
| 220 | - | ||
| 221 | - | // Get transfer data | |
| 222 | - | metadata, chunks, err := getTransferData(transferID) | |
| 223 | - | if err != nil { | |
| 224 | - | return sendDeployErrorResponse(c, "Failed to get transfer data", err) | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | // Create temporary directory for processing | |
| 228 | - | tmpDir, err := os.MkdirTemp("", "docker-deploy-*") | |
| 229 | - | if err != nil { | |
| 230 | - | return sendDeployErrorResponse(c, "Failed to create temp directory", err) | |
| 231 | - | } | |
| 232 | - | defer os.RemoveAll(tmpDir) | |
| 233 | - | ||
| 234 | - | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |
| 235 | - | if err != nil { | |
| 236 | - | return sendDeployErrorResponse(c, "Failed to create temp file", err) | |
| 237 | - | } | |
| 238 | - | defer tmpFile.Close() | |
| 239 | - | ||
| 240 | - | // Write chunks to file | |
| 241 | - | if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil { | |
| 242 | - | return sendDeployErrorResponse(c, "Failed to write chunks", err) | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | // Get deployment-specific headers | |
| 246 | - | port := normalizePort(c.Request().Header.Get("X-Ports")) | |
| 247 | - | targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain")) | |
| 248 | - | imageName := c.Request().Header.Get("X-Image-Name") | |
| 249 | - | ||
| 250 | - | if port == "" || targetDomain == "" || imageName == "" { | |
| 251 | - | return sendDeployErrorResponse(c, "Missing required deployment parameters", | |
| 252 | - | fmt.Errorf("port: %s, domain: %s, image: %s", port, targetDomain, imageName)) | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | // Extract container name from domain and perform safety checks | |
| 256 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 257 | - | containerName := strings.Split(cleanDomain, ".")[0] | |
| 258 | - | ||
| 259 | - | if containerName == "" { | |
| 260 | - | return sendDeployErrorResponse(c, "Invalid target domain", | |
| 261 | - | fmt.Errorf("couldn't extract container name from domain: %s", targetDomain)) | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | // Check for existing container | |
| 265 | - | existingContainerID := docker.GetContainerIDByName(containerName) | |
| 266 | - | if existingContainerID != "" { | |
| 267 | - | log.Warn("Container already exists", | |
| 268 | - | "name", containerName, | |
| 269 | - | "id", existingContainerID) | |
| 270 | - | return sendDeployErrorResponse(c, "Container already exists", nil) | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | // Import the image | |
| 274 | - | log.Info("Importing image from file", | |
| 275 | - | "path", tmpFile.Name(), | |
| 276 | - | "imageName", imageName) | |
| 277 | - | ||
| 278 | - | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |
| 279 | - | if err != nil { | |
| 280 | - | if strings.Contains(err.Error(), "platform compatibility check failed") { | |
| 281 | - | log.Error("Platform compatibility check failed", | |
| 282 | - | "error", err, | |
| 283 | - | "imageName", imageName) | |
| 284 | - | return sendDeployErrorResponse(c, "Platform compatibility check failed", err) | |
| 285 | - | } | |
| 286 | - | return sendDeployErrorResponse(c, "Failed to import image", err) | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | // Prepare deployment payload | |
| 290 | - | deployPayload := &common.DeployPayload{ | |
| 291 | - | Port: port, | |
| 292 | - | TargetDomain: targetDomain, | |
| 293 | - | ImageName: imageName, | |
| 294 | - | ImageID: imageID, | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // Create and start the container | |
| 298 | - | containerID, containerName, err := createAndStartContainer(a, deployPayload) | |
| 299 | - | if err != nil { | |
| 300 | - | if strings.Contains(err.Error(), "is already in use") { | |
| 301 | - | existingContainerID := extractContainerID(err.Error()) | |
| 302 | - | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 303 | - | existingContainerName = strings.TrimPrefix(existingContainerName, "/") | |
| 304 | - | ||
| 305 | - | return sendDeployErrorResponse(c, "Container already in use", err) | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | return sendDeployErrorResponse(c, "Failed to create or start container", err) | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | // Cleanup transfer data | |
| 312 | - | cleanupTransfer(transferID) | |
| 313 | - | ||
| 314 | - | log.Info("Deployment completed successfully", | |
| 315 | - | "containerID", containerID, | |
| 316 | - | "containerName", containerName, | |
| 317 | - | "domain", targetDomain) | |
| 318 | - | ||
| 319 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 320 | - | Success: true, | |
| 321 | - | Message: "Deployment successful", | |
| 322 | - | Domain: targetDomain, | |
| 323 | - | ContainerID: containerID, | |
| 324 | - | ContainerName: strings.TrimPrefix(containerName, "/"), | |
| 325 | - | }) | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | func sendDeployErrorResponse(c echo.Context, message string, err error) error { | |
| 329 | - | // The error becomes the message | |
| 330 | - | errorMsg := fmt.Sprintf("%s: %s", message, err.Error()) | |
| 331 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 332 | - | Success: false, | |
| 333 | - | Message: errorMsg, | |
| 334 | - | StatusCode: http.StatusInternalServerError, | |
| 335 | - | }) | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | // func waitForDeployment(domain string, containerID string) error { | |
| 339 | - | // client := &http.Client{Timeout: 10 * time.Second} | |
| 340 | - | // maxRetries := 10 | |
| 341 | - | // retryInterval := time.Second | |
| 342 | - | ||
| 343 | - | // var shortContainerID string | |
| 344 | - | // if containerID != "" { | |
| 345 | - | // shortContainerID = containerID[:12] | |
| 346 | - | // } | |
| 347 | - | ||
| 348 | - | // log.Info("Waiting for deployment to be reachable", | |
| 349 | - | // "domain", domain, | |
| 350 | - | // "container_id", shortContainerID) | |
| 351 | - | ||
| 352 | - | // for i := 0; i < maxRetries; i++ { | |
| 353 | - | // resp, err := client.Get(domain) | |
| 354 | - | // if err == nil { | |
| 355 | - | // defer resp.Body.Close() | |
| 356 | - | // if resp.StatusCode == http.StatusOK { | |
| 357 | - | // log.Info("Deployment successful", | |
| 358 | - | // "domain", domain, | |
| 359 | - | // "container_id", shortContainerID) | |
| 360 | - | // return nil | |
| 361 | - | // } | |
| 362 | - | // body, _ := io.ReadAll(resp.Body) | |
| 363 | - | // if strings.Contains(string(body), "failed to create container:") { | |
| 364 | - | // return fmt.Errorf("deployment failed: %s", string(body)) | |
| 365 | - | // } | |
| 366 | - | // } | |
| 367 | - | // log.Warn("Deployment not ready yet, retrying", | |
| 368 | - | // "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) | |
| 369 | - | // time.Sleep(retryInterval) | |
| 370 | - | // } | |
| 371 | - | ||
| 372 | - | // return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 373 | - | // } | |
D internal/httpserve/handlers/endpoint_deploy_check.go +0 -75
| @@ -1,75 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/http" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/bnema/gordon/pkg/docker" | |
| 10 | - | "github.com/charmbracelet/log" | |
| 11 | - | "github.com/labstack/echo/v4" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | func CheckDeployConflict(c echo.Context, a *server.App) error { | |
| 15 | - | targetDomain := c.QueryParam("domain") | |
| 16 | - | if targetDomain == "" { | |
| 17 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 18 | - | Success: false, | |
| 19 | - | Message: "Target domain is required", | |
| 20 | - | StatusCode: http.StatusBadRequest, | |
| 21 | - | }) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // Extract container name from the target domain | |
| 25 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 26 | - | containerName := strings.Split(cleanDomain, ".")[0] | |
| 27 | - | ||
| 28 | - | if containerName == "" { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 30 | - | Success: false, | |
| 31 | - | Message: "Invalid target domain format", | |
| 32 | - | StatusCode: http.StatusBadRequest, | |
| 33 | - | }) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // Check if container exists | |
| 37 | - | containerID := docker.GetContainerIDByName(containerName) | |
| 38 | - | if containerID == "" { | |
| 39 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 40 | - | Success: true, | |
| 41 | - | Message: "No conflicts found", | |
| 42 | - | StatusCode: http.StatusOK, | |
| 43 | - | }) | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // Get container details | |
| 47 | - | state, err := docker.GetContainerState(containerID) | |
| 48 | - | if err != nil { | |
| 49 | - | log.Error("Failed to get container state", "error", err) | |
| 50 | - | state = "unknown" | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | ports, err := docker.GetContainerPorts(containerID) | |
| 54 | - | if err != nil { | |
| 55 | - | log.Error("Failed to get container ports", "error", err) | |
| 56 | - | ports = "unknown" | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | uptime, err := docker.GetContainerUptime(containerID) | |
| 60 | - | if err != nil { | |
| 61 | - | log.Error("Failed to get container uptime", "error", err) | |
| 62 | - | uptime = "unknown" | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 66 | - | Success: false, | |
| 67 | - | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | |
| 68 | - | ContainerID: containerID, | |
| 69 | - | ContainerName: containerName, | |
| 70 | - | State: state, | |
| 71 | - | RunningTime: uptime, | |
| 72 | - | Ports: ports, | |
| 73 | - | StatusCode: http.StatusConflict, | |
| 74 | - | }) | |
| 75 | - | } | |
D internal/httpserve/handlers/endpoint_helpers.go +0 -445
| @@ -1,445 +0,0 @@ | |||
| 1 | - | // endpoint_helpers.go | |
| 2 | - | package handlers | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "bytes" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "os" | |
| 11 | - | "regexp" | |
| 12 | - | "sync" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/gordon/internal/common" | |
| 16 | - | "github.com/bnema/gordon/internal/server" | |
| 17 | - | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 18 | - | "github.com/bnema/gordon/pkg/docker" | |
| 19 | - | "github.com/bnema/gordon/pkg/store" | |
| 20 | - | "github.com/charmbracelet/log" | |
| 21 | - | "github.com/labstack/echo/v4" | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | type ChunkMetadata common.ChunkMetadata | |
| 25 | - | ||
| 26 | - | type ChunkStore struct { | |
| 27 | - | mu sync.Mutex | |
| 28 | - | chunks map[string]map[int][]byte | |
| 29 | - | metadata map[string]*ChunkMetadata | |
| 30 | - | started map[string]time.Time | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | type InfoResponse struct { | |
| 34 | - | Uptime string `json:"uptime"` | |
| 35 | - | Version string `json:"version"` | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | type DeployResponse = common.DeployResponse | |
| 39 | - | type PushResponse = common.PushResponse | |
| 40 | - | type StopResponse = common.StopResponse | |
| 41 | - | type RemoveResponse = common.RemoveResponse | |
| 42 | - | ||
| 43 | - | var chunkStore = &ChunkStore{ | |
| 44 | - | chunks: make(map[string]map[int][]byte), | |
| 45 | - | metadata: make(map[string]*ChunkMetadata), | |
| 46 | - | started: make(map[string]time.Time), | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | const ( | |
| 50 | - | cleanupInterval = 5 * time.Minute | |
| 51 | - | transferTimeout = 30 * time.Minute | |
| 52 | - | ) | |
| 53 | - | ||
| 54 | - | func init() { | |
| 55 | - | // Start a cleanup goroutine | |
| 56 | - | go func() { | |
| 57 | - | ticker := time.NewTicker(5 * time.Minute) | |
| 58 | - | for range ticker.C { | |
| 59 | - | cleanupOldTransfers() | |
| 60 | - | } | |
| 61 | - | }() | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Populate fills the InfoResponse struct with data from the App | |
| 65 | - | func (info *InfoResponse) Populate(a *server.App) { | |
| 66 | - | info.Uptime = a.GetUptime() | |
| 67 | - | info.Version = a.GetVersionstring() | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error { | |
| 71 | - | err := c.JSON(statusCode, response) | |
| 72 | - | if err != nil { | |
| 73 | - | log.Error("Failed to send JSON response", | |
| 74 | - | "error", err, | |
| 75 | - | "statusCode", statusCode) | |
| 76 | - | } | |
| 77 | - | return err | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // GetInfos handles GET requests on /api/ping endpoint | |
| 81 | - | func GetInfos(c echo.Context, a *server.App) error { | |
| 82 | - | payload := new(common.RequestPayload) | |
| 83 | - | if err := c.Bind(payload); err != nil { | |
| 84 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error()) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | if payload.Type != "ping" { | |
| 88 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type") | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | pingPayload, ok := payload.Payload.(common.PingPayload) | |
| 92 | - | if !ok { | |
| 93 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure") | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | if pingPayload.Message != "ping" { | |
| 97 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message") | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | info := &InfoResponse{} | |
| 101 | - | info.Populate(a) | |
| 102 | - | ||
| 103 | - | return sendJSONResponse(c, http.StatusOK, info) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // validateAndPrepareDeployPayload validates and prepares the deploy payload | |
| 107 | - | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | |
| 108 | - | payload := &common.DeployPayload{ | |
| 109 | - | Port: c.Request().Header.Get("X-Ports"), | |
| 110 | - | ImageName: c.Request().Header.Get("X-Image-Name"), | |
| 111 | - | TargetDomain: c.Request().Header.Get("X-Target-Domain"), | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | if payload.ImageName == "" { | |
| 115 | - | return nil, errors.New("invalid image name") | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | payload.Port = normalizePort(payload.Port) | |
| 119 | - | payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain) | |
| 120 | - | ||
| 121 | - | return payload, nil | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // ssaveAndImportImage saves and imports an image from the request body | |
| 125 | - | func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (string, error) { | |
| 126 | - | var imageName, imageID string | |
| 127 | - | ||
| 128 | - | // Determine payload type and extract image name | |
| 129 | - | switch p := payload.(type) { | |
| 130 | - | case *common.PushPayload: | |
| 131 | - | imageName = p.ImageName | |
| 132 | - | defer func() { p.ImageID = imageID }() | |
| 133 | - | case *common.DeployPayload: | |
| 134 | - | imageName = p.ImageName | |
| 135 | - | defer func() { p.ImageID = imageID }() | |
| 136 | - | default: | |
| 137 | - | return "", fmt.Errorf("unsupported payload type: %T", payload) | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | imageReader := c.Request().Body | |
| 141 | - | defer imageReader.Close() | |
| 142 | - | ||
| 143 | - | imageFileName := sanitizeImageFileName(imageName) | |
| 144 | - | ||
| 145 | - | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader) | |
| 146 | - | if err != nil { | |
| 147 | - | return "", fmt.Errorf("failed to save image: %v", err) | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | imageID, err = docker.ImportImageToEngine(imagePath) | |
| 151 | - | if err != nil { | |
| 152 | - | if removeErr := store.RemoveFromStorage(imagePath); removeErr != nil { | |
| 153 | - | log.Error("Failed to remove temporary image file after import failure", | |
| 154 | - | "error", removeErr, | |
| 155 | - | "path", imagePath) | |
| 156 | - | } | |
| 157 | - | return "", fmt.Errorf("failed to import image: %v", err) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | if imageID == "" { | |
| 161 | - | if removeErr := store.RemoveFromStorage(imagePath); removeErr != nil { | |
| 162 | - | log.Error("Failed to remove temporary image file after empty ID", | |
| 163 | - | "error", removeErr, | |
| 164 | - | "path", imagePath) | |
| 165 | - | } | |
| 166 | - | return "", errors.New("imported image ID is empty") | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | err = store.RemoveFromStorage(imagePath) | |
| 170 | - | if err != nil { | |
| 171 | - | log.Error("Failed to remove temporary image file after successful import", | |
| 172 | - | "error", err, | |
| 173 | - | "path", imagePath) | |
| 174 | - | return "", fmt.Errorf("failed to remove temporary image file: %v", err) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | return imagePath, nil | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | // createAndStartContainer creates a container from the image, starts it and returns the container ID | |
| 181 | - | func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) { | |
| 182 | - | log.Info("Starting container creation process", | |
| 183 | - | "imageName", payload.ImageName, | |
| 184 | - | "imageID", payload.ImageID, | |
| 185 | - | "port", payload.Port) | |
| 186 | - | ||
| 187 | - | params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID) | |
| 188 | - | if err != nil { | |
| 189 | - | return "", "", fmt.Errorf("failed to create command parameters: %v", err) | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | log.Debug("Container parameters prepared", "params", params) | |
| 193 | - | ||
| 194 | - | containerID, err := docker.CreateContainer(params) | |
| 195 | - | if err != nil { | |
| 196 | - | return "", "", fmt.Errorf("failed to create container: %v", err) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | log.Info("Container created successfully", "containerID", containerID) | |
| 200 | - | ||
| 201 | - | containerName, err := docker.GetContainerName(containerID) | |
| 202 | - | if err != nil { | |
| 203 | - | return "", "", fmt.Errorf("failed to get container name: %v", err) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | log.Info("Starting container", | |
| 207 | - | "containerID", containerID, | |
| 208 | - | "name", containerName) | |
| 209 | - | ||
| 210 | - | // Use regular StartContainer instead of StartContainerWithContext | |
| 211 | - | err = docker.StartContainer(containerID) | |
| 212 | - | if err != nil { | |
| 213 | - | // Get container logs if start failed | |
| 214 | - | logs, logErr := docker.GetContainerLogs(containerID) | |
| 215 | - | if logErr == nil { | |
| 216 | - | log.Error("Container start failed - container logs", | |
| 217 | - | "logs", logs, | |
| 218 | - | "containerID", containerID) | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | // Cleanup the failed container | |
| 222 | - | cleanupErr := docker.RemoveContainer(containerID) | |
| 223 | - | if cleanupErr != nil { | |
| 224 | - | log.Error("Failed to cleanup container after start failure", | |
| 225 | - | "containerID", containerID, | |
| 226 | - | "error", cleanupErr) | |
| 227 | - | } | |
| 228 | - | return "", "", fmt.Errorf("failed to start container: %v", err) | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | log.Info("Container started successfully", | |
| 232 | - | "containerID", containerID, | |
| 233 | - | "name", containerName) | |
| 234 | - | ||
| 235 | - | return containerID, containerName, nil | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | // Helpers for sanitizing and normalizing input | |
| 239 | - | func sanitizeImageFileName(imageName string) string { | |
| 240 | - | imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "") | |
| 241 | - | imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "") | |
| 242 | - | return imageName + ".tar" | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | // normalizePort ensures that the port is in the format of "port/protocol" | |
| 246 | - | func normalizePort(port string) string { | |
| 247 | - | if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) { | |
| 248 | - | return port + "/tcp" | |
| 249 | - | } | |
| 250 | - | return port | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | // normalizeTargetDomain ensures that the target domain is in the format of "https://domain" or "http://domain" | |
| 254 | - | func normalizeTargetDomain(domain string) string { | |
| 255 | - | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | |
| 256 | - | return "https://" + domain | |
| 257 | - | } | |
| 258 | - | return domain | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // generateShortID generates a short ID from the full image ID | |
| 262 | - | func generateShortID(imageID string) string { | |
| 263 | - | shortID := imageID[7:] | |
| 264 | - | if len(shortID) > 12 { | |
| 265 | - | shortID = shortID[:12] | |
| 266 | - | } | |
| 267 | - | return shortID | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // generateCreateContainerURL generates the URL for creating a container | |
| 271 | - | func generateCreateContainerURL(a *server.App, shortID string) string { | |
| 272 | - | return fmt.Sprintf("%s://%s%s/cc/%s", | |
| 273 | - | a.Config.Http.Protocol(), | |
| 274 | - | a.Config.Http.FullDomain(), | |
| 275 | - | a.Config.Admin.Path, | |
| 276 | - | shortID) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | // generateContainerLogsURL generates the URL for viewing container logs | |
| 280 | - | func isTransferComplete(transferID string) bool { | |
| 281 | - | chunkStore.mu.Lock() | |
| 282 | - | defer chunkStore.mu.Unlock() | |
| 283 | - | ||
| 284 | - | metadata := chunkStore.metadata[transferID] | |
| 285 | - | if metadata == nil { | |
| 286 | - | log.Warn("No metadata found for transfer", "transferID", transferID) | |
| 287 | - | return false | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | chunksReceived := len(chunkStore.chunks[transferID]) | |
| 291 | - | isComplete := chunksReceived == metadata.TotalChunks | |
| 292 | - | ||
| 293 | - | log.Debug("Transfer completion check", | |
| 294 | - | "transferID", transferID, | |
| 295 | - | "chunksReceived", chunksReceived, | |
| 296 | - | "totalExpected", metadata.TotalChunks, | |
| 297 | - | "isComplete", isComplete) | |
| 298 | - | ||
| 299 | - | return isComplete | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | // Cleanup helpers for old transfers | |
| 303 | - | func cleanupTransfer(transferID string) { | |
| 304 | - | chunkStore.mu.Lock() | |
| 305 | - | defer chunkStore.mu.Unlock() | |
| 306 | - | ||
| 307 | - | delete(chunkStore.chunks, transferID) | |
| 308 | - | delete(chunkStore.metadata, transferID) | |
| 309 | - | delete(chunkStore.started, transferID) | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | // cleanupOldTransfers periodically checks for and cleans up old transfers | |
| 313 | - | func cleanupOldTransfers() { | |
| 314 | - | ticker := time.NewTicker(cleanupInterval) | |
| 315 | - | defer ticker.Stop() // Good practice to clean up the ticker | |
| 316 | - | ||
| 317 | - | for range ticker.C { | |
| 318 | - | now := time.Now() | |
| 319 | - | chunkStore.mu.Lock() | |
| 320 | - | for id, startTime := range chunkStore.started { | |
| 321 | - | if now.Sub(startTime) > transferTimeout { | |
| 322 | - | log.Debug("Cleaning up stale transfer", | |
| 323 | - | "transferID", id, | |
| 324 | - | "age", now.Sub(startTime)) | |
| 325 | - | cleanupTransfer(id) | |
| 326 | - | } | |
| 327 | - | } | |
| 328 | - | chunkStore.mu.Unlock() | |
| 329 | - | } | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | // extractContainerID extracts the container ID from an error message | |
| 333 | - | func extractContainerID(errorMessage string) string { | |
| 334 | - | re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`) | |
| 335 | - | match := re.FindStringSubmatch(errorMessage) | |
| 336 | - | if len(match) > 1 { | |
| 337 | - | return match[1] | |
| 338 | - | } | |
| 339 | - | return "" | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | // storeIDMapping stores a mapping between a short ID and a full ID | |
| 343 | - | func storeIDMapping(shortID, fullID string) { | |
| 344 | - | safelyInteractWithIDMap(Update, shortID, fullID) | |
| 345 | - | } | |
| 346 | - | ||
| 347 | - | func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) error { | |
| 348 | - | // Validate input parameters | |
| 349 | - | if file == nil { | |
| 350 | - | return errors.New("nil file handle provided") | |
| 351 | - | } | |
| 352 | - | if len(chunks) == 0 { | |
| 353 | - | return errors.New("no chunks provided") | |
| 354 | - | } | |
| 355 | - | if totalChunks <= 0 { | |
| 356 | - | return fmt.Errorf("invalid total chunks count: %d", totalChunks) | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | // Verify we have all chunks before starting | |
| 360 | - | if len(chunks) != totalChunks { | |
| 361 | - | return fmt.Errorf("chunk count mismatch: got %d, expected %d", len(chunks), totalChunks) | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | // Calculate total expected size | |
| 365 | - | var totalSize int64 | |
| 366 | - | for _, chunk := range chunks { | |
| 367 | - | totalSize += int64(len(chunk)) | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | log.Debug("Starting chunk assembly", | |
| 371 | - | "totalChunks", totalChunks, | |
| 372 | - | "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024))) | |
| 373 | - | ||
| 374 | - | // First, seek to the beginning of the file | |
| 375 | - | if _, err := file.Seek(0, 0); err != nil { | |
| 376 | - | return fmt.Errorf("failed to seek to beginning: %w", err) | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | // Create a temporary buffer with capacity hint | |
| 380 | - | tempBuffer := bytes.NewBuffer(make([]byte, 0, totalSize)) | |
| 381 | - | ||
| 382 | - | // Write chunks in order with validation | |
| 383 | - | for i := 0; i < totalChunks; i++ { | |
| 384 | - | chunk, exists := chunks[i] | |
| 385 | - | if !exists { | |
| 386 | - | return fmt.Errorf("missing chunk %d", i) | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | if len(chunk) == 0 { | |
| 390 | - | return fmt.Errorf("empty chunk found at index %d", i) | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | n, err := tempBuffer.Write(chunk) | |
| 394 | - | if err != nil { | |
| 395 | - | return fmt.Errorf("failed to write chunk %d to buffer: %w", i, err) | |
| 396 | - | } | |
| 397 | - | if n != len(chunk) { | |
| 398 | - | return fmt.Errorf("incomplete chunk write: wrote %d of %d bytes for chunk %d", | |
| 399 | - | n, len(chunk), i) | |
| 400 | - | } | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | // Verify buffer size matches expected total | |
| 404 | - | if int64(tempBuffer.Len()) != totalSize { | |
| 405 | - | return fmt.Errorf("buffer size mismatch: got %d, expected %d", | |
| 406 | - | tempBuffer.Len(), totalSize) | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | // Write the complete buffer to file | |
| 410 | - | written, err := io.Copy(file, tempBuffer) | |
| 411 | - | if err != nil { | |
| 412 | - | return fmt.Errorf("failed to write buffer to file: %w", err) | |
| 413 | - | } | |
| 414 | - | if written != totalSize { | |
| 415 | - | return fmt.Errorf("file write size mismatch: wrote %d, expected %d", | |
| 416 | - | written, totalSize) | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | // Ensure all data is written to disk | |
| 420 | - | if err := file.Sync(); err != nil { | |
| 421 | - | return fmt.Errorf("failed to sync file: %w", err) | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | // Reset file pointer to beginning | |
| 425 | - | if _, err := file.Seek(0, 0); err != nil { | |
| 426 | - | return fmt.Errorf("failed to reset file pointer: %w", err) | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | log.Debug("Chunk assembly completed successfully", | |
| 430 | - | "totalBytesWritten", written, | |
| 431 | - | "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024))) | |
| 432 | - | ||
| 433 | - | return nil | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) { | |
| 437 | - | chunkStore.mu.Lock() | |
| 438 | - | defer chunkStore.mu.Unlock() | |
| 439 | - | ||
| 440 | - | metadata := chunkStore.metadata[transferID] | |
| 441 | - | if metadata == nil { | |
| 442 | - | return nil, nil, fmt.Errorf("transfer metadata not found") | |
| 443 | - | } | |
| 444 | - | return metadata, chunkStore.chunks[transferID], nil | |
| 445 | - | } | |
D internal/httpserve/handlers/endpoint_push.go +0 -249
| @@ -1,249 +0,0 @@ | |||
| 1 | - | // endpoint_push.go | |
| 2 | - | package handlers | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "os" | |
| 11 | - | "path/filepath" | |
| 12 | - | "strings" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/common" | |
| 15 | - | "github.com/bnema/gordon/internal/server" | |
| 16 | - | "github.com/bnema/gordon/pkg/docker" | |
| 17 | - | "github.com/charmbracelet/log" | |
| 18 | - | "github.com/labstack/echo/v4" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | // PostPush handles the image push request | |
| 22 | - | func PostPush(c echo.Context, a *server.App) error { | |
| 23 | - | requestID := c.Response().Header().Get(echo.HeaderXRequestID) | |
| 24 | - | log.Info("PostPush started", | |
| 25 | - | "requestID", requestID, | |
| 26 | - | "path", c.Request().URL.Path) | |
| 27 | - | payload, err := validateAndPreparePushPayload(c) | |
| 28 | - | if err != nil { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 30 | - | Success: false, | |
| 31 | - | Message: err.Error(), | |
| 32 | - | }) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | _, err = saveAndImportImage(c, a, payload) | |
| 36 | - | if err != nil { | |
| 37 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 38 | - | Success: false, | |
| 39 | - | Message: err.Error(), | |
| 40 | - | }) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | shortID := generateShortID(payload.ImageID) | |
| 44 | - | storeIDMapping(shortID, payload.ImageID) | |
| 45 | - | ||
| 46 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 47 | - | ||
| 48 | - | return sendJSONResponse(c, http.StatusOK, common.PushResponse{ | |
| 49 | - | Success: true, | |
| 50 | - | Message: "Image pushed and imported successfully", | |
| 51 | - | CreateContainerURL: createContainerURL, | |
| 52 | - | }) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // PostPushChunked handles chunked (large) image pushes | |
| 56 | - | func PostPushChunked(c echo.Context, a *server.App) error { | |
| 57 | - | return handleChunkedPush(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func handleChunkedPush(c echo.Context, a *server.App, metadataStr string) error { | |
| 61 | - | var metadata ChunkMetadata | |
| 62 | - | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |
| 63 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 64 | - | Success: false, | |
| 65 | - | Message: "Invalid chunk metadata", | |
| 66 | - | }) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Initialize chunk storage first | |
| 70 | - | chunkStore.mu.Lock() | |
| 71 | - | if chunkStore.chunks[metadata.TransferID] == nil { | |
| 72 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 73 | - | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 74 | - | } | |
| 75 | - | chunkStore.mu.Unlock() | |
| 76 | - | ||
| 77 | - | chunkData, err := io.ReadAll(c.Request().Body) | |
| 78 | - | if err != nil { | |
| 79 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 80 | - | Success: false, | |
| 81 | - | Message: "Failed to read chunk data", | |
| 82 | - | }) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // Validate chunk data | |
| 86 | - | if err := validatePushChunk(chunkData, &metadata); err != nil { | |
| 87 | - | log.Error("Chunk validation failed", | |
| 88 | - | "error", err, | |
| 89 | - | "chunkNumber", metadata.ChunkNumber, | |
| 90 | - | "transferID", metadata.TransferID) | |
| 91 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 92 | - | Success: false, | |
| 93 | - | Message: fmt.Sprintf("Invalid chunk: %v", err), | |
| 94 | - | }) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // Store chunk data | |
| 98 | - | chunkStore.mu.Lock() | |
| 99 | - | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | |
| 100 | - | chunkStore.mu.Unlock() | |
| 101 | - | ||
| 102 | - | // Process complete transfer if this was the last chunk | |
| 103 | - | if isTransferComplete(metadata.TransferID) { | |
| 104 | - | return processCompleteChunkedPushTransfer(c, a, metadata.TransferID) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // Acknowledge intermediate chunk | |
| 108 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | |
| 109 | - | Success: true, | |
| 110 | - | Message: fmt.Sprintf("Chunk %d/%d received successfully", | |
| 111 | - | metadata.ChunkNumber+1, metadata.TotalChunks), | |
| 112 | - | }) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func processCompleteChunkedPushTransfer(c echo.Context, a *server.App, transferID string) error { | |
| 116 | - | log.Info("Starting complete push transfer processing", | |
| 117 | - | "transferID", transferID) | |
| 118 | - | ||
| 119 | - | // Get transfer data | |
| 120 | - | metadata, chunks, err := getTransferData(transferID) | |
| 121 | - | if err != nil { | |
| 122 | - | return sendPushErrorResponse(c, "Failed to get transfer data", err) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Validate we have all chunks | |
| 126 | - | if len(chunks) != metadata.TotalChunks { | |
| 127 | - | return sendPushErrorResponse(c, "Incomplete transfer", | |
| 128 | - | fmt.Errorf("expected %d chunks, got %d", metadata.TotalChunks, len(chunks))) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // Create temporary directory with specific naming | |
| 132 | - | tmpDir, err := os.MkdirTemp("", "docker-push-*") | |
| 133 | - | if err != nil { | |
| 134 | - | return sendPushErrorResponse(c, "Failed to create temp directory", err) | |
| 135 | - | } | |
| 136 | - | defer os.RemoveAll(tmpDir) | |
| 137 | - | ||
| 138 | - | // Create temporary file with .tar extension | |
| 139 | - | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |
| 140 | - | if err != nil { | |
| 141 | - | return sendPushErrorResponse(c, "Failed to create temp file", err) | |
| 142 | - | } | |
| 143 | - | defer tmpFile.Close() | |
| 144 | - | ||
| 145 | - | // Write chunks in order to ensure proper tar assembly | |
| 146 | - | for i := 0; i < metadata.TotalChunks; i++ { | |
| 147 | - | chunk, exists := chunks[i] | |
| 148 | - | if !exists { | |
| 149 | - | return sendPushErrorResponse(c, "Missing chunk", | |
| 150 | - | fmt.Errorf("chunk %d not found", i)) | |
| 151 | - | } | |
| 152 | - | if _, err := tmpFile.Write(chunk); err != nil { | |
| 153 | - | return sendPushErrorResponse(c, "Failed to write chunk", | |
| 154 | - | fmt.Errorf("error writing chunk %d: %v", i, err)) | |
| 155 | - | } | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | // Ensure all data is written to disk | |
| 159 | - | if err := tmpFile.Sync(); err != nil { | |
| 160 | - | return sendPushErrorResponse(c, "Failed to sync file", err) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // Reset file pointer to beginning | |
| 164 | - | if _, err := tmpFile.Seek(0, 0); err != nil { | |
| 165 | - | return sendPushErrorResponse(c, "Failed to reset file pointer", err) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Verify file size | |
| 169 | - | fileInfo, err := tmpFile.Stat() | |
| 170 | - | if err != nil { | |
| 171 | - | return sendPushErrorResponse(c, "Failed to get file info", err) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | log.Info("Image file created", | |
| 175 | - | "size", fmt.Sprintf("%.2f MB", float64(fileInfo.Size())/(1024*1024)), | |
| 176 | - | "path", tmpFile.Name()) | |
| 177 | - | ||
| 178 | - | imageName := c.Request().Header.Get("X-Image-Name") | |
| 179 | - | if imageName == "" { | |
| 180 | - | return sendPushErrorResponse(c, "Missing image name", fmt.Errorf("X-Image-Name header is required")) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | log.Info("Importing image", | |
| 184 | - | "path", tmpFile.Name(), | |
| 185 | - | "imageName", imageName) | |
| 186 | - | ||
| 187 | - | // Import the image | |
| 188 | - | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |
| 189 | - | if err != nil { | |
| 190 | - | if strings.Contains(err.Error(), "platform compatibility check failed") { | |
| 191 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 192 | - | Success: false, | |
| 193 | - | Message: fmt.Sprintf("Image platform not compatible with server: %v", err), | |
| 194 | - | }) | |
| 195 | - | } | |
| 196 | - | return sendPushErrorResponse(c, "Failed to import image", err) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // Cleanup and prepare response | |
| 200 | - | cleanupTransfer(transferID) | |
| 201 | - | shortID := generateShortID(imageID) | |
| 202 | - | storeIDMapping(shortID, imageID) | |
| 203 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 204 | - | ||
| 205 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | |
| 206 | - | Success: true, | |
| 207 | - | Message: "Image pushed and imported successfully", | |
| 208 | - | CreateContainerURL: createContainerURL, | |
| 209 | - | ImageID: imageID, | |
| 210 | - | }) | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | func validatePushChunk(chunk []byte, metadata *ChunkMetadata) error { | |
| 214 | - | if len(chunk) == 0 { | |
| 215 | - | return errors.New("empty chunk received") | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | if metadata == nil { | |
| 219 | - | return errors.New("missing chunk metadata") | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | if int64(len(chunk)) != metadata.ChunkSize { | |
| 223 | - | return fmt.Errorf("chunk size mismatch: expected %d, got %d", | |
| 224 | - | metadata.ChunkSize, len(chunk)) | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | return nil | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | func sendPushErrorResponse(c echo.Context, message string, err error) error { | |
| 231 | - | log.Error(message, "error", err) | |
| 232 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 233 | - | Success: false, | |
| 234 | - | Message: message, | |
| 235 | - | }) | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | // validateAndPreparePushPayload validates and prepares the push payload | |
| 239 | - | func validateAndPreparePushPayload(c echo.Context) (*common.PushPayload, error) { | |
| 240 | - | payload := &common.PushPayload{ | |
| 241 | - | ImageName: c.Request().Header.Get("X-Image-Name"), | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | if payload.ImageName == "" { | |
| 245 | - | return nil, errors.New("invalid image name") | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | return payload, nil | |
| 249 | - | } | |
D internal/httpserve/handlers/endpoint_remove.go +0 -44
| @@ -1,44 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/common" | |
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/bnema/gordon/pkg/docker" | |
| 9 | - | "github.com/labstack/echo/v4" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // PostContainerRemove handles the container remove request | |
| 13 | - | func PostContainerRemove(c echo.Context, a *server.App) error { | |
| 14 | - | payload := new(common.RequestPayload) | |
| 15 | - | if err := c.Bind(payload); err != nil { | |
| 16 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error()) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | if payload.Type != "remove" { | |
| 20 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type") | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | removePayload, ok := payload.Payload.(common.RemovePayload) | |
| 24 | - | if !ok { | |
| 25 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure") | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | if removePayload.ContainerID == "" { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID") | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | err := docker.RemoveContainer(removePayload.ContainerID) | |
| 33 | - | if err != nil { | |
| 34 | - | return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{ | |
| 35 | - | Success: false, | |
| 36 | - | Message: err.Error(), | |
| 37 | - | }) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return sendJSONResponse(c, http.StatusOK, RemoveResponse{ | |
| 41 | - | Success: true, | |
| 42 | - | Message: "Container removed successfully", | |
| 43 | - | }) | |
| 44 | - | } | |
D internal/httpserve/handlers/endpoint_stop.go +0 -44
| @@ -1,44 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/common" | |
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/bnema/gordon/pkg/docker" | |
| 9 | - | "github.com/labstack/echo/v4" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // PostContainerStop handles the container stop request | |
| 13 | - | func PostContainerStop(c echo.Context, a *server.App) error { | |
| 14 | - | payload := new(common.RequestPayload) | |
| 15 | - | if err := c.Bind(payload); err != nil { | |
| 16 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error()) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | if payload.Type != "stop" { | |
| 20 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type") | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | stopPayload, ok := payload.Payload.(common.StopPayload) | |
| 24 | - | if !ok { | |
| 25 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure") | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | if stopPayload.ContainerID == "" { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID") | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | err := docker.StopContainer(stopPayload.ContainerID) | |
| 33 | - | if err != nil { | |
| 34 | - | return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{ | |
| 35 | - | Success: false, | |
| 36 | - | Message: err.Error(), | |
| 37 | - | }) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return sendJSONResponse(c, http.StatusOK, StopResponse{ | |
| 41 | - | Success: true, | |
| 42 | - | Message: "Container stopped successfully", | |
| 43 | - | }) | |
| 44 | - | } | |
D internal/httpserve/handlers/login.go +0 -380
| @@ -1,380 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/base64" | |
| 5 | - | "encoding/json" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "net/http" | |
| 9 | - | "net/url" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/db" | |
| 13 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 14 | - | "github.com/bnema/gordon/internal/server" | |
| 15 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 16 | - | "github.com/charmbracelet/log" | |
| 17 | - | "github.com/gorilla/sessions" | |
| 18 | - | _ "github.com/joho/godotenv/autoload" | |
| 19 | - | "github.com/labstack/echo-contrib/session" | |
| 20 | - | "github.com/labstack/echo/v4" | |
| 21 | - | ) | |
| 22 | - | ||
| 23 | - | var urlToken string | |
| 24 | - | ||
| 25 | - | // ClLI : | |
| 26 | - | // compareGordonToken compares the token from the URL query parameter with the one from the config.yml | |
| 27 | - | func CompareGordonToken(c echo.Context, a *server.App) error { | |
| 28 | - | configToken, err := a.Config.GetToken() | |
| 29 | - | if err != nil { | |
| 30 | - | return err | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | if urlToken != configToken { | |
| 34 | - | return fmt.Errorf("token is empty or not valid") | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | return nil | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // RenderLoginPage renders the login.html template | |
| 41 | - | func RenderLoginPage(c echo.Context, a *server.App) error { | |
| 42 | - | urlToken = c.QueryParam("token") | |
| 43 | - | data := map[string]interface{}{ | |
| 44 | - | "Title": "Login", | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // Navigate inside the fs.FS to get the template | |
| 48 | - | path := "html/login" | |
| 49 | - | rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a) | |
| 50 | - | if err != nil { | |
| 51 | - | return err | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 55 | - | if err != nil { | |
| 56 | - | return err | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | return c.HTML(200, renderedHTML) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // StartOAuthGithub starts the Github OAuth flow. | |
| 63 | - | ||
| 64 | - | func StartOAuthGithub(c echo.Context, a *server.App) error { | |
| 65 | - | log.Info("Attempting to get an existing session.") | |
| 66 | - | sess, err := getSession(c) | |
| 67 | - | if err != nil || sess == nil || sess.Values == nil { | |
| 68 | - | log.Info("Session retrieval failed, initiating new GitHub OAuth flow.") | |
| 69 | - | return initiateGithubOAuthFlow(c, a) | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | log.Info("Attempting to assert session values.") | |
| 73 | - | sessionID, ok1 := sess.Values["sessionID"].(string) | |
| 74 | - | accountID, ok2 := sess.Values["accountID"].(string) | |
| 75 | - | authenticated, ok3 := sess.Values["authenticated"].(bool) | |
| 76 | - | ||
| 77 | - | if !ok1 || !ok2 || !ok3 || !authenticated { | |
| 78 | - | log.Info("Session assertion failed, or user not authenticated. Initiating new OAuth flow.") | |
| 79 | - | return initiateGithubOAuthFlow(c, a) | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | log.Info("Checking if session exists in DB.") | |
| 83 | - | sessionExists, err := queries.CheckDBSessionExists(a, sessionID) | |
| 84 | - | if err != nil { | |
| 85 | - | log.Info("Error checking if session exists", "error", err) | |
| 86 | - | return fmt.Errorf("could not check if session exists: %w", err) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | if !sessionExists { | |
| 90 | - | log.Info("Session does not exist in DB, initiating new OAuth flow.") | |
| 91 | - | return initiateGithubOAuthFlow(c, a) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | log.Info("Getting the expiration time of the session.") | |
| 95 | - | currentTime := time.Now() | |
| 96 | - | sessionExpiration, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime) | |
| 97 | - | if err != nil { | |
| 98 | - | log.Info("Error getting session expiration", "error", err) | |
| 99 | - | return fmt.Errorf("could not get session expiration: %w", err) | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | if currentTime.After(sessionExpiration) { | |
| 103 | - | log.Info("Current time is after session expiration, initiating new OAuth flow.") | |
| 104 | - | return initiateGithubOAuthFlow(c, a) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | log.Info("Extending session expiration time by 30 minutes.") | |
| 108 | - | newExpirationTime := currentTime.Add(30 * time.Minute) | |
| 109 | - | err = queries.ExtendSessionExpiration(a, accountID, sessionID, newExpirationTime) | |
| 110 | - | if err != nil { | |
| 111 | - | log.Info("Error extending session expiration", "error", err) | |
| 112 | - | return fmt.Errorf("could not extend session expiration: %w", err) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | log.Info("Session is valid and extended. Redirecting to admin panel.") | |
| 116 | - | return c.Redirect(http.StatusFound, a.Config.Admin.Path) | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // initiateGithubOAuthFlow starts a new GitHub OAuth flow | |
| 120 | - | func initiateGithubOAuthFlow(c echo.Context, a *server.App) error { | |
| 121 | - | clientID := "" // IDK why it needs that even empty, but it does | |
| 122 | - | redirectDomain := a.GenerateOauthCallbackURL() | |
| 123 | - | encodedState := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain)) | |
| 124 | - | ||
| 125 | - | // Redirect to GitHub's OAuth endpoint to grab the OAuth access | |
| 126 | - | proxyURL := a.Config.Build.ProxyURL | |
| 127 | - | oauthURL := fmt.Sprintf( | |
| 128 | - | "%s/github/authorize?client_id=%s&redirect_uri=%s&state=%s", | |
| 129 | - | proxyURL, | |
| 130 | - | clientID, | |
| 131 | - | redirectDomain, | |
| 132 | - | encodedState, | |
| 133 | - | ) | |
| 134 | - | return c.Redirect(http.StatusFound, oauthURL) | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | type Sessions struct { | |
| 138 | - | *db.Sessions | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | // OAuthCallback handles the callback response from Github OAuth | |
| 142 | - | func OAuthCallback(c echo.Context, a *server.App) error { | |
| 143 | - | redirectPath := a.Config.Admin.Path | |
| 144 | - | ||
| 145 | - | accessToken, encodedState, err := parseQueryParams(c) | |
| 146 | - | if err != nil { | |
| 147 | - | return c.String(http.StatusBadRequest, err.Error()) | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // update the struct with the new access token | |
| 151 | - | a.DBTables.Sessions.AccessToken = accessToken | |
| 152 | - | ||
| 153 | - | // Compare the state parameter with the redirect domain | |
| 154 | - | redirectDomain := a.GenerateOauthCallbackURL() | |
| 155 | - | encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain)) | |
| 156 | - | if encodedState != encodedRedirectDomain { | |
| 157 | - | return c.String(http.StatusBadRequest, "invalid state parameter") | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | sess, err := getSession(c) | |
| 161 | - | if err != nil { | |
| 162 | - | return err | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | browserInfo := c.Request().UserAgent() | |
| 166 | - | userInfo, err := githubGetUserDetails(c) | |
| 167 | - | if err != nil { | |
| 168 | - | return err | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | _, err = handleUser(c, a, accessToken, browserInfo, userInfo) | |
| 172 | - | if err != nil { | |
| 173 | - | return err | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | err = setSessionValues(c, sess, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID) | |
| 177 | - | if err != nil { | |
| 178 | - | return err | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | return c.Redirect(http.StatusFound, redirectPath) | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | // parseQueryParams parses the query parameters from the callback URL | |
| 185 | - | func parseQueryParams(c echo.Context) (string, string, error) { | |
| 186 | - | accessToken := c.QueryParam("access_token") | |
| 187 | - | encodedState := c.QueryParam("state") | |
| 188 | - | ||
| 189 | - | _, err := base64.StdEncoding.DecodeString(encodedState) | |
| 190 | - | if err != nil { | |
| 191 | - | return "", "", fmt.Errorf("invalid state parameter") | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | return accessToken, encodedState, nil | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | // getSession gets the session from the context | |
| 198 | - | func getSession(c echo.Context) (*sessions.Session, error) { | |
| 199 | - | sess, err := session.Get("session", c) | |
| 200 | - | if err != nil { | |
| 201 | - | return nil, echo.NewHTTPError(http.StatusInternalServerError, "Could not get session") | |
| 202 | - | } | |
| 203 | - | return sess, nil | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | // handleUser handles the user creation or update | |
| 207 | - | func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | |
| 208 | - | userExists, err := queries.CheckDBUserExists(a) | |
| 209 | - | if err != nil { | |
| 210 | - | return nil, fmt.Errorf("could not check if user exists: %w", err) | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | if !userExists { | |
| 214 | - | // if it is a new user creation we compare the gordon token | |
| 215 | - | err := CompareGordonToken(c, a) | |
| 216 | - | if err != nil { | |
| 217 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is empty or not valid") | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | return createUser(a, accessToken, browserInfo, userInfo) | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | if userExists { | |
| 224 | - | // if it is an existing user we compare the login and email to see if it is the same user | |
| 225 | - | isGoodUser, err := queries.CheckDBUserIsGood(a, userInfo) | |
| 226 | - | if err != nil { | |
| 227 | - | return nil, fmt.Errorf("could not check if user is good: %w", err) | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | if !isGoodUser { | |
| 231 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user") | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | if isGoodUser { | |
| 235 | - | // Update the user | |
| 236 | - | updatedUser, err := updateUser(a, accessToken, browserInfo, userInfo) | |
| 237 | - | if err != nil { | |
| 238 | - | return nil, fmt.Errorf("could not update user: %w", err) | |
| 239 | - | } | |
| 240 | - | return updatedUser, nil | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | return nil, fmt.Errorf("could not handle user") | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | // createUser creates a new user in the database | |
| 248 | - | func createUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | |
| 249 | - | err := queries.CreateUser(a, accessToken, browserInfo, userInfo) | |
| 250 | - | if err != nil { | |
| 251 | - | return nil, fmt.Errorf("could not create user: %w", err) | |
| 252 | - | } | |
| 253 | - | return &a.DBTables.User, nil | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | // updateUser updates an existing user in the database | |
| 257 | - | func updateUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | |
| 258 | - | user, err := queries.UpdateUser(a, accessToken, browserInfo, userInfo) | |
| 259 | - | if err != nil { | |
| 260 | - | return nil, fmt.Errorf("could not update user: %w", err) | |
| 261 | - | } | |
| 262 | - | return user, nil | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // setSessionValues sets the session values for the user | |
| 266 | - | func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, expires string, sessionID string) error { | |
| 267 | - | sess.Values["authenticated"] = true | |
| 268 | - | sess.Values["accountID"] = accountID | |
| 269 | - | sess.Values["expires"] = expires | |
| 270 | - | sess.Values["sessionID"] = sessionID | |
| 271 | - | ||
| 272 | - | if err := sess.Save(c.Request(), c.Response()); err != nil { | |
| 273 | - | return fmt.Errorf("could not save session: %w", err) | |
| 274 | - | } | |
| 275 | - | return nil | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | // fetchGithubAPI fetches the Github API | |
| 279 | - | func fetchGithubAPI(url string, authHeader string, result interface{}) error { | |
| 280 | - | req, err := http.NewRequest("GET", url, nil) | |
| 281 | - | if err != nil { | |
| 282 | - | return err | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | req.Header.Set("Authorization", authHeader) | |
| 286 | - | ||
| 287 | - | resp, err := http.DefaultClient.Do(req) | |
| 288 | - | if err != nil { | |
| 289 | - | return err | |
| 290 | - | } | |
| 291 | - | defer resp.Body.Close() | |
| 292 | - | ||
| 293 | - | if resp.StatusCode != http.StatusOK { | |
| 294 | - | return fmt.Errorf("got status code %d", resp.StatusCode) | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | body, err := io.ReadAll(resp.Body) | |
| 298 | - | if err != nil { | |
| 299 | - | return err | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | err = json.Unmarshal(body, &result) | |
| 303 | - | if err != nil { | |
| 304 | - | return err | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | return nil | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | // githubGetUserDetails gets the user details from the Github API using the access token | |
| 311 | - | func githubGetUserDetails(c echo.Context) (userInfo *queries.GithubUserInfo, err error) { | |
| 312 | - | accessToken := c.QueryParam("access_token") | |
| 313 | - | ||
| 314 | - | // Fetch user info | |
| 315 | - | err = fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo) | |
| 316 | - | if err != nil { | |
| 317 | - | return nil, err | |
| 318 | - | } | |
| 319 | - | // Fetch user emails | |
| 320 | - | var emailObjects []struct { | |
| 321 | - | Email string `json:"email"` | |
| 322 | - | } | |
| 323 | - | err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects) | |
| 324 | - | if err != nil { | |
| 325 | - | return nil, err | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | // Extract emails and populate the Emails field in userInfo | |
| 329 | - | for _, emailObj := range emailObjects { | |
| 330 | - | userInfo.Emails = append(userInfo.Emails, emailObj.Email) | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | return userInfo, nil | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | func DeviceCodeRequest(c echo.Context, a *server.App) error { | |
| 337 | - | proxyURL := a.Config.Build.ProxyURL | |
| 338 | - | resp, err := http.Post(proxyURL+"/github/device/code", "application/json", nil) | |
| 339 | - | if err != nil { | |
| 340 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 341 | - | } | |
| 342 | - | defer resp.Body.Close() | |
| 343 | - | ||
| 344 | - | if resp.StatusCode != http.StatusOK { | |
| 345 | - | body, _ := io.ReadAll(resp.Body) | |
| 346 | - | return c.JSON(resp.StatusCode, map[string]string{"error": string(body)}) | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | var deviceCode map[string]interface{} | |
| 350 | - | if err := json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil { | |
| 351 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"}) | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | return c.JSON(http.StatusOK, deviceCode) | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | func DeviceTokenRequest(c echo.Context, a *server.App) error { | |
| 358 | - | proxyURL := a.Config.Build.ProxyURL | |
| 359 | - | deviceCode := c.FormValue("device_code") | |
| 360 | - | ||
| 361 | - | resp, err := http.PostForm(proxyURL+"/github/device/token", url.Values{ | |
| 362 | - | "device_code": {deviceCode}, | |
| 363 | - | }) | |
| 364 | - | if err != nil { | |
| 365 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 366 | - | } | |
| 367 | - | defer resp.Body.Close() | |
| 368 | - | ||
| 369 | - | if resp.StatusCode != http.StatusOK { | |
| 370 | - | body, _ := io.ReadAll(resp.Body) | |
| 371 | - | return c.JSON(resp.StatusCode, map[string]string{"error": string(body)}) | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | var tokenResp map[string]interface{} | |
| 375 | - | if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { | |
| 376 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"}) | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | return c.JSON(http.StatusOK, tokenResp) | |
| 380 | - | } | |
D internal/httpserve/handlers/logout.go +0 -52
| @@ -1,52 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/http" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/gorilla/sessions" | |
| 11 | - | "github.com/labstack/echo/v4" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | func Logout(c echo.Context, a *server.App) error { | |
| 15 | - | // Retrieve the session | |
| 16 | - | sess, err := getSession(c) | |
| 17 | - | if err != nil || sess == nil { | |
| 18 | - | // if there is no session, redirect to the login page | |
| 19 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 20 | - | ||
| 21 | - | } | |
| 22 | - | ||
| 23 | - | // Invalidate the session in the database | |
| 24 | - | sessionID, ok := sess.Values["sessionID"].(string) | |
| 25 | - | if ok { | |
| 26 | - | err := queries.InvalidateDBSession(a, sessionID) | |
| 27 | - | if err != nil { | |
| 28 | - | return fmt.Errorf("failed to invalidate session: %w", err) | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // Delete the session | |
| 33 | - | sess.Options = &sessions.Options{ | |
| 34 | - | Path: "/", | |
| 35 | - | MaxAge: -1, | |
| 36 | - | HttpOnly: true, | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | err = sess.Save(c.Request(), c.Response()) | |
| 40 | - | if err != nil { | |
| 41 | - | return fmt.Errorf("failed to save session: %w", err) | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Drop the cookie | |
| 45 | - | cookie := new(http.Cookie) | |
| 46 | - | cookie.Name = "session" | |
| 47 | - | cookie.Value = "" | |
| 48 | - | cookie.Expires = time.Now().Add(-1 * time.Hour) | |
| 49 | - | c.SetCookie(cookie) | |
| 50 | - | ||
| 51 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 52 | - | } | |
D internal/httpserve/handlers/send_error.go +0 -17
| @@ -1,17 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/pkg/sanitize" | |
| 7 | - | "github.com/labstack/echo/v4" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func sendError(c echo.Context, err error) error { | |
| 11 | - | rawErrHTML := `<div>Error: ` + err.Error() + `</div>` | |
| 12 | - | sanitizedHTML, err := sanitize.SanitizeHTML(rawErrHTML) | |
| 13 | - | if err != nil { | |
| 14 | - | return c.String(http.StatusInternalServerError, "An error occurred during sanitization") | |
| 15 | - | } | |
| 16 | - | return c.HTML(http.StatusInternalServerError, sanitizedHTML) | |
| 17 | - | } | |
D internal/httpserve/handlers/session_db.go +0 -92
| @@ -1,92 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/labstack/echo-contrib/session" | |
| 10 | - | "github.com/labstack/echo/v4" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func ValidateSessionAndUser(c echo.Context, a *server.App) error { | |
| 14 | - | sessionExpiryTime := 30 * time.Minute | |
| 15 | - | ||
| 16 | - | sess, err := session.Get("session", c) | |
| 17 | - | if err != nil { | |
| 18 | - | return err | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | accountID, ok := sess.Values["accountID"].(string) | |
| 22 | - | if !ok { | |
| 23 | - | return fmt.Errorf("accountID not found or invalid type") | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | sessionID, ok := sess.Values["sessionID"].(string) | |
| 27 | - | if !ok { | |
| 28 | - | return fmt.Errorf("sessionID not found or invalid type") | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | accountExists, err := IsAccountIDInDB(a, accountID) | |
| 32 | - | if err != nil || !accountExists { | |
| 33 | - | return fmt.Errorf("account does not exist") | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | sessionExpired, err := IsSessionExpiredInDB(a, accountID, sessionID) | |
| 37 | - | if err != nil || sessionExpired { | |
| 38 | - | return fmt.Errorf("session is expired") | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // Extend session expiration conditionally, e.g., if it's close to expiring | |
| 42 | - | err = queries.ExtendSessionExpiration(a, accountID, sessionID, time.Now().Add(sessionExpiryTime)) | |
| 43 | - | if err != nil { | |
| 44 | - | return fmt.Errorf("failed to extend session: %v", err) | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | return nil | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | func IsSessionExpiredInDB(a *server.App, accountID string, sessionID string) (bool, error) { | |
| 51 | - | currentTime := time.Now() | |
| 52 | - | expirationTime, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime) | |
| 53 | - | if err != nil { | |
| 54 | - | return false, err | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | return currentTime.After(expirationTime), nil | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func IsAccountIDInDB(a *server.App, accountID string) (bool, error) { | |
| 61 | - | accountExists, err := queries.CheckDBAccountExists(a, accountID) | |
| 62 | - | if err != nil { | |
| 63 | - | return false, err | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | return accountExists, nil | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // ResetSession resets the session cookie and session data | |
| 70 | - | // func ResetSession(c echo.Context) error { | |
| 71 | - | // // Manually expire the cookie | |
| 72 | - | // cookie := new(http.Cookie) | |
| 73 | - | // cookie.Name = "session" | |
| 74 | - | // cookie.Value = "" | |
| 75 | - | // cookie.Path = "/" | |
| 76 | - | // cookie.Expires = time.Unix(0, 0) | |
| 77 | - | // cookie.MaxAge = -1 // Clear immediately | |
| 78 | - | // c.SetCookie(cookie) | |
| 79 | - | // // Invalidate the existing session | |
| 80 | - | // sess, err := session.Get("session", c) | |
| 81 | - | // if err != nil { | |
| 82 | - | // log.Println(err) | |
| 83 | - | // return echo.NewHTTPError(http.StatusInternalServerError, "Could not get session") | |
| 84 | - | // } | |
| 85 | - | // sess.Options.MaxAge = -1 | |
| 86 | - | // if err := sess.Save(c.Request(), c.Response()); err != nil { | |
| 87 | - | // log.Println("Could not delete session:", err) | |
| 88 | - | // return echo.NewHTTPError(http.StatusInternalServerError, "Could not delete session") | |
| 89 | - | // } | |
| 90 | - | ||
| 91 | - | // return nil | |
| 92 | - | // } | |
D internal/httpserve/handlers/static.go +0 -55
| @@ -1,55 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "io/fs" | |
| 5 | - | "net/http" | |
| 6 | - | "os" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/labstack/echo/v4" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // NoDirFileSys restricts directory listing | |
| 13 | - | type NoDirFileSys struct { | |
| 14 | - | fs http.FileSystem | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func (nfs NoDirFileSys) Open(name string) (http.File, error) { | |
| 18 | - | f, err := nfs.fs.Open(name) | |
| 19 | - | if err != nil { | |
| 20 | - | return nil, err | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | return NoDirFile{f}, nil | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // NoDirFile restricts directory listing | |
| 27 | - | type NoDirFile struct { | |
| 28 | - | http.File | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | func (f NoDirFile) Readdir(count int) ([]fs.FileInfo, error) { | |
| 32 | - | // Disable directory listing | |
| 33 | - | return nil, nil | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // StaticRoute serves static files from the embedded filesystem | |
| 37 | - | func StaticRoute(c echo.Context, a *server.App) error { | |
| 38 | - | // Set the cache-control header if we are not in dev mode | |
| 39 | - | if os.Getenv("RUN_ENV") != "dev" { | |
| 40 | - | c.Response().Header().Set("Cache-Control", "public, max-age=86400") | |
| 41 | - | } else { | |
| 42 | - | c.Response().Header().Set("Cache-Control", "no-cache") | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // Convert fs.FS to http.FileSystem | |
| 46 | - | httpFS := http.FS(a.PublicFS) | |
| 47 | - | ||
| 48 | - | // Use custom file system wrapper to disable directory listing | |
| 49 | - | noDirFS := NoDirFileSys{httpFS} | |
| 50 | - | ||
| 51 | - | // Serve the file from the custom filesystem | |
| 52 | - | publicFS := http.FileServer(noDirFS) | |
| 53 | - | publicFS.ServeHTTP(c.Response(), c.Request()) | |
| 54 | - | return nil | |
| 55 | - | } | |
D internal/httpserve/handlers/upload_image.go +0 -100
| @@ -1,100 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "io" | |
| 6 | - | "net/http" | |
| 7 | - | "os" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 11 | - | "github.com/bnema/gordon/pkg/docker" | |
| 12 | - | "github.com/bnema/gordon/pkg/store" | |
| 13 | - | "github.com/labstack/echo/v4" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // 10G | |
| 17 | - | const MaxUploadSize = 10 * 1024 * 1024 * 1024 // 10GB | |
| 18 | - | ||
| 19 | - | // UploadImageHandler handles the /upload-image route to show the form | |
| 20 | - | func UploadImageGETHandler(c echo.Context, a *server.App) error { | |
| 21 | - | data := map[string]interface{}{ | |
| 22 | - | "Title": "Upload Image", | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | rendererData, err := render.GetHTMLRenderer("html/fragments", "uploadimage.gohtml", a.TemplateFS, a) | |
| 26 | - | if err != nil { | |
| 27 | - | return sendError(c, err) | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 31 | - | if err != nil { | |
| 32 | - | return err | |
| 33 | - | } | |
| 34 | - | return c.HTML(200, renderedHTML) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // UploadImageHandler handles the /upload-image | |
| 38 | - | func UploadImagePOSTHandler(c echo.Context, a *server.App) error { | |
| 39 | - | // Set upload size limit | |
| 40 | - | c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, MaxUploadSize) | |
| 41 | - | ||
| 42 | - | // Parse the multipart form | |
| 43 | - | form, err := c.MultipartForm() | |
| 44 | - | if err != nil { | |
| 45 | - | return sendError(c, err) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // Get the file from the form | |
| 49 | - | files := form.File["file"] | |
| 50 | - | if len(files) == 0 { | |
| 51 | - | return c.String(http.StatusBadRequest, "No file uploaded") | |
| 52 | - | } | |
| 53 | - | file := files[0] | |
| 54 | - | ||
| 55 | - | // Open the file | |
| 56 | - | src, err := file.Open() | |
| 57 | - | if err != nil { | |
| 58 | - | return sendError(c, err) | |
| 59 | - | } | |
| 60 | - | defer src.Close() | |
| 61 | - | ||
| 62 | - | // Create a temporary file to store the uploaded chunks | |
| 63 | - | tempFile, err := os.CreateTemp("", "upload-*") | |
| 64 | - | if err != nil { | |
| 65 | - | return sendError(c, err) | |
| 66 | - | } | |
| 67 | - | defer tempFile.Close() | |
| 68 | - | ||
| 69 | - | // Copy the file chunks to the temporary file | |
| 70 | - | _, err = io.Copy(tempFile, src) | |
| 71 | - | if err != nil { | |
| 72 | - | return sendError(c, err) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // Reset the temporary file pointer | |
| 76 | - | _, err = tempFile.Seek(0, io.SeekStart) | |
| 77 | - | if err != nil { | |
| 78 | - | return sendError(c, err) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Save the image to the storage directory | |
| 82 | - | saveInPath, err := store.SaveImageToStorage(&a.Config, file.Filename, tempFile) | |
| 83 | - | if err != nil { | |
| 84 | - | return sendError(c, err) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | // Import the image into Docker | |
| 88 | - | _, err = docker.ImportImageToEngine(saveInPath) | |
| 89 | - | if err != nil { | |
| 90 | - | return fmt.Errorf("failed to import image to Docker: %v", err) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // Remove the image from the storage directory | |
| 94 | - | err = store.RemoveFromStorage(saveInPath) | |
| 95 | - | if err != nil { | |
| 96 | - | return sendError(c, err) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 100 | - | } | |
D internal/httpserve/middleware/colorschemedetect.go +0 -35
| @@ -1,35 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/labstack/echo-contrib/session" | |
| 5 | - | "github.com/labstack/echo/v4" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // ColorSchemeDetection detects the current color scheme and sets it in the context based on the "Sec-CH-Prefers-Color-Scheme" header | |
| 9 | - | func ColorSchemeDetection(next echo.HandlerFunc) echo.HandlerFunc { | |
| 10 | - | ColorSchemeKey := "CurrentColorScheme" | |
| 11 | - | return func(c echo.Context) error { | |
| 12 | - | colorScheme := detectCurrentColorScheme(c) | |
| 13 | - | c.Set(ColorSchemeKey, colorScheme) | |
| 14 | - | return next(c) | |
| 15 | - | } | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func detectCurrentColorScheme(c echo.Context) string { | |
| 19 | - | // Check if the color scheme key is set in the session storage | |
| 20 | - | sess, err := session.Get("session", c) | |
| 21 | - | if err != nil { | |
| 22 | - | return "light" | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | if colorSchemeValue, ok := sess.Values["colorScheme"]; ok && colorSchemeValue != nil { | |
| 26 | - | return colorSchemeValue.(string) // Return the color scheme value from the session storage | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // If color scheme key is not set, proceed with "Sec-CH-Prefers-Color-Scheme" header detection | |
| 30 | - | header := c.Request().Header.Get("Sec-CH-Prefers-Color-Scheme") | |
| 31 | - | if header == "dark" { | |
| 32 | - | return "dark" | |
| 33 | - | } | |
| 34 | - | return "light" // Default to light mode | |
| 35 | - | } | |
D internal/httpserve/middleware/commondata.go +0 -15
| @@ -1,15 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/server" | |
| 5 | - | "github.com/labstack/echo/v4" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func SetCommonDataMiddleware(a *server.App) echo.MiddlewareFunc { | |
| 9 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 10 | - | return func(c echo.Context) error { | |
| 11 | - | // TODO: set common data | |
| 12 | - | return next(c) | |
| 13 | - | } | |
| 14 | - | } | |
| 15 | - | } | |
D internal/httpserve/middleware/error.go +0 -20
| @@ -1,20 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/charmbracelet/log" | |
| 5 | - | "github.com/labstack/echo/v4" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc { | |
| 9 | - | return func(c echo.Context) error { | |
| 10 | - | err := next(c) | |
| 11 | - | if err != nil { | |
| 12 | - | log.Error("Caught http error", | |
| 13 | - | "path", c.Request().URL.Path, | |
| 14 | - | "method", c.Request().Method, | |
| 15 | - | "error", err, | |
| 16 | - | "status", c.Response().Status) | |
| 17 | - | } | |
| 18 | - | return err | |
| 19 | - | } | |
| 20 | - | } | |
D internal/httpserve/middleware/langdetect.go +0 -25
| @@ -1,25 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "strings" | |
| 5 | - | ||
| 6 | - | "github.com/labstack/echo/v4" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // LanguageDetectiondetects the current language and sets it in the context | |
| 10 | - | func LanguageDetection(next echo.HandlerFunc) echo.HandlerFunc { | |
| 11 | - | return func(c echo.Context) error { | |
| 12 | - | lang := detectCurrentLanguage(c) | |
| 13 | - | c.Set("LangKey", lang) | |
| 14 | - | return next(c) | |
| 15 | - | } | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func detectCurrentLanguage(c echo.Context) string { | |
| 19 | - | // If lang key is not set, proceed with "Accept-Language" header detection | |
| 20 | - | header := c.Request().Header.Get("Accept-Language") | |
| 21 | - | if strings.Contains(header, "fr") { | |
| 22 | - | return "fr" | |
| 23 | - | } | |
| 24 | - | return "en" // Default to English | |
| 25 | - | } | |
D internal/httpserve/middleware/requirelogin.go +0 -47
| @@ -1,47 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | ||
| 10 | - | "github.com/labstack/echo/v4" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func redirectToLogin(c echo.Context, a *server.App) error { | |
| 14 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func RequireLogin(a *server.App) echo.MiddlewareFunc { | |
| 18 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 19 | - | return func(c echo.Context) error { | |
| 20 | - | const maxRetries = 3 | |
| 21 | - | var err error | |
| 22 | - | for i := 0; i < maxRetries; i++ { | |
| 23 | - | err = handlers.ValidateSessionAndUser(c, a) | |
| 24 | - | if err == nil { | |
| 25 | - | return next(c) | |
| 26 | - | } | |
| 27 | - | if i < maxRetries-1 { | |
| 28 | - | time.Sleep(time.Duration(i+1) * 500 * time.Millisecond) | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | // propose the redirect auth url as httpcode | |
| 32 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | } | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // func DeleteCookieAndRedirectToLogin(c echo.Context, a *server.App) error { | |
| 39 | - | // // Delete session cookie | |
| 40 | - | // cookie := new(http.Cookie) | |
| 41 | - | // cookie.Name = "session" | |
| 42 | - | // cookie.Value = "" | |
| 43 | - | // cookie.Expires = time.Now().Add(-1 * time.Hour) | |
| 44 | - | // c.SetCookie(cookie) | |
| 45 | - | ||
| 46 | - | // return redirectToLogin(c, a) | |
| 47 | - | // } | |
D internal/httpserve/middleware/requiretoken.go +0 -223
| @@ -1,223 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "net/http" | |
| 8 | - | "strings" | |
| 9 | - | "sync" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/cli/auth" | |
| 13 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 14 | - | "github.com/bnema/gordon/internal/server" | |
| 15 | - | "github.com/charmbracelet/log" | |
| 16 | - | "github.com/labstack/echo/v4" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | type GithubUserInfo = queries.GithubUserInfo | |
| 20 | - | ||
| 21 | - | // middleware/require_token.go | |
| 22 | - | func RequireToken(a *server.App) echo.MiddlewareFunc { | |
| 23 | - | tokenCache := auth.GetTokenCache() | |
| 24 | - | ||
| 25 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 26 | - | return func(c echo.Context) error { | |
| 27 | - | token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ") | |
| 28 | - | if token == "" { | |
| 29 | - | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 30 | - | "success": false, | |
| 31 | - | "error": "Token is missing", | |
| 32 | - | }) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Check cache first | |
| 36 | - | if _, cachedUser, found := tokenCache.GetWithUser(token); found { | |
| 37 | - | // Verify if user exists in database and is authorized | |
| 38 | - | isAuthorized, err := queries.CheckDBUserIsGood(a, cachedUser) | |
| 39 | - | if err != nil || !isAuthorized { | |
| 40 | - | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 41 | - | "success": false, | |
| 42 | - | "error": "User not authorized", | |
| 43 | - | }) | |
| 44 | - | } | |
| 45 | - | c.Set("user", cachedUser) | |
| 46 | - | return next(c) | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Validate with GitHub for new tokens | |
| 50 | - | githubUser, err := validateGitHubToken(c) | |
| 51 | - | if err != nil { | |
| 52 | - | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 53 | - | "success": false, | |
| 54 | - | "error": "Invalid token", | |
| 55 | - | }) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // Verify if user exists in database and is authorized | |
| 59 | - | isAuthorized, err := queries.CheckDBUserIsGood(a, githubUser) | |
| 60 | - | if err != nil { | |
| 61 | - | return c.JSON(http.StatusInternalServerError, map[string]interface{}{ | |
| 62 | - | "success": false, | |
| 63 | - | "error": "Internal server error", | |
| 64 | - | }) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | if !isAuthorized { | |
| 68 | - | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 69 | - | "success": false, | |
| 70 | - | "error": "User not authorized. Please login through the web interface first.", | |
| 71 | - | }) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | tokenCache.SetWithUser(token, token, githubUser, 15*time.Minute) | |
| 75 | - | c.Set("user", githubUser) | |
| 76 | - | return next(c) | |
| 77 | - | } | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | var sessionUpdateCache sync.Map | |
| 82 | - | ||
| 83 | - | func shouldUpdateSession(token string) bool { | |
| 84 | - | key := token | |
| 85 | - | now := time.Now() | |
| 86 | - | ||
| 87 | - | if lastUpdate, exists := sessionUpdateCache.Load(key); exists { | |
| 88 | - | if now.Sub(lastUpdate.(time.Time)) < 5*time.Minute { | |
| 89 | - | log.Debug("skipping session update", "reason", "too_recent") | |
| 90 | - | return false | |
| 91 | - | } | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | sessionUpdateCache.Store(key, now) | |
| 95 | - | log.Debug("session update required") | |
| 96 | - | return true | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) { | |
| 100 | - | requestID := c.Response().Header().Get(echo.HeaderXRequestID) | |
| 101 | - | token := c.Request().Header.Get("Authorization") | |
| 102 | - | if token == "" { | |
| 103 | - | log.Warn("missing token in validation request", | |
| 104 | - | "request_id", requestID) | |
| 105 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing") | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | token = strings.TrimPrefix(token, "Bearer ") | |
| 109 | - | log.Debug("validating GitHub token", | |
| 110 | - | "request_id", requestID) | |
| 111 | - | ||
| 112 | - | client := &http.Client{} | |
| 113 | - | req, err := http.NewRequest("GET", "https://api.github.com/user", nil) | |
| 114 | - | if err != nil { | |
| 115 | - | log.Error("failed to create GitHub API request", | |
| 116 | - | "request_id", requestID, | |
| 117 | - | "error", err) | |
| 118 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | req.Header.Set("Authorization", "token "+token) | |
| 122 | - | req.Header.Set("Accept", "application/vnd.github.v3+json") | |
| 123 | - | ||
| 124 | - | resp, err := client.Do(req) | |
| 125 | - | if err != nil { | |
| 126 | - | log.Error("GitHub API request failed", | |
| 127 | - | "request_id", requestID, | |
| 128 | - | "error", err) | |
| 129 | - | return nil, fmt.Errorf("failed to send request: %w", err) | |
| 130 | - | } | |
| 131 | - | defer resp.Body.Close() | |
| 132 | - | ||
| 133 | - | if resp.StatusCode != http.StatusOK { | |
| 134 | - | log.Warn("invalid GitHub token", | |
| 135 | - | "request_id", requestID, | |
| 136 | - | "status", resp.Status) | |
| 137 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token") | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | body, err := io.ReadAll(resp.Body) | |
| 141 | - | if err != nil { | |
| 142 | - | log.Error("failed to read GitHub response", | |
| 143 | - | "request_id", requestID, | |
| 144 | - | "error", err) | |
| 145 | - | return nil, fmt.Errorf("failed to read response body: %w", err) | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | var githubUser GithubUserInfo | |
| 149 | - | if err := json.Unmarshal(body, &githubUser); err != nil { | |
| 150 | - | log.Error("failed to parse GitHub user data", | |
| 151 | - | "request_id", requestID, | |
| 152 | - | "error", err) | |
| 153 | - | return nil, fmt.Errorf("failed to parse user data: %w", err) | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | log.Debug("GitHub user info fetched", | |
| 157 | - | "request_id", requestID, | |
| 158 | - | "user", githubUser.Login) | |
| 159 | - | ||
| 160 | - | emails, err := fetchGitHubUserEmails(token) | |
| 161 | - | if err != nil { | |
| 162 | - | log.Error("failed to fetch GitHub user emails", | |
| 163 | - | "request_id", requestID, | |
| 164 | - | "user", githubUser.Login, | |
| 165 | - | "error", err) | |
| 166 | - | return nil, fmt.Errorf("failed to fetch user emails: %w", err) | |
| 167 | - | } | |
| 168 | - | githubUser.Emails = emails | |
| 169 | - | ||
| 170 | - | log.Debug("completed GitHub user validation", | |
| 171 | - | "request_id", requestID, | |
| 172 | - | "user", githubUser.Login, | |
| 173 | - | "email_count", len(emails)) | |
| 174 | - | ||
| 175 | - | return &githubUser, nil | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | func fetchGitHubUserEmails(token string) ([]string, error) { | |
| 179 | - | log.Debug("fetching GitHub user emails") | |
| 180 | - | ||
| 181 | - | client := &http.Client{} | |
| 182 | - | req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil) | |
| 183 | - | if err != nil { | |
| 184 | - | log.Error("failed to create email request", | |
| 185 | - | "error", err) | |
| 186 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | req.Header.Set("Authorization", "token "+token) | |
| 190 | - | req.Header.Set("Accept", "application/vnd.github.v3+json") | |
| 191 | - | ||
| 192 | - | resp, err := client.Do(req) | |
| 193 | - | if err != nil { | |
| 194 | - | log.Error("failed to send email request", | |
| 195 | - | "error", err) | |
| 196 | - | return nil, fmt.Errorf("failed to send request: %w", err) | |
| 197 | - | } | |
| 198 | - | defer resp.Body.Close() | |
| 199 | - | ||
| 200 | - | if resp.StatusCode != http.StatusOK { | |
| 201 | - | log.Warn("failed to fetch emails", | |
| 202 | - | "status", resp.Status) | |
| 203 | - | return nil, fmt.Errorf("failed to fetch user emails: %s", resp.Status) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | var emailsResp []struct { | |
| 207 | - | Email string `json:"email"` | |
| 208 | - | } | |
| 209 | - | if err := json.NewDecoder(resp.Body).Decode(&emailsResp); err != nil { | |
| 210 | - | log.Error("failed to parse email response", | |
| 211 | - | "error", err) | |
| 212 | - | return nil, fmt.Errorf("failed to parse email response: %w", err) | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | emails := make([]string, len(emailsResp)) | |
| 216 | - | for i, e := range emailsResp { | |
| 217 | - | emails[i] = e.Email | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | log.Debug("fetched GitHub emails successfully", | |
| 221 | - | "email_count", len(emails)) | |
| 222 | - | return emails, nil | |
| 223 | - | } | |
D internal/httpserve/middleware/secure.go +0 -56
| @@ -1,56 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "log" | |
| 5 | - | "os" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/gorilla/sessions" | |
| 9 | - | _ "github.com/joho/godotenv/autoload" | |
| 10 | - | "github.com/labstack/echo-contrib/session" | |
| 11 | - | "github.com/labstack/echo/v4" | |
| 12 | - | "github.com/labstack/echo/v4/middleware" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func SecureRoutes(a *server.App) echo.MiddlewareFunc { | |
| 16 | - | proxyURL := a.Config.Build.ProxyURL | |
| 17 | - | urlCheckVersion := a.Config.Build.ProxyURL + "/version" | |
| 18 | - | return middleware.SecureWithConfig(middleware.SecureConfig{ | |
| 19 | - | XSSProtection: "1; mode=block", | |
| 20 | - | ContentTypeNosniff: "nosniff", | |
| 21 | - | XFrameOptions: "SAMEORIGIN", | |
| 22 | - | HSTSMaxAge: 3600, | |
| 23 | - | HSTSExcludeSubdomains: false, | |
| 24 | - | ContentSecurityPolicy: "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' " + proxyURL + " " + urlCheckVersion, | |
| 25 | - | }) | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // InitSessionMiddleware initializes the session middleware with secure options | |
| 29 | - | func InitSessionMiddleware(a *server.App) echo.MiddlewareFunc { | |
| 30 | - | isHttps := a.Config.Http.Https | |
| 31 | - | secret := os.Getenv("SESSION_SECRET") | |
| 32 | - | if secret == "" { | |
| 33 | - | log.Fatal("Environment variable SESSION_SECRET is not set or cannot be read") | |
| 34 | - | } | |
| 35 | - | store := sessions.NewCookieStore([]byte(secret)) | |
| 36 | - | store.Options = &sessions.Options{ | |
| 37 | - | Path: "/", | |
| 38 | - | HttpOnly: true, | |
| 39 | - | Secure: isHttps, | |
| 40 | - | MaxAge: 86400, | |
| 41 | - | } | |
| 42 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 43 | - | return func(c echo.Context) error { | |
| 44 | - | err := session.Middleware(store)(next)(c) | |
| 45 | - | if err != nil { | |
| 46 | - | log.Printf("Error in session middleware: %v", err) | |
| 47 | - | return err | |
| 48 | - | } | |
| 49 | - | _, err = session.Get("session", c) | |
| 50 | - | if err != nil { | |
| 51 | - | log.Printf("Could not retrieve session: %v", err) | |
| 52 | - | } | |
| 53 | - | return nil | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | } | |
D internal/httpserve/router.go +0 -190
| @@ -1,190 +0,0 @@ | |||
| 1 | - | package httpserve | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 5 | - | "github.com/bnema/gordon/internal/httpserve/middleware" | |
| 6 | - | "github.com/bnema/gordon/internal/server" | |
| 7 | - | _ "github.com/joho/godotenv/autoload" | |
| 8 | - | "github.com/labstack/echo/v4" | |
| 9 | - | echomid "github.com/labstack/echo/v4/middleware" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // RegisterRoutes registers all routes and middlewares | |
| 13 | - | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |
| 14 | - | // e.Use(echomid.Logger()) too verbose | |
| 15 | - | e.Use(echomid.Recover()) | |
| 16 | - | // Initiate the session middleware | |
| 17 | - | e.Use(middleware.InitSessionMiddleware(a)) | |
| 18 | - | ||
| 19 | - | // Language detection | |
| 20 | - | e.Use(middleware.LanguageDetection) | |
| 21 | - | ||
| 22 | - | AdminPath := a.Config.Admin.Path | |
| 23 | - | bindLoginRoute(e, a, AdminPath) | |
| 24 | - | bindAPIEndpoints(e, a) | |
| 25 | - | ||
| 26 | - | // SetCommonDataMiddleware will pass data to the renderer | |
| 27 | - | e.Use(middleware.SetCommonDataMiddleware(a)) | |
| 28 | - | ||
| 29 | - | // Error handler middleware | |
| 30 | - | e.Use(middleware.ErrorHandler) | |
| 31 | - | ||
| 32 | - | // Use middlewares | |
| 33 | - | e.Use(middleware.SecureRoutes(a)) | |
| 34 | - | ||
| 35 | - | // Color scheme detection for dark/light mode | |
| 36 | - | e.Use(middleware.ColorSchemeDetection) | |
| 37 | - | ||
| 38 | - | bindAdminRoute(e, a, AdminPath) | |
| 39 | - | bindStaticRoute(e, a, "/*") | |
| 40 | - | bindHTMXEndpoints(e, a) | |
| 41 | - | ||
| 42 | - | // Protect the root path with a 403 | |
| 43 | - | e.GET("/", func(c echo.Context) error { | |
| 44 | - | return c.String(403, "403 Forbidden") | |
| 45 | - | }) | |
| 46 | - | ||
| 47 | - | return e | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // bindAPIEndpoints expose /api endpoints and speaks only JSON | |
| 51 | - | func bindAPIEndpoints(e *echo.Echo, a *server.App) { | |
| 52 | - | apiGroup := e.Group("/api") | |
| 53 | - | ||
| 54 | - | // Device flow endpoints (without RequireToken middleware) | |
| 55 | - | apiGroup.POST("/device/code", func(c echo.Context) error { | |
| 56 | - | return handlers.DeviceCodeRequest(c, a) | |
| 57 | - | }) | |
| 58 | - | apiGroup.POST("/device/token", func(c echo.Context) error { | |
| 59 | - | return handlers.DeviceTokenRequest(c, a) | |
| 60 | - | }) | |
| 61 | - | ||
| 62 | - | // Other API endpoints that require a token | |
| 63 | - | protectedApiGroup := apiGroup.Group("", middleware.RequireToken(a)) | |
| 64 | - | protectedApiGroup.GET("/ping", func(c echo.Context) error { | |
| 65 | - | return handlers.GetInfos(c, a) | |
| 66 | - | }) | |
| 67 | - | protectedApiGroup.POST("/deploy", func(c echo.Context) error { | |
| 68 | - | return handlers.PostDeploy(c, a) | |
| 69 | - | }) | |
| 70 | - | protectedApiGroup.POST("/deploy/chunked", func(c echo.Context) error { | |
| 71 | - | return handlers.PostDeployChunked(c, a) | |
| 72 | - | }) | |
| 73 | - | protectedApiGroup.GET("/deploy/check-conflict", func(c echo.Context) error { | |
| 74 | - | return handlers.CheckDeployConflict(c, a) | |
| 75 | - | }) | |
| 76 | - | // Regular push endpoint for small images | |
| 77 | - | protectedApiGroup.POST("/push", func(c echo.Context) error { | |
| 78 | - | return handlers.PostPush(c, a) | |
| 79 | - | }) | |
| 80 | - | ||
| 81 | - | // Chunked push endpoint for large images | |
| 82 | - | protectedApiGroup.POST("/push/chunked", func(c echo.Context) error { | |
| 83 | - | return handlers.PostPushChunked(c, a) | |
| 84 | - | }) | |
| 85 | - | protectedApiGroup.POST("/stop", func(c echo.Context) error { | |
| 86 | - | return handlers.PostContainerStop(c, a) | |
| 87 | - | }) | |
| 88 | - | protectedApiGroup.POST("/remove", func(c echo.Context) error { | |
| 89 | - | return handlers.PostContainerRemove(c, a) | |
| 90 | - | }) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // bindStaticRoute bind static path | |
| 94 | - | func bindStaticRoute(e *echo.Echo, a *server.App, path string) { | |
| 95 | - | e.GET(path, func(c echo.Context) error { | |
| 96 | - | return handlers.StaticRoute(c, a) | |
| 97 | - | }, echomid.Gzip()) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // bindLoginRoute binds all login routes | |
| 101 | - | func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | |
| 102 | - | e.GET(adminPath+"/login", func(c echo.Context) error { | |
| 103 | - | return handlers.RenderLoginPage(c, a) | |
| 104 | - | }) | |
| 105 | - | e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error { | |
| 106 | - | return handlers.StartOAuthGithub(c, a) | |
| 107 | - | }) | |
| 108 | - | e.GET(adminPath+"/login/oauth/callback", func(c echo.Context) error { | |
| 109 | - | return handlers.OAuthCallback(c, a) | |
| 110 | - | }) | |
| 111 | - | e.GET(adminPath+"/logout", func(c echo.Context) error { | |
| 112 | - | return handlers.Logout(c, a) | |
| 113 | - | }) | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // bindAdminRoute binds all admin routes | |
| 117 | - | func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |
| 118 | - | adminGroup := e.Group(adminPath) | |
| 119 | - | // Since login is behind the admin path, we cannot use group middleware | |
| 120 | - | adminGroup.GET("", func(c echo.Context) error { | |
| 121 | - | return c.Redirect(302, adminPath+"/manager") | |
| 122 | - | }, middleware.RequireLogin(a)) | |
| 123 | - | ||
| 124 | - | adminGroup.GET("/manager", func(c echo.Context) error { | |
| 125 | - | return handlers.AdminManagerRoute(c, a) | |
| 126 | - | }, middleware.RequireLogin(a)) | |
| 127 | - | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | |
| 128 | - | return handlers.CreateContainerFullGET(c, a) | |
| 129 | - | }, middleware.RequireLogin(a)) | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // bindHTMXEndpoints binds all HTMX endpoints | |
| 133 | - | func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | |
| 134 | - | // Create a group for /htmx endpoints | |
| 135 | - | htmxGroup := e.Group("/htmx") | |
| 136 | - | ||
| 137 | - | // Apply middleware to the group, so all endpoints are protected | |
| 138 | - | htmxGroup.Use(middleware.RequireLogin(a)) | |
| 139 | - | ||
| 140 | - | // List all images component | |
| 141 | - | htmxGroup.GET("/image-manager", func(c echo.Context) error { | |
| 142 | - | return handlers.ImageManagerComponent(c, a) | |
| 143 | - | }) | |
| 144 | - | // Delete an image | |
| 145 | - | htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error { | |
| 146 | - | return handlers.ImageManagerDelete(c, a) | |
| 147 | - | }) | |
| 148 | - | ||
| 149 | - | // List all containers | |
| 150 | - | htmxGroup.GET("/container-manager", func(c echo.Context) error { | |
| 151 | - | return handlers.ContainerManagerComponent(c, a) | |
| 152 | - | }) | |
| 153 | - | // Stop a container | |
| 154 | - | htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error { | |
| 155 | - | return handlers.ContainerManagerStop(c, a) | |
| 156 | - | }) | |
| 157 | - | // Delete a container | |
| 158 | - | htmxGroup.DELETE("/container-manager/delete/:ID", func(c echo.Context) error { | |
| 159 | - | return handlers.ContainerManagerDelete(c, a) | |
| 160 | - | }) | |
| 161 | - | // Start a container | |
| 162 | - | htmxGroup.POST("/container-manager/start/:ID", func(c echo.Context) error { | |
| 163 | - | return handlers.ContainerManagerStart(c, a) | |
| 164 | - | }) | |
| 165 | - | // Edit a container view | |
| 166 | - | htmxGroup.GET("/container-manager/edit/:ID", func(c echo.Context) error { | |
| 167 | - | return handlers.ContainerManagerEditGET(c, a) | |
| 168 | - | }) | |
| 169 | - | // Edit a container action | |
| 170 | - | htmxGroup.POST("/container-manager/edit/:ID", func(c echo.Context) error { | |
| 171 | - | return handlers.ContainerManagerEditPOST(c, a) | |
| 172 | - | }) | |
| 173 | - | ||
| 174 | - | // Display upload-image component | |
| 175 | - | htmxGroup.GET("/upload-image", func(c echo.Context) error { | |
| 176 | - | return handlers.UploadImageGETHandler(c, a) | |
| 177 | - | }) | |
| 178 | - | // Upload image | |
| 179 | - | htmxGroup.POST("/upload-image", func(c echo.Context) error { | |
| 180 | - | return handlers.UploadImagePOSTHandler(c, a) | |
| 181 | - | }) | |
| 182 | - | // Display create-container component | |
| 183 | - | htmxGroup.GET("/create-container/:ID", func(c echo.Context) error { | |
| 184 | - | return handlers.CreateContainerGET(c, a) | |
| 185 | - | }) | |
| 186 | - | // Create container | |
| 187 | - | htmxGroup.POST("/create-container/:ID", func(c echo.Context) error { | |
| 188 | - | return handlers.CreateContainerPOST(c, a) | |
| 189 | - | }) | |
| 190 | - | } | |
A internal/logging/collector.go +236 -0 collapsed to keep the page light
A internal/logging/collector_test.go +597 -0 collapsed to keep the page light
A internal/logging/setup.go +161 -0
| @@ -0,0 +1,161 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "github.com/rs/zerolog" | |
| 10 | + | "github.com/rs/zerolog/log" | |
| 11 | + | "gopkg.in/natefinch/lumberjack.v2" | |
| 12 | + | "gordon/internal/config" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | var ( | |
| 16 | + | MainLogger zerolog.Logger | |
| 17 | + | ProxyLogger zerolog.Logger | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Setup initializes the logging system based on the configuration | |
| 21 | + | func Setup(cfg *config.Config) error { | |
| 22 | + | if !cfg.Logging.Enabled { | |
| 23 | + | // Keep console logging only | |
| 24 | + | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) | |
| 25 | + | MainLogger = log.Logger | |
| 26 | + | ProxyLogger = log.Logger | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Create logs directory with secure permissions (0700 - owner only) | |
| 31 | + | if err := os.MkdirAll(cfg.Logging.Dir, 0700); err != nil { | |
| 32 | + | return fmt.Errorf("failed to create logs directory: %w", err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Create container logs directory with secure permissions | |
| 36 | + | containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir) | |
| 37 | + | if err := os.MkdirAll(containerLogDir, 0700); err != nil { | |
| 38 | + | return fmt.Errorf("failed to create container logs directory: %w", err) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Set log level | |
| 42 | + | level, err := zerolog.ParseLevel(cfg.Logging.Level) | |
| 43 | + | if err != nil { | |
| 44 | + | level = zerolog.InfoLevel | |
| 45 | + | log.Warn().Str("invalid_level", cfg.Logging.Level).Msg("Invalid log level, using info") | |
| 46 | + | } | |
| 47 | + | zerolog.SetGlobalLevel(level) | |
| 48 | + | ||
| 49 | + | // Configure time format | |
| 50 | + | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix | |
| 51 | + | ||
| 52 | + | // Setup main application logger | |
| 53 | + | mainLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.MainLogFile) | |
| 54 | + | mainFileWriter := &lumberjack.Logger{ | |
| 55 | + | Filename: mainLogFile, | |
| 56 | + | MaxSize: cfg.Logging.MaxSize, | |
| 57 | + | MaxBackups: cfg.Logging.MaxBackups, | |
| 58 | + | MaxAge: cfg.Logging.MaxAge, | |
| 59 | + | Compress: cfg.Logging.Compress, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Set file permissions to be secure (readable only by owner) | |
| 63 | + | if err := os.Chmod(mainLogFile, 0600); err != nil && !os.IsNotExist(err) { | |
| 64 | + | log.Warn().Err(err).Str("file", mainLogFile).Msg("Failed to set secure permissions on log file") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Setup proxy logger | |
| 68 | + | proxyLogFile := filepath.Join(cfg.Logging.Dir, cfg.Logging.ProxyLogFile) | |
| 69 | + | proxyFileWriter := &lumberjack.Logger{ | |
| 70 | + | Filename: proxyLogFile, | |
| 71 | + | MaxSize: cfg.Logging.MaxSize, | |
| 72 | + | MaxBackups: cfg.Logging.MaxBackups, | |
| 73 | + | MaxAge: cfg.Logging.MaxAge, | |
| 74 | + | Compress: cfg.Logging.Compress, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Set file permissions to be secure (readable only by owner) | |
| 78 | + | if err := os.Chmod(proxyLogFile, 0600); err != nil && !os.IsNotExist(err) { | |
| 79 | + | log.Warn().Err(err).Str("file", proxyLogFile).Msg("Failed to set secure permissions on log file") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Create multi-writers (console + file) | |
| 83 | + | consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr} | |
| 84 | + | ||
| 85 | + | mainMultiWriter := io.MultiWriter(consoleWriter, mainFileWriter) | |
| 86 | + | proxyMultiWriter := io.MultiWriter(consoleWriter, proxyFileWriter) | |
| 87 | + | ||
| 88 | + | // Initialize loggers | |
| 89 | + | MainLogger = zerolog.New(mainMultiWriter).With().Timestamp().Logger() | |
| 90 | + | ProxyLogger = zerolog.New(proxyMultiWriter).With().Timestamp().Logger() | |
| 91 | + | ||
| 92 | + | // Set the global logger to the main logger | |
| 93 | + | log.Logger = MainLogger | |
| 94 | + | ||
| 95 | + | log.Info(). | |
| 96 | + | Str("main_log", mainLogFile). | |
| 97 | + | Str("proxy_log", proxyLogFile). | |
| 98 | + | Str("container_log_dir", containerLogDir). | |
| 99 | + | Str("level", level.String()). | |
| 100 | + | Msg("File logging initialized") | |
| 101 | + | ||
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // GetContainerLogWriter returns a log writer for a specific container | |
| 106 | + | func GetContainerLogWriter(cfg *config.Config, containerID string, containerName string) (*lumberjack.Logger, error) { | |
| 107 | + | if !cfg.Logging.Enabled { | |
| 108 | + | return nil, fmt.Errorf("logging is disabled") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | containerLogDir := filepath.Join(cfg.Logging.Dir, cfg.Logging.ContainerLogDir) | |
| 112 | + | ||
| 113 | + | // Ensure container log directory exists with secure permissions | |
| 114 | + | if err := os.MkdirAll(containerLogDir, 0700); err != nil { | |
| 115 | + | return nil, fmt.Errorf("failed to create container log directory: %w", err) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Use container ID as the main log file name | |
| 119 | + | logFile := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerID)) | |
| 120 | + | ||
| 121 | + | writer := &lumberjack.Logger{ | |
| 122 | + | Filename: logFile, | |
| 123 | + | MaxSize: cfg.Logging.MaxSize, | |
| 124 | + | MaxBackups: cfg.Logging.MaxBackups, | |
| 125 | + | MaxAge: cfg.Logging.MaxAge, | |
| 126 | + | Compress: cfg.Logging.Compress, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Set file permissions to be secure (readable only by owner) | |
| 130 | + | if err := os.Chmod(logFile, 0600); err != nil && !os.IsNotExist(err) { | |
| 131 | + | log.Warn().Err(err).Str("file", logFile).Msg("Failed to set secure permissions on container log file") | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Create a symlink for easier access using container name (if provided) | |
| 135 | + | if containerName != "" && containerName != containerID { | |
| 136 | + | symlinkPath := filepath.Join(containerLogDir, fmt.Sprintf("%s.log", containerName)) | |
| 137 | + | ||
| 138 | + | // Remove existing symlink if it exists | |
| 139 | + | if _, err := os.Lstat(symlinkPath); err == nil { | |
| 140 | + | os.Remove(symlinkPath) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Create new symlink | |
| 144 | + | relativeLogFile := fmt.Sprintf("%s.log", containerID) | |
| 145 | + | if err := os.Symlink(relativeLogFile, symlinkPath); err != nil { | |
| 146 | + | log.Warn(). | |
| 147 | + | Err(err). | |
| 148 | + | Str("symlink", symlinkPath). | |
| 149 | + | Str("target", relativeLogFile). | |
| 150 | + | Msg("Failed to create container name symlink") | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return writer, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Close gracefully closes all logging writers | |
| 158 | + | func Close() { | |
| 159 | + | // Lumberjack loggers will be closed automatically when the program exits | |
| 160 | + | // This function is provided for future extensibility | |
| 161 | + | }⏎̸ | |
A internal/logging/setup_test.go +381 -0 collapsed to keep the page light
A internal/middleware/auth.go +76 -0 collapsed to keep the page light
A internal/middleware/auth_test.go +301 -0 collapsed to keep the page light
A internal/middleware/logging.go +188 -0 collapsed to keep the page light
A internal/middleware/logging_test.go +541 -0 collapsed to keep the page light
A internal/proxy/server.go +296 -0 collapsed to keep the page light
A internal/proxy/server_test.go +1101 -0 collapsed to keep the page light
A internal/registry/manifest.go +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | package registry | |
| 2 | + | ||
| 3 | + | // This file is deprecated - manifest processing has been moved to pkg/manifest | |
| 4 | + | // to avoid circular import issues. This file will be removed in a future version.⏎̸ | |
A internal/registry/mocks/storage.go +106 -0 collapsed to keep the page light
A internal/registry/server.go +466 -0 collapsed to keep the page light
A internal/registry/server_test.go +614 -0 collapsed to keep the page light
A internal/registry/storage.go +503 -0 collapsed to keep the page light
A internal/registry/storage_test.go +466 -0 collapsed to keep the page light
D internal/server/app_server.go +0 -71 collapsed to keep the page light
D internal/server/cronjobs.go +0 -30
| @@ -1,30 +0,0 @@ | |||
| 1 | - | package server | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "log" | |
| 5 | - | "time" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // SessionCleaner, time as a string | |
| 9 | - | func SessionCleaner(a *App, currentTime string) error { | |
| 10 | - | query := "DELETE FROM sessions WHERE expires < ?" | |
| 11 | - | _, err := a.DB.Exec(query, currentTime) | |
| 12 | - | if err != nil { | |
| 13 | - | return err | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | return nil | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // StartSessionCleaner initializes the session cleaner to run at regular intervals. | |
| 20 | - | func (a *App) StartSessionCleaner() { | |
| 21 | - | go func() { | |
| 22 | - | for { | |
| 23 | - | currentTime := time.Now().Format(time.RFC3339) | |
| 24 | - | if err := SessionCleaner(a, currentTime); err != nil { | |
| 25 | - | log.Printf("Failed to clean up sessions: %v", err) | |
| 26 | - | } | |
| 27 | - | time.Sleep(1 * time.Hour) | |
| 28 | - | } | |
| 29 | - | }() | |
| 30 | - | } | |
D internal/server/new_server.go +0 -57 collapsed to keep the page light
D internal/server/sqlite_close.go +0 -30 collapsed to keep the page light
D internal/server/sqlite_init.go +0 -186 collapsed to keep the page light
D internal/server/token_init.go +0 -50 collapsed to keep the page light
D internal/templating/cmdparams/from_cli.go +0 -59 collapsed to keep the page light
D internal/templating/cmdparams/from_inputs.go +0 -47 collapsed to keep the page light
D internal/templating/cmdparams/from_yaml.go +0 -58 collapsed to keep the page light
D internal/templating/cmdparams/parsing.go +0 -39 collapsed to keep the page light
D internal/templating/cmdparams/traefik.go +0 -37 collapsed to keep the page light
D internal/templating/embed.go +0 -13
| @@ -1,13 +0,0 @@ | |||
| 1 | - | package templating | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "embed" | |
| 5 | - | ||
| 6 | - | "github.com/labstack/echo/v4" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // Embedding templates directories | |
| 10 | - | ||
| 11 | - | //go:embed models/* | |
| 12 | - | var template embed.FS | |
| 13 | - | var TemplateFS = echo.MustSubFS(template, "models") | |
D internal/templating/load/fragment.go +0 -28 collapsed to keep the page light
D internal/templating/models/html/admin/createcontainerfull.gohtml +0 -21 collapsed to keep the page light
D internal/templating/models/html/admin/dashboard.gohtml +0 -9 collapsed to keep the page light
D internal/templating/models/html/admin/index.gohtml +0 -29 collapsed to keep the page light
D internal/templating/models/html/admin/install.gohtml +0 -137 collapsed to keep the page light
D internal/templating/models/html/admin/manager.gohtml +0 -36 collapsed to keep the page light
D internal/templating/models/html/admin/user.gohtml +0 -0
no textual change.
D internal/templating/models/html/fragments/components.gohtml +0 -33 collapsed to keep the page light
D internal/templating/models/html/fragments/containerlist.gohtml +0 -103 collapsed to keep the page light
D internal/templating/models/html/fragments/createcontainer.gohtml +0 -100 collapsed to keep the page light
D internal/templating/models/html/fragments/editcontainer.gohtml +0 -30 collapsed to keep the page light
D internal/templating/models/html/fragments/footer.gohtml +0 -0
no textual change.
D internal/templating/models/html/fragments/header.gohtml +0 -13 collapsed to keep the page light
D internal/templating/models/html/fragments/imagelist.gohtml +0 -75 collapsed to keep the page light
D internal/templating/models/html/fragments/menu.gohtml +0 -45 collapsed to keep the page light
D internal/templating/models/html/fragments/success.gohtml +0 -6 collapsed to keep the page light
D internal/templating/models/html/fragments/uploadimage.gohtml +0 -47 collapsed to keep the page light
D internal/templating/models/html/login/index.gohtml +0 -64 collapsed to keep the page light
D internal/templating/models/txt/locstrings.yml +0 -149 collapsed to keep the page light
D internal/templating/render/html.go +0 -77 collapsed to keep the page light
D internal/templating/render/localization.go +0 -58 collapsed to keep the page light
A internal/testutils/fixtures/configs/basic.toml +27 -0 collapsed to keep the page light
A internal/testutils/fixtures/configs/invalid.toml +2 -0 collapsed to keep the page light
A internal/testutils/fixtures/configs/minimal.toml +2 -0 collapsed to keep the page light
A internal/testutils/helpers.go +83 -0 collapsed to keep the page light
A internal/testutils/mocks/runtime_gen.go +480 -0 generated file: collapsed
D internal/webui/embed.go +0 -14 collapsed to keep the page light
D internal/webui/public/404.gohtml +0 -12 collapsed to keep the page light
D internal/webui/public/500.gohtml +0 -12 collapsed to keep the page light
D internal/webui/public/android-chrome-192x192.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/android-chrome-512x512.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/apple-touch-icon.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/css/custom.css +0 -128 collapsed to keep the page light
D internal/webui/public/assets/css/tailwind.css +0 -1321 collapsed to keep the page light
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Light.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Medium.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Thin.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFontMono-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/imgs/github/github-mark-white.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/imgs/github/github-mark-white.svg +0 -1 collapsed to keep the page light
D internal/webui/public/assets/imgs/github/github-mark.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/imgs/github/github-mark.svg +0 -1 collapsed to keep the page light
D internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/js/custom.js +0 -53 collapsed to keep the page light
D internal/webui/public/favicon-16x16.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/favicon-32x32.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/favicon.ico +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/libs/chart.js +0 -20 collapsed to keep the page light
D internal/webui/public/libs/htmx-debug.js +0 -11
| @@ -1,11 +0,0 @@ | |||
| 1 | - | htmx.defineExtension('debug', { | |
| 2 | - | onEvent: function (name, evt) { | |
| 3 | - | if (console.debug) { | |
| 4 | - | console.debug(name, evt); | |
| 5 | - | } else if (console) { | |
| 6 | - | console.log("DEBUG:", name, evt); | |
| 7 | - | } else { | |
| 8 | - | throw "NO CONSOLE SUPPORTED" | |
| 9 | - | } | |
| 10 | - | } | |
| 11 | - | }); | |