feat(env): Add environment variable support with secret management
- Implement environment variable configuration in the application, allowing for per-route environment files. - Introduce support for secret providers like `pass` and `sops` for secure credential management. - Enhance container deployment to load environment variables from the created `.env` files. - Update README and example configuration files to reflect new environment variable usage and setup instructions.
- commit
- 57617f58615cb63352f92248f07198c51572efca
- author
- bamen · · 2025-06-08 09:18 UTC
- parent
- 975227e
- signature
- unsigned
13 files · +965 -49
M README.md +158 -41
| @@ -29,8 +29,8 @@ podman build -t myapp . | |||
| 29 | 29 | podman run -p 8080:8080 myapp # Works? Great! | |
| 30 | 30 | ||
| 31 | 31 | # 2. Push to deploy | |
| 32 | - | podman tag myapp registry.yourdomain.com/myapp:latest | |
| 33 | - | podman push registry.yourdomain.com/myapp:latest | |
| 32 | + | podman tag myapp registry.mydomain.com/myapp:latest | |
| 33 | + | podman push registry.mydomain.com/myapp:latest | |
| 34 | 34 | ||
| 35 | 35 | # 3. That's it. Gordon deploys it automatically. | |
| 36 | 36 | ``` | |
| @@ -43,10 +43,10 @@ Something broke? Just change your config: | |||
| 43 | 43 | ||
| 44 | 44 | ```toml | |
| 45 | 45 | # Before (in gordon.toml) | |
| 46 | - | "app.yourdomain.com" = "myapp:latest" | |
| 46 | + | "app.mydomain.com" = "myapp:latest" | |
| 47 | 47 | ||
| 48 | 48 | # After - instant rollback! | |
| 49 | - | "app.yourdomain.com" = "myapp:v1.2.3" | |
| 49 | + | "app.mydomain.com" = "myapp:v1.2.3" | |
| 50 | 50 | ``` | |
| 51 | 51 | ||
| 52 | 52 | Save the file. Gordon redeploys the previous version. Problem solved in seconds. | |
| @@ -54,7 +54,7 @@ Save the file. Gordon redeploys the previous version. Problem solved in seconds. | |||
| 54 | 54 | ## Key Features | |
| 55 | 55 | ||
| 56 | 56 | ### Local-First Development | |
| 57 | - | **Your machine is the build server.** Test locally with Podman's rootless containers, push when ready. No waiting for remote builds. | |
| 57 | + | **Your machine is the build server.** Test locally with your favorite container engine, push when ready. No waiting for remote builds. | |
| 58 | 58 | ||
| 59 | 59 | ### Built-in Container Registry | |
| 60 | 60 | Your VPS becomes a private container registry (Docker/Podman compatible). No Docker Hub subscription needed. | |
| @@ -124,10 +124,10 @@ systemctl --user enable --now podman.socket | |||
| 124 | 124 | mkdir -p ~/.config/containers | |
| 125 | 125 | tee ~/.config/containers/registries.conf > /dev/null <<EOF | |
| 126 | 126 | [registries.search] | |
| 127 | - | registries = ['docker.io', 'registry.yourdomain.com'] | |
| 127 | + | registries = ['docker.io', 'registry.mydomain.com'] | |
| 128 | 128 | ||
| 129 | 129 | [registries.insecure] | |
| 130 | - | registries = ['registry.yourdomain.com'] | |
| 130 | + | registries = ['registry.mydomain.com'] | |
| 131 | 131 | ||
| 132 | 132 | [registries.block] | |
| 133 | 133 | registries = [] | |
| @@ -147,7 +147,7 @@ sudo mv gordon-linux-amd64 /usr/local/bin/gordon | |||
| 147 | 147 | # (or use --config flag) | |
| 148 | 148 | [server] | |
| 149 | 149 | port = 8080 | |
| 150 | - | registry_domain = "registry.yourdomain.com" | |
| 150 | + | registry_domain = "registry.mydomain.com" | |
| 151 | 151 | runtime = "podman-rootless" # Can be auto, docker, podman, podman-rootless | |
| 152 | 152 | ||
| 153 | 153 | [registry_auth] | |
| @@ -156,9 +156,9 @@ username = "admin" | |||
| 156 | 156 | password = "your-secure-password" | |
| 157 | 157 | ||
| 158 | 158 | [routes] | |
| 159 | - | "app.yourdomain.com" = "myapp:latest" | |
| 160 | - | "api.yourdomain.com" = "api:v1" | |
| 161 | - | "blog.yourdomain.com" = "wordpress:latest" | |
| 159 | + | "app.mydomain.com" = "myapp:latest" | |
| 160 | + | "api.mydomain.com" = "api:v1" | |
| 161 | + | "blog.mydomain.com" = "wordpress:latest" | |
| 162 | 162 | ||
| 163 | 163 | [auto_route] | |
| 164 | 164 | enabled = false # Enable automatic route creation | |
| @@ -169,8 +169,8 @@ enabled = false # Enable automatic route creation | |||
| 169 | 169 | ||
| 170 | 170 | ### 7. Point Cloudflare DNS | |
| 171 | 171 | ``` | |
| 172 | - | A *.yourdomain.com → YOUR_VPS_IP | |
| 173 | - | A yourdomain.com → YOUR_VPS_IP | |
| 172 | + | A *.mydomain.com → YOUR_VPS_IP | |
| 173 | + | A mydomain.com → YOUR_VPS_IP | |
| 174 | 174 | ``` | |
| 175 | 175 | ||
| 176 | 176 | ### 8. Create Systemd Service (Rootless) | |
| @@ -211,13 +211,13 @@ systemctl --user status gordon | |||
| 211 | 211 | ```bash | |
| 212 | 212 | # Now from your local development machine: | |
| 213 | 213 | # Authenticate with your Gordon registry | |
| 214 | - | podman login registry.yourdomain.com | |
| 214 | + | podman login registry.mydomain.com | |
| 215 | 215 | # Use the username/password from your gordon.toml config | |
| 216 | 216 | ||
| 217 | 217 | # Deploy your first app (using podman) | |
| 218 | - | podman tag myapp:latest registry.yourdomain.com/myapp:latest | |
| 219 | - | podman push registry.yourdomain.com/myapp:latest | |
| 220 | - | # Visit https://app.yourdomain.com | |
| 218 | + | podman tag myapp:latest registry.mydomain.com/myapp:latest | |
| 219 | + | podman push registry.mydomain.com/myapp:latest | |
| 220 | + | # Visit https://app.mydomain.com | |
| 221 | 221 | ``` | |
| 222 | 222 | ||
| 223 | 223 | ## Real-World Examples | |
| @@ -231,13 +231,13 @@ podman build -t myapp . | |||
| 231 | 231 | podman run -p 3000:3000 myapp # Test it! | |
| 232 | 232 | ||
| 233 | 233 | # 2. In gordon.toml, point your domain to the 'latest' tag | |
| 234 | - | # "app.yourdomain.com" = "myapp:latest" | |
| 234 | + | # "app.mydomain.com" = "myapp:latest" | |
| 235 | 235 | ||
| 236 | 236 | # 3. Tag your image as 'latest' for your registry | |
| 237 | - | podman tag myapp registry.yourdomain.com/myapp:latest | |
| 237 | + | podman tag myapp registry.mydomain.com/myapp:latest | |
| 238 | 238 | ||
| 239 | 239 | # 4. Push to deploy | |
| 240 | - | podman push registry.yourdomain.com/myapp:latest | |
| 240 | + | podman push registry.mydomain.com/myapp:latest | |
| 241 | 241 | ||
| 242 | 242 | # Gordon automatically detects the push and deploys the new 'latest' version. | |
| 243 | 243 | ``` | |
| @@ -247,20 +247,18 @@ For more control, especially in production, you can use a combination of version | |||
| 247 | 247 | ||
| 248 | 248 | ```bash | |
| 249 | 249 | # 1. Always push a specific version tag first. This creates a history. | |
| 250 | - | podman tag myapp registry.yourdomain.com/myapp:v1.0.1 | |
| 251 | - | podman push registry.yourdomain.com/myapp:v1.0.1 | |
| 250 | + | podman tag myapp registry.mydomain.com/myapp:v1.0.1 | |
| 251 | + | podman push registry.mydomain.com/myapp:v1.0.1 | |
| 252 | 252 | ||
| 253 | 253 | # 2. Test the new version on a staging domain. | |
| 254 | 254 | # In gordon.toml: | |
| 255 | - | "staging.yourdomain.com" = "myapp:v1.0.1" | |
| 255 | + | "staging.mydomain.com" = "myapp:v1.0.1" | |
| 256 | + | # Get your production route ready : | |
| 257 | + | "app.mydomain.com" = "myapp:latest" | |
| 256 | 258 | ||
| 257 | 259 | # 3. Once tested and confirmed, promote it to production by updating 'latest'. | |
| 258 | - | # This assumes 'myapp' still refers to the image for v1.0.1 | |
| 259 | - | podman tag myapp registry.yourdomain.com/myapp:latest | |
| 260 | - | podman push registry.yourdomain.com/myapp:latest | |
| 261 | - | ||
| 262 | - | # Your production route, which follows 'latest', will now be updated automatically. | |
| 263 | - | # "app.yourdomain.com" = "myapp:latest" | |
| 260 | + | podman tag myapp registry.mydomain.com/myapp:latest | |
| 261 | + | podman push registry.mydomain.com/myapp:latest | |
| 264 | 262 | ``` | |
| 265 | 263 | ||
| 266 | 264 | ### Instant Rollback When Things Break | |
| @@ -269,10 +267,10 @@ podman push registry.yourdomain.com/myapp:latest | |||
| 269 | 267 | # Just edit gordon.toml to point to a previously pushed, stable version tag: | |
| 270 | 268 | ||
| 271 | 269 | # From: | |
| 272 | - | "app.yourdomain.com" = "myapp:latest" | |
| 270 | + | "app.mydomain.com" = "myapp:latest" | |
| 273 | 271 | ||
| 274 | 272 | # To: | |
| 275 | - | "app.yourdomain.com" = "myapp:v1.0.0" | |
| 273 | + | "app.mydomain.com" = "myapp:v1.0.0" | |
| 276 | 274 | ||
| 277 | 275 | # Save the file. Gordon redeploys the stable version in seconds. No scripts, no drama. | |
| 278 | 276 | ``` | |
| @@ -295,8 +293,8 @@ When you push an image with a domain name as the image name, Gordon automaticall | |||
| 295 | 293 | podman build -t myapp . | |
| 296 | 294 | ||
| 297 | 295 | # Tag with domain name as the image name | |
| 298 | - | podman tag myapp:latest registry.yourdomain.com/myapp.mydomain.dev:latest | |
| 299 | - | podman push registry.yourdomain.com/myapp.mydomain.dev:latest | |
| 296 | + | podman tag myapp:latest registry.mydomain.com/myapp.mydomain.dev:latest | |
| 297 | + | podman push registry.mydomain.com/myapp.mydomain.dev:latest | |
| 300 | 298 | ||
| 301 | 299 | # Gordon automatically creates: | |
| 302 | 300 | # "myapp.mydomain.dev" = "myapp.mydomain.dev:latest" | |
| @@ -309,11 +307,11 @@ podman push registry.yourdomain.com/myapp.mydomain.dev:latest | |||
| 309 | 307 | podman build -t myapp . | |
| 310 | 308 | ||
| 311 | 309 | # Push to different test domains using image names | |
| 312 | - | podman tag myapp:latest registry.yourdomain.com/staging.example.com:latest | |
| 313 | - | podman push registry.yourdomain.com/staging.example.com:latest | |
| 310 | + | podman tag myapp:latest registry.mydomain.com/staging.example.com:latest | |
| 311 | + | podman push registry.mydomain.com/staging.example.com:latest | |
| 314 | 312 | ||
| 315 | - | podman tag myapp:latest registry.yourdomain.com/demo.example.com:v1.0.0 | |
| 316 | - | podman push registry.yourdomain.com/demo.example.com:v1.0.0 | |
| 313 | + | podman tag myapp:latest registry.mydomain.com/demo.example.com:v1.0.0 | |
| 314 | + | podman push registry.mydomain.com/demo.example.com:v1.0.0 | |
| 317 | 315 | ||
| 318 | 316 | # Both automatically get their own routes: | |
| 319 | 317 | # staging.example.com -> staging.example.com:latest | |
| @@ -358,7 +356,7 @@ A: You can in theory, but exposing databases on the internet is risky. Database/ | |||
| 358 | 356 | A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <15MB of RAM. | |
| 359 | 357 | ||
| 360 | 358 | **Q: What about secrets?** | |
| 361 | - | A: Use environment variables in your container or Docker secrets. Gordon doesn't interfere. | |
| 359 | + | A: Gordon provides built-in environment variable support with secret management via `pass` and `sops`. See the Environment Variables section below. | |
| 362 | 360 | ||
| 363 | 361 | **Q: How does auto-route creation work?** | |
| 364 | 362 | A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits. | |
| @@ -420,7 +418,126 @@ Gordon automatically detects available container runtimes in this order: | |||
| 420 | 418 | 2. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`) | |
| 421 | 419 | 3. **Podman root** (`/run/podman/podman.sock`) | |
| 422 | 420 | ||
| 423 | - | ### Port binding | |
| 421 | + | ### Environment Variables & Secret Management | |
| 422 | + | ||
| 423 | + | Gordon supports per-route environment variables with secure secret management. | |
| 424 | + | ||
| 425 | + | #### Configuration | |
| 426 | + | ```toml | |
| 427 | + | [env] | |
| 428 | + | # Directory where .env files are stored for each route | |
| 429 | + | dir = "./data/env" # Default location | |
| 430 | + | # Secret providers for secure credential management | |
| 431 | + | providers = ["pass", "sops"] # Unix password manager and SOPS | |
| 432 | + | ``` | |
| 433 | + | ||
| 434 | + | #### Creating Environment Files | |
| 435 | + | Gordon automatically creates empty `.env` files for all configured routes when it starts up. The files are named after the domain with special characters replaced by underscores: | |
| 436 | + | ||
| 437 | + | ```bash | |
| 438 | + | # Gordon automatically creates these files for you: | |
| 439 | + | # For route "app.mydomain.com" | |
| 440 | + | ./data/env/app_mydomain_com.env | |
| 441 | + | ||
| 442 | + | # For route "api-v2.mydomain.com" | |
| 443 | + | ./data/env/api-v2_mydomain_com.env | |
| 444 | + | ``` | |
| 445 | + | ||
| 446 | + | #### Environment File Format | |
| 447 | + | When Gordon creates env files automatically, they include helpful comments and examples. | |
| 448 | + | ||
| 449 | + | **Security:** Gordon creates the env directory with `0700` permissions and env files with `0600` permissions (owner read/write only) to protect sensitive information. | |
| 450 | + | ||
| 451 | + | ```bash | |
| 452 | + | # ./data/env/app_mydomain_com.env (automatically created by Gordon) | |
| 453 | + | # Environment variables for route: app.mydomain.com | |
| 454 | + | # Image: myapp:latest | |
| 455 | + | ||
| 456 | + | NODE_ENV=production | |
| 457 | + | PORT=3000 | |
| 458 | + | DATABASE_URL=postgresql://localhost:5432/prod | |
| 459 | + | ||
| 460 | + | # Secret references (resolved at container start) | |
| 461 | + | API_KEY=${pass:company/api-key} | |
| 462 | + | JWT_SECRET=${sops:secrets.yaml:app.jwt_secret} | |
| 463 | + | ``` | |
| 464 | + | ||
| 465 | + | #### Secret Providers | |
| 466 | + | ||
| 467 | + | **Unix Password Manager (pass)** | |
| 468 | + | ```bash | |
| 469 | + | # Store secrets with pass | |
| 470 | + | pass insert company/api-key | |
| 471 | + | pass insert company/database-password | |
| 472 | + | ||
| 473 | + | # Reference in .env files | |
| 474 | + | API_KEY=${pass:company/api-key} | |
| 475 | + | DB_PASSWORD=${pass:company/database-password} | |
| 476 | + | ``` | |
| 477 | + | ||
| 478 | + | **SOPS (Secrets OPerationS)** | |
| 479 | + | ```bash | |
| 480 | + | # Create encrypted YAML file | |
| 481 | + | sops secrets.yaml | |
| 482 | + | ||
| 483 | + | # secrets.yaml content: | |
| 484 | + | # app: | |
| 485 | + | # jwt_secret: "super-secret-key" | |
| 486 | + | # database_url: "postgresql://user:pass@db:5432/prod" | |
| 487 | + | ||
| 488 | + | # Reference in .env files | |
| 489 | + | JWT_SECRET=${sops:secrets.yaml:app.jwt_secret} | |
| 490 | + | DATABASE_URL=${sops:secrets.yaml:app.database_url} | |
| 491 | + | ``` | |
| 492 | + | ||
| 493 | + | #### How It Works | |
| 494 | + | 1. Gordon loads the `.env` file for each route during container deployment | |
| 495 | + | 2. Secret references (`${provider:path}`) are resolved using the configured providers | |
| 496 | + | 3. Environment variables are passed to the container at startup | |
| 497 | + | 4. Missing env files are silently ignored (optional env vars) | |
| 498 | + | 5. Secret resolution failures cause deployment to fail for security | |
| 499 | + | ||
| 500 | + | #### Examples | |
| 501 | + | ||
| 502 | + | **Development Setup** | |
| 503 | + | ```toml | |
| 504 | + | # gordon.toml | |
| 505 | + | [env] | |
| 506 | + | dir = "./dev-data/env" | |
| 507 | + | providers = ["pass"] # Optional for dev secrets | |
| 508 | + | ||
| 509 | + | [routes] | |
| 510 | + | "app.local" = "myapp:latest" | |
| 511 | + | ``` | |
| 512 | + | ||
| 513 | + | ```bash | |
| 514 | + | # ./dev-data/env/app_local.env | |
| 515 | + | NODE_ENV=development | |
| 516 | + | DEBUG=true | |
| 517 | + | DATABASE_URL=postgresql://localhost:5432/dev | |
| 518 | + | API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets | |
| 519 | + | ``` | |
| 520 | + | ||
| 521 | + | **Production Setup** | |
| 522 | + | ```toml | |
| 523 | + | # gordon.toml | |
| 524 | + | [env] | |
| 525 | + | dir = "/var/lib/gordon/env" | |
| 526 | + | providers = ["pass", "sops"] | |
| 527 | + | ||
| 528 | + | [routes] | |
| 529 | + | "app.company.com" = "company-app:v2.1.0" | |
| 530 | + | ``` | |
| 531 | + | ||
| 532 | + | ```bash | |
| 533 | + | # /var/lib/gordon/env/app_company_com.env | |
| 534 | + | NODE_ENV=production | |
| 535 | + | DATABASE_URL=postgresql://user:${pass:company/db-password}@db:5432/prod | |
| 536 | + | API_KEY=${sops:company-secrets.yaml:api.key} | |
| 537 | + | JWT_SECRET=${sops:company-secrets.yaml:jwt.secret} | |
| 538 | + | ``` | |
| 539 | + | ||
| 540 | + | ### Port Binding | |
| 424 | 541 | Gordon requires your Dockerfile to explicitly expose ports using the `EXPOSE` instruction. When multiple ports are exposed, Gordon will use the first exposed port for HTTP traffic. | |
| 425 | 542 | ||
| 426 | 543 | Example Dockerfile: | |
| @@ -432,10 +549,10 @@ EXPOSE 8080 | |||
| 432 | 549 | ### Registry Operations | |
| 433 | 550 | ```bash | |
| 434 | 551 | # List images | |
| 435 | - | curl -u admin:password https://registry.yourdomain.com/v2/_catalog | |
| 552 | + | curl -u admin:password https://registry.mydomain.com/v2/_catalog | |
| 436 | 553 | ||
| 437 | 554 | # List tags | |
| 438 | - | curl -u admin:password https://registry.yourdomain.com/v2/myapp/tags/list | |
| 555 | + | curl -u admin:password https://registry.mydomain.com/v2/myapp/tags/list | |
| 439 | 556 | ``` | |
| 440 | 557 | ||
| 441 | 558 | ## Contributing | |
| @@ -29,8 +29,8 @@ podman build -t myapp . | |||||
| 29 | podman run -p 8080:8080 myapp # Works? Great! | 29 | podman run -p 8080:8080 myapp # Works? Great! | ||
| 30 | 30 | ||||
| 31 | # 2. Push to deploy | 31 | # 2. Push to deploy | ||
| 32 | - | podman tag myapp registry.yourdomain.com/myapp:latest | 32 | + | podman tag myapp registry.mydomain.com/myapp:latest |
| 33 | - | podman push registry.yourdomain.com/myapp:latest | 33 | + | podman push registry.mydomain.com/myapp:latest |
| 34 | 34 | ||||
| 35 | # 3. That's it. Gordon deploys it automatically. | 35 | # 3. That's it. Gordon deploys it automatically. | ||
| 36 | ``` | 36 | ``` | ||
| @@ -43,10 +43,10 @@ Something broke? Just change your config: | |||||
| 43 | 43 | ||||
| 44 | ```toml | 44 | ```toml | ||
| 45 | # Before (in gordon.toml) | 45 | # Before (in gordon.toml) | ||
| 46 | - | "app.yourdomain.com" = "myapp:latest" | 46 | + | "app.mydomain.com" = "myapp:latest" |
| 47 | 47 | ||||
| 48 | # After - instant rollback! | 48 | # After - instant rollback! | ||
| 49 | - | "app.yourdomain.com" = "myapp:v1.2.3" | 49 | + | "app.mydomain.com" = "myapp:v1.2.3" |
| 50 | ``` | 50 | ``` | ||
| 51 | 51 | ||||
| 52 | Save the file. Gordon redeploys the previous version. Problem solved in seconds. | 52 | Save the file. Gordon redeploys the previous version. Problem solved in seconds. | ||
| @@ -54,7 +54,7 @@ Save the file. Gordon redeploys the previous version. Problem solved in seconds. | |||||
| 54 | ## Key Features | 54 | ## Key Features | ||
| 55 | 55 | ||||
| 56 | ### Local-First Development | 56 | ### Local-First Development | ||
| 57 | - | **Your machine is the build server.** Test locally with Podman's rootless containers, push when ready. No waiting for remote builds. | 57 | + | **Your machine is the build server.** Test locally with your favorite container engine, push when ready. No waiting for remote builds. |
| 58 | 58 | ||||
| 59 | ### Built-in Container Registry | 59 | ### Built-in Container Registry | ||
| 60 | Your VPS becomes a private container registry (Docker/Podman compatible). No Docker Hub subscription needed. | 60 | Your VPS becomes a private container registry (Docker/Podman compatible). No Docker Hub subscription needed. | ||
| @@ -124,10 +124,10 @@ systemctl --user enable --now podman.socket | |||||
| 124 | mkdir -p ~/.config/containers | 124 | mkdir -p ~/.config/containers | ||
| 125 | tee ~/.config/containers/registries.conf > /dev/null <<EOF | 125 | tee ~/.config/containers/registries.conf > /dev/null <<EOF | ||
| 126 | [registries.search] | 126 | [registries.search] | ||
| 127 | - | registries = ['docker.io', 'registry.yourdomain.com'] | 127 | + | registries = ['docker.io', 'registry.mydomain.com'] |
| 128 | 128 | ||||
| 129 | [registries.insecure] | 129 | [registries.insecure] | ||
| 130 | - | registries = ['registry.yourdomain.com'] | 130 | + | registries = ['registry.mydomain.com'] |
| 131 | 131 | ||||
| 132 | [registries.block] | 132 | [registries.block] | ||
| 133 | registries = [] | 133 | registries = [] | ||
| @@ -147,7 +147,7 @@ sudo mv gordon-linux-amd64 /usr/local/bin/gordon | |||||
| 147 | # (or use --config flag) | 147 | # (or use --config flag) | ||
| 148 | [server] | 148 | [server] | ||
| 149 | port = 8080 | 149 | port = 8080 | ||
| 150 | - | registry_domain = "registry.yourdomain.com" | 150 | + | registry_domain = "registry.mydomain.com" |
| 151 | runtime = "podman-rootless" # Can be auto, docker, podman, podman-rootless | 151 | runtime = "podman-rootless" # Can be auto, docker, podman, podman-rootless | ||
| 152 | 152 | ||||
| 153 | [registry_auth] | 153 | [registry_auth] | ||
| @@ -156,9 +156,9 @@ username = "admin" | |||||
| 156 | password = "your-secure-password" | 156 | password = "your-secure-password" | ||
| 157 | 157 | ||||
| 158 | [routes] | 158 | [routes] | ||
| 159 | - | "app.yourdomain.com" = "myapp:latest" | 159 | + | "app.mydomain.com" = "myapp:latest" |
| 160 | - | "api.yourdomain.com" = "api:v1" | 160 | + | "api.mydomain.com" = "api:v1" |
| 161 | - | "blog.yourdomain.com" = "wordpress:latest" | 161 | + | "blog.mydomain.com" = "wordpress:latest" |
| 162 | 162 | ||||
| 163 | [auto_route] | 163 | [auto_route] | ||
| 164 | enabled = false # Enable automatic route creation | 164 | enabled = false # Enable automatic route creation | ||
| @@ -169,8 +169,8 @@ enabled = false # Enable automatic route creation | |||||
| 169 | 169 | ||||
| 170 | ### 7. Point Cloudflare DNS | 170 | ### 7. Point Cloudflare DNS | ||
| 171 | ``` | 171 | ``` | ||
| 172 | - | A *.yourdomain.com → YOUR_VPS_IP | 172 | + | A *.mydomain.com → YOUR_VPS_IP |
| 173 | - | A yourdomain.com → YOUR_VPS_IP | 173 | + | A mydomain.com → YOUR_VPS_IP |
| 174 | ``` | 174 | ``` | ||
| 175 | 175 | ||||
| 176 | ### 8. Create Systemd Service (Rootless) | 176 | ### 8. Create Systemd Service (Rootless) | ||
| @@ -211,13 +211,13 @@ systemctl --user status gordon | |||||
| 211 | ```bash | 211 | ```bash | ||
| 212 | # Now from your local development machine: | 212 | # Now from your local development machine: | ||
| 213 | # Authenticate with your Gordon registry | 213 | # Authenticate with your Gordon registry | ||
| 214 | - | podman login registry.yourdomain.com | 214 | + | podman login registry.mydomain.com |
| 215 | # Use the username/password from your gordon.toml config | 215 | # Use the username/password from your gordon.toml config | ||
| 216 | 216 | ||||
| 217 | # Deploy your first app (using podman) | 217 | # Deploy your first app (using podman) | ||
| 218 | - | podman tag myapp:latest registry.yourdomain.com/myapp:latest | 218 | + | podman tag myapp:latest registry.mydomain.com/myapp:latest |
| 219 | - | podman push registry.yourdomain.com/myapp:latest | 219 | + | podman push registry.mydomain.com/myapp:latest |
| 220 | - | # Visit https://app.yourdomain.com | 220 | + | # Visit https://app.mydomain.com |
| 221 | ``` | 221 | ``` | ||
| 222 | 222 | ||||
| 223 | ## Real-World Examples | 223 | ## Real-World Examples | ||
| @@ -231,13 +231,13 @@ podman build -t myapp . | |||||
| 231 | podman run -p 3000:3000 myapp # Test it! | 231 | podman run -p 3000:3000 myapp # Test it! | ||
| 232 | 232 | ||||
| 233 | # 2. In gordon.toml, point your domain to the 'latest' tag | 233 | # 2. In gordon.toml, point your domain to the 'latest' tag | ||
| 234 | - | # "app.yourdomain.com" = "myapp:latest" | 234 | + | # "app.mydomain.com" = "myapp:latest" |
| 235 | 235 | ||||
| 236 | # 3. Tag your image as 'latest' for your registry | 236 | # 3. Tag your image as 'latest' for your registry | ||
| 237 | - | podman tag myapp registry.yourdomain.com/myapp:latest | 237 | + | podman tag myapp registry.mydomain.com/myapp:latest |
| 238 | 238 | ||||
| 239 | # 4. Push to deploy | 239 | # 4. Push to deploy | ||
| 240 | - | podman push registry.yourdomain.com/myapp:latest | 240 | + | podman push registry.mydomain.com/myapp:latest |
| 241 | 241 | ||||
| 242 | # Gordon automatically detects the push and deploys the new 'latest' version. | 242 | # Gordon automatically detects the push and deploys the new 'latest' version. | ||
| 243 | ``` | 243 | ``` | ||
| @@ -247,20 +247,18 @@ For more control, especially in production, you can use a combination of version | |||||
| 247 | 247 | ||||
| 248 | ```bash | 248 | ```bash | ||
| 249 | # 1. Always push a specific version tag first. This creates a history. | 249 | # 1. Always push a specific version tag first. This creates a history. | ||
| 250 | - | podman tag myapp registry.yourdomain.com/myapp:v1.0.1 | 250 | + | podman tag myapp registry.mydomain.com/myapp:v1.0.1 |
| 251 | - | podman push registry.yourdomain.com/myapp:v1.0.1 | 251 | + | podman push registry.mydomain.com/myapp:v1.0.1 |
| 252 | 252 | ||||
| 253 | # 2. Test the new version on a staging domain. | 253 | # 2. Test the new version on a staging domain. | ||
| 254 | # In gordon.toml: | 254 | # In gordon.toml: | ||
| 255 | - | "staging.yourdomain.com" = "myapp:v1.0.1" | 255 | + | "staging.mydomain.com" = "myapp:v1.0.1" |
| 256 | + | # Get your production route ready : | |||
| 257 | + | "app.mydomain.com" = "myapp:latest" | |||
| 256 | 258 | ||||
| 257 | # 3. Once tested and confirmed, promote it to production by updating 'latest'. | 259 | # 3. Once tested and confirmed, promote it to production by updating 'latest'. | ||
| 258 | - | # This assumes 'myapp' still refers to the image for v1.0.1 | 260 | + | podman tag myapp registry.mydomain.com/myapp:latest |
| 259 | - | podman tag myapp registry.yourdomain.com/myapp:latest | 261 | + | podman push registry.mydomain.com/myapp:latest |
| 260 | - | podman push registry.yourdomain.com/myapp:latest | |||
| 261 | - | ||||
| 262 | - | # Your production route, which follows 'latest', will now be updated automatically. | |||
| 263 | - | # "app.yourdomain.com" = "myapp:latest" | |||
| 264 | ``` | 262 | ``` | ||
| 265 | 263 | ||||
| 266 | ### Instant Rollback When Things Break | 264 | ### Instant Rollback When Things Break | ||
| @@ -269,10 +267,10 @@ podman push registry.yourdomain.com/myapp:latest | |||||
| 269 | # Just edit gordon.toml to point to a previously pushed, stable version tag: | 267 | # Just edit gordon.toml to point to a previously pushed, stable version tag: | ||
| 270 | 268 | ||||
| 271 | # From: | 269 | # From: | ||
| 272 | - | "app.yourdomain.com" = "myapp:latest" | 270 | + | "app.mydomain.com" = "myapp:latest" |
| 273 | 271 | ||||
| 274 | # To: | 272 | # To: | ||
| 275 | - | "app.yourdomain.com" = "myapp:v1.0.0" | 273 | + | "app.mydomain.com" = "myapp:v1.0.0" |
| 276 | 274 | ||||
| 277 | # Save the file. Gordon redeploys the stable version in seconds. No scripts, no drama. | 275 | # Save the file. Gordon redeploys the stable version in seconds. No scripts, no drama. | ||
| 278 | ``` | 276 | ``` | ||
| @@ -295,8 +293,8 @@ When you push an image with a domain name as the image name, Gordon automaticall | |||||
| 295 | podman build -t myapp . | 293 | podman build -t myapp . | ||
| 296 | 294 | ||||
| 297 | # Tag with domain name as the image name | 295 | # Tag with domain name as the image name | ||
| 298 | - | podman tag myapp:latest registry.yourdomain.com/myapp.mydomain.dev:latest | 296 | + | podman tag myapp:latest registry.mydomain.com/myapp.mydomain.dev:latest |
| 299 | - | podman push registry.yourdomain.com/myapp.mydomain.dev:latest | 297 | + | podman push registry.mydomain.com/myapp.mydomain.dev:latest |
| 300 | 298 | ||||
| 301 | # Gordon automatically creates: | 299 | # Gordon automatically creates: | ||
| 302 | # "myapp.mydomain.dev" = "myapp.mydomain.dev:latest" | 300 | # "myapp.mydomain.dev" = "myapp.mydomain.dev:latest" | ||
| @@ -309,11 +307,11 @@ podman push registry.yourdomain.com/myapp.mydomain.dev:latest | |||||
| 309 | podman build -t myapp . | 307 | podman build -t myapp . | ||
| 310 | 308 | ||||
| 311 | # Push to different test domains using image names | 309 | # Push to different test domains using image names | ||
| 312 | - | podman tag myapp:latest registry.yourdomain.com/staging.example.com:latest | 310 | + | podman tag myapp:latest registry.mydomain.com/staging.example.com:latest |
| 313 | - | podman push registry.yourdomain.com/staging.example.com:latest | 311 | + | podman push registry.mydomain.com/staging.example.com:latest |
| 314 | 312 | ||||
| 315 | - | podman tag myapp:latest registry.yourdomain.com/demo.example.com:v1.0.0 | 313 | + | podman tag myapp:latest registry.mydomain.com/demo.example.com:v1.0.0 |
| 316 | - | podman push registry.yourdomain.com/demo.example.com:v1.0.0 | 314 | + | podman push registry.mydomain.com/demo.example.com:v1.0.0 |
| 317 | 315 | ||||
| 318 | # Both automatically get their own routes: | 316 | # Both automatically get their own routes: | ||
| 319 | # staging.example.com -> staging.example.com:latest | 317 | # staging.example.com -> staging.example.com:latest | ||
| @@ -358,7 +356,7 @@ A: You can in theory, but exposing databases on the internet is risky. Database/ | |||||
| 358 | A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <15MB of RAM. | 356 | A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <15MB of RAM. | ||
| 359 | 357 | ||||
| 360 | **Q: What about secrets?** | 358 | **Q: What about secrets?** | ||
| 361 | - | A: Use environment variables in your container or Docker secrets. Gordon doesn't interfere. | 359 | + | A: Gordon provides built-in environment variable support with secret management via `pass` and `sops`. See the Environment Variables section below. |
| 362 | 360 | ||||
| 363 | **Q: How does auto-route creation work?** | 361 | **Q: How does auto-route creation work?** | ||
| 364 | A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits. | 362 | A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits. | ||
| @@ -420,7 +418,126 @@ Gordon automatically detects available container runtimes in this order: | |||||
| 420 | 2. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`) | 418 | 2. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`) | ||
| 421 | 3. **Podman root** (`/run/podman/podman.sock`) | 419 | 3. **Podman root** (`/run/podman/podman.sock`) | ||
| 422 | 420 | ||||
| 423 | - | ### Port binding | 421 | + | ### Environment Variables & Secret Management |
| 422 | + | ||||
| 423 | + | Gordon supports per-route environment variables with secure secret management. | |||
| 424 | + | ||||
| 425 | + | #### Configuration | |||
| 426 | + | ```toml | |||
| 427 | + | [env] | |||
| 428 | + | # Directory where .env files are stored for each route | |||
| 429 | + | dir = "./data/env" # Default location | |||
| 430 | + | # Secret providers for secure credential management | |||
| 431 | + | providers = ["pass", "sops"] # Unix password manager and SOPS | |||
| 432 | + | ``` | |||
| 433 | + | ||||
| 434 | + | #### Creating Environment Files | |||
| 435 | + | Gordon automatically creates empty `.env` files for all configured routes when it starts up. The files are named after the domain with special characters replaced by underscores: | |||
| 436 | + | ||||
| 437 | + | ```bash | |||
| 438 | + | # Gordon automatically creates these files for you: | |||
| 439 | + | # For route "app.mydomain.com" | |||
| 440 | + | ./data/env/app_mydomain_com.env | |||
| 441 | + | ||||
| 442 | + | # For route "api-v2.mydomain.com" | |||
| 443 | + | ./data/env/api-v2_mydomain_com.env | |||
| 444 | + | ``` | |||
| 445 | + | ||||
| 446 | + | #### Environment File Format | |||
| 447 | + | When Gordon creates env files automatically, they include helpful comments and examples. | |||
| 448 | + | ||||
| 449 | + | **Security:** Gordon creates the env directory with `0700` permissions and env files with `0600` permissions (owner read/write only) to protect sensitive information. | |||
| 450 | + | ||||
| 451 | + | ```bash | |||
| 452 | + | # ./data/env/app_mydomain_com.env (automatically created by Gordon) | |||
| 453 | + | # Environment variables for route: app.mydomain.com | |||
| 454 | + | # Image: myapp:latest | |||
| 455 | + | ||||
| 456 | + | NODE_ENV=production | |||
| 457 | + | PORT=3000 | |||
| 458 | + | DATABASE_URL=postgresql://localhost:5432/prod | |||
| 459 | + | ||||
| 460 | + | # Secret references (resolved at container start) | |||
| 461 | + | API_KEY=${pass:company/api-key} | |||
| 462 | + | JWT_SECRET=${sops:secrets.yaml:app.jwt_secret} | |||
| 463 | + | ``` | |||
| 464 | + | ||||
| 465 | + | #### Secret Providers | |||
| 466 | + | ||||
| 467 | + | **Unix Password Manager (pass)** | |||
| 468 | + | ```bash | |||
| 469 | + | # Store secrets with pass | |||
| 470 | + | pass insert company/api-key | |||
| 471 | + | pass insert company/database-password | |||
| 472 | + | ||||
| 473 | + | # Reference in .env files | |||
| 474 | + | API_KEY=${pass:company/api-key} | |||
| 475 | + | DB_PASSWORD=${pass:company/database-password} | |||
| 476 | + | ``` | |||
| 477 | + | ||||
| 478 | + | **SOPS (Secrets OPerationS)** | |||
| 479 | + | ```bash | |||
| 480 | + | # Create encrypted YAML file | |||
| 481 | + | sops secrets.yaml | |||
| 482 | + | ||||
| 483 | + | # secrets.yaml content: | |||
| 484 | + | # app: | |||
| 485 | + | # jwt_secret: "super-secret-key" | |||
| 486 | + | # database_url: "postgresql://user:pass@db:5432/prod" | |||
| 487 | + | ||||
| 488 | + | # Reference in .env files | |||
| 489 | + | JWT_SECRET=${sops:secrets.yaml:app.jwt_secret} | |||
| 490 | + | DATABASE_URL=${sops:secrets.yaml:app.database_url} | |||
| 491 | + | ``` | |||
| 492 | + | ||||
| 493 | + | #### How It Works | |||
| 494 | + | 1. Gordon loads the `.env` file for each route during container deployment | |||
| 495 | + | 2. Secret references (`${provider:path}`) are resolved using the configured providers | |||
| 496 | + | 3. Environment variables are passed to the container at startup | |||
| 497 | + | 4. Missing env files are silently ignored (optional env vars) | |||
| 498 | + | 5. Secret resolution failures cause deployment to fail for security | |||
| 499 | + | ||||
| 500 | + | #### Examples | |||
| 501 | + | ||||
| 502 | + | **Development Setup** | |||
| 503 | + | ```toml | |||
| 504 | + | # gordon.toml | |||
| 505 | + | [env] | |||
| 506 | + | dir = "./dev-data/env" | |||
| 507 | + | providers = ["pass"] # Optional for dev secrets | |||
| 508 | + | ||||
| 509 | + | [routes] | |||
| 510 | + | "app.local" = "myapp:latest" | |||
| 511 | + | ``` | |||
| 512 | + | ||||
| 513 | + | ```bash | |||
| 514 | + | # ./dev-data/env/app_local.env | |||
| 515 | + | NODE_ENV=development | |||
| 516 | + | DEBUG=true | |||
| 517 | + | DATABASE_URL=postgresql://localhost:5432/dev | |||
| 518 | + | API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets | |||
| 519 | + | ``` | |||
| 520 | + | ||||
| 521 | + | **Production Setup** | |||
| 522 | + | ```toml | |||
| 523 | + | # gordon.toml | |||
| 524 | + | [env] | |||
| 525 | + | dir = "/var/lib/gordon/env" | |||
| 526 | + | providers = ["pass", "sops"] | |||
| 527 | + | ||||
| 528 | + | [routes] | |||
| 529 | + | "app.company.com" = "company-app:v2.1.0" | |||
| 530 | + | ``` | |||
| 531 | + | ||||
| 532 | + | ```bash | |||
| 533 | + | # /var/lib/gordon/env/app_company_com.env | |||
| 534 | + | NODE_ENV=production | |||
| 535 | + | DATABASE_URL=postgresql://user:${pass:company/db-password}@db:5432/prod | |||
| 536 | + | API_KEY=${sops:company-secrets.yaml:api.key} | |||
| 537 | + | JWT_SECRET=${sops:company-secrets.yaml:jwt.secret} | |||
| 538 | + | ``` | |||
| 539 | + | ||||
| 540 | + | ### Port Binding | |||
| 424 | Gordon requires your Dockerfile to explicitly expose ports using the `EXPOSE` instruction. When multiple ports are exposed, Gordon will use the first exposed port for HTTP traffic. | 541 | Gordon requires your Dockerfile to explicitly expose ports using the `EXPOSE` instruction. When multiple ports are exposed, Gordon will use the first exposed port for HTTP traffic. | ||
| 425 | 542 | ||||
| 426 | Example Dockerfile: | 543 | Example Dockerfile: | ||
| @@ -432,10 +549,10 @@ EXPOSE 8080 | |||||
| 432 | ### Registry Operations | 549 | ### Registry Operations | ||
| 433 | ```bash | 550 | ```bash | ||
| 434 | # List images | 551 | # List images | ||
| 435 | - | curl -u admin:password https://registry.yourdomain.com/v2/_catalog | 552 | + | curl -u admin:password https://registry.mydomain.com/v2/_catalog |
| 436 | 553 | ||||
| 437 | # List tags | 554 | # List tags | ||
| 438 | - | curl -u admin:password https://registry.yourdomain.com/v2/myapp/tags/list | 555 | + | curl -u admin:password https://registry.mydomain.com/v2/myapp/tags/list |
| 439 | ``` | 556 | ``` | ||
| 440 | 557 | ||||
| 441 | ## Contributing | 558 | ## Contributing | ||
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 | + | } | |
| @@ -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 | + | } | |||
M cmd/start.go +68 -2
| @@ -2,8 +2,10 @@ package cmd | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "os" | |
| 6 | 7 | "os/signal" | |
| 8 | + | "path/filepath" | |
| 7 | 9 | "sync" | |
| 8 | 10 | "syscall" | |
| 9 | 11 | "time" | |
| @@ -46,6 +48,12 @@ func runStart(cmd *cobra.Command, args []string) { | |||
| 46 | 48 | log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server") | |
| 47 | 49 | log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server") | |
| 48 | 50 | log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime") | |
| 51 | + | ||
| 52 | + | // Create PID file for reload command | |
| 53 | + | pidFile := createPidFile() | |
| 54 | + | if pidFile != "" { | |
| 55 | + | defer removePidFile(pidFile) | |
| 56 | + | } | |
| 49 | 57 | ||
| 50 | 58 | // Initialize event bus | |
| 51 | 59 | eventBus := events.NewInMemoryEventBus(100) | |
| @@ -118,6 +126,9 @@ func runStart(cmd *cobra.Command, args []string) { | |||
| 118 | 126 | // Update proxy server config | |
| 119 | 127 | proxyServer.UpdateConfig(cfg) | |
| 120 | 128 | ||
| 129 | + | // Update container manager config (includes env file creation for new routes) | |
| 130 | + | manager.UpdateConfig(cfg) | |
| 131 | + | ||
| 121 | 132 | // Unsubscribe old handlers before creating new ones | |
| 122 | 133 | if err := eventBus.Unsubscribe(containerHandler); err != nil { | |
| 123 | 134 | log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler") | |
| @@ -194,10 +205,30 @@ func runStart(cmd *cobra.Command, args []string) { | |||
| 194 | 205 | } | |
| 195 | 206 | }() | |
| 196 | 207 | ||
| 197 | - | // Wait for interrupt signal | |
| 208 | + | // Wait for interrupt or reload signals | |
| 198 | 209 | sigChan := make(chan os.Signal, 1) | |
| 210 | + | reloadChan := make(chan os.Signal, 1) | |
| 199 | 211 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | |
| 200 | - | <-sigChan | |
| 212 | + | signal.Notify(reloadChan, syscall.SIGUSR1) | |
| 213 | + | ||
| 214 | + | // Handle signals in a loop | |
| 215 | + | for { | |
| 216 | + | select { | |
| 217 | + | case <-sigChan: | |
| 218 | + | log.Info().Msg("Received shutdown signal") | |
| 219 | + | goto shutdown | |
| 220 | + | case <-reloadChan: | |
| 221 | + | log.Info().Msg("Received manual reload signal (SIGUSR1)") | |
| 222 | + | // Trigger manual reload event | |
| 223 | + | if err := eventBus.Publish(events.ManualReload, nil); err != nil { | |
| 224 | + | log.Error().Err(err).Msg("Failed to publish manual reload event") | |
| 225 | + | } else { | |
| 226 | + | log.Info().Msg("Manual reload event published successfully") | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | shutdown: | |
| 201 | 232 | ||
| 202 | 233 | log.Info().Msg("Shutting down servers...") | |
| 203 | 234 | cancel() | |
| @@ -230,3 +261,38 @@ func runStart(cmd *cobra.Command, args []string) { | |||
| 230 | 261 | log.Warn().Msg("Force shutdown after timeout") | |
| 231 | 262 | } | |
| 232 | 263 | } | |
| 264 | + | ||
| 265 | + | // createPidFile creates a PID file for the Gordon process | |
| 266 | + | func createPidFile() string { | |
| 267 | + | pid := os.Getpid() | |
| 268 | + | ||
| 269 | + | // Try multiple locations for the PID file | |
| 270 | + | locations := []string{ | |
| 271 | + | "/tmp/gordon.pid", | |
| 272 | + | filepath.Join(os.TempDir(), "gordon.pid"), | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Also try user's home directory | |
| 276 | + | if homeDir, err := os.UserHomeDir(); err == nil { | |
| 277 | + | locations = append(locations, filepath.Join(homeDir, ".gordon.pid")) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | for _, location := range locations { | |
| 281 | + | if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0644); err == nil { | |
| 282 | + | log.Debug().Str("pid_file", location).Int("pid", pid).Msg("Created PID file") | |
| 283 | + | return location | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | log.Warn().Int("pid", pid).Msg("Failed to create PID file in any location") | |
| 288 | + | return "" | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // removePidFile removes the PID file | |
| 292 | + | func removePidFile(pidFile string) { | |
| 293 | + | if err := os.Remove(pidFile); err != nil { | |
| 294 | + | log.Warn().Err(err).Str("pid_file", pidFile).Msg("Failed to remove PID file") | |
| 295 | + | } else { | |
| 296 | + | log.Debug().Str("pid_file", pidFile).Msg("Removed PID file") | |
| 297 | + | } | |
| 298 | + | } | |
| @@ -2,8 +2,10 @@ package cmd | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "os/signal" | 7 | "os/signal" | ||
| 8 | + | "path/filepath" | |||
| 7 | "sync" | 9 | "sync" | ||
| 8 | "syscall" | 10 | "syscall" | ||
| 9 | "time" | 11 | "time" | ||
| @@ -46,6 +48,12 @@ func runStart(cmd *cobra.Command, args []string) { | |||||
| 46 | log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server") | 48 | log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server") | ||
| 47 | log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server") | 49 | log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server") | ||
| 48 | log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime") | 50 | log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime") | ||
| 51 | + | ||||
| 52 | + | // Create PID file for reload command | |||
| 53 | + | pidFile := createPidFile() | |||
| 54 | + | if pidFile != "" { | |||
| 55 | + | defer removePidFile(pidFile) | |||
| 56 | + | } | |||
| 49 | 57 | ||||
| 50 | // Initialize event bus | 58 | // Initialize event bus | ||
| 51 | eventBus := events.NewInMemoryEventBus(100) | 59 | eventBus := events.NewInMemoryEventBus(100) | ||
| @@ -118,6 +126,9 @@ func runStart(cmd *cobra.Command, args []string) { | |||||
| 118 | // Update proxy server config | 126 | // Update proxy server config | ||
| 119 | proxyServer.UpdateConfig(cfg) | 127 | proxyServer.UpdateConfig(cfg) | ||
| 120 | 128 | ||||
| 129 | + | // Update container manager config (includes env file creation for new routes) | |||
| 130 | + | manager.UpdateConfig(cfg) | |||
| 131 | + | ||||
| 121 | // Unsubscribe old handlers before creating new ones | 132 | // Unsubscribe old handlers before creating new ones | ||
| 122 | if err := eventBus.Unsubscribe(containerHandler); err != nil { | 133 | if err := eventBus.Unsubscribe(containerHandler); err != nil { | ||
| 123 | log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler") | 134 | log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler") | ||
| @@ -194,10 +205,30 @@ func runStart(cmd *cobra.Command, args []string) { | |||||
| 194 | } | 205 | } | ||
| 195 | }() | 206 | }() | ||
| 196 | 207 | ||||
| 197 | - | // Wait for interrupt signal | 208 | + | // Wait for interrupt or reload signals |
| 198 | sigChan := make(chan os.Signal, 1) | 209 | sigChan := make(chan os.Signal, 1) | ||
| 210 | + | reloadChan := make(chan os.Signal, 1) | |||
| 199 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | 211 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | ||
| 200 | - | <-sigChan | 212 | + | signal.Notify(reloadChan, syscall.SIGUSR1) |
| 213 | + | ||||
| 214 | + | // Handle signals in a loop | |||
| 215 | + | for { | |||
| 216 | + | select { | |||
| 217 | + | case <-sigChan: | |||
| 218 | + | log.Info().Msg("Received shutdown signal") | |||
| 219 | + | goto shutdown | |||
| 220 | + | case <-reloadChan: | |||
| 221 | + | log.Info().Msg("Received manual reload signal (SIGUSR1)") | |||
| 222 | + | // Trigger manual reload event | |||
| 223 | + | if err := eventBus.Publish(events.ManualReload, nil); err != nil { | |||
| 224 | + | log.Error().Err(err).Msg("Failed to publish manual reload event") | |||
| 225 | + | } else { | |||
| 226 | + | log.Info().Msg("Manual reload event published successfully") | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | shutdown: | |||
| 201 | 232 | ||||
| 202 | log.Info().Msg("Shutting down servers...") | 233 | log.Info().Msg("Shutting down servers...") | ||
| 203 | cancel() | 234 | cancel() | ||
| @@ -230,3 +261,38 @@ func runStart(cmd *cobra.Command, args []string) { | |||||
| 230 | log.Warn().Msg("Force shutdown after timeout") | 261 | log.Warn().Msg("Force shutdown after timeout") | ||
| 231 | } | 262 | } | ||
| 232 | } | 263 | } | ||
| 264 | + | ||||
| 265 | + | // createPidFile creates a PID file for the Gordon process | |||
| 266 | + | func createPidFile() string { | |||
| 267 | + | pid := os.Getpid() | |||
| 268 | + | ||||
| 269 | + | // Try multiple locations for the PID file | |||
| 270 | + | locations := []string{ | |||
| 271 | + | "/tmp/gordon.pid", | |||
| 272 | + | filepath.Join(os.TempDir(), "gordon.pid"), | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Also try user's home directory | |||
| 276 | + | if homeDir, err := os.UserHomeDir(); err == nil { | |||
| 277 | + | locations = append(locations, filepath.Join(homeDir, ".gordon.pid")) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | for _, location := range locations { | |||
| 281 | + | if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0644); err == nil { | |||
| 282 | + | log.Debug().Str("pid_file", location).Int("pid", pid).Msg("Created PID file") | |||
| 283 | + | return location | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | log.Warn().Int("pid", pid).Msg("Failed to create PID file in any location") | |||
| 288 | + | return "" | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // removePidFile removes the PID file | |||
| 292 | + | func removePidFile(pidFile string) { | |||
| 293 | + | if err := os.Remove(pidFile); err != nil { | |||
| 294 | + | log.Warn().Err(err).Str("pid_file", pidFile).Msg("Failed to remove PID file") | |||
| 295 | + | } else { | |||
| 296 | + | log.Debug().Str("pid_file", pidFile).Msg("Removed PID file") | |||
| 297 | + | } | |||
| 298 | + | } | |||
M examples/development.toml +22 -2
| @@ -15,6 +15,13 @@ enabled = false # Disable auth for easier local development | |||
| 15 | 15 | [auto_route] | |
| 16 | 16 | enabled = true # Enable automatic route creation from image names | |
| 17 | 17 | ||
| 18 | + | # Environment variables configuration | |
| 19 | + | [env] | |
| 20 | + | # Directory where .env files are stored for each route | |
| 21 | + | dir = "./dev-data/env" | |
| 22 | + | # Secret providers (optional for development) | |
| 23 | + | providers = ["pass"] # For development secrets | |
| 24 | + | ||
| 18 | 25 | # Development routes using your domain | |
| 19 | 26 | [routes] | |
| 20 | 27 | "app.yourdomain.com" = "myapp:latest" # Main app - latest build | |
| @@ -30,16 +37,29 @@ enabled = true # Enable automatic route creation from image names | |||
| 30 | 37 | "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin | |
| 31 | 38 | "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing | |
| 32 | 39 | ||
| 40 | + | # Environment Variables Usage: | |
| 41 | + | # | |
| 42 | + | # For development, create .env files for your routes: | |
| 43 | + | # ./dev-data/env/app_yourdomain_com.env | |
| 44 | + | # ./dev-data/env/api_yourdomain_com.env | |
| 45 | + | # | |
| 46 | + | # Example env file content: | |
| 47 | + | # NODE_ENV=development | |
| 48 | + | # DEBUG=true | |
| 49 | + | # DATABASE_URL=postgresql://localhost:5432/dev | |
| 50 | + | # API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets | |
| 51 | + | ||
| 33 | 52 | # Development workflow: | |
| 34 | 53 | # 1. Point DNS to your Gordon server: | |
| 35 | 54 | # A *.yourdomain.com → YOUR_GORDON_SERVER_IP | |
| 36 | 55 | # A yourdomain.com → YOUR_GORDON_SERVER_IP | |
| 37 | 56 | # | |
| 38 | 57 | # 2. Build your app: docker build -t myapp:latest . | |
| 39 | - | # 3. Push to Gordon registry: | |
| 58 | + | # 3. Create env file: ./dev-data/env/app_yourdomain_com.env | |
| 59 | + | # 4. Push to Gordon registry: | |
| 40 | 60 | # docker tag myapp:latest registry.yourdomain.com/myapp:latest | |
| 41 | 61 | # docker push registry.yourdomain.com/myapp:latest | |
| 42 | - | # 4. Access at https://app.yourdomain.com | |
| 62 | + | # 5. Access at https://app.yourdomain.com | |
| 43 | 63 | # | |
| 44 | 64 | # AUTO-ROUTE FEATURE: | |
| 45 | 65 | # With auto_route.enabled = true, you can also push images with domain names as image names: | |
| @@ -15,6 +15,13 @@ enabled = false # Disable auth for easier local development | |||||
| 15 | [auto_route] | 15 | [auto_route] | ||
| 16 | enabled = true # Enable automatic route creation from image names | 16 | enabled = true # Enable automatic route creation from image names | ||
| 17 | 17 | ||||
| 18 | + | # Environment variables configuration | |||
| 19 | + | [env] | |||
| 20 | + | # Directory where .env files are stored for each route | |||
| 21 | + | dir = "./dev-data/env" | |||
| 22 | + | # Secret providers (optional for development) | |||
| 23 | + | providers = ["pass"] # For development secrets | |||
| 24 | + | ||||
| 18 | # Development routes using your domain | 25 | # Development routes using your domain | ||
| 19 | [routes] | 26 | [routes] | ||
| 20 | "app.yourdomain.com" = "myapp:latest" # Main app - latest build | 27 | "app.yourdomain.com" = "myapp:latest" # Main app - latest build | ||
| @@ -30,16 +37,29 @@ enabled = true # Enable automatic route creation from image names | |||||
| 30 | "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin | 37 | "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin | ||
| 31 | "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing | 38 | "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing | ||
| 32 | 39 | ||||
| 40 | + | # Environment Variables Usage: | |||
| 41 | + | # | |||
| 42 | + | # For development, create .env files for your routes: | |||
| 43 | + | # ./dev-data/env/app_yourdomain_com.env | |||
| 44 | + | # ./dev-data/env/api_yourdomain_com.env | |||
| 45 | + | # | |||
| 46 | + | # Example env file content: | |||
| 47 | + | # NODE_ENV=development | |||
| 48 | + | # DEBUG=true | |||
| 49 | + | # DATABASE_URL=postgresql://localhost:5432/dev | |||
| 50 | + | # API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets | |||
| 51 | + | ||||
| 33 | # Development workflow: | 52 | # Development workflow: | ||
| 34 | # 1. Point DNS to your Gordon server: | 53 | # 1. Point DNS to your Gordon server: | ||
| 35 | # A *.yourdomain.com → YOUR_GORDON_SERVER_IP | 54 | # A *.yourdomain.com → YOUR_GORDON_SERVER_IP | ||
| 36 | # A yourdomain.com → YOUR_GORDON_SERVER_IP | 55 | # A yourdomain.com → YOUR_GORDON_SERVER_IP | ||
| 37 | # | 56 | # | ||
| 38 | # 2. Build your app: docker build -t myapp:latest . | 57 | # 2. Build your app: docker build -t myapp:latest . | ||
| 39 | - | # 3. Push to Gordon registry: | 58 | + | # 3. Create env file: ./dev-data/env/app_yourdomain_com.env |
| 59 | + | # 4. Push to Gordon registry: | |||
| 40 | # docker tag myapp:latest registry.yourdomain.com/myapp:latest | 60 | # docker tag myapp:latest registry.yourdomain.com/myapp:latest | ||
| 41 | # docker push registry.yourdomain.com/myapp:latest | 61 | # docker push registry.yourdomain.com/myapp:latest | ||
| 42 | - | # 4. Access at https://app.yourdomain.com | 62 | + | # 5. Access at https://app.yourdomain.com |
| 43 | # | 63 | # | ||
| 44 | # AUTO-ROUTE FEATURE: | 64 | # AUTO-ROUTE FEATURE: | ||
| 45 | # With auto_route.enabled = true, you can also push images with domain names as image names: | 65 | # With auto_route.enabled = true, you can also push images with domain names as image names: | ||
A examples/minimal.toml +41 -0
| @@ -0,0 +1,41 @@ | |||
| 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 | + | # Environment variables (optional) | |
| 13 | + | [env] | |
| 14 | + | dir = "./data/env" # Default location | |
| 15 | + | ||
| 16 | + | # Simple routes | |
| 17 | + | [routes] | |
| 18 | + | "app.local" = "myapp:latest" | |
| 19 | + | "api.local" = "myapi:latest" | |
| 20 | + | ||
| 21 | + | # Usage: | |
| 22 | + | # 1. Create env files (optional): | |
| 23 | + | # ./data/env/app_local.env | |
| 24 | + | # ./data/env/api_local.env | |
| 25 | + | # | |
| 26 | + | # 2. Example env file content: | |
| 27 | + | # NODE_ENV=production | |
| 28 | + | # PORT=3000 | |
| 29 | + | # DATABASE_URL=postgresql://localhost:5432/app | |
| 30 | + | # | |
| 31 | + | # 3. Add to /etc/hosts: | |
| 32 | + | # 127.0.0.1 app.local | |
| 33 | + | # 127.0.0.1 api.local | |
| 34 | + | # 127.0.0.1 registry.local | |
| 35 | + | # | |
| 36 | + | # 4. Build and push: | |
| 37 | + | # docker build -t myapp:latest . | |
| 38 | + | # docker tag myapp:latest registry.local/myapp:latest | |
| 39 | + | # docker push registry.local/myapp:latest | |
| 40 | + | # | |
| 41 | + | # 5. Access at http://app.local:8080⏎̸ | |
| @@ -0,0 +1,41 @@ | |||||
| 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 | + | # Environment variables (optional) | |||
| 13 | + | [env] | |||
| 14 | + | dir = "./data/env" # Default location | |||
| 15 | + | ||||
| 16 | + | # Simple routes | |||
| 17 | + | [routes] | |||
| 18 | + | "app.local" = "myapp:latest" | |||
| 19 | + | "api.local" = "myapi:latest" | |||
| 20 | + | ||||
| 21 | + | # Usage: | |||
| 22 | + | # 1. Create env files (optional): | |||
| 23 | + | # ./data/env/app_local.env | |||
| 24 | + | # ./data/env/api_local.env | |||
| 25 | + | # | |||
| 26 | + | # 2. Example env file content: | |||
| 27 | + | # NODE_ENV=production | |||
| 28 | + | # PORT=3000 | |||
| 29 | + | # DATABASE_URL=postgresql://localhost:5432/app | |||
| 30 | + | # | |||
| 31 | + | # 3. Add to /etc/hosts: | |||
| 32 | + | # 127.0.0.1 app.local | |||
| 33 | + | # 127.0.0.1 api.local | |||
| 34 | + | # 127.0.0.1 registry.local | |||
| 35 | + | # | |||
| 36 | + | # 4. Build and push: | |||
| 37 | + | # docker build -t myapp:latest . | |||
| 38 | + | # docker tag myapp:latest registry.local/myapp:latest | |||
| 39 | + | # docker push registry.local/myapp:latest | |||
| 40 | + | # | |||
| 41 | + | # 5. Access at http://app.local:8080⏎̸ | |||
M examples/production.toml +27 -3
| @@ -15,6 +15,13 @@ enabled = true | |||
| 15 | 15 | username = "deploy-bot" | |
| 16 | 16 | password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production! | |
| 17 | 17 | ||
| 18 | + | # Environment variables configuration | |
| 19 | + | [env] | |
| 20 | + | # Directory where .env files are stored for each route | |
| 21 | + | dir = "/var/lib/gordon/env" | |
| 22 | + | # Secret providers for secure credential management | |
| 23 | + | providers = ["pass", "sops"] | |
| 24 | + | ||
| 18 | 25 | # Production service routes | |
| 19 | 26 | [routes] | |
| 20 | 27 | "app.company.com" = "company-app:v2.1.0" # Main app with pinned version | |
| @@ -27,10 +34,27 @@ password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in productio | |||
| 27 | 34 | "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard | |
| 28 | 35 | "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection | |
| 29 | 36 | ||
| 37 | + | # Environment Variables Usage: | |
| 38 | + | # | |
| 39 | + | # For each route, create a .env file named after the domain: | |
| 40 | + | # /var/lib/gordon/env/app_company_com.env | |
| 41 | + | # /var/lib/gordon/env/api_company_com.env | |
| 42 | + | # | |
| 43 | + | # Example env file content: | |
| 44 | + | # NODE_ENV=production | |
| 45 | + | # DATABASE_URL=postgresql://user:pass@db:5432/prod | |
| 46 | + | # API_KEY=${pass:company/api-key} # From pass | |
| 47 | + | # SECRET_KEY=${sops:secrets.yaml:app.secret} # From sops | |
| 48 | + | # | |
| 49 | + | # Secret syntax: | |
| 50 | + | # ${pass:path/to/secret} - Unix password manager | |
| 51 | + | # ${sops:file.yaml:key.path} - SOPS encrypted file | |
| 52 | + | ||
| 30 | 53 | # Production deployment workflow: | |
| 31 | 54 | # 1. Build and tag: docker build -t company-app:v2.1.1 . | |
| 32 | 55 | # 2. Tag for registry: docker tag company-app:v2.1.1 registry.company.com/company-app:v2.1.1 | |
| 33 | 56 | # 3. Update this config: "app.company.com" = "company-app:v2.1.1" | |
| 34 | - | # 4. Restart Gordon to reload config | |
| 35 | - | # 5. Push to deploy: docker push registry.company.com/company-app:v2.1.1 | |
| 36 | - | # 6. Gordon automatically deploys to app.company.com!⏎̸ | |
| 57 | + | # 4. Create/update env file: /var/lib/gordon/env/app_company_com.env | |
| 58 | + | # 5. Restart Gordon to reload config | |
| 59 | + | # 6. Push to deploy: docker push registry.company.com/company-app:v2.1.1 | |
| 60 | + | # 7. Gordon automatically deploys to app.company.com with environment variables!⏎̸ | |
| @@ -15,6 +15,13 @@ enabled = true | |||||
| 15 | username = "deploy-bot" | 15 | username = "deploy-bot" | ||
| 16 | password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production! | 16 | password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production! | ||
| 17 | 17 | ||||
| 18 | + | # Environment variables configuration | |||
| 19 | + | [env] | |||
| 20 | + | # Directory where .env files are stored for each route | |||
| 21 | + | dir = "/var/lib/gordon/env" | |||
| 22 | + | # Secret providers for secure credential management | |||
| 23 | + | providers = ["pass", "sops"] | |||
| 24 | + | ||||
| 18 | # Production service routes | 25 | # Production service routes | ||
| 19 | [routes] | 26 | [routes] | ||
| 20 | "app.company.com" = "company-app:v2.1.0" # Main app with pinned version | 27 | "app.company.com" = "company-app:v2.1.0" # Main app with pinned version | ||
| @@ -27,10 +34,27 @@ password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in productio | |||||
| 27 | "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard | 34 | "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard | ||
| 28 | "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection | 35 | "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection | ||
| 29 | 36 | ||||
| 37 | + | # Environment Variables Usage: | |||
| 38 | + | # | |||
| 39 | + | # For each route, create a .env file named after the domain: | |||
| 40 | + | # /var/lib/gordon/env/app_company_com.env | |||
| 41 | + | # /var/lib/gordon/env/api_company_com.env | |||
| 42 | + | # | |||
| 43 | + | # Example env file content: | |||
| 44 | + | # NODE_ENV=production | |||
| 45 | + | # DATABASE_URL=postgresql://user:pass@db:5432/prod | |||
| 46 | + | # API_KEY=${pass:company/api-key} # From pass | |||
| 47 | + | # SECRET_KEY=${sops:secrets.yaml:app.secret} # From sops | |||
| 48 | + | # | |||
| 49 | + | # Secret syntax: | |||
| 50 | + | # ${pass:path/to/secret} - Unix password manager | |||
| 51 | + | # ${sops:file.yaml:key.path} - SOPS encrypted file | |||
| 52 | + | ||||
| 30 | # Production deployment workflow: | 53 | # Production deployment workflow: | ||
| 31 | # 1. Build and tag: docker build -t company-app:v2.1.1 . | 54 | # 1. Build and tag: docker build -t company-app:v2.1.1 . | ||
| 32 | # 2. Tag for registry: docker tag company-app:v2.1.1 registry.company.com/company-app:v2.1.1 | 55 | # 2. Tag for registry: docker tag company-app:v2.1.1 registry.company.com/company-app:v2.1.1 | ||
| 33 | # 3. Update this config: "app.company.com" = "company-app:v2.1.1" | 56 | # 3. Update this config: "app.company.com" = "company-app:v2.1.1" | ||
| 34 | - | # 4. Restart Gordon to reload config | 57 | + | # 4. Create/update env file: /var/lib/gordon/env/app_company_com.env |
| 35 | - | # 5. Push to deploy: docker push registry.company.com/company-app:v2.1.1 | 58 | + | # 5. Restart Gordon to reload config |
| 36 | - | # 6. Gordon automatically deploys to app.company.com!⏎̸ | 59 | + | # 6. Push to deploy: docker push registry.company.com/company-app:v2.1.1 |
| 60 | + | # 7. Gordon automatically deploys to app.company.com with environment variables!⏎̸ | |||
M internal/config/config.go +73 -0
| @@ -16,6 +16,7 @@ type Config struct { | |||
| 16 | 16 | RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"` | |
| 17 | 17 | Routes map[string]string `mapstructure:"routes"` | |
| 18 | 18 | AutoRoute AutoRouteConfig `mapstructure:"auto_route"` | |
| 19 | + | Env EnvConfig `mapstructure:"env"` | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | type ServerConfig struct { | |
| @@ -38,6 +39,16 @@ type AutoRouteConfig struct { | |||
| 38 | 39 | Enabled bool `mapstructure:"enabled"` | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | type EnvConfig struct { | |
| 43 | + | Dir string `mapstructure:"dir"` | |
| 44 | + | Providers []string `mapstructure:"providers"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type SecretProvider struct { | |
| 48 | + | Type string `mapstructure:"type"` | |
| 49 | + | Config map[string]string `mapstructure:"config"` | |
| 50 | + | } | |
| 51 | + | ||
| 41 | 52 | type Route struct { | |
| 42 | 53 | Domain string | |
| 43 | 54 | Image string | |
| @@ -58,6 +69,8 @@ func Load() (*Config, error) { | |||
| 58 | 69 | viper.SetDefault("server.data_dir", defaultDataDir) | |
| 59 | 70 | viper.SetDefault("registry_auth.enabled", true) | |
| 60 | 71 | viper.SetDefault("auto_route.enabled", false) | |
| 72 | + | viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env")) | |
| 73 | + | viper.SetDefault("env.providers", []string{"pass", "sops"}) | |
| 61 | 74 | ||
| 62 | 75 | // Handle the routes manually since Viper struggles with domain names | |
| 63 | 76 | cfg.Routes = make(map[string]string) | |
| @@ -88,6 +101,17 @@ func Load() (*Config, error) { | |||
| 88 | 101 | if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil { | |
| 89 | 102 | return nil, fmt.Errorf("unable to decode auto route config: %v", err) | |
| 90 | 103 | } | |
| 104 | + | ||
| 105 | + | // Get env config | |
| 106 | + | if err := viper.UnmarshalKey("env", &cfg.Env); err != nil { | |
| 107 | + | return nil, fmt.Errorf("unable to decode env config: %v", err) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // If env.dir is empty after loading config, use the default | |
| 111 | + | if cfg.Env.Dir == "" { | |
| 112 | + | cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env") | |
| 113 | + | log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default") | |
| 114 | + | } | |
| 91 | 115 | ||
| 92 | 116 | // Get routes manually from the raw config | |
| 93 | 117 | routesRaw := viper.Get("routes") | |
| @@ -131,6 +155,11 @@ func Load() (*Config, error) { | |||
| 131 | 155 | } | |
| 132 | 156 | } | |
| 133 | 157 | ||
| 158 | + | // Validate env config | |
| 159 | + | if err := validateEnvConfig(&cfg.Env); err != nil { | |
| 160 | + | return nil, fmt.Errorf("invalid env config: %w", err) | |
| 161 | + | } | |
| 162 | + | ||
| 134 | 163 | return &cfg, nil | |
| 135 | 164 | } | |
| 136 | 165 | ||
| @@ -266,6 +295,50 @@ func (c *Config) AddRoute(domain, image string) error { | |||
| 266 | 295 | Str("domain", domain). | |
| 267 | 296 | Str("image", image). | |
| 268 | 297 | Msg("Auto-added route to configuration") | |
| 298 | + | ||
| 299 | + | // Create env file for the new route (if env loader is available) | |
| 300 | + | // This will be called from the auto-route handler which has access to the env loader | |
| 269 | 301 | ||
| 302 | + | return nil | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // validateEnvConfig validates the environment configuration | |
| 306 | + | func validateEnvConfig(envCfg *EnvConfig) error { | |
| 307 | + | // Validate env directory path | |
| 308 | + | if envCfg.Dir != "" { | |
| 309 | + | if filepath.IsAbs(envCfg.Dir) { | |
| 310 | + | // For absolute paths, check if the directory is accessible | |
| 311 | + | if info, err := os.Stat(envCfg.Dir); err != nil { | |
| 312 | + | if !os.IsNotExist(err) { | |
| 313 | + | return fmt.Errorf("env.dir path is not accessible: %w", err) | |
| 314 | + | } | |
| 315 | + | // Directory doesn't exist - that's okay, it will be created | |
| 316 | + | } else if !info.IsDir() { | |
| 317 | + | return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | // For relative paths, we can't easily validate without changing working directory | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // Validate secret providers configuration | |
| 324 | + | validProviders := []string{"pass", "sops"} | |
| 325 | + | for _, providerName := range envCfg.Providers { | |
| 326 | + | if providerName == "" { | |
| 327 | + | return fmt.Errorf("empty provider name in env.providers") | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | isValid := false | |
| 331 | + | for _, validType := range validProviders { | |
| 332 | + | if providerName == validType { | |
| 333 | + | isValid = true | |
| 334 | + | break | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | if !isValid { | |
| 338 | + | return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s", | |
| 339 | + | providerName, strings.Join(validProviders, ", ")) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 270 | 343 | return nil | |
| 271 | 344 | }⏎̸ | |
| @@ -16,6 +16,7 @@ type Config struct { | |||||
| 16 | RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"` | 16 | RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"` | ||
| 17 | Routes map[string]string `mapstructure:"routes"` | 17 | Routes map[string]string `mapstructure:"routes"` | ||
| 18 | AutoRoute AutoRouteConfig `mapstructure:"auto_route"` | 18 | AutoRoute AutoRouteConfig `mapstructure:"auto_route"` | ||
| 19 | + | Env EnvConfig `mapstructure:"env"` | |||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | type ServerConfig struct { | 22 | type ServerConfig struct { | ||
| @@ -38,6 +39,16 @@ type AutoRouteConfig struct { | |||||
| 38 | Enabled bool `mapstructure:"enabled"` | 39 | Enabled bool `mapstructure:"enabled"` | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 42 | + | type EnvConfig struct { | |||
| 43 | + | Dir string `mapstructure:"dir"` | |||
| 44 | + | Providers []string `mapstructure:"providers"` | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | type SecretProvider struct { | |||
| 48 | + | Type string `mapstructure:"type"` | |||
| 49 | + | Config map[string]string `mapstructure:"config"` | |||
| 50 | + | } | |||
| 51 | + | ||||
| 41 | type Route struct { | 52 | type Route struct { | ||
| 42 | Domain string | 53 | Domain string | ||
| 43 | Image string | 54 | Image string | ||
| @@ -58,6 +69,8 @@ func Load() (*Config, error) { | |||||
| 58 | viper.SetDefault("server.data_dir", defaultDataDir) | 69 | viper.SetDefault("server.data_dir", defaultDataDir) | ||
| 59 | viper.SetDefault("registry_auth.enabled", true) | 70 | viper.SetDefault("registry_auth.enabled", true) | ||
| 60 | viper.SetDefault("auto_route.enabled", false) | 71 | viper.SetDefault("auto_route.enabled", false) | ||
| 72 | + | viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env")) | |||
| 73 | + | viper.SetDefault("env.providers", []string{"pass", "sops"}) | |||
| 61 | 74 | ||||
| 62 | // Handle the routes manually since Viper struggles with domain names | 75 | // Handle the routes manually since Viper struggles with domain names | ||
| 63 | cfg.Routes = make(map[string]string) | 76 | cfg.Routes = make(map[string]string) | ||
| @@ -88,6 +101,17 @@ func Load() (*Config, error) { | |||||
| 88 | if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil { | 101 | if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil { | ||
| 89 | return nil, fmt.Errorf("unable to decode auto route config: %v", err) | 102 | return nil, fmt.Errorf("unable to decode auto route config: %v", err) | ||
| 90 | } | 103 | } | ||
| 104 | + | ||||
| 105 | + | // Get env config | |||
| 106 | + | if err := viper.UnmarshalKey("env", &cfg.Env); err != nil { | |||
| 107 | + | return nil, fmt.Errorf("unable to decode env config: %v", err) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // If env.dir is empty after loading config, use the default | |||
| 111 | + | if cfg.Env.Dir == "" { | |||
| 112 | + | cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env") | |||
| 113 | + | log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default") | |||
| 114 | + | } | |||
| 91 | 115 | ||||
| 92 | // Get routes manually from the raw config | 116 | // Get routes manually from the raw config | ||
| 93 | routesRaw := viper.Get("routes") | 117 | routesRaw := viper.Get("routes") | ||
| @@ -131,6 +155,11 @@ func Load() (*Config, error) { | |||||
| 131 | } | 155 | } | ||
| 132 | } | 156 | } | ||
| 133 | 157 | ||||
| 158 | + | // Validate env config | |||
| 159 | + | if err := validateEnvConfig(&cfg.Env); err != nil { | |||
| 160 | + | return nil, fmt.Errorf("invalid env config: %w", err) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 134 | return &cfg, nil | 163 | return &cfg, nil | ||
| 135 | } | 164 | } | ||
| 136 | 165 | ||||
| @@ -266,6 +295,50 @@ func (c *Config) AddRoute(domain, image string) error { | |||||
| 266 | Str("domain", domain). | 295 | Str("domain", domain). | ||
| 267 | Str("image", image). | 296 | Str("image", image). | ||
| 268 | Msg("Auto-added route to configuration") | 297 | Msg("Auto-added route to configuration") | ||
| 298 | + | ||||
| 299 | + | // Create env file for the new route (if env loader is available) | |||
| 300 | + | // This will be called from the auto-route handler which has access to the env loader | |||
| 269 | 301 | ||||
| 302 | + | return nil | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // validateEnvConfig validates the environment configuration | |||
| 306 | + | func validateEnvConfig(envCfg *EnvConfig) error { | |||
| 307 | + | // Validate env directory path | |||
| 308 | + | if envCfg.Dir != "" { | |||
| 309 | + | if filepath.IsAbs(envCfg.Dir) { | |||
| 310 | + | // For absolute paths, check if the directory is accessible | |||
| 311 | + | if info, err := os.Stat(envCfg.Dir); err != nil { | |||
| 312 | + | if !os.IsNotExist(err) { | |||
| 313 | + | return fmt.Errorf("env.dir path is not accessible: %w", err) | |||
| 314 | + | } | |||
| 315 | + | // Directory doesn't exist - that's okay, it will be created | |||
| 316 | + | } else if !info.IsDir() { | |||
| 317 | + | return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir) | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | // For relative paths, we can't easily validate without changing working directory | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // Validate secret providers configuration | |||
| 324 | + | validProviders := []string{"pass", "sops"} | |||
| 325 | + | for _, providerName := range envCfg.Providers { | |||
| 326 | + | if providerName == "" { | |||
| 327 | + | return fmt.Errorf("empty provider name in env.providers") | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | isValid := false | |||
| 331 | + | for _, validType := range validProviders { | |||
| 332 | + | if providerName == validType { | |||
| 333 | + | isValid = true | |||
| 334 | + | break | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | if !isValid { | |||
| 338 | + | return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s", | |||
| 339 | + | providerName, strings.Join(validProviders, ", ")) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 270 | return nil | 343 | return nil | ||
| 271 | }⏎̸ | 344 | }⏎̸ | ||
M internal/container/manager.go +47 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/rs/zerolog/log" | |
| 10 | 10 | ||
| 11 | 11 | "gordon/internal/config" | |
| 12 | + | "gordon/internal/env" | |
| 12 | 13 | "gordon/pkg/runtime" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -17,6 +18,7 @@ type Manager struct { | |||
| 17 | 18 | runtime runtime.Runtime | |
| 18 | 19 | config *config.Config | |
| 19 | 20 | containers map[string]*runtime.Container // map[domain] -> container | |
| 21 | + | envLoader *env.Loader | |
| 20 | 22 | mu sync.RWMutex | |
| 21 | 23 | } | |
| 22 | 24 | ||
| @@ -43,10 +45,34 @@ func NewManager(cfg *config.Config) (*Manager, error) { | |||
| 43 | 45 | log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected") | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 48 | + | // Create environment loader | |
| 49 | + | envLoader := env.NewLoader(cfg) | |
| 50 | + | ||
| 51 | + | // Register secret providers based on config | |
| 52 | + | for _, providerName := range cfg.Env.Providers { | |
| 53 | + | switch providerName { | |
| 54 | + | case "pass": | |
| 55 | + | envLoader.RegisterSecretProvider("pass", env.NewPassProvider()) | |
| 56 | + | case "sops": | |
| 57 | + | envLoader.RegisterSecretProvider("sops", env.NewSopsProvider()) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Ensure env directory exists | |
| 62 | + | if err := envLoader.EnsureEnvDir(); err != nil { | |
| 63 | + | log.Warn().Err(err).Msg("Failed to ensure env directory exists") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Create empty env files for all configured routes (if they don't exist) | |
| 67 | + | if err := envLoader.CreateEnvFilesForRoutes(); err != nil { | |
| 68 | + | log.Warn().Err(err).Msg("Failed to create env files for routes") | |
| 69 | + | } | |
| 70 | + | ||
| 46 | 71 | return &Manager{ | |
| 47 | 72 | runtime: rt, | |
| 48 | 73 | config: cfg, | |
| 49 | 74 | containers: make(map[string]*runtime.Container), | |
| 75 | + | envLoader: envLoader, | |
| 50 | 76 | }, nil | |
| 51 | 77 | } | |
| 52 | 78 | ||
| @@ -183,11 +209,19 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 183 | 209 | exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports | |
| 184 | 210 | } | |
| 185 | 211 | ||
| 212 | + | // Load environment variables for this route | |
| 213 | + | envVars, err := m.envLoader.LoadEnvForRoute(route.Domain) | |
| 214 | + | if err != nil { | |
| 215 | + | log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to load environment variables for route") | |
| 216 | + | return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err) | |
| 217 | + | } | |
| 218 | + | ||
| 186 | 219 | // Create container configuration | |
| 187 | 220 | containerConfig := &runtime.ContainerConfig{ | |
| 188 | 221 | Image: imageRef, | |
| 189 | 222 | Name: fmt.Sprintf("gordon-%s", route.Domain), | |
| 190 | 223 | Ports: exposedPorts, | |
| 224 | + | Env: envVars, | |
| 191 | 225 | Labels: map[string]string{ | |
| 192 | 226 | "gordon.domain": route.Domain, | |
| 193 | 227 | "gordon.image": route.Image, | |
| @@ -587,6 +621,19 @@ func (m *Manager) Runtime() runtime.Runtime { | |||
| 587 | 621 | return m.runtime | |
| 588 | 622 | } | |
| 589 | 623 | ||
| 624 | + | // UpdateConfig updates the manager's configuration and creates env files for new routes | |
| 625 | + | func (m *Manager) UpdateConfig(cfg *config.Config) { | |
| 626 | + | m.config = cfg | |
| 627 | + | ||
| 628 | + | // Update env loader config | |
| 629 | + | m.envLoader.UpdateConfig(cfg) | |
| 630 | + | ||
| 631 | + | // Create env files for any new routes | |
| 632 | + | if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil { | |
| 633 | + | log.Warn().Err(err).Msg("Failed to create env files for new routes during config update") | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | ||
| 590 | 637 | // normalizeImageRef normalizes image references for comparison | |
| 591 | 638 | func normalizeImageRef(imageRef string) string { | |
| 592 | 639 | // Split image and tag | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/rs/zerolog/log" | 9 | "github.com/rs/zerolog/log" | ||
| 10 | 10 | ||||
| 11 | "gordon/internal/config" | 11 | "gordon/internal/config" | ||
| 12 | + | "gordon/internal/env" | |||
| 12 | "gordon/pkg/runtime" | 13 | "gordon/pkg/runtime" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| @@ -17,6 +18,7 @@ type Manager struct { | |||||
| 17 | runtime runtime.Runtime | 18 | runtime runtime.Runtime | ||
| 18 | config *config.Config | 19 | config *config.Config | ||
| 19 | containers map[string]*runtime.Container // map[domain] -> container | 20 | containers map[string]*runtime.Container // map[domain] -> container | ||
| 21 | + | envLoader *env.Loader | |||
| 20 | mu sync.RWMutex | 22 | mu sync.RWMutex | ||
| 21 | } | 23 | } | ||
| 22 | 24 | ||||
| @@ -43,10 +45,34 @@ func NewManager(cfg *config.Config) (*Manager, error) { | |||||
| 43 | log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected") | 45 | log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected") | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 48 | + | // Create environment loader | |||
| 49 | + | envLoader := env.NewLoader(cfg) | |||
| 50 | + | ||||
| 51 | + | // Register secret providers based on config | |||
| 52 | + | for _, providerName := range cfg.Env.Providers { | |||
| 53 | + | switch providerName { | |||
| 54 | + | case "pass": | |||
| 55 | + | envLoader.RegisterSecretProvider("pass", env.NewPassProvider()) | |||
| 56 | + | case "sops": | |||
| 57 | + | envLoader.RegisterSecretProvider("sops", env.NewSopsProvider()) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Ensure env directory exists | |||
| 62 | + | if err := envLoader.EnsureEnvDir(); err != nil { | |||
| 63 | + | log.Warn().Err(err).Msg("Failed to ensure env directory exists") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Create empty env files for all configured routes (if they don't exist) | |||
| 67 | + | if err := envLoader.CreateEnvFilesForRoutes(); err != nil { | |||
| 68 | + | log.Warn().Err(err).Msg("Failed to create env files for routes") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 46 | return &Manager{ | 71 | return &Manager{ | ||
| 47 | runtime: rt, | 72 | runtime: rt, | ||
| 48 | config: cfg, | 73 | config: cfg, | ||
| 49 | containers: make(map[string]*runtime.Container), | 74 | containers: make(map[string]*runtime.Container), | ||
| 75 | + | envLoader: envLoader, | |||
| 50 | }, nil | 76 | }, nil | ||
| 51 | } | 77 | } | ||
| 52 | 78 | ||||
| @@ -183,11 +209,19 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||||
| 183 | exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports | 209 | exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports | ||
| 184 | } | 210 | } | ||
| 185 | 211 | ||||
| 212 | + | // Load environment variables for this route | |||
| 213 | + | envVars, err := m.envLoader.LoadEnvForRoute(route.Domain) | |||
| 214 | + | if err != nil { | |||
| 215 | + | log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to load environment variables for route") | |||
| 216 | + | return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 186 | // Create container configuration | 219 | // Create container configuration | ||
| 187 | containerConfig := &runtime.ContainerConfig{ | 220 | containerConfig := &runtime.ContainerConfig{ | ||
| 188 | Image: imageRef, | 221 | Image: imageRef, | ||
| 189 | Name: fmt.Sprintf("gordon-%s", route.Domain), | 222 | Name: fmt.Sprintf("gordon-%s", route.Domain), | ||
| 190 | Ports: exposedPorts, | 223 | Ports: exposedPorts, | ||
| 224 | + | Env: envVars, | |||
| 191 | Labels: map[string]string{ | 225 | Labels: map[string]string{ | ||
| 192 | "gordon.domain": route.Domain, | 226 | "gordon.domain": route.Domain, | ||
| 193 | "gordon.image": route.Image, | 227 | "gordon.image": route.Image, | ||
| @@ -587,6 +621,19 @@ func (m *Manager) Runtime() runtime.Runtime { | |||||
| 587 | return m.runtime | 621 | return m.runtime | ||
| 588 | } | 622 | } | ||
| 589 | 623 | ||||
| 624 | + | // UpdateConfig updates the manager's configuration and creates env files for new routes | |||
| 625 | + | func (m *Manager) UpdateConfig(cfg *config.Config) { | |||
| 626 | + | m.config = cfg | |||
| 627 | + | ||||
| 628 | + | // Update env loader config | |||
| 629 | + | m.envLoader.UpdateConfig(cfg) | |||
| 630 | + | ||||
| 631 | + | // Create env files for any new routes | |||
| 632 | + | if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil { | |||
| 633 | + | log.Warn().Err(err).Msg("Failed to create env files for new routes during config update") | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | ||||
| 590 | // normalizeImageRef normalizes image references for comparison | 637 | // normalizeImageRef normalizes image references for comparison | ||
| 591 | func normalizeImageRef(imageRef string) string { | 638 | func normalizeImageRef(imageRef string) string { | ||
| 592 | // Split image and tag | 639 | // Split image and tag | ||
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 | + | }⏎̸ | |
| @@ -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/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 | + | }⏎̸ | |
| @@ -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 | + | }⏎̸ | |||
M internal/events/handlers.go +44 -1
| @@ -25,7 +25,7 @@ func NewContainerEventHandler(manager *container.Manager, config *config.Config) | |||
| 25 | 25 | ||
| 26 | 26 | func (h *ContainerEventHandler) CanHandle(eventType EventType) bool { | |
| 27 | 27 | switch eventType { | |
| 28 | - | case ImagePushed, ConfigReload, ContainerStop, ContainerStart: | |
| 28 | + | case ImagePushed, ConfigReload, ManualReload, ContainerStop, ContainerStart: | |
| 29 | 29 | return true | |
| 30 | 30 | default: | |
| 31 | 31 | return false | |
| @@ -38,6 +38,8 @@ func (h *ContainerEventHandler) Handle(event Event) error { | |||
| 38 | 38 | return h.handleImagePushed(event) | |
| 39 | 39 | case ConfigReload: | |
| 40 | 40 | return h.handleConfigReload(event) | |
| 41 | + | case ManualReload: | |
| 42 | + | return h.handleManualReload(event) | |
| 41 | 43 | case ContainerStop: | |
| 42 | 44 | return h.handleContainerStop(event) | |
| 43 | 45 | case ContainerStart: | |
| @@ -167,6 +169,47 @@ func (h *ContainerEventHandler) handleConfigReload(event Event) error { | |||
| 167 | 169 | return nil | |
| 168 | 170 | } | |
| 169 | 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 | + | ||
| 170 | 213 | func (h *ContainerEventHandler) handleContainerStop(event Event) error { | |
| 171 | 214 | containerID := event.ContainerID | |
| 172 | 215 | if containerID == "" { | |
| @@ -25,7 +25,7 @@ func NewContainerEventHandler(manager *container.Manager, config *config.Config) | |||||
| 25 | 25 | ||||
| 26 | func (h *ContainerEventHandler) CanHandle(eventType EventType) bool { | 26 | func (h *ContainerEventHandler) CanHandle(eventType EventType) bool { | ||
| 27 | switch eventType { | 27 | switch eventType { | ||
| 28 | - | case ImagePushed, ConfigReload, ContainerStop, ContainerStart: | 28 | + | case ImagePushed, ConfigReload, ManualReload, ContainerStop, ContainerStart: |
| 29 | return true | 29 | return true | ||
| 30 | default: | 30 | default: | ||
| 31 | return false | 31 | return false | ||
| @@ -38,6 +38,8 @@ func (h *ContainerEventHandler) Handle(event Event) error { | |||||
| 38 | return h.handleImagePushed(event) | 38 | return h.handleImagePushed(event) | ||
| 39 | case ConfigReload: | 39 | case ConfigReload: | ||
| 40 | return h.handleConfigReload(event) | 40 | return h.handleConfigReload(event) | ||
| 41 | + | case ManualReload: | |||
| 42 | + | return h.handleManualReload(event) | |||
| 41 | case ContainerStop: | 43 | case ContainerStop: | ||
| 42 | return h.handleContainerStop(event) | 44 | return h.handleContainerStop(event) | ||
| 43 | case ContainerStart: | 45 | case ContainerStart: | ||
| @@ -167,6 +169,47 @@ func (h *ContainerEventHandler) handleConfigReload(event Event) error { | |||||
| 167 | return nil | 169 | return nil | ||
| 168 | } | 170 | } | ||
| 169 | 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 | + | ||||
| 170 | func (h *ContainerEventHandler) handleContainerStop(event Event) error { | 213 | func (h *ContainerEventHandler) handleContainerStop(event Event) error { | ||
| 171 | containerID := event.ContainerID | 214 | containerID := event.ContainerID | ||
| 172 | if containerID == "" { | 215 | if containerID == "" { | ||
M internal/events/types.go +1 -0
| @@ -10,6 +10,7 @@ const ( | |||
| 10 | 10 | ImagePushed EventType = "image.pushed" | |
| 11 | 11 | ImageDeleted EventType = "image.deleted" | |
| 12 | 12 | ConfigReload EventType = "config.reload" | |
| 13 | + | ManualReload EventType = "manual.reload" | |
| 13 | 14 | ContainerStop EventType = "container.stop" | |
| 14 | 15 | ContainerStart EventType = "container.start" | |
| 15 | 16 | ContainerHealthCheck EventType = "container.health_check" | |
| @@ -10,6 +10,7 @@ const ( | |||||
| 10 | ImagePushed EventType = "image.pushed" | 10 | ImagePushed EventType = "image.pushed" | ||
| 11 | ImageDeleted EventType = "image.deleted" | 11 | ImageDeleted EventType = "image.deleted" | ||
| 12 | ConfigReload EventType = "config.reload" | 12 | ConfigReload EventType = "config.reload" | ||
| 13 | + | ManualReload EventType = "manual.reload" | |||
| 13 | ContainerStop EventType = "container.stop" | 14 | ContainerStop EventType = "container.stop" | ||
| 14 | ContainerStart EventType = "container.start" | 15 | ContainerStart EventType = "container.start" | ||
| 15 | ContainerHealthCheck EventType = "container.health_check" | 16 | ContainerHealthCheck EventType = "container.health_check" | ||
M internal/proxy/server.go +1 -0
| @@ -285,3 +285,4 @@ func (h *ProxyEventHandler) Handle(event events.Event) error { | |||
| 285 | 285 | return fmt.Errorf("unsupported event type: %s", event.Type) | |
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | + | ||
| @@ -285,3 +285,4 @@ func (h *ProxyEventHandler) Handle(event events.Event) error { | |||||
| 285 | return fmt.Errorf("unsupported event type: %s", event.Type) | 285 | return fmt.Errorf("unsupported event type: %s", event.Type) | ||
| 286 | } | 286 | } | ||
| 287 | 287 | ||||
| 288 | + | ||||