diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8ce97c3c..9f224108 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -6,6 +6,7 @@ This directory contains architectural decision records for the Torrust Tracker D | Status | Date | Decision | Summary | | ------------- | ---------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| ✅ Accepted | 2025-12-20 | [Grafana Integration Pattern](./grafana-integration-pattern.md) | Enable Grafana by default with hard Prometheus dependency and environment variable config | | ✅ Accepted | 2025-12-17 | [Secrecy Crate for Sensitive Data Handling](./secrecy-crate-for-sensitive-data.md) | Use secrecy crate for type-safe secret handling with memory zeroing | | ✅ Accepted | 2025-12-14 | [Database Configuration Structure in Templates](./database-configuration-structure-in-templates.md) | Expose structured database fields in templates rather than pre-resolved connection strings | | ✅ Accepted | 2025-12-13 | [Environment Variable Injection in Docker Compose](./environment-variable-injection-in-docker-compose.md) | Use .env file injection instead of hardcoded values for runtime configuration changes | diff --git a/docs/decisions/grafana-integration-pattern.md b/docs/decisions/grafana-integration-pattern.md new file mode 100644 index 00000000..72e7cb0b --- /dev/null +++ b/docs/decisions/grafana-integration-pattern.md @@ -0,0 +1,405 @@ +# Decision: Grafana Integration Pattern - Enabled by Default with Prometheus Dependency + +## Status + +Accepted + +## Date + +2025-12-20 + +## Context + +Following the Prometheus integration (see [prometheus-integration-pattern.md](./prometheus-integration-pattern.md)), we needed to add Grafana as a metrics visualization service. The key design considerations were: + +1. **Enablement Strategy**: Should Grafana be mandatory, opt-in, or enabled-by-default like Prometheus? +2. **Service Dependencies**: How should we enforce the Grafana-Prometheus dependency? +3. **Configuration Management**: Should Grafana have separate config files or use environment variables? +4. **Storage Pattern**: Should Grafana use bind mounts or named volumes for data persistence? +5. **Port Exposure**: How should Grafana UI be exposed for user access? + +The decision impacts: + +- User experience and deployment simplicity +- Validation logic and error messages +- System architecture consistency +- Security posture and network access + +## Decision + +### 1. Enabled-by-Default with Hard Prometheus Dependency + +Grafana is **included by default** in generated environment templates but requires Prometheus to be enabled. + +**Implementation**: + +```rust +pub struct UserInputs { + pub prometheus: Option, // Required if grafana is Some + pub grafana: Option, // Some by default, None to disable +} +``` + +**Configuration**: + +```json +{ + "prometheus": { + "scrape_interval": 15 + }, + "grafana": { + "admin_user": "admin", + "admin_password": "SecurePassword123!" + } +} +``` + +**Validation at Environment Creation**: + +```rust +fn validate_grafana_prometheus_dependency( + grafana: &Option, + prometheus: &Option, +) -> Result<(), ConfigError> { + match (grafana, prometheus) { + (Some(_), None) => Err(ConfigError::GrafanaRequiresPrometheus { /* ... */ }), + _ => Ok(()), + } +} +``` + +**Disabling**: Remove the `grafana` section from the environment config. Prometheus can remain enabled independently. + +**Rationale**: + +- Grafana is useless without a data source - Prometheus is the natural choice +- Hard dependency at validation time prevents invalid configurations +- Users get complete monitoring stack (collection + visualization) by default +- Consistent with Prometheus enabled-by-default pattern +- Follows principle of least surprise (monitoring expected for production) + +### 2. Environment Variable Configuration (No Separate Config Files) + +Grafana is configured entirely through environment variables, not separate config files. + +**Implementation**: + +```yaml +# docker-compose.yml +services: + grafana: + environment: + - GF_SECURITY_ADMIN_USER=${GF_SECURITY_ADMIN_USER} + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD} +``` + +```tera +# .env.tera +{% if grafana_config %} +GF_SECURITY_ADMIN_USER='{{ grafana_admin_user }}' +GF_SECURITY_ADMIN_PASSWORD='{{ grafana_admin_password }}' +{% endif %} +``` + +**Rationale**: + +- Consistent with Docker Compose environment variable injection pattern (see [environment-variable-injection-in-docker-compose.md](./environment-variable-injection-in-docker-compose.md)) +- Grafana supports comprehensive environment variable configuration +- Simplifies template structure (no separate grafana.ini file) +- Admin credentials are the only required configuration for MVP +- Future automation will handle datasource and dashboard provisioning + +**Anti-Pattern Avoided**: Creating separate `grafana.ini` config file that duplicates what environment variables can handle. + +### 3. Named Volume for Data Persistence + +Grafana uses a named Docker volume, not a bind mount. + +**Implementation**: + +```yaml +services: + grafana: + volumes: + - grafana_data:/var/lib/grafana + +volumes: + grafana_data: {} +``` + +**Rationale**: + +- Standard Grafana practice (official Grafana Docker documentation uses named volumes) +- Named volumes are managed by Docker (automatic creation, cleanup) +- Simpler for users (no host directory permissions issues) +- Stores dashboards, datasources, user preferences persistently +- Different from Prometheus which uses bind mount for direct config access +- Grafana config is via environment variables, not files, so bind mount unnecessary + +**Comparison with Prometheus**: + +- **Prometheus**: Bind mount (`./prometheus.yml:/etc/prometheus/prometheus.yml`) - Direct access to config file for easy editing +- **Grafana**: Named volume (`grafana_data:/var/lib/grafana`) - Internal storage for user-created content + +### 4. External Port Exposure for UI Access + +Grafana UI is exposed on host port 3100 for external access. + +**Implementation**: + +```yaml +services: + grafana: + ports: + - "3100:3000" # Host:Container +``` + +**Port Choice**: 3100 on host to avoid conflicts with common port 3000 usage (Node.js dev servers, other services). + +**Security Considerations**: + +- **Docker Bypasses UFW**: Published ports bypass firewall rules entirely (see [DRAFT-docker-ufw-firewall-security-strategy.md](../issues/DRAFT-docker-ufw-firewall-security-strategy.md)) +- **Current Exposure**: Port 3100 accessible from any network that can reach the host +- **Acceptable for MVP**: Public exposure acceptable for development/testing environments +- **Future Security**: Reverse proxy with TLS termination (roadmap task 6) + +**Rationale**: + +- Users need web UI access from their local machines +- Simple port mapping for MVP (no reverse proxy complexity) +- Port 3100 avoids common conflicts +- Security tradeoffs documented and deferred to reverse proxy implementation + +### 5. Service Dependencies in Docker Compose + +Grafana service uses simple `depends_on` without health checks. + +**Implementation**: + +```yaml +services: + grafana: + depends_on: + - prometheus +``` + +**Rationale**: + +- Grafana UI remains functional even if Prometheus is temporarily unavailable +- Health check complexity not required for MVP +- Container startup order sufficient (Prometheus starts first) +- Users can access Grafana UI and configure it while Prometheus initializes + +### 6. Manual Datasource and Dashboard Configuration (MVP) + +Initial implementation does **not** auto-provision Prometheus datasource or import dashboards. + +**User Experience**: + +1. Grafana starts with default settings +2. User logs in with configured credentials +3. User manually adds Prometheus datasource (`http://prometheus:9090`) +4. User imports dashboards or creates custom ones + +**Rationale**: + +- Keep MVP scope minimal (prove service integration works) +- Manual setup well-documented in verification guide (see [grafana-verification.md](../e2e-testing/manual/grafana-verification.md)) +- Future automation planned for better UX (see Future Work section) +- Sample dashboards available from torrust-demo for manual import + +**Future Automation** (planned issue): + +- Auto-provision Prometheus datasource during deployment +- Auto-import tracker dashboards (stats.json, metrics.json) +- Provide customizable dashboard templates + +## Alternatives Considered + +### Alternative 1: Opt-In Grafana (User Must Explicitly Enable) + +**Approach**: Grafana not included in default templates, users add section to enable. + +**Rejected Because**: + +- Inconsistent with Prometheus enabled-by-default pattern +- More friction for users wanting visualization +- Monitoring is best practice - should be included by default +- Opt-out is simpler (just remove section) + +### Alternative 2: Separate Config Files (grafana.ini) + +**Approach**: Generate separate `grafana.ini` config file like `prometheus.yml`. + +**Rejected Because**: + +- Adds complexity without benefit for MVP requirements +- Environment variables sufficient for admin credentials +- Future automation will use Grafana provisioning directory, not grafana.ini +- Inconsistent with Docker Compose environment variable injection pattern + +### Alternative 3: Mandatory Grafana (Always Included) + +**Approach**: Grafana always deployed, no opt-out option. + +**Rejected Because**: + +- Users may only want Prometheus (programmatic access, custom visualization tools) +- Increases resource usage for minimal deployments +- Reduces deployment flexibility +- Inconsistent with optional service pattern + +### Alternative 4: Separate Grafana Provisioning (Independent from Deployment) + +**Approach**: Grafana deployed separately after tracker deployment completes. + +**Rejected Because**: + +- Fragments deployment workflow (multiple commands) +- Harder to ensure service compatibility +- Complicates docker-compose orchestration +- Better to include in single deployment workflow + +### Alternative 5: Bind Mount for Grafana Data + +**Approach**: Use bind mount like Prometheus instead of named volume. + +**Rejected Because**: + +- Named volume is Grafana standard practice +- No need for direct host access to Grafana database files +- Simplifies deployment (no host directory permissions issues) +- Grafana config via environment variables, not files + +## Consequences + +### Positive + +1. **Complete Monitoring Stack Out-of-the-Box**: + + - Users get metrics collection (Prometheus) + visualization (Grafana) by default + - Production-ready monitoring without manual setup + - Consistent with infrastructure best practices + +2. **Clear Dependency Management**: + + - Validation enforces Grafana-Prometheus dependency at creation time + - Helpful error messages guide users to fix configuration + - Prevents invalid configurations before deployment + +3. **Consistent Configuration Pattern**: + + - All services use environment variable injection pattern + - Predictable structure for users and maintainers + - Easy to add future services (Alertmanager, Loki) + +4. **Simple Storage Management**: + + - Named volume managed by Docker (no permission issues) + - Persistent across container restarts + - Standard Grafana practice + +5. **Extensibility**: + - Manual setup provides foundation for future automation + - Verification guide documents complete workflow + - Clear path to auto-provisioning (planned issue) + +### Negative + +1. **Manual Initial Setup Required**: + + - Users must add Prometheus datasource manually + - Users must import/create dashboards manually + - Extra steps before visualization works + - **Mitigation**: Comprehensive verification guide provided + - **Future**: Automation planned in follow-up issue + +2. **Port Exposure Security Concerns**: + + - Port 3100 publicly accessible (Docker bypasses UFW) + - No authentication beyond Grafana login (no TLS) + - Potential security risk for production deployments + - **Mitigation**: Documented security implications and limitations + - **Future**: Reverse proxy with TLS (roadmap task 6) + +3. **Hard Prometheus Dependency**: + + - Grafana cannot be enabled without Prometheus + - Limits flexibility for users with alternative data sources + - **Mitigation**: Prometheus is the natural choice for tracker metrics + - **Acceptable**: Hard dependency makes sense for this use case + +4. **Default Resource Overhead**: + + - Grafana container included by default increases memory/disk usage + - Users who don't want visualization must manually remove section + - **Mitigation**: Simple opt-out (remove config section) + - **Acceptable**: Monitoring is best practice for production + +5. **Named Volume Backup Complexity**: + - Named volumes harder to backup than bind mounts + - Requires Docker volume commands for backup/restore + - **Mitigation**: Standard Docker volume management practices + - **Acceptable**: Grafana dashboards can be exported/imported via UI + +### Implementation Maintenance + +1. **Template Consistency**: + + - Conditional Grafana service in docker-compose.yml.tera + - Conditional environment variables in .env.tera + - Conditional volume declaration + - Must be kept in sync with environment state + +2. **Validation Logic**: + + - Dependency validation called during environment creation + - Error messages must remain clear and actionable + - Unit tests cover all validation scenarios + +3. **Testing**: + - E2E tests validate Grafana deployment when enabled + - Manual verification guide documents complete workflow + - Unit tests cover GrafanaValidator logic (14 tests) + +### Future Work + +**Planned Automation** (separate issue): + +1. **Auto-Provision Prometheus Datasource**: + + - Create `provisioning/datasources/prometheus.yml` during release + - Grafana automatically connects to Prometheus on startup + - Zero-config experience for users + +2. **Auto-Import Tracker Dashboards**: + + - Copy `stats.json` and `metrics.json` from torrust-demo + - Create `provisioning/dashboards/` directory during release + - Dashboards available immediately after deployment + +3. **Customizable Dashboard Templates**: + - Allow users to provide custom dashboard JSON files + - Support for dashboard provisioning configuration + - Template-based dashboard generation + +**Related Roadmap Items**: + +- Task 6: Reverse proxy implementation with TLS termination +- Task 7: Automated backup and restore procedures +- Task 8: Multi-environment dashboard management + +## Related Decisions + +- [Prometheus Integration Pattern](./prometheus-integration-pattern.md) - Consistent enabled-by-default approach +- [Environment Variable Injection in Docker Compose](./environment-variable-injection-in-docker-compose.md) - Configuration pattern +- [DRAFT: Docker UFW Firewall Security Strategy](../issues/DRAFT-docker-ufw-firewall-security-strategy.md) - Port exposure security + +## References + +- [Grafana Docker Documentation](https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/) +- [Grafana Configuration Environment Variables](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#override-configuration-with-environment-variables) +- [Grafana Provisioning](https://grafana.com/docs/grafana/latest/administration/provisioning/) +- [Torrust Demo Grafana Setup](https://github.com/torrust/torrust-demo/blob/main/compose.yaml) +- [Sample Dashboards](https://github.com/torrust/torrust-demo/tree/main/share/grafana/dashboards) +- [Manual Verification Guide](../e2e-testing/manual/grafana-verification.md) diff --git a/docs/e2e-testing/manual/README.md b/docs/e2e-testing/manual/README.md index 5495bf72..19f77cba 100644 --- a/docs/e2e-testing/manual/README.md +++ b/docs/e2e-testing/manual/README.md @@ -404,6 +404,17 @@ ls data/manual-test 2>/dev/null || echo "Cleaned up successfully" After deploying your environment, you may want to verify that specific services are working correctly. The following guides provide detailed verification steps for each supported service: +### Torrust Tracker + +The tracker is the core service deployed by this tool. See the [Tracker Verification Guide](tracker-verification.md) for detailed steps to: + +- Test HTTP tracker announce and scrape endpoints +- Test UDP tracker functionality (overview and tooling) +- Verify tracker REST API endpoints +- Check health endpoints +- Troubleshoot tracker-specific issues +- Monitor tracker logs and performance + ### MySQL Database If your deployment includes MySQL as the database backend, see the [MySQL Verification Guide](mysql-verification.md) for detailed steps to: @@ -425,9 +436,19 @@ If your deployment includes Prometheus for metrics collection (enabled by defaul - Query collected metrics - Troubleshoot Prometheus-specific issues +### Grafana Dashboards + +If your deployment includes Grafana for metrics visualization, see the [Grafana Verification Guide](grafana-verification.md) for detailed steps to: + +- Verify Grafana container health and connectivity +- Check dashboard and datasource provisioning +- Validate Prometheus datasource connection +- Test end-to-end data flow (Tracker → Prometheus → Grafana) +- Troubleshoot Grafana-specific issues + ### Basic Tracker Verification -For basic tracker functionality without service-specific checks: +For quick basic tracker functionality checks without the detailed guide: ```bash # Get the VM IP diff --git a/docs/e2e-testing/manual/grafana-verification.md b/docs/e2e-testing/manual/grafana-verification.md new file mode 100644 index 00000000..fab20c85 --- /dev/null +++ b/docs/e2e-testing/manual/grafana-verification.md @@ -0,0 +1,850 @@ +# Manual Grafana Service Verification + +This guide provides Grafana-specific verification steps for manual E2E testing. For the complete deployment workflow, see the [Manual E2E Testing Guide](README.md). + +## Overview + +This guide covers: + +- Grafana container health and connectivity +- Dashboard and datasource provisioning verification +- Prometheus datasource connection validation +- End-to-end data flow testing (Tracker → Prometheus → Grafana) +- Grafana-specific troubleshooting + +## Prerequisites + +Complete the standard deployment workflow first (see [Manual E2E Testing Guide](README.md)): + +1. ✅ Environment created with Prometheus and Grafana configuration +2. ✅ Infrastructure provisioned +3. ✅ Services configured +4. ✅ Software released +5. ✅ Services running + +**Your environment configuration must include both Prometheus and Grafana**: + +```json +{ + "prometheus": { + "scrape_interval_in_secs": 15 + }, + "grafana": { + "admin_user": "admin", + "admin_password": "SecurePassword123!" + } +} +``` + +**Note:** Grafana requires Prometheus to be configured. The environment creation will fail if you try to enable Grafana without Prometheus. + +## Grafana-Specific Verification + +This section provides detailed Grafana verification steps that should be performed after completing the standard deployment workflow. + +### Get the VM IP Address + +Extract the instance IP from the environment state (see [main guide](README.md#step-3-provision-infrastructure) for details): + +```bash +cat data//environment.json | jq -r '.Running.context.runtime_outputs.instance_ip' +``` + +> **💡 Tip**: A `show` command is planned (issue [#241](https://github.com/torrust/torrust-tracker-deployer/issues/241)) that will display environment information including the IP address in a more user-friendly format. Once implemented, you'll be able to use: +> +> ```bash +> cargo run -- show +> ``` + +## Verification Steps + +### 1. Verify Grafana Container is Running + +SSH into the VM and check that the Grafana container is running: + +```bash +# SSH into the VM +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ + +# Check running containers +docker ps +``` + +**Expected output:** + +You should see three containers running: + +```text +CONTAINER ID IMAGE COMMAND STATUS +a1b2c3d4e5f6 grafana/grafana:11.4.0 "/run.sh" Up 2 minutes +b2d988505fae prom/prometheus:v3.0.1 "/bin/prometheus --c…" Up 2 minutes +f0e3124878de torrust/tracker:develop "/usr/local/bin/entr…" Up 2 minutes (healthy) +``` + +**Key verification points:** + +- ✅ `grafana/grafana:11.4.0` container is present +- ✅ Container status shows "Up" (not "Restarting" or "Exited") +- ✅ Port 3100 is exposed (`0.0.0.0:3100->3000/tcp`) + +### 2. Verify Grafana Web Interface is Accessible + +Test that you can access the Grafana web interface from your local machine: + +```bash +# Test HTTP response (should get redirect to login page) +curl -v http://:3100/ +``` + +**Expected output:** + +```text +< HTTP/1.1 302 Found +< Location: /login +``` + +This confirms Grafana is running and accessible. The 302 redirect is expected - it's redirecting unauthenticated requests to the login page. + +**Browser access:** + +Open your web browser and navigate to: + +```text +http://:3100/ +``` + +You should see the Grafana login page. + +**Key verification points:** + +- ✅ HTTP response is 302 (redirect) +- ✅ Location header points to `/login` +- ✅ Browser shows Grafana login interface + +### 3. Verify Authentication with Configured Credentials + +Test that you can authenticate with the credentials from your environment configuration: + +```bash +# Test with your configured credentials +curl -u admin:SecurePassword123! http://:3100/api/datasources +``` + +**Expected output:** + +```json +[] +``` + +An empty array indicates successful authentication (no datasources configured yet via API). + +**Test with wrong credentials:** + +```bash +# This should fail +curl -u admin:wrongpassword http://:3100/api/datasources +``` + +**Expected output:** + +```json +{ + "message": "Invalid username or password", + "messageId": "password-auth.failed", + "statusCode": 401, + "traceID": "" +} +``` + +**Key verification points:** + +- ✅ Correct credentials return HTTP 200 +- ✅ Wrong credentials return HTTP 401 +- ✅ Error message is clear: "Invalid username or password" + +### 4. Verify Prometheus is Accessible from Grafana Container + +Since Prometheus binds to `127.0.0.1:9090` on the VM (internal only), it's not directly accessible from outside. However, Grafana needs to access it. Let's verify the Docker network connectivity: + +```bash +# SSH into the VM +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ + +# Check that Prometheus is NOT accessible externally (expected to fail) +curl -s http://localhost:9090/api/v1/targets +``` + +**Expected output:** + +```text +curl: (7) Failed to connect to localhost port 9090 after 0 ms: Couldn't connect to server +``` + +This is **correct behavior** - Prometheus is bound to 127.0.0.1 and not accessible from the Docker host network. + +```bash +# Now test from within the Grafana container (should succeed) +docker exec -it wget -q -O - http://prometheus:9090/api/v1/targets | head -c 100 +``` + +**Expected output:** + +```json +{"status":"success","data":{"activeTargets":[...]}}... +``` + +**Key verification points:** + +- ✅ Prometheus is NOT accessible from VM host (localhost) +- ✅ Prometheus IS accessible from Grafana container via service name +- ✅ Docker network allows inter-container communication + +### 5. Verify Grafana Provisioning Files Are Deployed + +Check that the Grafana provisioning files (datasource and dashboards) were correctly deployed to the VM: + +```bash +# SSH into the VM +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ + +# Check datasource provisioning file +cat /opt/torrust/storage/grafana/provisioning/datasources/prometheus.yml +``` + +**Expected output:** + +```yaml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: "15s" + httpMethod: POST +``` + +**Key verification points:** + +- ✅ File exists at the correct path +- ✅ `uid: prometheus` is set (critical for dashboard compatibility) +- ✅ URL is `http://prometheus:9090` (Docker service name) +- ✅ `timeInterval` matches your configured scrape interval + +**Check dashboard provider configuration:** + +```bash +# Check dashboard provider file +cat /opt/torrust/storage/grafana/provisioning/dashboards/torrust.yml +``` + +**Expected output:** + +```yaml +apiVersion: 1 + +providers: + - name: "Torrust Dashboards" + orgId: 1 + folder: "Torrust Tracker" + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards/torrust + foldersFromFilesStructure: false +``` + +**Check dashboard JSON files:** + +```bash +# List dashboard files +ls -lh /opt/torrust/storage/grafana/provisioning/dashboards/torrust/ + +# Verify datasource UID in dashboards +grep -c '"uid": "prometheus"' /opt/torrust/storage/grafana/provisioning/dashboards/torrust/*.json +``` + +**Expected output:** + +```text +/opt/torrust/storage/grafana/provisioning/dashboards/torrust/metrics.json:20 +/opt/torrust/storage/grafana/provisioning/dashboards/torrust/stats.json:20 +``` + +This shows that both dashboard files contain 20 references to the `prometheus` datasource UID (one for each panel). + +### 6. Verify Prometheus Datasource in Grafana API + +Check the Prometheus datasource configuration via Grafana API: + +```bash +# List configured datasources +curl -u admin:SecurePassword123! http://:3100/api/datasources +``` + +**Expected output:** + +```json +[ + { + "id": 1, + "uid": "prometheus", + "orgId": 1, + "name": "Prometheus", + "type": "prometheus", + "typeName": "Prometheus", + "typeLogoUrl": "public/app/plugins/datasource/prometheus/img/prometheus_logo.svg", + "access": "proxy", + "url": "http://prometheus:9090", + "user": "", + "database": "", + "basicAuth": false, + "isDefault": true, + "jsonData": { + "httpMethod": "POST", + "timeInterval": "15s" + }, + "readOnly": false + } +] +``` + +**Key verification points:** + +- ✅ Datasource `uid` is `"prometheus"` (must match dashboard references) +- ✅ Datasource type is `"prometheus"` +- ✅ URL is `"http://prometheus:9090"` (using Docker service name) +- ✅ Access mode is `"proxy"` (requests go through Grafana backend) +- ✅ Datasource is set as default (`"isDefault": true`) +- ✅ `jsonData` contains `timeInterval` matching your configuration + +**⚠️ Critical:** The datasource `uid` must be `"prometheus"` to match the dashboard configurations. If you see a different UID (like `"ce6lwx047kutca"` from an old deployment), the dashboards will fail to load with "Datasource was not found" errors. + +### 7. Test Datasource Connection and Query Metrics + +Test that Grafana can successfully query metrics from Prometheus: + +```bash +# Test datasource health check +curl -u admin:SecurePassword123! \ + "http://:3100/api/datasources/proxy/1/api/v1/query?query=up" +``` + +**Expected output:** + +```json +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "up", + "instance": "tracker:1212", + "job": "tracker_metrics" + }, + "value": [1734699623.123, "1"] + }, + { + "metric": { + "__name__": "up", + "instance": "tracker:1212", + "job": "tracker_stats" + }, + "value": [1734699623.123, "1"] + } + ] + } +} +``` + +**Query tracker-specific metrics:** + +```bash +# Query total announces +curl -u admin:SecurePassword123! \ + "http://:3100/api/datasources/proxy/1/api/v1/query?query=tracker_announces_total" +``` + +**Key verification points:** + +- ✅ Status is `"success"` +- ✅ Both `tracker_metrics` and `tracker_stats` targets show `"1"` (up) +- ✅ Tracker-specific metrics return valid data +- ✅ Timestamps are recent (within last few seconds) + +### 8. Verify End-to-End Data Flow (Tracker → Prometheus → Grafana) + +Now verify that data flows correctly from the tracker through Prometheus to Grafana by generating actual tracker activity: + +#### Step 8.1: Generate Tracker Activity + +Make HTTP announce requests to the tracker to generate metrics: + +```bash +# SSH into the VM +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ + +# Send a single HTTP announce request +curl -s -H 'X-Forwarded-For: 203.0.113.195' \ + 'http://localhost:7070/announce?info_hash=%3C%A6%7F%CB%3C%0B%DE%85%91%1C%82%16%7B%ED%15S%83%00%22%15&peer_id=-qB00000000000000001&port=17548&uploaded=0&downloaded=0&left=0&event=started' +``` + +**Expected response (bencoded):** + +```text +d8:completei1e10:incompletei0e8:intervali300e12:min intervali300e5:peerslee +``` + +This indicates a successful announce (1 complete peer, 0 incomplete, empty peers list). + +**Generate multiple requests for better visualization:** + +```bash +# Send 10 announce requests with different peer IDs +for i in {1..10}; do + curl -s -H "X-Forwarded-For: 203.0.113.$((RANDOM % 255))" \ + "http://localhost:7070/announce?info_hash=%3C%A6%7F%CB%3C%0B%DE%85%91%1C%82%16%7B%ED%15S%83%00%22%15&peer_id=-qB0000000000000000$i&port=17548&uploaded=0&downloaded=0&left=0&event=started" \ + > /dev/null +done +echo "Sent 10 announce requests" +``` + +#### Step 8.2: Verify Tracker Metrics API + +Check that the tracker is exposing the metrics: + +```bash +# Query tracker metrics endpoint (JSON format) +curl -s 'http://localhost:1212/api/v1/metrics?token=MyAccessToken' | head -100 +``` + +**Expected output (truncated):** + +```json +{ + "metrics": [ + { + "type": "counter", + "name": "http_tracker_core_requests_received_total", + "samples": [ + { + "value": 10, + "labels": [ + { "name": "request_kind", "value": "announce" }, + { "name": "server_binding_protocol", "value": "http" } + ] + } + ] + }, + { + "type": "gauge", + "name": "swarm_coordination_registry_torrents_total", + "samples": [{ "value": 1.0 }] + }, + { + "type": "gauge", + "name": "swarm_coordination_registry_peer_connections_total", + "samples": [ + { + "value": 10.0, + "labels": [{ "name": "peer_role", "value": "seeder" }] + } + ] + } + ] +} +``` + +**Key metrics to verify:** + +- `http_tracker_core_requests_received_total`: Should show 10 requests +- `swarm_coordination_registry_torrents_total`: Should show 1 torrent +- `swarm_coordination_registry_peer_connections_total`: Should show 10 seeders + +#### Step 8.3: Verify Prometheus Has Scraped the Data + +Query Prometheus directly to confirm it's collecting the tracker metrics: + +```bash +# Query HTTP requests metric +curl -s 'http://localhost:9090/api/v1/query?query=http_tracker_core_requests_received_total' | jq . + +# Query torrents metric +curl -s 'http://localhost:9090/api/v1/query?query=swarm_coordination_registry_torrents_total' | jq . + +# Query seeders metric +curl -s 'http://localhost:9090/api/v1/query?query=swarm_coordination_registry_peer_connections_total' | jq '.data.result[] | select(.metric.peer_role=="seeder")' +``` + +**Expected outputs:** + +```json +{ + "status": "success", + "data": { + "result": [ + { + "metric": { + "__name__": "http_tracker_core_requests_received_total", + "instance": "tracker:1212", + "job": "tracker_metrics", + "request_kind": "announce" + }, + "value": [1766259745.624, "10"] + } + ] + } +} +``` + +**Key verification points:** + +- ✅ Status is `"success"` +- ✅ Metric values match what the tracker API reports +- ✅ `job` label shows `"tracker_metrics"` or `"tracker_stats"` +- ✅ Timestamp is recent (within last scrape interval) + +#### Step 8.4: Verify Grafana Dashboards Display the Data + +Finally, verify that the Grafana dashboards can display the data: + +**Via Browser:** + +1. Open Grafana: `http://:3100/` +2. Login with your credentials (admin / SecurePassword123!) +3. Navigate to Dashboards → Browse +4. Open the "Torrust Tracker" folder +5. Open "Torrust Tracker - Metrics" or "Torrust Tracker - Stats" dashboard + +**Expected results in dashboards:** + +- **Torrents panel**: Should show `1` +- **Seeders panel**: Should show `10` +- **HTTP requests graphs**: Should show activity over time +- **No "Datasource not found" errors** + +**Via API (alternative):** + +```bash +# Query via Grafana datasource proxy +curl -u admin:SecurePassword123! \ + "http://:3100/api/datasources/proxy/1/api/v1/query?query=swarm_coordination_registry_torrents_total{job=\"tracker_metrics\"}" | jq . +``` + +**Expected output:** + +```json +{ + "status": "success", + "data": { + "result": [ + { + "metric": { + "__name__": "swarm_coordination_registry_torrents_total", + "instance": "tracker:1212", + "job": "tracker_metrics" + }, + "value": [1766259767.583, "1"] + } + ] + } +} +``` + +**Key verification points:** + +- ✅ Grafana can query Prometheus through the datasource proxy +- ✅ Data is flowing from tracker → Prometheus → Grafana +- ✅ Dashboard panels display actual values (not "N/A" or errors) +- ✅ Graphs show historical data (if enough time has passed for multiple scrapes) + +## Troubleshooting + +### Grafana Container Not Running + +**Symptoms:** + +- `docker ps` doesn't show Grafana container +- Container status is "Exited" or "Restarting" + +**Diagnosis:** + +```bash +# Check container logs +docker logs + +# Check if container exits immediately +docker ps -a | grep grafana +``` + +**Common causes:** + +- Port 3100 already in use on the VM +- Invalid environment variable in `.env` file +- Insufficient permissions on data directory + +### Cannot Access Grafana Web Interface + +**Symptoms:** + +- `curl http://:3100/` times out or connection refused +- Browser cannot load the page + +**Diagnosis:** + +```bash +# Check if port is listening +ssh torrust@ "netstat -tlnp | grep 3100" + +# Check container networking +docker inspect | grep IPAddress + +# Check firewall rules (if applicable) +ssh torrust@ "sudo ufw status" +``` + +**Solutions:** + +- Verify container is running: `docker ps` +- Check container logs for errors: `docker logs ` +- Verify port mapping in docker-compose.yml + +### Authentication Fails with Configured Password + +**Symptoms:** + +- Configured password doesn't work +- Error: "Invalid username or password" +- Can only login with default "admin/admin" + +**Diagnosis:** + +```bash +# Check what password is in the .env file +cat /opt/torrust/storage/docker-compose/.env | grep GF_SECURITY_ADMIN_PASSWORD + +# Check environment variables in running container +docker exec env | grep GF_SECURITY_ADMIN_PASSWORD +``` + +**Root cause:** + +This was a bug where the configured password wasn't being passed from the environment config to the `.env` file. It was fixed by updating: + +- `UserInputs::with_tracker()` to accept optional Prometheus/Grafana configs +- `EnvironmentContext::with_working_dir_and_tracker()` to pass configs through +- `Environment::with_working_dir_and_tracker()` to accept configs +- Create handler to pass configs instead of using defaults + +**Solution:** + +If you encounter this: + +1. Verify your environment config file has the correct password +2. Destroy and recreate the environment with the latest code +3. Check that `data/your-env/environment.json` contains the correct password +4. Verify `build/your-env/docker-compose/.env` has the correct `GF_SECURITY_ADMIN_PASSWORD` + +### Prometheus Datasource Connection Failed + +**Symptoms:** + +- Datasource shows as "Not working" in Grafana UI +- API queries return empty results or errors +- Datasource health check fails + +**Diagnosis:** + +```bash +# Test Prometheus connectivity from Grafana container +docker exec -it wget -O - http://prometheus:9090/-/healthy + +# Check Prometheus container logs +docker logs + +# Verify Docker network +docker network inspect +``` + +**Common causes:** + +- Prometheus container not running +- Wrong datasource URL (should be `http://prometheus:9090`, not `http://localhost:9090`) +- Network connectivity issues between containers +- Prometheus not fully initialized yet + +**Solutions:** + +1. Verify Prometheus is running: `docker ps | grep prometheus` +2. Check datasource URL: should use Docker service name `prometheus` +3. Test network: `docker exec ping prometheus` +4. Wait a few seconds for Prometheus to initialize after container start + +### Dashboards Show "Datasource Not Found" Error + +**Symptoms:** + +- Dashboards load but all panels show error: "Datasource [UID] was not found" +- Example error: "Datasource ce6lwx047kutca was not found" +- All dashboard panels are empty with red error messages + +**Root cause:** + +The dashboard JSON files contain hardcoded datasource UIDs that don't match the provisioned datasource UID. This typically happens if: + +- Dashboard files were copied from another installation (like torrust-demo) +- Datasource was recreated with a different UID +- Dashboard files weren't updated when datasource UID changed + +**Diagnosis:** + +```bash +# SSH into the VM +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ + +# Check what UID the dashboards are using +grep '"uid":' /opt/torrust/storage/grafana/provisioning/dashboards/torrust/*.json | head -5 + +# Check what UID the datasource actually has +cat /opt/torrust/storage/grafana/provisioning/datasources/prometheus.yml | grep uid +``` + +**Example mismatch:** + +```bash +# Dashboard expects: +"uid": "ce6lwx047kutca" # ❌ Wrong - from old demo installation + +# But datasource has: +uid: prometheus # ✅ Correct - what we provisioned +``` + +**Solution:** + +The datasource template and dashboard files must use matching UIDs. The correct configuration is: + +1. **Datasource template** (`templates/grafana/provisioning/datasources/prometheus.yml.tera`): + + ```yaml + datasources: + - name: Prometheus + uid: prometheus # ← Fixed UID + ``` + +2. **Dashboard JSON files** must reference the same UID: + + ```json + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" # ← Must match datasource + } + } + ``` + +**If you encounter this issue:** + +1. Verify the template source has the correct UID +2. Destroy and recreate the environment with updated templates +3. Check the deployed files match: `grep -c '"uid": "prometheus"' /opt/torrust/storage/grafana/provisioning/dashboards/torrust/*.json` +4. Should show 20 matches per dashboard file (one per panel) + +**Prevention:** + +- Always use `uid: prometheus` in the datasource template +- When importing dashboards from external sources, update all datasource UID references +- Validate dashboard UIDs match datasource UID before deployment + +### Dashboards Show Placeholder Domain + +**Symptoms:** + +- Dashboard descriptions reference `tracker.example.com` +- URLs in dashboard descriptions don't match your actual deployment + +**Expected behavior:** + +This is intentional. The dashboards use `tracker.example.com` as a generic placeholder to indicate where metrics are collected from. Users should understand this is a placeholder and replace it with their actual tracker domain or IP address when customizing dashboards. + +**If you need to customize:** + +The placeholder domain appears only in dashboard **descriptions** (not in actual queries). To customize: + +1. Export the dashboard JSON from Grafana UI +2. Search and replace `tracker.example.com` with your domain +3. Re-import the customized dashboard + +## Testing Checklist + +Use this checklist when verifying a Grafana deployment: + +- [ ] Three containers running (grafana, prometheus, tracker) +- [ ] Grafana web interface accessible (HTTP 302 redirect to /login) +- [ ] Can authenticate with configured credentials +- [ ] Wrong credentials are rejected (HTTP 401) +- [ ] Prometheus NOT accessible from VM host (security check) +- [ ] Prometheus accessible from Grafana container +- [ ] Prometheus datasource configured +- [ ] Datasource health check passes +- [ ] Can query `up` metric successfully +- [ ] Can query tracker-specific metrics +- [ ] Both tracker targets show as "up" in results + +## Browser-Based Verification + +For a complete verification, you can also test through the Grafana web UI: + +1. **Login**: + + - Navigate to `http://:3100/` + - Login with your configured credentials + +2. **Check Datasource**: + + - Go to Configuration → Data Sources + - Verify "Prometheus" datasource exists + - Click "Test" button → should show "Data source is working" + +3. **Explore Metrics**: + + - Go to Explore (compass icon in sidebar) + - Select "Prometheus" datasource + - Try queries: + - `up` → should show both tracker targets + - `tracker_announces_total` → should show tracker metrics + - `tracker_metrics_scrape_duration_seconds` → should show scrape timing + +4. **Create Dashboard**: + - Create → Dashboard + - Add Panel + - Query: `rate(tracker_announces_total[5m])` + - Should show announce rate graph + +## Next Steps + +After successful Grafana verification: + +1. **Explore Dashboards**: Review the pre-loaded Torrust tracker dashboards +2. **Customize Dashboards**: Modify existing dashboards or create new ones for your specific needs +3. **Configure Alerts**: Set up alerting rules for important metrics (requires Alertmanager) +4. **Backup Grafana Data**: Export customized dashboards for version control +5. **Continue Testing**: Return to the [Manual E2E Testing Guide](README.md) for cleanup or additional verification + +For troubleshooting common issues during manual testing, see the [Troubleshooting section](README.md#troubleshooting-manual-tests) in the main guide. + +## References + +- [Grafana Documentation](https://grafana.com/docs/grafana/latest/) +- [Grafana HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/) +- [Grafana Provisioning](https://grafana.com/docs/grafana/latest/administration/provisioning/) +- [Prometheus Data Source](https://grafana.com/docs/grafana/latest/datasources/prometheus/) +- [Torrust Tracker Metrics](https://github.com/torrust/torrust-tracker) diff --git a/docs/e2e-testing/manual/tracker-verification.md b/docs/e2e-testing/manual/tracker-verification.md new file mode 100644 index 00000000..7a917511 --- /dev/null +++ b/docs/e2e-testing/manual/tracker-verification.md @@ -0,0 +1,573 @@ +# Manual Tracker Service Verification + +This guide provides Tracker-specific verification steps for manual E2E testing. For the complete deployment workflow, see the [Manual E2E Testing Guide](README.md). + +## Overview + +This guide covers: + +- HTTP tracker announce/scrape endpoint testing +- UDP tracker endpoint testing (overview and future tooling) +- Tracker REST API testing +- Health check verification +- Tracker-specific troubleshooting + +## Prerequisites + +Complete the standard deployment workflow first (see [Manual E2E Testing Guide](README.md)): + +1. ✅ Environment created +2. ✅ Infrastructure provisioned +3. ✅ Services configured +4. ✅ Software released +5. ✅ Services running + +**Your environment configuration must include tracker settings**: + +```json +{ + "tracker": { + "core": { + "database": { + "driver": "sqlite3", + "database_name": "tracker.db" + } + }, + "udp_trackers": [ + { + "bind_address": "0.0.0.0:6969" + } + ], + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070" + } + ], + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MyAccessToken" + } + } +} +``` + +## Tracker-Specific Verification + +This section provides detailed tracker verification steps that should be performed after completing the standard deployment workflow. + +### Get the VM IP Address + +Extract the instance IP from the environment state (see [main guide](README.md#step-3-provision-infrastructure) for details): + +```bash +cat data//environment.json | jq -r '.Running.context.runtime_outputs.instance_ip' +``` + +> **💡 Tip**: A `show` command is planned (issue [#241](https://github.com/torrust/torrust-tracker-deployer/issues/241)) that will display environment information including the IP address in a more user-friendly format. + +## Verification Steps + +### 1. Verify Tracker Container is Running + +SSH into the VM and check that the tracker container is running: + +```bash +# SSH into the VM +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ + +# Check running containers +docker ps +``` + +**Expected output:** + +```text +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +acb7e4fe0569 torrust/tracker:develop "/usr/local/bin/entr…" 32 minutes ago Up 32 minutes (healthy) 0.0.0.0:1212->1212/tcp, [::]:1212->1212/tcp, 0.0.0.0:7070->7070/tcp, [::]:7070->7070/tcp, 1313/tcp, 0.0.0.0:6969->6969/udp, [::]:6969->6969/udp tracker +``` + +**Key verification points:** + +- ✅ `torrust/tracker:develop` container is present +- ✅ Container status shows "Up" with "(healthy)" indicator +- ✅ UDP port 6969 is exposed (`0.0.0.0:6969->6969/udp`) +- ✅ HTTP port 7070 is exposed (`0.0.0.0:7070->7070/tcp`) +- ✅ API port 1212 is exposed (`0.0.0.0:1212->1212/tcp`) + +### 2. Test HTTP Tracker Health Check + +Verify the HTTP tracker health endpoint responds: + +```bash +# From your local machine +export VM_IP= + +# Test HTTP tracker health +curl http://$VM_IP:7070/health_check +``` + +**Expected response:** + +```json +{ "status": "Ok" } +``` + +**Verification points:** + +- ✅ HTTP 200 OK status code +- ✅ JSON response with `"status":"Ok"` (note the capital 'O') +- ✅ Response received within 1-2 seconds + +### 3. Test Tracker REST API Health Check + +Verify the REST API health endpoint responds: + +```bash +# Test API health +curl http://$VM_IP:1212/api/health_check +``` + +**Expected response:** + +```json +{ "status": "Ok" } +``` + +**Verification points:** + +- ✅ HTTP 200 OK status code +- ✅ JSON response with `"status":"Ok"` (note the capital 'O') + +**Alternative API endpoints to verify:** + +```bash +# Get tracker statistics +curl http://$VM_IP:1212/api/v1/stats?token=MyAccessToken + +# Get Prometheus metrics +curl http://$VM_IP:1212/api/v1/metrics?token=MyAccessToken +``` + +### 4. Test HTTP Tracker Announce Endpoint + +Test the HTTP tracker announce endpoint with a sample announce request: + +```bash +# Test announce endpoint +curl "http://$VM_IP:7070/announce?info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&peer_id=-qB00000000000000001&port=17548&uploaded=0&downloaded=0&left=0&event=started" +``` + +**Expected response (bencoded):** + +```text +d8:completei1e10:incompletei0e8:intervali300e12:min intervali300e5:peerslee +``` + +This is a valid bencoded dictionary response indicating: + +- `complete`: 1 seeder +- `incomplete`: 0 leechers +- `interval`: 300 seconds (time before next announce) +- `min interval`: 300 seconds +- `peers`: Empty list (no other peers to return) + +**Note about reverse proxy mode:** + +If your tracker is configured with `on_reverse_proxy = true`, you'll need to include the `X-Forwarded-For` header: + +```bash +curl -H "X-Forwarded-For: 203.0.113.195" \ + "http://$VM_IP:7070/announce?info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&peer_id=-qB00000000000000001&port=17548&uploaded=0&downloaded=0&left=0&event=started" +``` + +Without this header, you'll get an error: + +```text +d14:failure reason208:Error resolving peer IP: missing or invalid the right most X-Forwarded-For IP (mandatory on reverse proxy tracker configuration)e +``` + +### 5. Test HTTP Tracker Scrape Endpoint + +Test the scrape endpoint to get torrent statistics: + +```bash +# Test scrape endpoint +curl "http://$VM_IP:7070/scrape?info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0" +``` + +**Expected response (bencoded):** + +```text +d5:filesd20:;$U04CF5F11BBDBE1201CEAjkF4ZEE1BC0d8:completei1e10:downloadedi0e10:incompletei0eeee +``` + +This shows statistics for the torrent: + +- `complete`: 1 (number of seeders) +- `downloaded`: 0 (number of completed downloads) +- `incomplete`: 0 (number of leechers) + +**Note:** Same reverse proxy considerations apply - add `X-Forwarded-For` header if needed. + +### 6. Test REST API Endpoints + +Test various REST API endpoints: + +#### Get Tracker Statistics + +```bash +curl "http://$VM_IP:1212/api/v1/stats?token=MyAccessToken" | jq +``` + +**Expected response:** + +```json +{ + "torrents": 1, + "seeders": 1, + "completed": 0, + "leechers": 0, + "tcp4_connections_handled": 12, + "tcp4_announces_handled": 11, + "tcp4_scrapes_handled": 1, + "tcp6_connections_handled": 0, + "tcp6_announces_handled": 0, + "tcp6_scrapes_handled": 0, + "udp4_connections_handled": 377, + "udp4_announces_handled": 0, + "udp4_scrapes_handled": 0, + "udp4_requests": 377, + "udp6_connections_handled": 0, + "udp6_announces_handled": 0, + "udp6_scrapes_handled": 0, + "udp6_requests": 0 +} +``` + +#### Get Prometheus Metrics + +```bash +curl "http://$VM_IP:1212/api/v1/metrics?token=MyAccessToken" +``` + +**Expected response (JSON format with metrics):** + +```json +{ + "metrics": [ + { + "type": "counter", + "name": "udp_tracker_server_connection_id_errors_total", + "help": "Total number of connection ID errors in the UDP tracker server", + "samples": [{ "labels": {}, "value": 0.0 }] + }, + { + "type": "counter", + "name": "tracker_core_persistent_torrents_downloads_total", + "help": "Total number of torrents successfully downloaded to tracker persistent storage", + "samples": [{ "labels": {}, "value": 0.0 }] + }, + { + "type": "counter", + "name": "swarm_coordination_registry_peers_added_total", + "help": "Total number of peers added to the registry", + "samples": [ + { "labels": { "peer_type": "seeder" }, "value": 11.0 }, + { "labels": { "peer_type": "leecher" }, "value": 0.0 } + ] + } + ] +} +``` + +> **Note**: The metrics endpoint returns JSON format containing an array of metric objects. Each metric includes type, name, help text, and sample values with optional labels. + +### 7. UDP Tracker Testing (Advanced) + +Testing the UDP tracker requires a BitTorrent UDP protocol client. While HTTP endpoints can be easily tested with `curl`, UDP requires specialized tooling. + +#### Current State + +The Torrust Tracker project includes a UDP client implementation at: + +- **Repository**: https://github.com/torrust/torrust-tracker +- **Path**: `console/tracker-client` (in `develop` branch) +- **Status**: Not yet published as a crate + +#### Using the UDP Client + +To test UDP tracker functionality: + +1. **Clone the tracker repository**: + + ```bash + git clone https://github.com/torrust/torrust-tracker.git + cd torrust-tracker + git checkout develop + ``` + +2. **Run the UDP client**: + + ```bash + cd console/tracker-client + cargo run -- udp --tracker-url "udp://$VM_IP:6969" \ + --info-hash "3B2455044CF55F11BBDBE1201CEA6A6BF45AEE1BC0" + ``` + +3. **Expected behavior**: + - Connection to UDP tracker succeeds + - Announce request returns peer list + - Scrape request returns torrent statistics + +#### Alternative: Basic UDP Testing + +For basic UDP connectivity testing without the specialized client: + +```bash +# Test if UDP port is open (from local machine) +nc -u -v -w3 $VM_IP 6969 + +# Note: This only tests connectivity, not protocol compliance +# The tracker won't respond to arbitrary UDP packets +``` + +#### Future Tooling + +> **📋 Note**: The UDP tracker client will be published as a standalone Rust crate in a future release, making UDP testing much easier. Once published, you'll be able to install it with: +> +> ```bash +> cargo install torrust-tracker-client +> torrust-tracker-client udp --tracker-url "udp://$VM_IP:6969" --info-hash +> ``` + +### 8. Verify Tracker Logs + +Check tracker logs for any errors or warnings: + +```bash +# SSH into the VM +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ + +# View tracker logs +docker logs tracker + +# Follow logs in real-time +docker logs -f tracker +``` + +**Look for:** + +- ✅ No ERROR level messages +- ✅ Successful announce/scrape operations +- ✅ Health check requests logged +- ✅ UDP and HTTP servers handling requests successfully + +**Example healthy log output:** + +```text +2025-12-20T20:10:38.800766Z INFO request{method=GET uri=/api/health_check version=HTTP/1.1}: API: request method=GET uri=/api/health_check request_id=50ea1dc8-fce1-4941-8fdd-7af67af8464d +2025-12-20T20:10:38.800889Z INFO request{method=GET uri=/api/health_check version=HTTP/1.1}: API: response latency_ms=0 status_code=200 OK server_socket_addr=0.0.0.0:1212 request_id=50ea1dc8-fce1-4941-8fdd-7af67af8464d +2025-12-20T20:10:38.801562Z INFO request{method=GET uri=/health_check version=HTTP/1.1}: HEALTH CHECK API: response latency_ms=35 status_code=200 OK request_id=1e842d0e-ee0a-47ef-b8fd-22d541a4c723 +2025-12-20T20:10:38.836476Z INFO torrust_tracker_swarm_coordination_registry::swarm::registry: active_peers_total=1 inactive_peers_total=0 active_torrents_total=1 inactive_torrents_total=0 +2025-12-20T20:10:43.888743Z INFO request{method=GET uri=/health_check version=HTTP/1.1}: HTTP TRACKER: request server_socket_addr=0.0.0.0:7070 method=GET uri=/health_check request_id=4e8be641-fc3d-4551-a929-10e347f7b8ba +2025-12-20T20:10:43.888770Z INFO request{method=GET uri=/health_check version=HTTP/1.1}: HTTP TRACKER: response server_socket_addr=0.0.0.0:7070 latency_ms=0 status_code=200 OK request_id=4e8be641-fc3d-4551-a929-10e347f7b8ba +``` + +## Troubleshooting + +### Tracker Container Not Running + +**Symptoms:** + +- `docker ps` doesn't show tracker container +- Health checks timeout or fail + +**Diagnosis:** + +```bash +# Check if container exists (including stopped) +docker ps -a | grep tracker + +# Check container logs +docker logs tracker + +# Check Docker Compose status +cd /opt/torrust +docker-compose ps +```` + +**Common causes:** + +- Configuration error in tracker.toml +- Port conflicts (6969, 7070, or 1212 already in use) +- Database file permissions issues +- Invalid database configuration (MySQL connection failed) + +**Solutions:** + +1. **Fix configuration and restart**: + + ```bash + # Edit configuration + nano /opt/torrust/config/tracker/tracker.toml + + # Restart services + docker-compose restart tracker + ``` + +2. **Check port availability**: + + ```bash + # Check if ports are already in use + ss -tulpn | grep -E ':(6969|7070|1212)' + ``` + +3. **Verify database connectivity** (if using MySQL): + + ```bash + # Check MySQL container + docker ps | grep mysql + # Test MySQL connection + docker exec mysql mysql -u tracker_user -p -e "SHOW DATABASES;" + ``` + +### HTTP Tracker Returns 404 + +**Symptoms:** + +- `curl http://$VM_IP:7070/announce` returns 404 Not Found +- Health check works but announce/scrape don't + +**Diagnosis:** + +```bash +# Check if HTTP tracker is enabled in config +docker exec tracker cat /etc/torrust/tracker/tracker.toml | grep -A5 "http_trackers" +``` + +**Solutions:** + +- Ensure `http_trackers` array is not empty in configuration +- Verify bind address is `0.0.0.0:7070` (not `127.0.0.1`) +- Check firewall rules allow port 7070 + +### UDP Tracker Not Responding + +**Symptoms:** + +- UDP client times out +- No response from UDP tracker + +**Diagnosis:** + +```bash +# Check if UDP tracker is enabled +docker exec tracker cat /etc/torrust/tracker/tracker.toml | grep -A5 "udp_trackers" + +# Check UDP port is listening +ss -ulpn | grep 6969 +``` + +**Solutions:** + +1. **Verify UDP tracker configuration**: + + ```bash + # Ensure bind address is 0.0.0.0:6969 + docker exec tracker cat /etc/torrust/tracker/tracker.toml + ``` + +2. **Check firewall** (UDP port 6969 must be open): + + ```bash + # Check firewall status + sudo ufw status + # Open UDP port if needed + sudo ufw allow 6969/udp + ``` + +3. **Restart tracker**: + + ```bash + docker-compose restart tracker + ``` + +### API Authentication Failed + +**Symptoms:** + +- `curl http://$VM_IP:1212/api/v1/stats?token=MyAccessToken` returns 401 Unauthorized + +**Diagnosis:** + +```bash +# Check API token in configuration +docker exec tracker cat /etc/torrust/tracker/tracker.toml | grep admin_token +``` + +**Solutions:** + +- Verify token in environment configuration matches tracker.toml +- Ensure token is URL-encoded if it contains special characters +- Token is case-sensitive - verify exact match + +### Reverse Proxy Mode Issues + +**Symptoms:** + +- Announces fail with "missing client IP" error +- Tracker rejects announces without X-Forwarded-For header + +**Diagnosis:** + +```bash +# Check reverse proxy setting +docker exec tracker cat /etc/torrust/tracker/tracker.toml | grep on_reverse_proxy +``` + +**Solutions:** + +If `on_reverse_proxy = true`: + +```bash +# Always include X-Forwarded-For header +curl -H "X-Forwarded-For: 203.0.113.1" \ + "http://$VM_IP:7070/announce?..." +``` + +If not behind a reverse proxy, set `on_reverse_proxy = false`. + +## Testing Checklist + +After deployment, verify all tracker functionality: + +- [ ] Tracker container is running with healthy status +- [ ] HTTP tracker health check responds (port 7070) +- [ ] HTTP API health check responds (port 1212) +- [ ] HTTP announce endpoint accepts requests and returns bencoded response +- [ ] HTTP scrape endpoint returns torrent statistics +- [ ] REST API `/stats` endpoint returns tracker statistics +- [ ] REST API `/metrics` endpoint returns Prometheus metrics +- [ ] UDP tracker port is listening (port 6969) +- [ ] Tracker logs show no errors +- [ ] Database connectivity confirmed (if using MySQL) + +## Next Steps + +After successful tracker verification: + +1. **Generate Load Testing**: Use torrent clients to generate realistic announce/scrape traffic +2. **Monitor Performance**: Check metrics via Prometheus/Grafana (see [prometheus-verification.md](prometheus-verification.md) and [grafana-verification.md](grafana-verification.md)) +3. **Test Database Scaling**: If using MySQL, test with larger peer counts (see [mysql-verification.md](mysql-verification.md)) +4. **Configure Backup**: Set up automated backups for tracker database +5. **Continue Testing**: Return to the [Manual E2E Testing Guide](README.md) for cleanup or additional verification + +For troubleshooting common issues during manual testing, see the [Troubleshooting section](README.md#troubleshooting-manual-tests) in the main guide. + +## References + +- [Torrust Tracker Documentation](https://github.com/torrust/torrust-tracker) +- [BitTorrent Protocol Specification (BEP 3)](http://www.bittorrent.org/beps/bep_0003.html) +- [UDP Tracker Protocol (BEP 15)](http://www.bittorrent.org/beps/bep_0015.html) +- [Torrust Tracker UDP Client](https://github.com/torrust/torrust-tracker/tree/develop/console/tracker-client) +- [Tracker HTTP API Documentation](https://github.com/torrust/torrust-tracker/blob/develop/docs/http-api.md) diff --git a/docs/issues/246-grafana-slice-release-run-commands-extension.md b/docs/issues/246-grafana-slice-release-run-commands-extension.md new file mode 100644 index 00000000..681c7400 --- /dev/null +++ b/docs/issues/246-grafana-slice-release-run-commands-extension.md @@ -0,0 +1,1280 @@ +# Grafana Slice - Extension Tasks + +**Parent Issue**: [#246](https://github.com/torrust/torrust-tracker-deployer/issues/246) - Grafana Slice - Release and Run Commands +**Branch**: `246-grafana-slice` +**Pull Request**: [#247](https://github.com/torrust/torrust-tracker-deployer/pull/247) +**Status**: Active - Implementation in Progress + +## Overview + +This document tracks extension tasks for the Grafana service that were identified after the initial implementation but were not included in the original issue scope. These enhancements will be implemented immediately on the current branch and included in PR #247 before merging. They improve the robustness, usability, and automation of the Grafana deployment. + +**Current State**: Grafana is deployed with basic configuration - admin credentials are configured via environment variables, but the Prometheus datasource and dashboards require manual setup through the Grafana UI. + +**Enhancement Goals**: Make Grafana deployment fully automated and production-ready by adding health checks and automatic configuration provisioning. + +## Extension Tasks + +### Task 1: Add Prometheus Docker Health Check + +**Status**: ✅ Completed +**Priority**: Medium (improves deployment reliability) + +#### Problem Statement + +The Prometheus container has no health check configured, which means: + +- Docker Compose doesn't know when Prometheus is actually ready to serve requests +- The `depends_on` directive only waits for container start, not service readiness +- Services depending on Prometheus can't wait for actual service readiness + +#### Proposed Solution + +Add Docker Compose health check using Prometheus's built-in health API endpoint: + +```yaml +prometheus: + image: prom/prometheus:v3.0.1 + # ... existing configuration ... + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s +``` + +**Health Check Configuration**: + +- `test`: Uses Prometheus's `/-/healthy` endpoint + - `http://localhost:9090`: Internal container port +- `interval: 10s`: Check health every 10 seconds after initial start period +- `timeout: 5s`: Each health check must complete within 5 seconds +- `retries: 5`: Container marked unhealthy after 5 consecutive failures +- `start_period: 10s`: Grace period for Prometheus initialization + +**Expected Benefits**: + +1. **Docker Awareness**: Docker Compose knows when Prometheus is truly ready +2. **Better Monitoring**: Health status visible in `docker ps` and `docker-compose ps` +3. **Service Dependencies**: Grafana can wait for `service_healthy` condition +4. **Automatic Restart**: Can configure restart policies based on health status + +**Implementation Impact**: + +- **Files to Modify**: `templates/docker-compose/docker-compose.yml.tera` - Add healthcheck block to Prometheus service +- **Testing**: Verify health check works correctly during E2E tests + +--- + +### Task 2: Add Grafana Docker Health Check + +**Status**: ✅ Completed +**Priority**: High (improves deployment reliability and reduces E2E retry logic) + +**Status**: ✅ Completed +**Priority**: High (improves deployment reliability and reduces E2E retry logic) + +#### Problem Statement + +The Grafana container has no health check configured, which means: + +- Docker Compose doesn't know when Grafana is actually ready to serve requests +- The `depends_on` directive only waits for container start, not service readiness +- E2E tests need retry logic to handle Grafana startup delay (30 attempts × 2 seconds) +- Manual deployments may access Grafana before it's fully initialized + +#### Proposed Solution + +Add Docker Compose health check using Grafana's built-in health API endpoint: + +```yaml +grafana: + image: grafana/grafana:11.4.0 + # ... existing configuration ... + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s +``` + +**Health Check Configuration**: + +- `test`: Uses `wget --spider` to check Grafana's `/api/health` endpoint + - `--spider`: Don't download, just check if the page exists + - `-q`: Quiet mode (no output) + - `http://localhost:3000`: Internal container port (not the mapped external port 3100) +- `interval: 10s`: Check health every 10 seconds after initial start period +- `timeout: 5s`: Each health check must complete within 5 seconds +- `retries: 5`: Container marked unhealthy after 5 consecutive failures +- `start_period: 30s`: Grace period for Grafana initialization (longer than Prometheus due to heavier startup) + +**Expected Benefits**: + +1. **Docker Awareness**: Docker Compose knows when Grafana is truly ready +2. **Simplified Validators**: E2E tests can potentially remove retry logic (wait for healthy status instead) +3. **Better Monitoring**: Health status visible in `docker ps` and `docker-compose ps` +4. **Automatic Restart**: Can configure restart policies based on health status +5. **Service Dependencies**: Can depend on Prometheus `service_healthy` condition + +**Implementation Impact**: + +- **Files to Modify**: `templates/docker-compose/docker-compose.yml.tera` - Add healthcheck block to Grafana service +- **Validation Changes**: `src/infrastructure/remote_actions/validators/grafana.rs` - Consider simplifying retry logic +- **Testing**: Verify health check works correctly during E2E tests + +--- + +### Task 3: Automatically Configure Prometheus Datasource in Grafana + +**Status**: ✅ Completed +**Priority**: High (eliminates manual configuration, core automation goal) + +```yaml +prometheus: + image: prom/prometheus:v3.0.1 + # ... existing configuration ... + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s +``` + +**Health Check Configuration**: + +**Grafana**: + +- `test`: Uses `wget --spider` to check Grafana's `/api/health` endpoint + - `--spider`: Don't download, just check if the page exists + - `-q`: Quiet mode (no output) + - `http://localhost:3000`: Internal container port (not the mapped external port 3100) +- `interval: 10s`: Check health every 10 seconds after initial start period +- `timeout: 5s`: Each health check must complete within 5 seconds +- `retries: 5`: Container marked unhealthy after 5 consecutive failures +- `start_period: 30s`: Grace period for Grafana initialization (health check failures ignored) + +**Prometheus**: + +- `test`: Uses Prometheus's `/-/healthy` endpoint + - `http://localhost:9090`: Internal container port (not the mapped external port) +- `interval: 10s`: Check health every 10 seconds after initial start period +- `timeout: 5s`: Each health check must complete within 5 seconds +- `retries: 5`: Container marked unhealthy after 5 consecutive failures +- `start_period: 10s`: Shorter grace period (Prometheus starts faster than Grafana) + +**Expected Benefits**: + +1. **Docker Awareness**: Docker Compose and orchestrators know when services are truly ready +2. **Simplified Validators**: E2E tests can potentially remove retry logic (wait for healthy status instead) +3. **Better Monitoring**: Health status visible in `docker ps` and `docker-compose ps` +4. **Automatic Restart**: Can configure restart policies based on health status +5. **Service Dependencies**: Other services can wait for `service_healthy` condition +6. **Grafana Dependency**: Grafana can wait for Prometheus to be healthy before starting (using `depends_on: prometheus: condition: service_healthy`) + +**Implementation Impact**: + +- **Files to Modify**: + - `templates/docker-compose/docker-compose.yml.tera` - Add healthcheck blocks to both Prometheus and Grafana services +- **Validation Changes**: + - `src/infrastructure/remote_actions/validators/grafana.rs` - Consider simplifying retry logic or checking health status + - `src/infrastructure/remote_actions/validators/prometheus.rs` - Consider adding health status check +- **Testing**: + - Verify health checks work correctly during E2E tests + - Test that containers report healthy after successful startup + - Test that unhealthy containers trigger restart (if restart policy configured) + - Verify Grafana waits for Prometheus to be healthy before starting + +**Alternative Approaches Considered**: + +1. **Use `curl` instead of `wget`**: Images may not have curl pre-installed +2. **Check published ports (3100/9090)**: Would check the published port, not the internal service +3. **No health check**: Current approach (requires retry logic in consumers) +4. **Different endpoints**: Grafana and Prometheus both offer multiple health endpoints, chose the most standard ones + +**Risks and Considerations**: + +- Health checks add slight CPU/network overhead (negligible for 10s interval) +- `wget` must be available in both Grafana and Prometheus containers (verify in images) +- Health endpoints might return 200 before all features are ready (acceptable - basic service health) +- Prometheus starts faster than Grafana (10s vs 30s start periods reflect this difference) + +--- + +### Task 2: Automatically Configure Grafana (Datasource + Dashboards) + +**Status**: ✅ Completed +**Priority**: High (eliminates manual configuration, core automation goal) + +#### Problem Statement + +Currently, users must manually configure the Prometheus datasource in Grafana after deployment: + +1. Login to Grafana UI +2. Navigate to Data Sources settings +3. Add Prometheus datasource +4. Configure connection URL (`http://prometheus:9090`) +5. Test and save connection + +This manual process is: + +- Time-consuming (2-3 minutes per deployment) +- Error-prone (users may misconfigure the URL) +- Inconsistent (different deployments may have different settings) +- Against "Infrastructure as Software" vision + +#### Proposed Solution + +Implement Grafana provisioning to automatically configure the Prometheus datasource on container startup using Grafana's [datasource provisioning feature](https://grafana.com/docs/grafana/latest/administration/provisioning/#data-sources). + +**Create Template**: `templates/grafana/provisioning/datasources/prometheus.yml.tera` + +```yaml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: "{{ prometheus_scrape_interval_in_secs }}s" + httpMethod: POST +``` + +**Template Variables**: + +- `prometheus_scrape_interval_in_secs`: From `PrometheusConfig::scrape_interval_in_secs` + +**Datasource Configuration**: + +- `url: http://prometheus:9090`: Uses Docker internal network name +- `isDefault: true`: Makes this the default datasource for all dashboards +- `editable: false`: Prevents accidental modification through UI (infrastructure-as-code principle) + +**Docker Compose Integration**: Update `templates/docker-compose/docker-compose.yml.tera` + +````yaml +grafana: + # ... existing configuration ... + volumes: + - grafana_data:/var/lib/grafana + - ./storage/grafana/provisioning:/etc/grafana/provisioning:ro # NEW +```yaml + +**File Structure After Deployment**: + +```text +/opt/torrust/storage/grafana/provisioning/ +└── datasources/ + └── prometheus.yml +```` + +**Expected Benefits**: + +1. **Zero Manual Configuration**: Prometheus datasource automatically configured +2. **Consistent Deployments**: Every deployment has identical datasource setup +3. **Error Prevention**: No misconfigured datasource URLs +4. **Infrastructure as Code**: Configuration is version-controlled and reproducible + +**Implementation Impact**: + +- **Files to Create**: + - `templates/grafana/provisioning/datasources/prometheus.yml.tera` (Tera template) + - `templates/ansible/deploy-grafana-provisioning.yml` (new Ansible playbook - static file) + - `src/infrastructure/templating/grafana/` (new module structure) +- **Files to Modify**: + - `templates/docker-compose/docker-compose.yml.tera` - Add provisioning volume mount + - `src/application/command_handlers/configure/handler.rs` - Add Grafana provisioning step + - `src/infrastructure/external_tools/ansible/template/renderer/project_generator.rs` - Register new playbook in `copy_static_templates()` +- **Testing**: Verify datasource appears automatically in Grafana UI after deployment + +--- + +### Task 4: Preload Grafana Dashboards + +**Status**: ✅ Completed +**Priority**: High (completes full automation, provides immediate value) + +**Status**: ✅ Completed +**Priority**: High (completes full automation, provides immediate value) + +#### Problem Statement + +Currently, users must manually import dashboards after deployment: + +1. Search for suitable Grafana dashboards online or create custom ones +2. Export/import dashboard JSON files +3. Configure dashboard queries and variables +4. Save dashboards + +This manual process is: + +- Time-consuming (5-10 minutes per dashboard) +- Requires Grafana expertise (query syntax, panel configuration) +- Results may vary (different users create different dashboards) +- Users don't immediately see the value of the monitoring stack + +#### Proposed Solution + +Implement Grafana provisioning to automatically load two pre-configured dashboards on container startup using Grafana's [dashboard provisioning feature](https://grafana.com/docs/grafana/latest/administration/provisioning/#dashboards). + +**Create Dashboard Provider Config**: `templates/grafana/provisioning/dashboards/torrust.yml` + +```yaml +apiVersion: 1 + +providers: + - name: "Torrust Dashboards" + orgId: 1 + folder: "Torrust Tracker" + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards/torrust + foldersFromFilesStructure: false +``` + +**Dashboard Files to Create**: + +1. **Torrust Tracker Stats**: `templates/grafana/dashboards/stats.json` + + - Source: https://github.com/torrust/torrust-demo/blob/main/share/grafana/dashboards/stats.json + - Prometheus Job: `tracker_stats` (scrapes `/api/v1/stats` endpoint) + - Displays tracker aggregate statistics and state metrics + - Pre-configured dashboard from Torrust Tracker Live Demo + +2. **Torrust Tracker Metrics**: `templates/grafana/dashboards/metrics.json` + - Source: https://github.com/torrust/torrust-demo/blob/main/share/grafana/dashboards/metrics.json + - Prometheus Job: `tracker_metrics` (scrapes `/api/v1/metrics` endpoint) + - Displays detailed operational metrics and performance data + - Pre-configured dashboard from Torrust Tracker Live Demo + +**Prometheus Job Mapping** (from `templates/prometheus/prometheus.yml.tera`): + +```yaml +scrape_configs: + # Stats dashboard queries this job + - job_name: "tracker_stats" + metrics_path: "/api/v1/stats" + + # Metrics dashboard queries this job + - job_name: "tracker_metrics" + metrics_path: "/api/v1/metrics" +``` + +**Docker Compose Integration**: Already handled by Task 3 volume mount + +**File Structure After Deployment**: + +```text +/opt/torrust/storage/grafana/provisioning/ +├── datasources/ +│ └── prometheus.yml +└── dashboards/ + ├── torrust.yml + └── torrust/ + ├── stats.json + └── metrics.json +``` + +**Expected Benefits**: + +1. **Immediate Value**: Users see metrics visualization immediately after deployment +2. **Consistent Experience**: All deployments have the same dashboards as the live demo +3. **Proven Dashboards**: Uses battle-tested dashboards from Torrust Tracker Live Demo +4. **Faster Time to Value**: No dashboard creation/import required +5. **Customizable**: Users can modify dashboards through UI (allowUiUpdates: true) + +**Implementation Impact**: + +- **Files to Create**: + - `templates/grafana/provisioning/dashboards/torrust.yml` (static YAML) + - `templates/grafana/dashboards/stats.json` (copied from torrust-demo) + - `templates/grafana/dashboards/metrics.json` (copied from torrust-demo) +- **Files to Modify**: + - Grafana template rendering module (copy dashboard files to build directory) +- **Ansible Integration**: Uses `deploy-grafana-provisioning.yml` created in Task 3 (single playbook handles both datasource and dashboards) +- **Testing**: Verify dashboards appear automatically in Grafana UI and display metrics correctly + +**Dashboard Sources**: + +- Stats Dashboard: https://github.com/torrust/torrust-demo/blob/main/share/grafana/dashboards/stats.json (uses `tracker_stats` Prometheus job) +- Metrics Dashboard: https://github.com/torrust/torrust-demo/blob/main/share/grafana/dashboards/metrics.json (uses `tracker_metrics` Prometheus job) +- Dashboard Documentation: https://github.com/torrust/torrust-demo/tree/main/share/grafana/dashboards + +--- + +## Implementation Sequence + +**Recommended Order**: + +1. **Task 1** (Prometheus Health Check) - Simple, no dependencies +2. **Task 2** (Grafana Health Check) - Simple, can depend on Task 1 +3. **Task 3** (Prometheus Datasource) - More complex, enables Task 4 +4. **Task 4** (Preload Dashboards) - Depends on Task 3 for datasource + +**Dependencies**: + +- Task 2 can optionally use `depends_on: prometheus: condition: service_healthy` (requires Task 1) +- Task 4 requires Task 3 (dashboards need datasource to display metrics) +- Tasks 1 and 2 are independent and can be done in parallel +- Tasks 3 and 4 could be combined but separated for better tracking + +**Estimated Effort**: + +- Task 1: 1-2 hours (simple healthcheck addition) +- Task 2: 1-2 hours (simple healthcheck addition + optional retry logic simplification) +- Task 3: 4-6 hours (template creation + module structure + rendering integration) +- Task 4: 4-6 hours (dashboard JSON creation + testing metrics display) +- **Total**: 10-16 hours + +--- + +## Success Criteria + +### Task 1 (Prometheus Health Check) + +- [ ] Health check added to Prometheus service in docker-compose template +- [ ] `docker-compose ps` shows `healthy` status for Prometheus after startup +- [ ] Health check fails appropriately if Prometheus service crashes +- [ ] E2E tests pass with Prometheus health check enabled + +### Task 2 (Grafana Health Check) + +- [ ] Health check added to Grafana service in docker-compose template +- [ ] `docker-compose ps` shows `healthy` status for Grafana after startup +- [ ] Health check fails appropriately if Grafana service crashes +- [ ] Grafana optionally depends on Prometheus being healthy (using `condition: service_healthy`) +- [ ] E2E tests pass with Grafana health check enabled +- [ ] Consider simplifying Grafana validator retry logic + +### Task 3 (Prometheus Datasource) + +- [ ] Prometheus datasource template created (`prometheus.yml.tera`) +- [ ] Grafana templating module structure created (`src/infrastructure/templating/grafana/`) +- [ ] Ansible playbook created (`deploy-grafana-provisioning.yml`) +- [ ] Playbook registered in `copy_static_templates()` method +- [ ] Datasource provisioning integrated into `configure` command +- [ ] Provisioning directory mounted in docker-compose +- [ ] Datasource appears automatically in Grafana UI after deployment +- [ ] Datasource connection to Prometheus works (test query succeeds) +- [ ] E2E tests verify datasource is configured + +### Task 4 (Preload Dashboards) + +- [ ] Dashboard provider config created (`torrust.yml`) +- [ ] Stats dashboard JSON copied from torrust-demo repository +- [ ] Metrics dashboard JSON copied from torrust-demo repository +- [ ] Dashboard files copied to build directory during template rendering +- [ ] Both dashboards appear automatically in Grafana UI after deployment +- [ ] Dashboards display metrics correctly (panels show data, no errors) +- [ ] Dashboards organized in "Torrust Tracker" folder +- [ ] Users can modify dashboards through UI +- [ ] E2E tests verify dashboards are accessible + +--- + +## Implementation Details (Consolidated from Tasks 2-4) + +### Grafana Provisioning Configuration + +#### 2.1. Datasource Provisioning + +**Create Template**: `templates/grafana/provisioning/datasources/prometheus.yml.tera` + +```yaml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: "{{ prometheus_scrape_interval_in_secs }}s" + httpMethod: POST +``` + +**Template Variables**: + +- `prometheus_scrape_interval_in_secs`: From `PrometheusConfig::scrape_interval_in_secs` +- Datasource URL uses Docker internal network name: `http://prometheus:9090` +- `editable: false`: Prevents accidental modification through UI (following infrastructure-as-code principle) +- `isDefault: true`: Makes this the default datasource for all dashboards + +**File Placement After Rendering**: + +- Build directory: `build/{env-name}/grafana/provisioning/datasources/prometheus.yml` +- Remote host: `/opt/torrust/storage/grafana/provisioning/datasources/prometheus.yml` + +#### 2.2. Dashboard Provisioning + +**Create Dashboard Provider Config**: `templates/grafana/provisioning/dashboards/torrust.yml` + +```yaml +apiVersion: 1 + +providers: + - name: "Torrust Dashboards" + orgId: 1 + folder: "Torrust Tracker" + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards/torrust + foldersFromFilesStructure: false +``` + +**Dashboard Provider Settings**: + +- `folder: 'Torrust Tracker'`: Dashboards organized in dedicated folder +- `disableDeletion: false`: Users can delete/modify dashboards through UI (not enforced like datasource) +- `allowUiUpdates: true`: Users can customize dashboards and save changes +- `path`: Directory containing dashboard JSON files + +**Dashboard JSON Files**: Copy two pre-configured dashboards from torrust-demo + +1. **Torrust Tracker Stats**: `templates/grafana/dashboards/stats.json` + + - Source: https://github.com/torrust/torrust-demo/blob/main/share/grafana/dashboards/stats.json + - Prometheus Job: `tracker_stats` (scrapes `/api/v1/stats` endpoint) + - Displays tracker aggregate statistics and state metrics + +2. **Torrust Tracker Metrics**: `templates/grafana/dashboards/metrics.json` + - Source: https://github.com/torrust/torrust-demo/blob/main/share/grafana/dashboards/metrics.json + - Prometheus Job: `tracker_metrics` (scrapes `/api/v1/metrics` endpoint) + - Displays detailed operational metrics and performance data + +**File Placement After Rendering**: + +- Build directory: `build/{env-name}/grafana/provisioning/dashboards/` +- Remote host: `/opt/torrust/storage/grafana/provisioning/dashboards/` + +#### 2.3. Docker Compose Integration + +**Update**: `templates/docker-compose/docker-compose.yml.tera` + +Add bind mount for provisioning directory: + +```yaml +grafana: + image: grafana/grafana:11.4.0 + container_name: torrust-grafana + restart: unless-stopped + depends_on: + - prometheus + ports: + - "{{ grafana_external_port }}:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_INSTALL_PLUGINS= # Space for future plugin additions + volumes: + - grafana_data:/var/lib/grafana + - ./storage/grafana/provisioning:/etc/grafana/provisioning:ro # NEW: Provisioning configs + networks: + - torrust-network + +volumes: + grafana_data: +``` + +**Mount Configuration**: + +- `./storage/grafana/provisioning:/etc/grafana/provisioning:ro` +- Bind mount (not named volume) - allows editing files on host +- Read-only (`:ro`) - Grafana doesn't need write access to provisioning configs +- Path relative to docker-compose file: `storage/grafana/provisioning/` + +#### 2.4. Ansible Deployment Integration + +**Create New Playbook**: `templates/ansible/deploy-grafana-provisioning.yml` + +Single playbook handles both datasource and dashboard provisioning (follows one-operation-per-playbook pattern): + +```yaml +--- +- name: Deploy Grafana provisioning configuration + hosts: all + vars_files: + - variables.yml + + tasks: + - name: Create Grafana provisioning directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ deploy_dir }}/storage/grafana/provisioning/datasources" + - "{{ deploy_dir }}/storage/grafana/provisioning/dashboards" + - "{{ deploy_dir }}/storage/grafana/provisioning/dashboards/torrust" + when: grafana_enabled | default(false) + + - name: Deploy Grafana provisioning files + ansible.builtin.copy: + src: "{{ build_dir }}/grafana/provisioning/" + dest: "{{ deploy_dir }}/storage/grafana/provisioning/" + mode: "0644" + when: grafana_enabled | default(false) +``` + +**Design Decision**: Single playbook for all Grafana provisioning + +- **Rationale**: Grafana provisioning is one atomic operation (datasource + dashboards together) +- **User Flexibility**: Users can delete unwanted dashboard files after deployment: `rm -rf /opt/torrust/storage/grafana/provisioning/dashboards/torrust/*.json` +- **Consistent with Project Pattern**: Follows `templates/ansible/README.md` philosophy (one logical operation per playbook) +- **Registration Required**: Must add to `copy_static_templates()` in `src/infrastructure/external_tools/ansible/template/renderer/project_generator.rs` + +#### 2.5. Template Rendering Integration + +**Update**: `src/infrastructure/templating/grafana/` (NEW module structure) + +Create module structure similar to Prometheus: + +```text +src/infrastructure/templating/grafana/ +├── mod.rs +└── template/ + ├── mod.rs + └── renderer/ + ├── mod.rs + ├── project_generator.rs # NEW: Grafana provisioning generator + └── datasource.rs # NEW: Datasource YAML context + └── dashboards.rs # NEW: Dashboard provider YAML context +``` + +**Project Generator**: Similar to `PrometheusProjectGenerator` + +```rust +pub struct GrafanaProjectGenerator { + source_templates_dir: PathBuf, + build_output_dir: PathBuf, +} + +impl GrafanaProjectGenerator { + pub fn generate(&self, context: &GrafanaContext) -> Result<(), TemplateError> { + // 1. Render datasource YAML from template + // 2. Copy dashboard provider YAML (static file) + // 3. Copy dashboard JSON files (static files) + // 4. Create directory structure in build output + } +} +``` + +**Context Structs**: + +```rust +pub struct GrafanaContext { + pub prometheus_scrape_interval_in_secs: u32, +} + +pub struct DatasourceContext { + pub prometheus_scrape_interval_in_secs: u32, +} +``` + +**Template Registration**: + +- Datasource template: `templates/grafana/provisioning/datasources/prometheus.yml.tera` (dynamic) +- Dashboard provider: `templates/grafana/provisioning/dashboards/torrust.yml` (static - copy directly) +- Dashboard JSONs: `templates/grafana/dashboards/*.json` (static - copy directly) + +#### 2.6. Configure Command Integration + +**Update**: `src/application/command_handlers/configure/handler.rs` + +Add Grafana provisioning step to configure workflow: + +```rust +// After deploying tracker config, before or alongside Prometheus config +if let Some(grafana_config) = &user_inputs.grafana { + // Generate Grafana provisioning files + let grafana_generator = GrafanaProjectGenerator::new( + template_paths.grafana_templates_dir(), + build_paths.grafana_build_dir(), + ); + + let grafana_context = GrafanaContext { + prometheus_scrape_interval_in_secs: user_inputs + .prometheus + .as_ref() + .map(|p| p.scrape_interval_in_secs) + .unwrap_or(15), // Default if not specified + }; + + grafana_generator.generate(&grafana_context)?; +} +``` + +**Error Handling**: + +- If Grafana enabled but template generation fails → `ConfigureStep::ConfigureGrafana` variant in `ConfigureFailed` state +- Include clear error messages about which provisioning file failed to generate + +#### Expected Benefits + +1. **Zero Manual Configuration**: Users deploy and immediately access fully configured Grafana +2. **Consistent Deployments**: Every deployment has identical datasource and dashboard setup +3. **Faster Time to Value**: Users see metrics immediately without setup delays +4. **Reduced Documentation**: User guide shows dashboards, not setup instructions +5. **Infrastructure as Code**: Grafana configuration is version-controlled and reproducible +6. **Error Prevention**: No misconfigured datasource URLs or authentication issues + +#### Implementation Impact + +**Files to Create**: + +- `templates/grafana/provisioning/datasources/prometheus.yml.tera` (Tera template) +- `templates/grafana/provisioning/dashboards/torrust.yml` (static YAML) +- `templates/grafana/dashboards/stats.json` (copied from torrust-demo) +- `templates/grafana/dashboards/metrics.json` (copied from torrust-demo) +- `src/infrastructure/templating/grafana/` (new module tree) + +**Files to Modify**: + +- `templates/docker-compose/docker-compose.yml.tera` - Add provisioning volume mount +- `templates/ansible/deploy-docker-compose-files.yml` - Create provisioning directories +- `src/application/command_handlers/configure/handler.rs` - Add Grafana provisioning step +- `src/domain/environment/state/configure_failed.rs` - Add `ConfigureStep::ConfigureGrafana` variant (if needed) + +**Testing Requirements**: + +1. **Unit Tests**: Grafana context serialization, template rendering +2. **Integration Tests**: Provisioning file generation, directory structure creation +3. **E2E Tests**: Full deployment verification, dashboard accessibility +4. **Manual Testing**: Dashboard functionality, metric queries, Prometheus connection + +#### Alternative Approaches Considered + +1. **Manual Configuration (Current)**: Simple but poor user experience +2. **UI Automation (Selenium/API)**: Complex, brittle, requires Grafana to be running +3. **Pre-configured Container Image**: Less flexible, harder to customize +4. **Init Container Script**: More complex than provisioning, non-standard approach + +#### Risks and Considerations + +- **Dashboard Maintenance**: Dashboard JSON files need updates when metrics change +- **Grafana Version Compatibility**: Provisioning format may change between Grafana versions +- **Dashboard Customization**: Users may want different dashboards (document how to add custom dashboards) +- **Prometheus Metrics**: Dashboards assume specific metric names from Torrust Tracker +- **Testing Complexity**: Need to verify dashboard queries return valid data + +--- + +## Implementation Sequence + +**Recommended Order**: + +1. **Task 1 (Health Check)** - Simpler, provides immediate value, no breaking changes +2. **Task 2 (Provisioning)** - More complex, builds on Task 1's health check for reliability + +**Estimated Effort**: + +- Task 1: 2-4 hours (implementation + testing) +- Task 2: 8-12 hours (implementation + dashboard creation + testing + documentation) + +**Testing Strategy**: + +- Both tasks should be tested together in E2E workflow +- Verify health check reports healthy status after provisioning completes +- Verify dashboards load automatically and display metrics correctly + +--- + +## Success Criteria + +### Task 1 (Health Check) + +- [ ] Health checks added to docker-compose template (both Prometheus and Grafana) +- [ ] `docker-compose ps` shows `healthy` status after services start +- [ ] Health checks fail appropriately if services crash +- [ ] Grafana can optionally depend on Prometheus being healthy (using `condition: service_healthy`) +- [ ] E2E tests pass with health checks enabled +- [ ] Documentation updated to mention health check feature + +### Task 2 (Provisioning) + +- [ ] Prometheus datasource automatically configured on deployment +- [ ] Two dashboards (Tracker Overview + System Metrics) automatically loaded +- [ ] Dashboards display metrics correctly (no empty/broken panels) +- [ ] Users can access dashboards immediately after deployment without manual setup +- [ ] Provisioning files generated during `configure` command +- [ ] Provisioning directories created by Ansible playbooks +- [ ] User guide updated to show pre-configured dashboards instead of manual setup instructions +- [ ] E2E tests verify dashboard accessibility + +--- + +## Manual Testing Guide + +### Task 1: Verify Prometheus Health Check + +**After Implementation**: + +1. **Deploy environment with Prometheus enabled**: + + ```bash + cargo run -- create environment --env-file envs/manual-test-prometheus.json + cargo run -- provision + cargo run -- configure + cargo run -- release + cargo run -- run + ``` + +2. **Check health status**: + + ```bash + # SSH into the VM + ssh -i ~/.ssh/your-key user@vm-ip + + # Check container health status + cd /opt/torrust + docker-compose ps + + # Should show 'healthy' in STATUS column for prometheus container + # Example: torrust-prometheus ... Up 2 minutes (healthy) + ``` + +3. **Verify health check endpoint**: + + ```bash + # From inside VM + docker exec torrust-prometheus wget --spider -q http://localhost:9090/-/healthy + echo $? # Should return 0 (success) + + # Test failure scenario (stop Prometheus) + docker-compose stop prometheus + docker-compose ps # Should show 'unhealthy' or 'exited' + ``` + +4. **Check health check configuration**: + + ```bash + # Inspect healthcheck settings + docker inspect torrust-prometheus | jq '.[0].State.Health' + + # Should show: + # - Status: "healthy" + # - FailingStreak: 0 + # - Log entries with exit code 0 + ``` + +**Expected Results**: + +- ✅ Container shows `(healthy)` status within 20 seconds of startup +- ✅ Health endpoint returns success (exit code 0) +- ✅ Container becomes unhealthy when Prometheus stops + +--- + +### Task 2: Verify Grafana Health Check + +**After Implementation**: + +1. **Deploy environment with Grafana enabled**: + + ```bash + cargo run -- create environment --env-file envs/manual-test-grafana.json + cargo run -- provision + cargo run -- configure + cargo run -- release + cargo run -- run + ``` + +2. **Check health status**: + + ```bash + # SSH into the VM + ssh -i ~/.ssh/your-key user@vm-ip + cd /opt/torrust + + # Check both Prometheus and Grafana health + docker-compose ps + + # Should show 'healthy' for both containers: + # torrust-prometheus ... Up 2 minutes (healthy) + # torrust-grafana ... Up 2 minutes (healthy) + ``` + +3. **Verify Grafana health check endpoint**: + + ```bash + # From inside VM + docker exec torrust-grafana wget --spider -q http://localhost:3000/api/health + echo $? # Should return 0 (success) + + # Check health status details + docker exec torrust-grafana wget -qO- http://localhost:3000/api/health + # Should return: {"commit":"...","database":"ok","version":"11.4.0"} + ``` + +4. **Verify dependency on Prometheus** (if implemented): + + ```bash + # Check if Grafana waits for Prometheus to be healthy + docker-compose down + docker-compose up -d + + # Watch container startup order + docker-compose ps --format "table {{.Name}}\t{{.Status}}" + + # Prometheus should reach 'healthy' before Grafana starts + ``` + +5. **Test E2E validator simplification**: + + ```bash + # Run E2E tests - should no longer need 30-retry logic + cargo run --bin e2e-deployment-workflow-tests + + # Check validator code - retry logic should be simplified or removed + ``` + +**Expected Results**: + +- ✅ Grafana shows `(healthy)` status within 40 seconds of startup +- ✅ Health endpoint returns success with database status +- ✅ Grafana waits for Prometheus to be healthy (if dependency configured) +- ✅ E2E tests pass without long retry delays + +--- + +### Task 3: Verify Prometheus Datasource Auto-Configuration + +**After Implementation**: + +1. **Deploy with Grafana provisioning**: + + ```bash + cargo run -- create environment --env-file envs/manual-test-grafana.json + cargo run -- provision + cargo run -- configure # Should generate provisioning files + cargo run -- release + cargo run -- run + ``` + +2. **Verify provisioning files were generated**: + + ```bash + # Check build directory + ls -la build/manual-test-grafana/grafana/provisioning/datasources/ + # Should contain: prometheus.yml + + cat build/manual-test-grafana/grafana/provisioning/datasources/prometheus.yml + # Verify: url: http://prometheus:9090, isDefault: true, editable: false + ``` + +3. **Verify files deployed to remote host**: + + ```bash + # SSH into VM + ssh -i ~/.ssh/your-key user@vm-ip + + # Check provisioning directory structure + tree /opt/torrust/storage/grafana/provisioning/ + + # Should show: + # /opt/torrust/storage/grafana/provisioning/ + # └── datasources/ + # └── prometheus.yml + + cat /opt/torrust/storage/grafana/provisioning/datasources/prometheus.yml + # Verify content matches build directory + ``` + +4. **Verify datasource in Grafana UI**: + + ```bash + # Get VM IP + VM_IP=$(terraform -chdir=build/manual-test-grafana/tofu output -raw instance_ip) + + # Access Grafana (credentials from environment config) + # Open browser: http://$VM_IP:3100 + # Login with admin credentials + ``` + + In Grafana UI: + + - Navigate to **Configuration** → **Data Sources** + - Should see **Prometheus** datasource (with star icon indicating default) + - Click on it to view settings: + - URL: `http://prometheus:9090` + - Access: `Server (default)` + - Editable: No (grayed out fields) + - Click **Test** button → Should show "Data source is working" + +5. **Verify Prometheus queries work**: + + ```bash + # In Grafana UI + # Navigate to Explore (compass icon) + # Select Prometheus datasource + # Run query: up + # Should show metrics for tracker_stats and tracker_metrics jobs + ``` + +6. **Test datasource was created at startup** (not manually): + + ```bash + # Check Grafana logs for provisioning messages + docker logs torrust-grafana | grep -i provisioning + + # Should see: + # "Provisioning datasources" + # "Provisioned datasources: Prometheus" + ``` + +**Expected Results**: + +- ✅ Provisioning files generated in build directory during `configure` +- ✅ Files deployed to `/opt/torrust/storage/grafana/provisioning/datasources/` +- ✅ Prometheus datasource appears in Grafana UI automatically +- ✅ Datasource is marked as default (star icon) +- ✅ Datasource fields are not editable through UI +- ✅ Test connection succeeds +- ✅ Can query Prometheus metrics in Explore view + +--- + +### Task 4: Verify Preloaded Grafana Dashboards + +**After Implementation**: + +1. **Deploy with dashboard provisioning**: + + ```bash + cargo run -- create environment --env-file envs/manual-test-grafana.json + cargo run -- provision + cargo run -- configure + cargo run -- release + cargo run -- run + ``` + +2. **Verify dashboard files were generated**: + + ```bash + # Check build directory for dashboard files + ls -la build/manual-test-grafana/grafana/provisioning/dashboards/ + # Should contain: torrust.yml + + ls -la build/manual-test-grafana/grafana/dashboards/ + # Should contain: stats.json, metrics.json + + # Verify dashboard provider config + cat build/manual-test-grafana/grafana/provisioning/dashboards/torrust.yml + # Should specify: path: /etc/grafana/provisioning/dashboards/torrust + ``` + +3. **Verify files deployed to remote host**: + + ```bash + # SSH into VM + ssh -i ~/.ssh/your-key user@vm-ip + + # Check complete provisioning directory structure + tree /opt/torrust/storage/grafana/provisioning/ + + # Should show: + # /opt/torrust/storage/grafana/provisioning/ + # ├── datasources/ + # │ └── prometheus.yml + # └── dashboards/ + # ├── torrust.yml + # └── torrust/ + # ├── stats.json + # └── metrics.json + ``` + +4. **Verify dashboards in Grafana UI**: + + ```bash + # Access Grafana: http://$VM_IP:3100 + ``` + + In Grafana UI: + + - Navigate to **Dashboards** (four squares icon) + - Should see folder **"Torrust Tracker"** with 2 dashboards + - Click on folder to expand: + - **Torrust Tracker Stats** + - **Torrust Tracker Metrics** + +5. **Verify Stats Dashboard**: + + - Open **Torrust Tracker Stats** dashboard + - Should see panels with data (not empty): + - Tracker statistics and state metrics + - Data from `tracker_stats` Prometheus job + - Check time range selector (top right) - adjust if needed + - All panels should display metrics (no "No data" messages) + - Check datasource (top right) - should be "Prometheus" + +6. **Verify Metrics Dashboard**: + + - Open **Torrust Tracker Metrics** dashboard + - Should see panels with data: + - Operational metrics and performance data + - Data from `tracker_metrics` Prometheus job + - All panels should display metrics + +7. **Verify dashboards are editable**: + + - In any dashboard, click **Dashboard settings** (gear icon) + - Try editing a panel (click panel title → Edit) + - Make a change (e.g., modify title) + - Click **Save dashboard** (disk icon) + - Should save successfully (allowUiUpdates: true) + +8. **Verify dashboard provisioning logs**: + + ```bash + # Check Grafana logs for dashboard provisioning + docker logs torrust-grafana | grep -i dashboard + + # Should see: + # "Provisioning dashboards" + # "Dashboard provisioned: Torrust Tracker Stats" + # "Dashboard provisioned: Torrust Tracker Metrics" + ``` + +9. **Verify Prometheus job mapping**: + + ```bash + # Check Prometheus targets to ensure jobs are configured + # Open browser: http://$VM_IP:9090/targets + + # Should see two targets: + # - tracker_stats (endpoint: http://tracker:1212/api/v1/stats) + # - tracker_metrics (endpoint: http://tracker:1212/api/v1/metrics) + # Both should be in "UP" state + ``` + +10. **Test dashboard persistence**: + + ```bash + # Restart Grafana container + docker-compose restart grafana + + # Wait for healthy status + docker-compose ps + + # Dashboards should still be present and unchanged + ``` + +**Expected Results**: + +- ✅ Dashboard provider config and JSON files generated in build directory +- ✅ Files deployed to `/opt/torrust/storage/grafana/provisioning/dashboards/` +- ✅ "Torrust Tracker" folder appears in Grafana UI +- ✅ Both dashboards (Stats and Metrics) are visible in the folder +- ✅ Stats dashboard displays metrics from tracker_stats job +- ✅ Metrics dashboard displays metrics from tracker_metrics job +- ✅ All panels show data (no empty panels) +- ✅ Dashboards can be edited and saved through UI +- ✅ Dashboards persist after container restart +- ✅ Prometheus targets show both jobs in UP state + +--- + +### Complete Integration Test + +**After All Tasks Implemented**: + +1. **Full deployment workflow**: + + ```bash + # Clean slate + cargo run -- destroy --force + rm -rf build/manual-test-grafana data/manual-test-grafana + + # Complete workflow + cargo run -- create environment --env-file envs/manual-test-grafana.json + cargo run -- provision + cargo run -- configure + cargo run -- release + cargo run -- run + ``` + +2. **Verify complete stack**: + + ```bash + # Check all containers are healthy + ssh -i ~/.ssh/your-key user@vm-ip + cd /opt/torrust + docker-compose ps + + # Should show all healthy: + # torrust-tracker ... Up (healthy) + # torrust-prometheus ... Up (healthy) + # torrust-grafana ... Up (healthy) + ``` + +3. **Verify end-to-end metrics flow**: + + - Tracker generates metrics + - Prometheus scrapes both endpoints + - Grafana displays metrics in both dashboards + - No manual configuration required + +4. **Run E2E tests**: + + ```bash + cargo run --bin e2e-deployment-workflow-tests + # Should pass with Prometheus and Grafana validation + ``` + +**Expected Results**: + +- ✅ Complete deployment works without manual intervention +- ✅ All containers healthy within expected timeframes +- ✅ Grafana accessible with datasource and dashboards pre-configured +- ✅ Metrics flow from tracker → Prometheus → Grafana +- ✅ E2E tests pass +- ✅ User can immediately view metrics without any setup + +--- + +## Future Enhancements (Out of Scope for This PR) + +These are potential future improvements not included in the current implementation (may be addressed in separate issues): + +- **Alert Configuration**: Provision Grafana alert rules for tracker health monitoring +- **Additional Dashboards**: More specialized dashboards (database metrics, cache metrics) +- **Multi-Datasource Support**: Support for additional datasources beyond Prometheus +- **Custom Plugin Installation**: Allow users to specify Grafana plugins in environment config +- **LDAP/OAuth Integration**: Enterprise authentication instead of admin credentials +- **Dashboard Versioning**: Track dashboard changes in git, allow rollback +- **Grafana as Code**: Use Terraform provider or Grafonnet for dashboard definition + +--- + +## Related Documentation + +- **Parent Issue**: [Issue #246](https://github.com/torrust/torrust-tracker-deployer/issues/246) +- **Parent Issue Tracking**: [docs/issues/246-grafana-slice-release-run-commands.md](./246-grafana-slice-release-run-commands.md) +- **Grafana Integration ADR**: [docs/decisions/grafana-integration-pattern.md](../decisions/grafana-integration-pattern.md) +- **Grafana User Guide**: [docs/user-guide/services/grafana.md](../user-guide/services/grafana.md) +- **Template System Architecture**: [docs/technical/template-system-architecture.md](../technical/template-system-architecture.md) +- **Prometheus Implementation**: See `src/infrastructure/templating/prometheus/` for similar pattern + +--- + +## Notes + +- These tasks will be implemented on branch `246-grafana-slice` and included in PR #247 +- Implementation to begin immediately after document review +- Dashboard JSON content will be defined during implementation (requires knowledge of actual Prometheus metrics exposed by Torrust Tracker) +- Both tasks should be completed before merging PR #247 to main diff --git a/docs/issues/246-grafana-slice-release-run-commands.md b/docs/issues/246-grafana-slice-release-run-commands.md index 2fedac6c..dd5b9693 100644 --- a/docs/issues/246-grafana-slice-release-run-commands.md +++ b/docs/issues/246-grafana-slice-release-run-commands.md @@ -11,23 +11,110 @@ This task adds Grafana as a metrics visualization service for the Torrust Tracke ## Goals -- [ ] Add Grafana service conditionally to docker-compose stack (only when present in environment config) -- [ ] Validate that Prometheus is enabled when Grafana is requested (dependency check) -- [ ] Create Grafana configuration section in environment schema -- [ ] Extend environment configuration schema to include Grafana monitoring section -- [ ] Configure service dependency - Grafana depends on Prometheus service -- [ ] Include Grafana in generated environment templates by default (enabled by default) -- [ ] Allow users to disable Grafana by removing its configuration section -- [ ] Deploy and verify Grafana connects to Prometheus and displays metrics +- [x] Add Grafana service conditionally to docker-compose stack (only when present in environment config) +- [x] Validate that Prometheus is enabled when Grafana is requested (dependency check) +- [x] Create Grafana configuration section in environment schema +- [x] Extend environment configuration schema to include Grafana monitoring section +- [x] Configure service dependency - Grafana depends on Prometheus service +- [x] Include Grafana in generated environment templates by default (enabled by default) +- [x] Allow users to disable Grafana by removing its configuration section +- [x] Deploy and verify Grafana connects to Prometheus and displays metrics (manual testing complete - workflow validated) ## Progress -_This section will be updated as implementation progresses._ - -- [ ] **Phase 1**: Environment Configuration & Validation -- [ ] **Phase 2**: Docker Compose Integration -- [ ] **Phase 3**: Testing & Verification -- [ ] **Phase 4**: Documentation +**Current Status**: Phase 3 (Testing & Verification) - Manual testing complete, security fix applied, firewall configuration removed + +**Implementation Summary**: + +- ✅ **Phase 1**: Environment Configuration & Validation (COMPLETE) + - Domain models, validation logic, error handling + - 3 commits: domain layer, validation, integration +- ✅ **Phase 2**: Docker Compose Integration (COMPLETE) + - DockerComposeContext and EnvContext extensions + - Template updates (docker-compose.yml.tera, .env.tera) + - 1 commit: comprehensive Phase 2 implementation +- ✅ **Phase 3**: Testing & Verification (COMPLETE) + - ✅ Task 1: E2E test configurations created (3 configs) + - ✅ Task 2: E2E validation extension for Grafana (GrafanaValidator implemented with 14 unit tests) + - ✅ Task 3: E2E test updates (validation structure integrated) + - ✅ Task 4: Manual E2E testing (complete - full deployment verified, password bug fixed) + - ✅ Task 5: Final verification (all pre-commit checks passing) +- ✅ **Phase 4**: Documentation (COMPLETE) + - ✅ Issue documentation updated with implementation details + - ✅ Manual verification guide created (docs/e2e-testing/manual/grafana-verification.md) + - ✅ Security issue documented (DRAFT issue spec created) + - ✅ Password bug fixed and documented + - ⏳ ADR and user guide (deferred - not critical for MVP) + +**Total Commits**: 16 commits for issue #246 + +- 3 for Phase 1 (domain layer, validation, integration) +- 1 for Phase 2 (Docker Compose integration) +- 1 for E2E test configs documentation +- 1 for commit message correction +- 1 for issue documentation update (implementation details) +- 1 for manual E2E testing results +- 1 for security fix (Prometheus port exposure) +- 1 for security documentation update +- 1 for documentation reorganization +- 1 for DRAFT security issue specification +- 1 for firewall configuration removal +- 1 for Grafana E2E validation (Phase 3 Task 2) +- 1 for Phase 3 Task 2 & 3 completion update +- 1 for password bug fix (Grafana credentials propagation) + +**Password Bug Fixed**: During manual testing, discovered that Grafana admin password wasn't being passed from environment config to the deployed service. Root cause: `UserInputs::with_tracker()` was using hardcoded defaults instead of configured values. Fixed by updating the constructor chain (`UserInputs` → `EnvironmentContext` → `Environment`) to accept and pass through optional Prometheus/Grafana configs. Verified that password now correctly propagates from config file → environment state → .env file → Grafana container. + +**Security Fix Applied**: During manual testing, discovered that Docker bypasses UFW firewall rules when publishing ports. Fixed by removing Prometheus port mapping (9090) from docker-compose - service now internal-only, accessible to Grafana via Docker network. See [docs/issues/DRAFT-docker-ufw-firewall-security-strategy.md](./DRAFT-docker-ufw-firewall-security-strategy.md) for comprehensive analysis. + +## Implementation Notes + +**Key Architectural Decisions Made During Implementation** (may differ from original plan): + +1. **Static Playbook vs Dynamic Template** (REMOVED - see decision 3): + + - **Original Plan**: `configure-grafana-firewall.yml.tera` (dynamic Tera template) or `configure-grafana-firewall.yml` (static playbook) + - **Final Decision**: NO firewall configuration for Grafana + - **Rationale**: Docker bypasses UFW firewall rules when publishing ports (see decision 3) + +2. **Step-Level Conditional Execution** (OBSOLETE - firewall step removed): + + - **Original Approach**: Execute firewall configuration step conditionally based on Grafana presence + - **Final Decision**: Firewall configuration step removed entirely + - **Rationale**: Cannot secure published Docker ports with UFW (see decision 3) + +3. **Firewall Configuration Removal** (NEW - critical security decision): + + - **Discovery**: During manual testing, discovered that Docker bypasses UFW firewall when publishing ports + - **Impact**: Opening port 3100 in UFW provides false sense of security - port is accessible regardless + - **Decision**: Remove Grafana firewall configuration entirely (playbook, step, code) + - **Files Removed**: + - `templates/ansible/configure-grafana-firewall.yml` (Ansible playbook) + - `src/application/steps/system/configure_grafana_firewall.rs` (step implementation) + - References in `project_generator.rs`, `handler.rs`, `configure_failed.rs` + - **Documentation**: See [docs/issues/DRAFT-docker-ufw-firewall-security-strategy.md](./DRAFT-docker-ufw-firewall-security-strategy.md) + - **Rationale**: + - Docker modifies iptables directly, bypassing UFW rules + - Published ports (docker-compose `ports:` directive) are always accessible + - UFW configuration is misleading and provides no actual security + - Proper solution requires reverse proxy with TLS (roadmap task 6) + +4. **Module Locations**: + + - **Plan**: Generic reference to `src/domain/environment/state.rs` for enum variant + - **Actual**: `src/domain/environment/state/configure_failed.rs` contains the `ConfigureStep` enum + - **Note**: The state module is organized into separate files per state type (configure_failed.rs, release_failed.rs, etc.) + - **Update**: `ConfigureStep::ConfigureGrafanaFirewall` variant was added then removed (no longer present) + +5. **Port Exposure Pattern** (CHANGED - security discovery): + - **Original Pattern**: + - Prometheus: Port 9090 NOT exposed (internal service) + - Grafana: Port 3100 IS exposed + firewall rule + - **Current Pattern**: + - Prometheus: Port 9090 NOT exposed (internal service) + - Grafana: Port 3100 IS exposed via docker-compose (no firewall config needed) + - Both accessible only through published Docker ports (UFW bypass) + - **Security Note**: Public exposure is temporary until HTTPS/reverse proxy (roadmap task 6) ## 🏗️ Architecture Requirements @@ -37,8 +124,6 @@ _This section will be updated as implementation progresses._ - `src/infrastructure/templating/docker_compose/` - Docker Compose template rendering with Grafana service - `src/domain/grafana/` - Grafana configuration domain types (NEW) - `src/application/command_handlers/create/config/validation/` - Grafana-Prometheus dependency validation (NEW) -- `src/application/steps/configure_grafana_firewall.rs` - Grafana firewall configuration step (NEW) -- `src/domain/environment/state.rs` - Add `ConfigureGrafanaFirewall` variant to `ConfigureStep` enum (NEW) **Pattern**: Configuration-driven service selection with dependency validation @@ -65,11 +150,6 @@ _This section will be updated as implementation progresses._ - Grafana is configured entirely through environment variables and docker-compose settings - Dashboards can be added later through the UI or mounted as optional files - **Rationale**: Grafana has sensible defaults and the Prometheus datasource can be configured through environment variables -- ✅ **Firewall Configuration**: Grafana UI port (3100) is exposed publicly through firewall during `configure` command - - Firewall rules added conditionally (only when Grafana is enabled in environment config) - - Port exposure is **temporary** until HTTPS/reverse proxy support is added (roadmap task 6) - - When proxy is implemented, public port exposure will be removed - - **Pattern**: Similar to tracker firewall configuration - opens port only if service enabled ### Anti-Patterns to Avoid @@ -235,66 +315,14 @@ fn validate_grafana_dependency( - **Depends on**: `prometheus` service (simple dependency, no health check) - **Rationale**: Grafana will start after Prometheus container starts. Grafana UI will be accessible even if Prometheus is temporarily unavailable. -### Firewall Configuration - -**Grafana UI Port Exposure**: Port 3100 must be opened in the firewall to allow public access to the Grafana web interface. - -**Ansible Playbook**: `templates/ansible/configure-grafana-firewall.yml` (NEW) +**Port Exposure and Security Note**: -```yaml ---- -# Configure Grafana-specific firewall rules -# Opens port 3100 for Grafana UI access (conditionally, only when Grafana is enabled) - -- name: Configure Grafana Firewall Rules - hosts: all - become: true - vars_files: - - "{{ playbook_dir }}/group_vars/all/variables.yml" - tasks: - - name: Allow Grafana UI port through firewall (port 3100) - community.general.ufw: - rule: allow - port: "3100" - proto: tcp - comment: "Grafana UI" - when: grafana_enabled | default(false) | bool - notify: Reload UFW - - handlers: - - name: Reload UFW - community.general.ufw: - state: reloaded -``` - -**Variables in `group_vars/all/variables.yml`**: - -```yaml -# Grafana Configuration (conditional) -grafana_enabled: {{ 'true' if grafana_config else 'false' }} -``` - -**Template Location**: `templates/ansible/configure-grafana-firewall.yml.tera` (uses Tera to inject variables) - -**Execution**: During `configure` command, after `ConfigureTrackerFirewall` step - -**Conditional Behavior**: - -- If Grafana is **enabled** in environment config → Port 3100 opened in firewall -- If Grafana is **disabled** (section removed) → Playbook tasks skipped (no port opened) -- If `TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER=true` → Entire firewall configuration skipped - -**Security Note**: This public exposure is **temporary** until HTTPS support with reverse proxy is implemented (roadmap task 6). Once a reverse proxy (like nginx) is added with HTTPS, this public port exposure will be removed, and Grafana will only be accessible through the proxy. - -**Firewall Configuration Pattern**: - -1. First, UFW closes all ports except SSH (which may be a custom port) -2. Then, individual service ports are opened conditionally based on enabled services: - - SSH port (always, custom or default) - - Tracker ports (if tracker configured) - - Prometheus port (if Prometheus enabled) - - Grafana port (if Grafana enabled) - - Future services... +- Grafana UI is exposed on port 3100 via docker-compose `ports:` directive +- **Docker Bypasses UFW**: Docker published ports bypass UFW firewall rules entirely (see [docs/issues/DRAFT-docker-ufw-firewall-security-strategy.md](./DRAFT-docker-ufw-firewall-security-strategy.md)) +- **No Firewall Configuration**: UFW rules provide no actual security for Docker published ports +- **Current Security Posture**: Port 3100 is accessible from any network that can reach the host +- **Future Security**: Proper security requires reverse proxy with TLS termination (roadmap task 6) +- **Temporary Exposure**: This public exposure is acceptable for MVP/testing environments until reverse proxy is implemented ### Environment Configuration Schema Extensions @@ -507,37 +535,37 @@ fn create_environment_from_config(config: UserInputs) -> Result` field to `UserInputs` struct - - [ ] Add `#[serde(skip_serializing_if = "Option::is_none")]` attribute - - [ ] Update all constructors and test fixtures to include `grafana` field - - [ ] Update JSON schema (`schemas/environment-config.json`) with Grafana section + - [x] Add `grafana: Option` field to `UserInputs` struct + - [x] Add `#[serde(skip_serializing_if = "Option::is_none")]` attribute + - [x] Update all constructors and test fixtures to include `grafana` field + - [x] Update JSON schema (`schemas/environment-config.json`) with Grafana section 3. **Validation Logic** (`src/application/command_handlers/create/config/validation/`): - - [ ] Create validation module if it doesn't exist - - [ ] Implement `validate_grafana_prometheus_dependency()` function - - [ ] Add `ConfigError::GrafanaRequiresPrometheus` error variant - - [ ] Add comprehensive error help text with fix instructions - - [ ] Write unit tests for all validation scenarios: - - [ ] Both enabled (valid) - - [ ] Both disabled (valid) - - [ ] Only Prometheus enabled (valid) - - [ ] Only Grafana enabled (invalid - should error) - - [ ] Integrate validation call in environment creation handler - - [ ] Run linters and tests + - [x] Create validation module if it doesn't exist + - [x] Implement `validate_grafana_prometheus_dependency()` function + - [x] Add `ConfigError::GrafanaRequiresPrometheus` error variant + - [x] Add comprehensive error help text with fix instructions + - [x] Write unit tests for all validation scenarios: + - [x] Both enabled (valid) + - [x] Both disabled (valid) + - [x] Only Prometheus enabled (valid) + - [x] Only Grafana enabled (invalid - should error) + - [x] Integrate validation call in environment creation handler + - [x] Run linters and tests 4. **Testing**: - - [ ] Run `cargo test` - all tests should pass - - [ ] Run `cargo run --bin linter all` - all linters should pass + - [x] Run `cargo test` - all tests should pass + - [x] Run `cargo run --bin linter all` - all linters should pass ### Phase 2: Docker Compose Integration @@ -547,26 +575,26 @@ fn create_environment_from_config(config: UserInputs) -> Result` field to `DockerComposeContext` - - [ ] Implement `with_grafana()` method for context builder pattern - - [ ] Add unit tests for Grafana context inclusion + - [x] Add `grafana_config: Option` field to `DockerComposeContext` + - [x] Implement `with_grafana()` method for context builder pattern + - [x] Add unit tests for Grafana context inclusion 2. **Environment Variables Context** (`src/infrastructure/templating/docker_compose/template/wrappers/env/context.rs`): - - [ ] Add optional Grafana fields to `EnvContext` struct: + - [x] Add optional Grafana fields to `EnvContext` struct: - `grafana_admin_user: Option` - `grafana_admin_password: Option` (plain String for template rendering) - - [ ] Implement `new_with_grafana()` constructor method - - [ ] Constructor must call `.expose_secret()` on `Password` to extract plaintext for template - - [ ] Add getters for Grafana fields - - [ ] Add unit tests for environment variable generation + - [x] Implement `new_with_grafana()` constructor method + - [x] Constructor must call `.expose_secret()` on `Password` to extract plaintext for template + - [x] Add getters for Grafana fields + - [x] Add unit tests for environment variable generation **Security Note**: The `admin_password` is stored as plain `String` in the context because Tera templates need the plaintext value. The `Password` wrapper is only used in the domain model and configuration. Call `.expose_secret()` when constructing the context from `GrafanaConfig`. 3. **Docker Compose Template** (`templates/docker-compose/docker-compose.yml.tera`): - - [ ] Add conditional Grafana service block with `{% if grafana_config %}` - - [ ] Configure Grafana service: + - [x] Add conditional Grafana service block with `{% if grafana_config %}` + - [x] Configure Grafana service: - Image: `grafana/grafana:11.4.0` - Container name: `grafana` - Restart policy: `unless-stopped` @@ -576,47 +604,28 @@ fn create_environment_from_config(config: UserInputs) -> Result Result` - - [ ] Check UFW status: `sudo ufw status` - - [ ] Verify port 3100 is allowed: Look for "3100/tcp" with "ALLOW" in UFW output - - [ ] Exit SSH - - [ ] **Verify external access**: - - [ ] Access Grafana UI from local machine: `http://:3100` - - [ ] Verify UI loads successfully (Grafana login page appears) - - [ ] Login with admin credentials - - [ ] Add Prometheus datasource manually: + - [x] Create manual test environment: `envs/manual-test-grafana.json` + - [x] Run full deployment workflow: + - [x] `create environment --env-file envs/manual-test-grafana.json` + - [x] `provision` + - [x] `configure` + - [x] `release` + - [x] `run` + - [x] Verify Grafana deployment: + - [x] Check Grafana container running: `docker ps` + - [x] **Verify external access**: + - [x] Access Grafana UI from local machine: `http://:3100` + - [x] Verify UI loads successfully (Grafana login page appears) + - [x] **Note**: Port is accessible due to Docker bypassing UFW (no firewall config needed) + - [x] Login with admin credentials + - [x] Add Prometheus datasource manually: - URL: `http://prometheus:9090` - Access: "Server (default)" - - [ ] Verify Prometheus connection works ("Save & Test" button) - - [ ] Import basic dashboard (optional) - - [ ] Test dependency validation: - - [ ] Create config with Grafana but without Prometheus - - [ ] Verify environment creation fails with clear error message - - [ ] Verify error suggests fixing by adding Prometheus or removing Grafana - - [ ] Document manual testing steps in `docs/e2e-testing/manual/grafana-verification.md` + - [x] Verify Prometheus connection works ("Save & Test" button) + - [x] Import basic dashboard (optional) + - [x] Test dependency validation: + - [x] Create config with Grafana but without Prometheus + - [x] Verify environment creation fails with clear error message + - [x] Verify error suggests fixing by adding Prometheus or removing Grafana + - [x] Document manual testing steps in `docs/e2e-testing/manual/grafana-verification.md` + - [x] **Bug Fix**: Discovered and fixed password propagation bug: + - **Issue**: Configured Grafana password wasn't being used (defaulted to "admin") + - **Root Cause**: `UserInputs::with_tracker()` was using hardcoded defaults + - **Fix**: Updated constructor chain to accept and pass through optional Prometheus/Grafana configs + - **Verification**: Password now correctly propagates from config → state → .env → container 5. **Final Verification**: - - [ ] Run all linters: `cargo run --bin linter all` - - [ ] Run all unit tests: `cargo test` - - [ ] Run E2E tests: `cargo run --bin e2e-deployment-workflow-tests` - - [ ] Verify pre-commit checks pass: `./scripts/pre-commit.sh` + - [x] Run all linters: `cargo run --bin linter all` + - [x] Run all unit tests: `cargo test` + - [x] Run E2E tests: `cargo run --bin e2e-deployment-workflow-tests` + - [x] Verify pre-commit checks pass: `./scripts/pre-commit.sh` ### Phase 4: Documentation @@ -695,107 +709,107 @@ fn create_environment_from_config(config: UserInputs) -> Result:9090` from external network +- UFW rules completely bypassed +- Security breach - internal service exposed publicly + +**Root Cause**: Docker creates iptables rules that take precedence over UFW rules when publishing ports with `0.0.0.0::` binding. + +### Where This Was Discovered + +**File**: `templates/docker-compose/docker-compose.yml.tera` +**Commit**: Security fix applied in commit 8323def +**Issue**: #246 - Grafana slice implementation + +**Evidence**: + +```bash +# UFW status shows port 9090 NOT allowed +$ sudo ufw status | grep 9090 +# (no output - port not in UFW rules) + +# But Prometheus is accessible externally +$ curl http://10.140.190.35:9090 +HTTP/1.1 405 Method Not Allowed # Accessible! +``` + +**Manual testing documentation**: [docs/e2e-testing/manual/grafana-testing-results.md](../e2e-testing/manual/grafana-testing-results.md) + +## Original Security Strategy + +The deployment was designed to: + +1. **Use UFW exclusively** for firewall management (provider-agnostic) +2. **Avoid provider-specific firewalls** (AWS Security Groups, Hetzner Cloud Firewall, etc.) +3. **Maintain portability** across different hosting providers +4. **Simple configuration** - single firewall mechanism (UFW) + +**Rationale**: Integrating with multiple provider-specific firewalls would significantly increase complexity and make deployment harder across different providers. + +**NOTE**: No ADR was created for this decision initially, but it was the working assumption. + +## Potential Solution (Needs Validation) + +### Proposed Strategy + +Use a **layered security approach** combining UFW and Docker networking: + +#### Layer 1: UFW Firewall (Instance-Level Protection) + +- **Purpose**: Secure the entire VM instance +- **Configuration**: Deny all incoming traffic except SSH +- **Responsibility**: Prevent unauthorized access to the instance itself + +```yaml +# templates/ansible/configure-firewall.yml +- Set default policy: deny incoming +- Allow only SSH port (22 or custom) +- Do NOT allow application ports (tracker, grafana, etc.) +``` + +#### Layer 2: Docker Port Bindings (Service-Level Exposure) + +- **Purpose**: Selectively expose services to external network +- **Configuration**: Only bind ports for public-facing services +- **Responsibility**: Control which services are accessible from outside + +```yaml +# templates/docker-compose/docker-compose.yml.tera + +# Public services - port binding +tracker: + ports: + - "8080:8080" # Public API + - "6969:6969/udp" # Public tracker + +grafana: + ports: + - "3100:3000" # Public UI + +# Internal services - NO port binding +prometheus: + # No ports section - internal only + # Accessed via Docker network: http://prometheus:9090 + +mysql: + # No ports section - internal only + # Accessed via Docker network: mysql:3306 +``` + +#### Layer 3: Docker Internal Networks (Inter-Service Communication) + +- **Purpose**: Allow services to communicate securely within Docker +- **Configuration**: Use Docker network names for service discovery +- **Responsibility**: Internal service communication without external exposure + +```yaml +networks: + backend_network: {} + +services: + grafana: + networks: + - backend_network + # Connects to Prometheus via: http://prometheus:9090 + + prometheus: + networks: + - backend_network + # Connects to Tracker via: http://tracker:8080 +``` + +### Key Principle + +UFW secures the instance, Docker secures the services: + +- UFW closes everything except SSH (instance-level security) +- Docker port bindings control external service exposure (service-level security) +- Docker networks enable internal service communication (no external exposure) + +### Benefits + +1. ✅ **Provider-agnostic** - Works on any VM provider without provider-specific firewall integration +2. ✅ **Layered security** - Multiple security boundaries +3. ✅ **Explicit exposure** - Port bindings make it clear what's public vs internal +4. ✅ **Simple configuration** - No need for UFW rules per service +5. ✅ **Docker-native** - Leverages Docker's built-in networking and security + +### Drawbacks + +1. ⚠️ **UFW not controlling application ports** - Relies on correct docker-compose configuration +2. ⚠️ **Human error risk** - Mistakenly adding port binding exposes service immediately +3. ⚠️ **No defense-in-depth for Docker** - If docker-compose misconfigured, service exposed +4. ⚠️ **Trust in Docker networking** - Assumes Docker network isolation is secure + +## Questions to Investigate + +### Technical Questions + +1. **Docker Network Isolation**: How secure is Docker's internal network isolation? Can containers on different networks communicate? + +2. **Port Binding Risk**: What happens if a developer accidentally adds a port binding to an internal service? Is there any safeguard? + +3. **iptables Priority**: Can we configure UFW to take precedence over Docker's iptables rules? (Likely not without breaking Docker) + +4. **Alternative Solutions**: + + - Could we use `127.0.0.1::` bindings and nginx/reverse-proxy? + - Should we integrate with provider-specific firewalls despite complexity? + - Can we use Docker's built-in firewall features (docker-proxy, etc.)? + +5. **Testing Strategy**: How do we automatically verify no unintended ports are exposed during E2E tests? + +### Security Questions + +1. **Threat Model**: What attack vectors exist with this approach? + + - Misconfigured docker-compose exposing internal services + - Docker daemon compromise + - Container escape vulnerabilities + +2. **Compliance**: Does this approach meet security best practices for production deployments? + +3. **Monitoring**: How do we detect if internal services become accidentally exposed? + +4. **Recovery**: If a service is exposed, what's the remediation process? + +### Implementation Questions + +1. **Migration**: How do we update existing deployments to this strategy? + +2. **Documentation**: What warnings/guidance do we provide to prevent misconfigurations? + +3. **Validation**: Can we add linting/validation to detect port bindings on internal services? + +4. **Testing**: How do we test the security posture in E2E tests? + +## Required Actions + +### 1. Research Phase + +- [ ] Study Docker networking security model +- [ ] Review Docker iptables integration and UFW interaction +- [ ] Research how other projects handle this (Kubernetes, Docker Swarm, etc.) +- [ ] Analyze the torrust-demo#72 issue for related lessons learned +- [ ] Review security best practices for Docker deployments +- [ ] Investigate alternative firewall strategies + +### 2. Analysis Phase + +- [ ] Document threat model for proposed strategy +- [ ] Analyze attack vectors and security boundaries +- [ ] Compare with provider-specific firewall integration complexity +- [ ] Evaluate trade-offs: simplicity vs security vs portability +- [ ] Define clear security requirements + +### 3. Design Phase + +- [ ] Create comprehensive ADR for firewall security strategy +- [ ] Define explicit rules for which services get port bindings +- [ ] Design validation/linting for docker-compose security +- [ ] Create security testing strategy for E2E tests +- [ ] Document operational procedures (monitoring, incident response) + +### 4. Implementation Phase + +- [ ] Update all docker-compose templates with security principles +- [ ] Remove unnecessary port bindings (like Prometheus 9090) +- [ ] Add validation to prevent accidental exposures +- [ ] Implement E2E security tests +- [ ] Update documentation and user guides + +### 5. Review Phase + +- [ ] Security audit of implementation +- [ ] Penetration testing +- [ ] Documentation review +- [ ] Team review and sign-off + +## Immediate Actions (Already Taken) + +As part of issue #246 implementation: + +✅ **Security fix applied** (commit 8323def): + +- Removed Prometheus port binding (`9090:9090`) +- Added comments explaining internal-only services +- Updated tests to verify port NOT exposed +- Documented security issue in manual testing results + +✅ **Documentation**: + +- Recorded security issue discovery in [manual testing results](../e2e-testing/manual/grafana-testing-results.md) +- Explained Docker bypassing UFW in commit messages +- Created this draft issue specification + +## Related Documentation + +### Internal Documentation + +- [Manual Grafana Testing Results](../e2e-testing/manual/grafana-testing-results.md) - Where security issue was discovered +- [Issue #246 - Grafana Slice](./246-grafana-slice-release-run-commands.md) - Implementation that revealed the issue +- [Firewall Ansible Playbook](../../templates/ansible/configure-firewall.yml) - Current UFW configuration + +### External References + +- [torrust-demo#72 - Docker bypassing systemd-resolved](https://github.com/torrust/torrust-demo/issues/72) - Related Docker bypass issue +- Docker Documentation: [Packet filtering and firewalls](https://docs.docker.com/network/packet-filtering-firewalls/) +- UFW and Docker: [Known interactions and issues](https://github.com/docker/for-linux/issues/690) + +### Similar Problems in the Wild + +- [UFW and Docker: The Problem](https://github.com/chaifeng/ufw-docker) - Community solutions +- [Docker and Firewall Issues](https://www.techrepublic.com/article/how-to-fix-the-docker-and-ufw-security-flaw/) + +## Priority Justification + +**CRITICAL Priority** because: + +1. **Security vulnerability** - Internal services can be accidentally exposed +2. **Silent failure** - UFW shows correct configuration but doesn't protect +3. **False sense of security** - Developers may assume UFW is protecting them +4. **Production impact** - Affects all deployments using Docker +5. **Architecture foundation** - Firewall strategy is fundamental to security + +**Why DRAFT**: + +- Requires thorough analysis before making architectural decisions +- Need to validate proposed solution against security requirements +- Must consider all alternatives and trade-offs +- ADR required for such a fundamental decision + +## Next Steps + +1. **Schedule analysis session** - Dedicate time to research and analyze +2. **Consult security resources** - Review Docker security best practices +3. **Draft ADR** - Create comprehensive architectural decision record +4. **Team review** - Get feedback on proposed strategy +5. **Implement and test** - Apply solution across codebase +6. **Document** - Update all relevant documentation + +## Notes + +- This issue was discovered during real-world manual E2E testing +- The fix for Prometheus (removing port binding) is a band-aid, not a complete solution +- We need a coherent, documented strategy for all current and future services +- This affects not just this project but potentially all Torrust projects using Docker + +## Open Questions for Discussion + +1. Should we reconsider provider-specific firewall integration despite complexity? +2. Is Docker network isolation sufficient for production security? +3. What's the acceptable level of risk for accidental service exposure? +4. Should we implement automated security scanning for port bindings? +5. How do other similar projects (deployment tools for containerized apps) handle this? + +--- + +**Created**: 2025-12-19 +**Discovered During**: Issue #246 - Grafana slice implementation +**Needs**: Research → Analysis → ADR → Implementation diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md index 3fa578d9..7abf6774 100644 --- a/docs/user-guide/README.md +++ b/docs/user-guide/README.md @@ -282,11 +282,21 @@ The Torrust Tracker Deployer supports optional services that can be enabled in y ### Available Services - **[Prometheus Monitoring](services/prometheus.md)** - Metrics collection and monitoring (enabled by default) + - Automatic metrics scraping from tracker API - Web UI on port 9090 - Configurable scrape intervals - Can be disabled by removing from configuration +- **[Grafana Visualization](services/grafana.md)** - Metrics visualization and dashboards (enabled by default) + - Web UI on port 3100 for dashboard access + - Requires Prometheus to be enabled + - Configurable admin credentials + - Pre-built tracker dashboards available for import + - Can be disabled by removing from configuration + +For detailed configuration, setup instructions, and troubleshooting, see the individual service guides linked above. + ### Adding or Removing Services Services are configured in your environment JSON file. To enable a service, include its configuration section. To disable it, remove the section. diff --git a/docs/user-guide/services/README.md b/docs/user-guide/services/README.md index c16d568e..fe99c758 100644 --- a/docs/user-guide/services/README.md +++ b/docs/user-guide/services/README.md @@ -15,11 +15,19 @@ The services documentation provides comprehensive guides for each optional servi ## Available Services - **[Prometheus Monitoring](prometheus.md)** - Metrics collection and monitoring service + - Automatic metrics scraping from tracker API endpoints - Web UI for querying and visualizing metrics - Configurable scrape intervals - Enabled by default, can be disabled +- **[Grafana Visualization](grafana.md)** - Metrics visualization and dashboards + - Web-based dashboard interface for visualizing Prometheus metrics + - Configurable admin credentials + - Auto-import of tracker dashboards (planned) + - Requires Prometheus to be enabled + - Enabled by default, can be disabled + ## Service Organization Each service guide follows a consistent structure: @@ -88,7 +96,6 @@ As the deployer evolves, additional optional services may be added to this direc - Reverse proxy services (Nginx, Traefik) - Logging aggregation (Loki, Elasticsearch) - Alerting services (Alertmanager) -- Visualization services (Grafana) ## Related Documentation diff --git a/docs/user-guide/services/grafana.md b/docs/user-guide/services/grafana.md new file mode 100644 index 00000000..2d02791a --- /dev/null +++ b/docs/user-guide/services/grafana.md @@ -0,0 +1,546 @@ +# Grafana Visualization Service + +This guide covers the Grafana visualization service integration in the Torrust Tracker Deployer. + +## Overview + +The deployer includes Grafana for metrics visualization by default. Grafana provides a web-based interface for creating dashboards and visualizing metrics collected by Prometheus, making it easy to monitor tracker performance and behavior. + +## Default Behavior + +- **Enabled by default** in generated environment templates +- **Requires Prometheus** to be enabled (hard dependency) +- Web UI accessible on port `3100` +- Default admin credentials: `admin` / `admin` (should be changed) +- Uses Docker named volume for persistent storage + +## Configuration + +### Basic Configuration + +Add the `grafana` section to your environment configuration file: + +```json +{ + // ... environment, provider, ssh_credentials, tracker config ... + "prometheus": { + "scrape_interval_in_secs": 15 + }, + "grafana": { + "admin_user": "admin", + "admin_password": "SecurePassword123!" + } +} +``` + +**Note**: Only relevant sections shown. See [`envs/manual-test-grafana.json`](../../../envs/manual-test-grafana.json) for a complete example. + +### Configuration Fields + +**grafana.admin_user** (required): + +- Administrator username for Grafana UI login +- Default: `admin` +- Used for first login and administrative tasks + +**grafana.admin_password** (required): + +- Administrator password for Grafana UI login +- Default: `admin` +- **⚠️ SECURITY WARNING**: Always change the default password before deploying to production environments + +**Examples**: + +```json +// Development environment (simple credentials) +{ + "grafana": { + "admin_user": "admin", + "admin_password": "devpass123" + } +} + +// Production environment (strong credentials) +{ + "grafana": { + "admin_user": "grafana-admin", + "admin_password": "Str0ng!P@ssw0rd#2024" + } +} +``` + +**Real Example**: See [`envs/manual-test-grafana.json`](../../../envs/manual-test-grafana.json) for a working configuration. + +### Prometheus Dependency + +**IMPORTANT**: Grafana requires Prometheus to be enabled. If you include the `grafana` section but remove the `prometheus` section, environment creation will fail with a clear validation error. + +**Valid Configuration** (both services): + +```json +{ + "prometheus": { "scrape_interval_in_secs": 15 }, + "grafana": { "admin_user": "admin", "admin_password": "SecurePassword123!" } +} +``` + +**Invalid Configuration** (Grafana without Prometheus): + +```json +{ + // Missing prometheus section - will fail validation + "grafana": { "admin_user": "admin", "admin_password": "SecurePassword123!" } +} +``` + +## Disabling Grafana + +To deploy without Grafana visualization, remove the entire `grafana` section from your environment config: + +```json +{ + // ... environment, provider, ssh_credentials, tracker config ... + "prometheus": { + "scrape_interval_in_secs": 15 + } + // No grafana section = visualization disabled +} +``` + +**Note**: Prometheus can run independently without Grafana. This is useful if you: + +- Want programmatic access to metrics only +- Use custom visualization tools +- Need minimal resource usage + +## Accessing Grafana + +After deployment, the Grafana web UI is available at: + +```text +http://:3100 +``` + +Where `` is the IP address of your deployed VM instance. + +### Finding Your VM IP + +```bash +# Extract IP from environment state +cat data//environment.json | grep ip_address + +# Or use jq for cleaner output +INSTANCE_IP=$(cat data//environment.json | jq -r '.Running.context.runtime_outputs.instance_ip') +echo "Grafana UI: http://$INSTANCE_IP:3100" +``` + +### First Login + +1. Open `http://:3100` in your web browser +2. Enter your credentials: + - **Username**: Value from `grafana.admin_user` in your config + - **Password**: Value from `grafana.admin_password` in your config +3. You'll be taken to the Grafana home page + +## Initial Setup + +### Adding Prometheus Datasource + +**Current Status**: Manual setup required (automation planned for future release) + +After first login, you need to add Prometheus as a datasource: + +1. **Navigate to Configuration**: + + - Click the gear icon (⚙️) in the left sidebar + - Select **Data Sources** + +2. **Add New Datasource**: + + - Click **Add data source** + - Select **Prometheus** from the list + +3. **Configure Datasource**: + + - **Name**: `Prometheus` (or any name you prefer) + - **URL**: `http://prometheus:9090` + - **Access**: `Server (default)` + - Leave other settings as default + +4. **Verify Connection**: + - Click **Save & Test** at the bottom + - You should see a green "Data source is working" message + +**Troubleshooting**: If connection fails, verify: + +- Prometheus service is running: `docker ps | grep prometheus` +- Prometheus is on the same Docker network as Grafana +- URL uses internal Docker service name: `http://prometheus:9090` (not `localhost`) + +### Importing Tracker Dashboards + +The Torrust project provides two sample dashboards for visualizing tracker metrics: + +#### Available Dashboards + +1. **stats.json** - Statistics Dashboard + + - Displays data from the `/api/v1/stats` tracker endpoint + - Shows high-level tracker statistics + - Good for general monitoring + +2. **metrics.json** - Metrics Dashboard + - Displays data from the `/api/v1/metrics` tracker endpoint (Prometheus format) + - Shows detailed performance metrics + - Good for in-depth analysis + +**Source**: [torrust-demo/share/grafana/dashboards/](https://github.com/torrust/torrust-demo/tree/main/share/grafana/dashboards) + +#### Import Process + +1. **Navigate to Dashboards**: + + - Click the **+** icon in the left sidebar + - Select **Import** + +2. **Upload Dashboard**: + + - Click **Upload JSON file** and select the dashboard file + - Or paste the JSON content directly into the text area + +3. **Configure Import**: + + - **Name**: Keep the default or customize + - **Folder**: Select a folder or leave as default + - **Prometheus**: Select the datasource you created earlier + +4. **Complete Import**: + - Click **Import** + - The dashboard will open automatically + +#### Customizing Dashboards + +After importing, you can: + +- Modify panels to show different metrics +- Add new panels with custom queries +- Change time ranges and refresh intervals +- Export modified dashboards for reuse +- Share dashboards with team members + +## Using Grafana + +### Dashboard Features + +**Time Range Selection**: + +- Use the time picker in the top-right to select ranges +- Common options: Last 5 minutes, Last 1 hour, Last 24 hours +- Custom ranges supported + +**Auto-Refresh**: + +- Enable auto-refresh for real-time monitoring +- Options: 5s, 10s, 30s, 1m, 5m, 15m, 30m, 1h +- Disable when not actively monitoring to reduce load + +**Panel Interactions**: + +- Click on legends to show/hide specific series +- Hover over graphs to see detailed values +- Click and drag to zoom into time ranges +- Double-click to reset zoom + +### Creating Custom Dashboards + +1. **New Dashboard**: + + - Click **+** icon → **Dashboard** + - Click **Add visualization** + +2. **Select Data Source**: + + - Choose your Prometheus datasource + +3. **Write Query**: + + - Use Prometheus query language (PromQL) + - Examples: + + ```promql + # Total announced peers + torrust_tracker_announced_peers_total + + # Rate of announcements per second + rate(torrust_tracker_announced_peers_total[5m]) + + # Active torrents + torrust_tracker_active_torrents + ``` + +4. **Customize Visualization**: + + - Choose panel type (Graph, Stat, Gauge, Table, etc.) + - Set thresholds and colors + - Add units and labels + +5. **Save Dashboard**: + - Click the save icon (💾) in the top-right + - Give it a name and optional description + +## Verification + +### Manual Verification + +For detailed step-by-step verification instructions, see the [Grafana Verification Guide](../../e2e-testing/manual/grafana-verification.md). + +**Quick Check**: + +```bash +# 1. Verify Grafana container is running +ssh torrust@ "docker ps | grep grafana" + +# 2. Check Grafana is accessible +curl -u admin:SecurePassword123! http://:3100/api/health + +# Expected output: {"commit":"...","database":"ok","version":"11.4.0"} + +# 3. Verify login credentials +curl -u admin:SecurePassword123! http://:3100/api/org +# Should return HTTP 200 with organization info + +# 4. Test with wrong credentials (should fail) +curl -u admin:wrongpassword http://:3100/api/org +# Should return HTTP 401 Unauthorized +``` + +### Automated Verification + +The E2E tests include automated Grafana validation: + +```rust +// From tests/e2e/validators/grafana.rs +GrafanaValidator::validate( + &ssh_credentials, + &expected_credentials, +)?; +``` + +## Troubleshooting + +### Login Fails with "Invalid username or password" + +**Symptom**: Cannot log in with configured credentials. + +**Possible Causes**: + +1. **Password mismatch**: Check `data//environment.json` to verify the stored password +2. **Container restarted**: Environment variables not persisted across restarts +3. **Typo in configuration**: Verify exact password in config file + +**Solution**: + +```bash +# 1. Check stored password in environment state +cat data//environment.json | jq '.Created.user_inputs.grafana.admin_password' + +# 2. Verify environment variable in container +ssh torrust@ "docker exec grafana printenv | grep GF_SECURITY" + +# 3. Check .env file +ssh torrust@ "cat docker-compose/.env | grep GRAFANA" + +# 4. If mismatch found, re-run release and run commands +torrust-tracker-deployer release +torrust-tracker-deployer run +``` + +### Grafana UI Not Accessible + +**Symptom**: Browser cannot connect to `http://:3100`. + +**Diagnosis**: + +```bash +# 1. Verify Grafana container is running +ssh torrust@ "docker ps | grep grafana" + +# 2. Check port binding +ssh torrust@ "docker ps | grep grafana" | grep "3100" +# Should show: 0.0.0.0:3100->3000/tcp + +# 3. Test from VM itself +ssh torrust@ "curl -s http://localhost:3100/api/health" + +# 4. Check container logs +ssh torrust@ "docker logs grafana" +``` + +**Common Solutions**: + +- Container not running: `docker start grafana` +- Port conflict: Check if port 3100 is already in use +- Network issues: Verify Docker network `backend_network` exists + +### Prometheus Datasource Connection Fails + +**Symptom**: "Data source is not working" error when testing Prometheus connection. + +**Diagnosis**: + +```bash +# 1. Verify Prometheus is running +ssh torrust@ "docker ps | grep prometheus" + +# 2. Check Prometheus accessibility from Grafana container +ssh torrust@ "docker exec grafana curl -s http://prometheus:9090/api/v1/status/config" + +# 3. Verify both are on same network +ssh torrust@ "docker network inspect backend_network" +``` + +**Common Solutions**: + +- Wrong URL: Must use `http://prometheus:9090` (Docker service name, not `localhost`) +- Network issue: Ensure both containers are on `backend_network` +- Prometheus not running: Start Prometheus container first + +### Dashboards Show No Data + +**Symptom**: Panels show "No data" or empty graphs. + +**Diagnosis**: + +1. **Check Time Range**: Ensure time range covers when tracker was running +2. **Verify Datasource**: Confirm Prometheus datasource is selected in dashboard +3. **Test Query**: Use Prometheus UI (`http://:9090`) to verify data exists +4. **Check Tracker**: Ensure tracker is running and generating metrics + +**Solution**: + +```bash +# 1. Verify tracker is running and generating metrics +curl http://:8080/api/v1/metrics + +# 2. Check Prometheus is scraping metrics +# Go to http://:9090/targets +# Verify tracker targets are "UP" + +# 3. In Grafana, try a simple query first +# Query: up{job="tracker"} +# Should show 1 if tracker is up +``` + +## Architecture + +### Deployment Structure + +```text +VM Instance +├── Docker Containers +│ ├── grafana (port 3100 → 3000) +│ ├── prometheus (port 9090) +│ └── tracker (port 8080) +├── Docker Networks +│ └── backend_network (connects all services) +└── Docker Volumes + └── grafana_data (persistent dashboards/datasources) +``` + +### Storage + +**Named Volume**: `grafana_data` + +- **Location**: `/var/lib/grafana` inside container +- **Contents**: Dashboards, datasources, user preferences, database +- **Persistence**: Survives container restarts and updates +- **Backup**: Requires Docker volume commands + +**Backup Example**: + +```bash +# Export dashboard JSON from Grafana UI, or: +docker run --rm -v grafana_data:/data -v $(pwd):/backup \ + ubuntu tar czf /backup/grafana-backup.tar.gz /data +``` + +### Port Exposure + +**Port Mapping**: `3100:3000` (Host:Container) + +- **Host Port**: `3100` - Accessible from outside VM +- **Container Port**: `3000` - Grafana's default internal port +- **Reason for 3100**: Avoid conflicts with other services commonly using port 3000 + +**Security Note**: Docker published ports **bypass UFW firewall rules**. The port is accessible from any network that can reach the host. This is acceptable for development/testing but requires reverse proxy with TLS for production (see roadmap). + +### Environment Variables + +Configuration via environment variables (injected from `.env` file): + +- `GF_SECURITY_ADMIN_USER` - Admin username +- `GF_SECURITY_ADMIN_PASSWORD` - Admin password + +### Service Dependencies + +**Docker Compose**: + +```yaml +services: + grafana: + depends_on: + - prometheus +``` + +**Startup Order**: Prometheus starts first, then Grafana. Grafana UI remains functional even if Prometheus is temporarily unavailable. + +## Future Enhancements + +### Planned Automation + +A separate issue is planned to add: + +1. **Auto-Provision Prometheus Datasource**: + + - Automatically create datasource during deployment + - Zero-config experience for users + - No manual setup steps required + +2. **Auto-Import Tracker Dashboards**: + + - Automatically import `stats.json` and `metrics.json` + - Dashboards available immediately after deployment + - Provisioning via `provisioning/dashboards/` directory + +3. **Customizable Dashboard Templates**: + - Support for user-provided dashboard JSON files + - Template-based dashboard generation + - Environment-specific dashboard configuration + +### Roadmap Items + +- **Reverse Proxy**: TLS termination for secure external access (Task 6) +- **Automated Backups**: Scheduled dashboard and configuration backups (Task 7) +- **Multi-Environment Dashboards**: Aggregate metrics from multiple deployments (Task 8) + +## Related Documentation + +- **[Prometheus Service Guide](prometheus.md)** - Metrics collection service +- **[Manual Verification Guide](../../e2e-testing/manual/grafana-verification.md)** - Detailed verification steps +- **[Grafana Integration ADR](../../decisions/grafana-integration-pattern.md)** - Design decisions and rationale +- **[Sample Dashboards](https://github.com/torrust/torrust-demo/tree/main/share/grafana/dashboards)** - Torrust tracker dashboard examples +- **[Grafana Documentation](https://grafana.com/docs/grafana/latest/)** - Official Grafana documentation + +## Support + +For issues specific to Grafana integration in the deployer: + +- Check the [troubleshooting section](#troubleshooting) above +- Review the [manual verification guide](../../e2e-testing/manual/grafana-verification.md) +- Search existing [GitHub issues](https://github.com/torrust/torrust-tracker-deployer/issues) +- Open a new issue with detailed logs and environment information + +For general Grafana usage questions: + +- [Grafana Community Forums](https://community.grafana.com/) +- [Grafana Documentation](https://grafana.com/docs/) diff --git a/project-words.txt b/project-words.txt index da40ad75..db58a5ae 100644 --- a/project-words.txt +++ b/project-words.txt @@ -13,6 +13,8 @@ EAAAADAQABAAABAQC EPEL Falkenstein Gossman +Grafana +Grafonnet Graça Herberto Hillsboro @@ -46,6 +48,14 @@ VARCHAR Zeroize addgroup adduser +BBDBE +bencoded +completei +downloadedi +filesd +incompletei +intervali +peerslee appender appendonly architecting @@ -75,7 +85,9 @@ customuser dearmor debootstrap debuginfo +deogmiudufm derefs +devpass distro distutils doctest @@ -99,6 +111,7 @@ exfiltration exitcode flatlined frontends +fswc getent getopt handleable @@ -119,7 +132,9 @@ keepalive keygen keypair keyrings +kutca larstobi +leecher leechers libc lifecycles @@ -236,6 +251,7 @@ testuser tfstate tfvars thiserror +timepicker tlnp tlsv tmpbwr @@ -248,6 +264,7 @@ tulnp tulpn turbofish tést +ulpn undertested unergonomic unittests @@ -263,6 +280,7 @@ vbqajnc viewmodel webservers writeln +wrongpassword youruser zeroize Émojis diff --git a/schema.json b/schema.json deleted file mode 100644 index e0a83953..00000000 --- a/schema.json +++ /dev/null @@ -1,311 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "EnvironmentCreationConfig", - "description": "Configuration for creating a deployment environment\n\nThis is the top-level configuration object that contains all information\nneeded to create a new deployment environment. It deserializes from JSON\nconfiguration and provides type-safe conversion to domain parameters.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::{\n EnvironmentCreationConfig, EnvironmentSection, ProviderSection, LxdProviderSection\n};\n\nlet json = r#\"{\n \"environment\": {\n \"name\": \"dev\"\n },\n \"ssh_credentials\": {\n \"private_key_path\": \"fixtures/testing_rsa\",\n \"public_key_path\": \"fixtures/testing_rsa.pub\"\n },\n \"provider\": {\n \"provider\": \"lxd\",\n \"profile_name\": \"torrust-profile-dev\"\n },\n \"tracker\": {\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:6969\"\n }\n ],\n \"http_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:7070\"\n }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n }\n }\n}\"#;\n\nlet config: EnvironmentCreationConfig = serde_json::from_str(json)?;\n# Ok::<(), Box>(())\n```", - "type": "object", - "properties": { - "environment": { - "description": "Environment-specific settings", - "$ref": "#/$defs/EnvironmentSection" - }, - "provider": { - "description": "Provider-specific configuration (LXD, Hetzner, etc.)\n\nUses `ProviderSection` for JSON parsing with raw primitives.\nConverted to domain `ProviderConfig` via `to_environment_params()`.", - "$ref": "#/$defs/ProviderSection" - }, - "ssh_credentials": { - "description": "SSH credentials configuration", - "$ref": "#/$defs/SshCredentialsConfig" - }, - "tracker": { - "description": "Tracker deployment configuration\n\nUses `TrackerSection` for JSON parsing with String primitives.\nConverted to domain `TrackerConfig` via `to_environment_params()`.", - "$ref": "#/$defs/TrackerSection" - } - }, - "required": [ - "environment", - "ssh_credentials", - "provider", - "tracker" - ], - "$defs": { - "DatabaseSection": { - "description": "Database configuration section (application DTO)\n\nMirrors the domain `DatabaseConfig` enum but at the application layer.\nSupports both `SQLite` and `MySQL` database backends.\n\n# Examples\n\n```json\n{\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n}\n```\n\n```json\n{\n \"driver\": \"mysql\",\n \"host\": \"localhost\",\n \"port\": 3306,\n \"database_name\": \"tracker\",\n \"username\": \"tracker_user\",\n \"password\": \"secure_password\"\n}\n```", - "oneOf": [ - { - "description": "`SQLite` file-based database", - "type": "object", - "properties": { - "database_name": { - "description": "Database file name", - "type": "string" - }, - "driver": { - "type": "string", - "const": "sqlite3" - } - }, - "required": [ - "driver", - "database_name" - ] - }, - { - "description": "`MySQL` server-based database", - "type": "object", - "properties": { - "database_name": { - "description": "Database name", - "type": "string" - }, - "driver": { - "type": "string", - "const": "mysql" - }, - "host": { - "description": "`MySQL` server host", - "type": "string" - }, - "password": { - "description": "Database password (plain text during DTO serialization/deserialization)\n\nUses `PlainPassword` type alias to explicitly mark this as a temporarily visible secret.\nConverted to secure `Password` type in `to_database_config()` at the DTO-to-domain boundary.", - "type": "string" - }, - "port": { - "description": "`MySQL` server port", - "type": "integer", - "format": "uint16", - "maximum": 65535, - "minimum": 0 - }, - "username": { - "description": "Database username", - "type": "string" - } - }, - "required": [ - "driver", - "host", - "port", - "database_name", - "username", - "password" - ] - } - ] - }, - "EnvironmentSection": { - "description": "Environment-specific configuration section\n\nContains configuration specific to the environment being created.", - "type": "object", - "properties": { - "instance_name": { - "description": "Optional custom instance name for the VM/container\n\nIf not provided, auto-generated as `torrust-tracker-vm-{env_name}`.\nWhen provided, must follow instance naming rules:\n- 1-63 characters\n- ASCII letters, numbers, and dashes only\n- Cannot start with digit or dash\n- Cannot end with dash", - "type": [ - "string", - "null" - ], - "default": null - }, - "name": { - "description": "Name of the environment to create\n\nMust follow environment naming rules:\n- Lowercase letters and numbers only\n- Dashes as word separators\n- Cannot start or end with separators\n- Cannot start with numbers", - "type": "string" - } - }, - "required": [ - "name" - ] - }, - "HetznerProviderSection": { - "description": "Hetzner-specific configuration section\n\nUses raw `String` fields for JSON deserialization. Convert to domain\n`HetznerConfig` via `ProviderSection::to_provider_config()`.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::HetznerProviderSection;\n\nlet section = HetznerProviderSection {\n api_token: \"your-api-token\".to_string(),\n server_type: \"cx22\".to_string(),\n location: \"nbg1\".to_string(),\n image: \"ubuntu-24.04\".to_string(),\n};\n```", - "type": "object", - "properties": { - "api_token": { - "description": "Hetzner API token in plain text format (DTO layer).\n\nThis uses [`PlainApiToken`] to mark it as a transparent secret during\ndeserialization. Convert to domain `ApiToken` at the DTO-to-domain boundary.", - "type": "string" - }, - "image": { - "description": "Hetzner server image (e.g., \"ubuntu-24.04\", \"ubuntu-22.04\", \"debian-12\").", - "type": "string" - }, - "location": { - "description": "Hetzner datacenter location (e.g., \"fsn1\", \"nbg1\", \"hel1\").", - "type": "string" - }, - "server_type": { - "description": "Hetzner server type (e.g., \"cx22\", \"cx32\", \"cpx11\").", - "type": "string" - } - }, - "required": [ - "api_token", - "server_type", - "location", - "image" - ] - }, - "HttpApiSection": { - "type": "object", - "properties": { - "admin_token": { - "type": "string" - }, - "bind_address": { - "type": "string" - } - }, - "required": [ - "bind_address", - "admin_token" - ] - }, - "HttpTrackerSection": { - "type": "object", - "properties": { - "bind_address": { - "type": "string" - } - }, - "required": [ - "bind_address" - ] - }, - "LxdProviderSection": { - "description": "LXD-specific configuration section\n\nUses raw `String` for JSON deserialization. Convert to domain `LxdConfig`\nvia `ProviderSection::to_provider_config()`.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::LxdProviderSection;\n\nlet section = LxdProviderSection {\n profile_name: \"torrust-profile-dev\".to_string(),\n};\n```", - "type": "object", - "properties": { - "profile_name": { - "description": "LXD profile name (raw string - validated on conversion).", - "type": "string" - } - }, - "required": [ - "profile_name" - ] - }, - "ProviderSection": { - "description": "Provider-specific configuration section\n\nEach variant contains the configuration fields specific to that provider\nusing **raw primitives** (`String`) for JSON deserialization.\n\nThis is a tagged enum that deserializes based on the `\"provider\"` field in JSON.\n\n# Conversion\n\nUse `to_provider_config()` to validate and convert to domain types.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::{\n ProviderSection, LxdProviderSection\n};\n\nlet section = ProviderSection::Lxd(LxdProviderSection {\n profile_name: \"torrust-profile-dev\".to_string(),\n});\n\nlet config = section.to_provider_config().unwrap();\nassert_eq!(config.provider_name(), \"lxd\");\n```", - "oneOf": [ - { - "description": "LXD provider configuration", - "type": "object", - "properties": { - "provider": { - "type": "string", - "const": "lxd" - } - }, - "$ref": "#/$defs/LxdProviderSection", - "required": [ - "provider" - ] - }, - { - "description": "Hetzner provider configuration", - "type": "object", - "properties": { - "provider": { - "type": "string", - "const": "hetzner" - } - }, - "$ref": "#/$defs/HetznerProviderSection", - "required": [ - "provider" - ] - } - ] - }, - "SshCredentialsConfig": { - "description": "SSH credentials configuration for remote instance authentication\n\nThis is a configuration-layer value object that uses strings for paths\nand username. It is distinct from `adapters::ssh::SshCredentials` which\nuses domain types (`PathBuf`, `Username`).\n\n# Examples\n\n```no_run\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::SshCredentialsConfig;\n\nlet config = SshCredentialsConfig {\n private_key_path: \"fixtures/testing_rsa\".to_string(),\n public_key_path: \"fixtures/testing_rsa.pub\".to_string(),\n username: \"torrust\".to_string(),\n port: 22,\n};\n```", - "type": "object", - "properties": { - "port": { - "description": "SSH port for remote connections\n\nDefaults to 22 (standard SSH port) if not specified in configuration.", - "type": "integer", - "format": "uint16", - "default": 22, - "maximum": 65535, - "minimum": 0 - }, - "private_key_path": { - "description": "Path to the SSH private key file (as string in config)", - "type": "string" - }, - "public_key_path": { - "description": "Path to the SSH public key file (as string in config)", - "type": "string" - }, - "username": { - "description": "SSH username (as string in config)\n\nDefaults to \"torrust\" if not specified in configuration.", - "type": "string", - "default": "torrust" - } - }, - "required": [ - "private_key_path", - "public_key_path" - ] - }, - "TrackerCoreSection": { - "description": "Tracker core configuration section (application DTO)\n\nContains core tracker settings like database and privacy mode.\n\n# Examples\n\n```json\n{\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n}\n```", - "type": "object", - "properties": { - "database": { - "description": "Database configuration", - "$ref": "#/$defs/DatabaseSection" - }, - "private": { - "description": "Privacy mode: true for private tracker, false for public", - "type": "boolean" - } - }, - "required": [ - "database", - "private" - ] - }, - "TrackerSection": { - "description": "Tracker configuration section (application DTO)\n\nAggregates all tracker configuration sections: core, UDP trackers,\nHTTP trackers, and HTTP API.\n\n# Examples\n\n```json\n{\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n { \"bind_address\": \"0.0.0.0:6969\" }\n ],\n \"http_trackers\": [\n { \"bind_address\": \"0.0.0.0:7070\" }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n }\n}\n```", - "type": "object", - "properties": { - "core": { - "description": "Core tracker configuration (database, privacy mode)", - "$ref": "#/$defs/TrackerCoreSection" - }, - "http_api": { - "description": "HTTP API configuration", - "$ref": "#/$defs/HttpApiSection" - }, - "http_trackers": { - "description": "HTTP tracker instances", - "type": "array", - "items": { - "$ref": "#/$defs/HttpTrackerSection" - } - }, - "udp_trackers": { - "description": "UDP tracker instances", - "type": "array", - "items": { - "$ref": "#/$defs/UdpTrackerSection" - } - } - }, - "required": [ - "core", - "udp_trackers", - "http_trackers", - "http_api" - ] - }, - "UdpTrackerSection": { - "type": "object", - "properties": { - "bind_address": { - "type": "string" - } - }, - "required": [ - "bind_address" - ] - } - } -} \ No newline at end of file diff --git a/schemas/environment-config.json b/schemas/environment-config.json index 49a6113a..682321f7 100644 --- a/schemas/environment-config.json +++ b/schemas/environment-config.json @@ -1,13 +1,37 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "EnvironmentCreationConfig", - "description": "Configuration for creating a deployment environment\n\nThis is the top-level configuration object that contains all information\nneeded to create a new deployment environment. It deserializes from JSON\nconfiguration and provides type-safe conversion to domain parameters.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::{\n EnvironmentCreationConfig, EnvironmentSection, ProviderSection, LxdProviderSection\n};\n\nlet json = r#\"{\n \"environment\": {\n \"name\": \"dev\"\n },\n \"ssh_credentials\": {\n \"private_key_path\": \"fixtures/testing_rsa\",\n \"public_key_path\": \"fixtures/testing_rsa.pub\"\n },\n \"provider\": {\n \"provider\": \"lxd\",\n \"profile_name\": \"torrust-profile-dev\"\n },\n \"tracker\": {\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:6969\"\n }\n ],\n \"http_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:7070\"\n }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n }\n }\n}\"#;\n\nlet config: EnvironmentCreationConfig = serde_json::from_str(json)?;\n# Ok::<(), Box>(())\n```", + "description": "Configuration for creating a deployment environment\n\nThis is the top-level configuration object that contains all information\nneeded to create a new deployment environment. It deserializes from JSON\nconfiguration and provides type-safe conversion to domain parameters.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::{\n EnvironmentCreationConfig, EnvironmentSection, ProviderSection, LxdProviderSection\n};\n\nlet json = r#\"{\n \"environment\": {\n \"name\": \"dev\"\n },\n \"ssh_credentials\": {\n \"private_key_path\": \"fixtures/testing_rsa\",\n \"public_key_path\": \"fixtures/testing_rsa.pub\"\n },\n \"provider\": {\n \"provider\": \"lxd\",\n \"profile_name\": \"torrust-profile-dev\"\n },\n \"tracker\": {\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:6969\"\n }\n ],\n \"http_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:7070\"\n }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n }\n },\n \"prometheus\": {\n \"scrape_interval_in_secs\": 15\n },\n \"grafana\": {\n \"admin_user\": \"admin\",\n \"admin_password\": \"admin\"\n }\n}\"#;\n\nlet config: EnvironmentCreationConfig = serde_json::from_str(json)?;\n# Ok::<(), Box>(())\n```", "type": "object", "properties": { "environment": { "description": "Environment-specific settings", "$ref": "#/$defs/EnvironmentSection" }, + "grafana": { + "description": "Grafana dashboard configuration (optional)\n\nWhen present, Grafana will be deployed for visualization.\n**Requires Prometheus to be configured** - Grafana depends on\nPrometheus as its data source.\n\nUses `GrafanaSection` for JSON parsing with String primitives.\nConverted to domain `GrafanaConfig` via `to_environment_params()`.", + "anyOf": [ + { + "$ref": "#/$defs/GrafanaSection" + }, + { + "type": "null" + } + ], + "default": null + }, + "prometheus": { + "description": "Prometheus monitoring configuration (optional)\n\nWhen present, Prometheus will be deployed to monitor the tracker.\nUses `PrometheusSection` for JSON parsing with String primitives.\nConverted to domain `PrometheusConfig` via `to_environment_params()`.", + "anyOf": [ + { + "$ref": "#/$defs/PrometheusSection" + }, + { + "type": "null" + } + ], + "default": null + }, "provider": { "description": "Provider-specific configuration (LXD, Hetzner, etc.)\n\nUses `ProviderSection` for JSON parsing with raw primitives.\nConverted to domain `ProviderConfig` via `to_environment_params()`.", "$ref": "#/$defs/ProviderSection" @@ -66,7 +90,7 @@ "type": "string" }, "password": { - "description": "Database password", + "description": "Database password (plain text during DTO serialization/deserialization)\n\nUses `PlainPassword` type alias to explicitly mark this as a temporarily visible secret.\nConverted to secure `Password` type in `to_database_config()` at the DTO-to-domain boundary.", "type": "string" }, "port": { @@ -113,12 +137,30 @@ "name" ] }, + "GrafanaSection": { + "description": "Grafana configuration section (DTO)\n\nThis is a DTO that deserializes from JSON strings and validates\nwhen converting to the domain `GrafanaConfig`.\n\n# Security\n\nThe `admin_password` field uses `PlainPassword` type alias for string at\nDTO boundaries. It will be converted to `Password` (secrecy-wrapped) in\nthe domain layer.\n\n# Examples\n\n```json\n{\n \"admin_user\": \"admin\",\n \"admin_password\": \"admin\"\n}\n```", + "type": "object", + "properties": { + "admin_password": { + "description": "Grafana admin password (plain string at DTO boundary)\n\nThis will be converted to `Password` type in the domain layer\nto prevent accidental exposure in logs or debug output.", + "type": "string" + }, + "admin_user": { + "description": "Grafana admin username", + "type": "string" + } + }, + "required": [ + "admin_user", + "admin_password" + ] + }, "HetznerProviderSection": { "description": "Hetzner-specific configuration section\n\nUses raw `String` fields for JSON deserialization. Convert to domain\n`HetznerConfig` via `ProviderSection::to_provider_config()`.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::HetznerProviderSection;\n\nlet section = HetznerProviderSection {\n api_token: \"your-api-token\".to_string(),\n server_type: \"cx22\".to_string(),\n location: \"nbg1\".to_string(),\n image: \"ubuntu-24.04\".to_string(),\n};\n```", "type": "object", "properties": { "api_token": { - "description": "Hetzner API token (raw string).", + "description": "Hetzner API token in plain text format (DTO layer).\n\nThis uses [`PlainApiToken`] to mark it as a transparent secret during\ndeserialization. Convert to domain `ApiToken` at the DTO-to-domain boundary.", "type": "string" }, "image": { @@ -180,6 +222,21 @@ "profile_name" ] }, + "PrometheusSection": { + "description": "Prometheus configuration section (DTO)\n\nThis is a simple DTO that deserializes from JSON numbers and validates\nwhen converting to the domain `PrometheusConfig`.\n\n# Examples\n\n```json\n{\n \"scrape_interval_in_secs\": 15\n}\n```", + "type": "object", + "properties": { + "scrape_interval_in_secs": { + "description": "Interval for Prometheus to scrape metrics from targets (in seconds)\n\nMust be greater than 0. The Prometheus template adds the 's' suffix.\nExamples: 15 (15 seconds), 30 (30 seconds), 60 (1 minute)", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "scrape_interval_in_secs" + ] + }, "ProviderSection": { "description": "Provider-specific configuration section\n\nEach variant contains the configuration fields specific to that provider\nusing **raw primitives** (`String`) for JSON deserialization.\n\nThis is a tagged enum that deserializes based on the `\"provider\"` field in JSON.\n\n# Conversion\n\nUse `to_provider_config()` to validate and convert to domain types.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::{\n ProviderSection, LxdProviderSection\n};\n\nlet section = ProviderSection::Lxd(LxdProviderSection {\n profile_name: \"torrust-profile-dev\".to_string(),\n});\n\nlet config = section.to_provider_config().unwrap();\nassert_eq!(config.provider_name(), \"lxd\");\n```", "oneOf": [ @@ -308,4 +365,4 @@ ] } } -} +} \ No newline at end of file diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index d18d936a..f99c311e 100644 --- a/src/application/command_handlers/create/config/environment_config.rs +++ b/src/application/command_handlers/create/config/environment_config.rs @@ -8,11 +8,15 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::adapters::ssh::SshCredentials; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::tracker::TrackerConfig; use crate::domain::{EnvironmentName, InstanceName}; use super::errors::CreateConfigError; +use super::grafana::GrafanaSection; +use super::prometheus::PrometheusSection; use super::provider::{HetznerProviderSection, LxdProviderSection, ProviderSection}; use super::ssh_credentials_config::SshCredentialsConfig; use super::tracker::TrackerSection; @@ -64,6 +68,13 @@ use super::tracker::TrackerSection; /// "bind_address": "0.0.0.0:1212", /// "admin_token": "MyAccessToken" /// } +/// }, +/// "prometheus": { +/// "scrape_interval_in_secs": 15 +/// }, +/// "grafana": { +/// "admin_user": "admin", +/// "admin_password": "admin" /// } /// }"#; /// @@ -89,6 +100,25 @@ pub struct EnvironmentCreationConfig { /// Uses `TrackerSection` for JSON parsing with String primitives. /// Converted to domain `TrackerConfig` via `to_environment_params()`. pub tracker: TrackerSection, + + /// Prometheus monitoring configuration (optional) + /// + /// When present, Prometheus will be deployed to monitor the tracker. + /// Uses `PrometheusSection` for JSON parsing with String primitives. + /// Converted to domain `PrometheusConfig` via `to_environment_params()`. + #[serde(default)] + pub prometheus: Option, + + /// Grafana dashboard configuration (optional) + /// + /// When present, Grafana will be deployed for visualization. + /// **Requires Prometheus to be configured** - Grafana depends on + /// Prometheus as its data source. + /// + /// Uses `GrafanaSection` for JSON parsing with String primitives. + /// Converted to domain `GrafanaConfig` via `to_environment_params()`. + #[serde(default)] + pub grafana: Option, } /// Environment-specific configuration section @@ -144,6 +174,8 @@ impl EnvironmentCreationConfig { /// profile_name: "torrust-profile-dev".to_string(), /// }), /// TrackerSection::default(), + /// None, + /// None, /// ); /// ``` #[must_use] @@ -152,12 +184,16 @@ impl EnvironmentCreationConfig { ssh_credentials: SshCredentialsConfig, provider: ProviderSection, tracker: TrackerSection, + prometheus: Option, + grafana: Option, ) -> Self { Self { environment, ssh_credentials, provider, tracker, + prometheus, + grafana, } } @@ -168,8 +204,7 @@ impl EnvironmentCreationConfig { /// /// # Returns /// - /// Returns a tuple of `(EnvironmentName, InstanceName, ProviderConfig, SshCredentials, u16)` - /// that matches the signature of `Environment::new()`. + /// Returns a tuple of domain types. /// /// # Validation /// @@ -178,6 +213,7 @@ impl EnvironmentCreationConfig { /// - Provider config must be valid (e.g., valid profile name for LXD) /// - SSH username must follow Linux username requirements (see `Username`) /// - SSH key files must exist and be accessible + /// - Grafana requires Prometheus (dependency validation) /// /// # Instance Name Auto-Generation /// @@ -193,6 +229,7 @@ impl EnvironmentCreationConfig { /// - SSH username is invalid /// - SSH private key file does not exist /// - SSH public key file does not exist + /// - Grafana is configured but Prometheus is not (dependency violation) /// /// # Examples /// @@ -219,14 +256,17 @@ impl EnvironmentCreationConfig { /// profile_name: "torrust-profile-dev".to_string(), /// }), /// TrackerSection::default(), + /// None, + /// None, /// ); /// - /// let (name, instance_name, provider_config, credentials, port, tracker) = config.to_environment_params()?; + /// let result = config.to_environment_params()?; /// /// // Instance name auto-generated from environment name - /// assert_eq!(instance_name.as_str(), "torrust-tracker-vm-dev"); + /// assert_eq!(result.1.as_str(), "torrust-tracker-vm-dev"); /// # Ok::<(), Box>(()) /// ``` + #[allow(clippy::type_complexity)] pub fn to_environment_params( self, ) -> Result< @@ -237,6 +277,8 @@ impl EnvironmentCreationConfig { SshCredentials, u16, TrackerConfig, + Option, + Option, ), CreateConfigError, > { @@ -266,6 +308,22 @@ impl EnvironmentCreationConfig { // Convert TrackerSection (DTO) to domain TrackerConfig (validates bind addresses, etc.) let tracker_config = self.tracker.to_tracker_config()?; + // Convert Prometheus and Grafana sections to domain types + let prometheus_config = self + .prometheus + .map(|section| section.to_prometheus_config()) + .transpose()?; + + let grafana_config = self + .grafana + .map(|section| section.to_grafana_config()) + .transpose()?; + + // Validate Grafana-Prometheus dependency + if grafana_config.is_some() && prometheus_config.is_none() { + return Err(CreateConfigError::GrafanaRequiresPrometheus); + } + Ok(( environment_name, instance_name, @@ -273,6 +331,8 @@ impl EnvironmentCreationConfig { ssh_credentials, ssh_port, tracker_config, + prometheus_config, + grafana_config, )) } @@ -358,6 +418,8 @@ impl EnvironmentCreationConfig { admin_token: "MyAccessToken".to_string(), }, }, + prometheus: Some(PrometheusSection::default()), + grafana: Some(GrafanaSection::default()), } } @@ -461,6 +523,8 @@ mod tests { ), default_lxd_provider("torrust-profile-dev"), TrackerSection::default(), + None, + None, ); assert_eq!(config.environment.name, "dev"); @@ -602,6 +666,8 @@ mod tests { ), default_lxd_provider("torrust-profile-staging"), TrackerSection::default(), + None, + None, ); let json = serde_json::to_string(&config).unwrap(); @@ -626,12 +692,23 @@ mod tests { SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile-dev"), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); assert!(result.is_ok(), "Expected successful conversion"); - let (name, instance_name, provider_config, credentials, port, _tracker) = result.unwrap(); + let ( + name, + instance_name, + provider_config, + credentials, + port, + _tracker, + _prometheus, + _grafana, + ) = result.unwrap(); assert_eq!(name.as_str(), "dev"); assert_eq!(instance_name.as_str(), "torrust-tracker-vm-dev"); // Auto-generated @@ -656,13 +733,23 @@ mod tests { SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile-prod"), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); assert!(result.is_ok(), "Expected successful conversion"); - let (name, instance_name, _provider_config, _credentials, _port, _tracker) = - result.unwrap(); + let ( + name, + instance_name, + _provider_config, + _credentials, + _port, + _tracker, + _prometheus, + _grafana, + ) = result.unwrap(); assert_eq!(name.as_str(), "prod"); assert_eq!(instance_name.as_str(), "my-custom-instance"); // Custom provided @@ -684,6 +771,8 @@ mod tests { SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile"), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); @@ -713,6 +802,8 @@ mod tests { SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile"), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); @@ -745,6 +836,8 @@ mod tests { profile_name: "invalid-".to_string(), // ends with dash - invalid }), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); @@ -779,6 +872,8 @@ mod tests { ), default_lxd_provider("torrust-profile-dev"), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); @@ -812,6 +907,8 @@ mod tests { ), default_lxd_provider("torrust-profile-dev"), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); @@ -845,6 +942,8 @@ mod tests { ), default_lxd_provider("torrust-profile-dev"), TrackerSection::default(), + None, + None, ); let result = config.to_environment_params(); @@ -876,10 +975,20 @@ mod tests { SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile-test-env"), TrackerSection::default(), + None, + None, ); - let (name, _instance_name, provider_config, credentials, port, _tracker) = - config.to_environment_params().unwrap(); + let ( + name, + _instance_name, + provider_config, + credentials, + port, + _tracker, + _prometheus, + _grafana, + ) = config.to_environment_params().unwrap(); let environment = Environment::new(name.clone(), provider_config, credentials, port); assert_eq!(environment.name().as_str(), "test-env"); @@ -904,6 +1013,8 @@ mod tests { ), default_lxd_provider("torrust-profile-dev"), TrackerSection::default(), + None, + None, ); let json = serde_json::to_string_pretty(&original).unwrap(); @@ -992,6 +1103,8 @@ mod tests { ), default_lxd_provider("test-profile"), TrackerSection::default(), + None, + None, ); // Both should serialize to same structure (different values) diff --git a/src/application/command_handlers/create/config/errors.rs b/src/application/command_handlers/create/config/errors.rs index f3972fe5..b2ed885d 100644 --- a/src/application/command_handlers/create/config/errors.rs +++ b/src/application/command_handlers/create/config/errors.rs @@ -97,6 +97,14 @@ pub enum CreateConfigError { #[source] source: std::io::Error, }, + + /// Grafana requires Prometheus to be enabled + #[error("Grafana requires Prometheus to be enabled")] + GrafanaRequiresPrometheus, + + /// Invalid Prometheus configuration + #[error("Invalid Prometheus configuration: {0}")] + InvalidPrometheusConfig(String), } impl CreateConfigError { @@ -366,6 +374,56 @@ impl CreateConfigError { 3. Ensure the file is not open in another application\n\ 4. Check if antivirus software is blocking file creation" } + Self::GrafanaRequiresPrometheus => { + "Grafana requires Prometheus to be enabled.\n\ + \n\ + Grafana is a visualization tool that displays metrics collected by Prometheus.\n\ + It cannot function without Prometheus as its data source.\n\ + \n\ + Current configuration issue:\n\ + - Grafana section is present in your configuration\n\ + - Prometheus section is absent or disabled\n\ + \n\ + Fix (choose one):\n\ + \n\ + Option 1 - Enable Prometheus:\n\ + Add a prometheus section to your environment configuration:\n\ + \n\ + \"prometheus\": {\n\ + \"scrape_interval_in_secs\": 15\n\ + }\n\ + \n\ + Option 2 - Disable Grafana:\n\ + Remove the grafana section from your environment configuration\n\ + \n\ + Note: Prometheus can run independently without Grafana, but Grafana\n\ + requires Prometheus to be enabled." + } + Self::InvalidPrometheusConfig(_) => { + "Invalid Prometheus configuration.\n\ + \n\ + Prometheus scrape_interval must be a positive integer representing seconds.\n\ + \n\ + Requirements:\n\ + - Must be greater than 0\n\ + - Represents the interval in seconds between metric collections\n\ + \n\ + Common values:\n\ + - 15 (default, recommended for most use cases)\n\ + - 10 (high-frequency monitoring)\n\ + - 30 (lower resource usage)\n\ + - 60 (minimal monitoring overhead)\n\ + \n\ + Fix:\n\ + Update the scrape_interval_in_secs in your configuration:\n\ + \n\ + \"prometheus\": {\n\ + \"scrape_interval_in_secs\": 15\n\ + }\n\ + \n\ + Note: The template automatically adds the 's' suffix (e.g., 15 becomes '15s'),\n\ + so you only need to specify the numeric value." + } } } } @@ -508,4 +566,16 @@ mod tests { assert!(error.help().contains("permissions")); assert!(error.help().contains("disk space")); } + + #[test] + fn it_should_return_error_when_grafana_requires_prometheus() { + let error = CreateConfigError::GrafanaRequiresPrometheus; + + assert!(error.to_string().contains("Grafana requires Prometheus")); + assert!(error.help().contains("Grafana section is present")); + assert!(error.help().contains("Prometheus section is absent")); + assert!(error.help().contains("Add a prometheus section")); + assert!(error.help().contains("Remove the grafana section")); + assert!(error.help().contains("scrape_interval")); + } } diff --git a/src/application/command_handlers/create/config/grafana.rs b/src/application/command_handlers/create/config/grafana.rs new file mode 100644 index 00000000..0c703da4 --- /dev/null +++ b/src/application/command_handlers/create/config/grafana.rs @@ -0,0 +1,124 @@ +//! Grafana Configuration DTO (Application Layer) +//! +//! This module contains the DTO type for Grafana configuration used in +//! environment creation. This type uses raw primitives (String) for JSON +//! deserialization and converts to the rich domain type (`GrafanaConfig`). + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::application::command_handlers::create::config::errors::CreateConfigError; +use crate::domain::grafana::GrafanaConfig; +use crate::shared::secrets::PlainPassword; + +/// Grafana configuration section (DTO) +/// +/// This is a DTO that deserializes from JSON strings and validates +/// when converting to the domain `GrafanaConfig`. +/// +/// # Security +/// +/// The `admin_password` field uses `PlainPassword` type alias for string at +/// DTO boundaries. It will be converted to `Password` (secrecy-wrapped) in +/// the domain layer. +/// +/// # Examples +/// +/// ```json +/// { +/// "admin_user": "admin", +/// "admin_password": "admin" +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct GrafanaSection { + /// Grafana admin username + pub admin_user: String, + + /// Grafana admin password (plain string at DTO boundary) + /// + /// This will be converted to `Password` type in the domain layer + /// to prevent accidental exposure in logs or debug output. + pub admin_password: PlainPassword, +} + +impl Default for GrafanaSection { + fn default() -> Self { + let default_config = GrafanaConfig::default(); + Self { + admin_user: default_config.admin_user().to_string(), + admin_password: default_config.admin_password().expose_secret().to_string(), + } + } +} + +impl GrafanaSection { + /// Converts this DTO to a domain `GrafanaConfig` + /// + /// This method performs validation and type conversion from the + /// string-based DTO to the strongly-typed domain model with secrecy + /// protection for the password. + /// + /// # Errors + /// + /// Currently returns `Ok` for all valid inputs. Future versions may + /// add validation for `admin_user` format or password strength requirements. + pub fn to_grafana_config(&self) -> Result { + Ok(GrafanaConfig::new( + self.admin_user.clone(), + self.admin_password.clone(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_have_default_values() { + let section = GrafanaSection::default(); + assert_eq!(section.admin_user, "admin"); + assert_eq!(section.admin_password, "admin"); + } + + #[test] + fn it_should_convert_to_grafana_config() { + let section = GrafanaSection { + admin_user: "custom_admin".to_string(), + admin_password: "secure_password".to_string(), + }; + + let result = section.to_grafana_config(); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.admin_user(), "custom_admin"); + assert_eq!(config.admin_password().expose_secret(), "secure_password"); + } + + #[test] + fn it_should_convert_default_section_to_default_config() { + let section = GrafanaSection::default(); + let result = section.to_grafana_config(); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config, GrafanaConfig::default()); + } + + #[test] + fn it_should_not_expose_password_in_debug_output() { + let section = GrafanaSection { + admin_user: "admin".to_string(), + admin_password: "secret_password".to_string(), + }; + + let config = section.to_grafana_config().unwrap(); + let debug_output = format!("{config:?}"); + + // Password should be redacted in debug output + assert!(debug_output.contains("[REDACTED]")); + assert!(!debug_output.contains("secret_password")); + } +} diff --git a/src/application/command_handlers/create/config/mod.rs b/src/application/command_handlers/create/config/mod.rs index 52adfc16..9341af81 100644 --- a/src/application/command_handlers/create/config/mod.rs +++ b/src/application/command_handlers/create/config/mod.rs @@ -98,7 +98,7 @@ //! let config: EnvironmentCreationConfig = serde_json::from_str(json)?; //! //! // Convert to domain parameters -//! let (name, instance_name, provider_config, credentials, port, tracker) = config.to_environment_params()?; +//! let (name, instance_name, provider_config, credentials, port, tracker, _prometheus, _grafana) = config.to_environment_params()?; //! //! // Create domain entity - Environment::new() will use the provider_config //! let environment = Environment::new(name, provider_config, credentials, port); @@ -130,6 +130,8 @@ pub mod environment_config; pub mod errors; +pub mod grafana; +pub mod prometheus; pub mod provider; pub mod ssh_credentials_config; pub mod tracker; @@ -137,5 +139,7 @@ pub mod tracker; // Re-export commonly used types for convenience pub use environment_config::{EnvironmentCreationConfig, EnvironmentSection}; pub use errors::CreateConfigError; +pub use grafana::GrafanaSection; +pub use prometheus::PrometheusSection; pub use provider::{HetznerProviderSection, LxdProviderSection, ProviderSection}; pub use ssh_credentials_config::SshCredentialsConfig; diff --git a/src/application/command_handlers/create/config/prometheus.rs b/src/application/command_handlers/create/config/prometheus.rs new file mode 100644 index 00000000..4be15e3c --- /dev/null +++ b/src/application/command_handlers/create/config/prometheus.rs @@ -0,0 +1,101 @@ +//! Prometheus Configuration DTO (Application Layer) +//! +//! This module contains the DTO type for Prometheus configuration used in +//! environment creation. This type uses raw primitives (u32) for JSON +//! deserialization and converts to the rich domain type (`PrometheusConfig`). + +use std::num::NonZeroU32; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::application::command_handlers::create::config::errors::CreateConfigError; +use crate::domain::prometheus::PrometheusConfig; + +/// Prometheus configuration section (DTO) +/// +/// This is a simple DTO that deserializes from JSON numbers and validates +/// when converting to the domain `PrometheusConfig`. +/// +/// # Examples +/// +/// ```json +/// { +/// "scrape_interval_in_secs": 15 +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PrometheusSection { + /// Interval for Prometheus to scrape metrics from targets (in seconds) + /// + /// Must be greater than 0. The Prometheus template adds the 's' suffix. + /// Examples: 15 (15 seconds), 30 (30 seconds), 60 (1 minute) + pub scrape_interval_in_secs: u32, +} + +impl Default for PrometheusSection { + fn default() -> Self { + Self { + scrape_interval_in_secs: PrometheusConfig::default().scrape_interval_in_secs(), + } + } +} + +impl PrometheusSection { + /// Converts this DTO to a domain `PrometheusConfig` + /// + /// This method performs validation and type conversion from the + /// u32 DTO to the strongly-typed domain model with `NonZeroU32`. + /// + /// # Errors + /// + /// Returns error if scrape interval is 0 + pub fn to_prometheus_config(&self) -> Result { + let interval = NonZeroU32::new(self.scrape_interval_in_secs).ok_or_else(|| { + CreateConfigError::InvalidPrometheusConfig(format!( + "Scrape interval must be greater than 0, got: {}", + self.scrape_interval_in_secs + )) + })?; + Ok(PrometheusConfig::new(interval)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_have_default_values() { + let section = PrometheusSection::default(); + assert_eq!(section.scrape_interval_in_secs, 15); + } + + #[test] + fn it_should_convert_to_prometheus_config() { + let section = PrometheusSection { + scrape_interval_in_secs: 30, + }; + + let config = section.to_prometheus_config().expect("Valid config"); + assert_eq!(config.scrape_interval_in_secs(), 30); + } + + #[test] + fn it_should_convert_default_section_to_default_config() { + let section = PrometheusSection::default(); + let config = section.to_prometheus_config().expect("Valid config"); + + assert_eq!(config, PrometheusConfig::default()); + } + + #[test] + fn it_should_reject_zero_interval() { + let section = PrometheusSection { + scrape_interval_in_secs: 0, + }; + + let result = section.to_prometheus_config(); + assert!(result.is_err()); + } +} diff --git a/src/application/command_handlers/create/handler.rs b/src/application/command_handlers/create/handler.rs index 13a97c78..016771ec 100644 --- a/src/application/command_handlers/create/handler.rs +++ b/src/application/command_handlers/create/handler.rs @@ -74,6 +74,8 @@ use super::errors::CreateCommandHandlerError; /// profile_name: "lxd-dev".to_string(), /// }), /// TrackerSection::default(), +/// None, // prometheus +/// None, // grafana /// ); /// /// // Execute command with working directory @@ -189,6 +191,8 @@ impl CreateCommandHandler { /// profile_name: "lxd-staging".to_string(), /// }), /// TrackerSection::default(), + /// None, // prometheus + /// None, // grafana /// ); /// /// let working_dir = std::path::Path::new("."); @@ -217,6 +221,8 @@ impl CreateCommandHandler { ssh_credentials, ssh_port, tracker_config, + prometheus_config, + grafana_config, ) = config .to_environment_params() .map_err(CreateCommandHandlerError::InvalidConfiguration)?; @@ -237,6 +243,8 @@ impl CreateCommandHandler { ssh_credentials, ssh_port, tracker_config, + prometheus_config, + grafana_config, working_dir, ); diff --git a/src/application/command_handlers/create/mod.rs b/src/application/command_handlers/create/mod.rs index 45be4b59..5bdebf1f 100644 --- a/src/application/command_handlers/create/mod.rs +++ b/src/application/command_handlers/create/mod.rs @@ -57,6 +57,8 @@ //! profile_name: "lxd-production".to_string(), //! }), //! TrackerSection::default(), +//! None, // prometheus +//! None, // grafana //! ); //! //! // Execute command with working directory diff --git a/src/application/command_handlers/create/schema/handler.rs b/src/application/command_handlers/create/schema/handler.rs index 315bb319..90db3736 100644 --- a/src/application/command_handlers/create/schema/handler.rs +++ b/src/application/command_handlers/create/schema/handler.rs @@ -26,13 +26,16 @@ use super::errors::CreateSchemaCommandHandlerError; /// ```rust /// use torrust_tracker_deployer_lib::application::command_handlers::create::schema::CreateSchemaCommandHandler; /// use std::path::PathBuf; +/// use tempfile::TempDir; /// /// // Generate to stdout /// let schema = CreateSchemaCommandHandler::execute(None)?; /// println!("{}", schema); /// -/// // Generate to file -/// CreateSchemaCommandHandler::execute(Some(PathBuf::from("schema.json")))?; +/// // Generate to file (use temp directory to avoid leaving artifacts) +/// let temp_dir = TempDir::new()?; +/// let schema_path = temp_dir.path().join("schema.json"); +/// CreateSchemaCommandHandler::execute(Some(schema_path))?; /// # Ok::<(), Box>(()) /// ``` pub struct CreateSchemaCommandHandler; @@ -62,13 +65,16 @@ impl CreateSchemaCommandHandler { /// ```rust /// use torrust_tracker_deployer_lib::application::command_handlers::create::schema::CreateSchemaCommandHandler; /// use std::path::PathBuf; + /// use tempfile::TempDir; /// /// // Output to stdout (caller prints the returned string) /// let schema = CreateSchemaCommandHandler::execute(None)?; /// println!("{}", schema); /// - /// // Output to file - /// let schema = CreateSchemaCommandHandler::execute(Some(PathBuf::from("./schema.json")))?; + /// // Output to file (use temp directory to avoid leaving artifacts) + /// let temp_dir = TempDir::new()?; + /// let schema_path = temp_dir.path().join("schema.json"); + /// let schema = CreateSchemaCommandHandler::execute(Some(schema_path))?; /// // File is written, schema string also returned for confirmation /// # Ok::<(), Box>(()) /// ``` diff --git a/src/application/command_handlers/create/tests/builders.rs b/src/application/command_handlers/create/tests/builders.rs index 9ec9b264..aba280ef 100644 --- a/src/application/command_handlers/create/tests/builders.rs +++ b/src/application/command_handlers/create/tests/builders.rs @@ -271,6 +271,8 @@ pub fn create_valid_test_config(temp_dir: &TempDir, env_name: &str) -> Environme profile_name: format!("lxd-{env_name}"), }), TrackerSection::default(), + None, + None, ) } diff --git a/src/application/command_handlers/create/tests/integration.rs b/src/application/command_handlers/create/tests/integration.rs index dc664758..34215b2b 100644 --- a/src/application/command_handlers/create/tests/integration.rs +++ b/src/application/command_handlers/create/tests/integration.rs @@ -141,6 +141,8 @@ fn it_should_fail_with_invalid_environment_name() { profile_name: "test-profile".to_string(), }), TrackerSection::default(), + None, + None, ); // Act @@ -190,6 +192,8 @@ fn it_should_fail_when_ssh_private_key_not_found() { profile_name: "test-profile".to_string(), }), TrackerSection::default(), + None, + None, ); // Act diff --git a/src/application/command_handlers/release/handler.rs b/src/application/command_handlers/release/handler.rs index e3efcd95..36610c35 100644 --- a/src/application/command_handlers/release/handler.rs +++ b/src/application/command_handlers/release/handler.rs @@ -11,10 +11,12 @@ use crate::adapters::ansible::AnsibleClient; use crate::application::command_handlers::common::StepResult; use crate::application::steps::{ application::{ - CreatePrometheusStorageStep, CreateTrackerStorageStep, DeployPrometheusConfigStep, - DeployTrackerConfigStep, InitTrackerDatabaseStep, + CreatePrometheusStorageStep, CreateTrackerStorageStep, DeployGrafanaProvisioningStep, + DeployPrometheusConfigStep, DeployTrackerConfigStep, InitTrackerDatabaseStep, + }, + rendering::{ + RenderGrafanaTemplatesStep, RenderPrometheusTemplatesStep, RenderTrackerTemplatesStep, }, - rendering::{RenderPrometheusTemplatesStep, RenderTrackerTemplatesStep}, DeployComposeFilesStep, RenderDockerComposeTemplatesStep, }; use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; @@ -211,10 +213,16 @@ impl ReleaseCommandHandler { // Step 7: Deploy Prometheus configuration to remote (if enabled) self.deploy_prometheus_config_to_remote(environment, instance_ip)?; - // Step 8: Render Docker Compose templates + // Step 8: Render Grafana provisioning templates (if enabled) + Self::render_grafana_templates(environment)?; + + // Step 9: Deploy Grafana provisioning to remote (if enabled) + self.deploy_grafana_provisioning_to_remote(environment, instance_ip)?; + + // Step 10: Render Docker Compose templates let compose_build_dir = self.render_docker_compose_templates(environment).await?; - // Step 9: Deploy compose files to remote + // Step 11: Deploy compose files to remote self.deploy_compose_files_to_remote(environment, &compose_build_dir, instance_ip)?; let released = environment.clone().released(); @@ -466,6 +474,123 @@ impl ReleaseCommandHandler { Ok(()) } + /// Render Grafana provisioning templates (if enabled) + /// + /// This step is optional and only executes if Grafana is configured in the environment. + /// If Grafana is not configured, the step is skipped without error. + /// + /// # Errors + /// + /// Returns a tuple of (error, `ReleaseStep::RenderGrafanaTemplates`) if rendering fails + #[allow(clippy::result_large_err)] + fn render_grafana_templates( + environment: &Environment, + ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::RenderGrafanaTemplates; + + // Check if Grafana is configured + if environment.context().user_inputs.grafana.is_none() { + info!( + command = "release", + step = %current_step, + status = "skipped", + "Grafana not configured - skipping provisioning template rendering" + ); + return Ok(()); + } + + // Check if Prometheus is configured (required for datasource) + if environment.context().user_inputs.prometheus.is_none() { + info!( + command = "release", + step = %current_step, + status = "skipped", + "Prometheus not configured - skipping Grafana provisioning (datasource requires Prometheus)" + ); + return Ok(()); + } + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderGrafanaTemplatesStep::new( + Arc::new(environment.clone()), + template_manager, + environment.build_dir().clone(), + ); + + step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering(e.to_string()), + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Grafana provisioning templates rendered successfully" + ); + + Ok(()) + } + + /// Deploy Grafana provisioning configuration to the remote host (if enabled) + /// + /// This step is optional and only executes if Grafana is configured in the environment. + /// If Grafana is not configured, the step is skipped without error. + /// + /// # Errors + /// + /// Returns a tuple of (error, `ReleaseStep::DeployGrafanaProvisioning`) if deployment fails + #[allow(clippy::result_large_err, clippy::unused_self)] + fn deploy_grafana_provisioning_to_remote( + &self, + environment: &Environment, + _instance_ip: IpAddr, + ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployGrafanaProvisioning; + + // Check if Grafana is configured + if environment.context().user_inputs.grafana.is_none() { + info!( + command = "release", + step = %current_step, + status = "skipped", + "Grafana not configured - skipping provisioning deployment" + ); + return Ok(()); + } + + // Check if Prometheus is configured (required for datasource) + if environment.context().user_inputs.prometheus.is_none() { + info!( + command = "release", + step = %current_step, + status = "skipped", + "Prometheus not configured - skipping Grafana provisioning deployment" + ); + return Ok(()); + } + + let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); + + DeployGrafanaProvisioningStep::new(ansible_client) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering(e.to_string()), + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Grafana provisioning configuration deployed successfully" + ); + + Ok(()) + } + /// Deploy tracker configuration to the remote host via Ansible /// /// # Arguments diff --git a/src/application/services/ansible_template_service.rs b/src/application/services/ansible_template_service.rs index eb725a58..386b3132 100644 --- a/src/application/services/ansible_template_service.rs +++ b/src/application/services/ansible_template_service.rs @@ -153,6 +153,7 @@ impl AnsibleTemplateService { user_inputs.ssh_credentials.clone(), ssh_socket_addr, user_inputs.tracker.clone(), + user_inputs.grafana.clone(), ) .execute() .await diff --git a/src/application/steps/application/deploy_grafana_provisioning.rs b/src/application/steps/application/deploy_grafana_provisioning.rs new file mode 100644 index 00000000..87337675 --- /dev/null +++ b/src/application/steps/application/deploy_grafana_provisioning.rs @@ -0,0 +1,99 @@ +//! Grafana provisioning deployment step +//! +//! This module provides the `DeployGrafanaProvisioningStep` which handles deployment +//! of Grafana provisioning configuration files (datasources and dashboards) to remote hosts +//! via Ansible playbooks. +//! +//! ## Key Features +//! +//! - Deploys Grafana datasource configuration (prometheus.yml) +//! - Deploys Grafana dashboard provider configuration +//! - Deploys dashboard JSON files +//! - Sets appropriate ownership and permissions +//! - Only executes when Grafana is enabled in environment configuration +//! +//! ## Deployment Flow +//! +//! 1. Create provisioning directory structure on remote host +//! 2. Copy all provisioning files from build directory to remote host +//! 3. Set file permissions (0644) and directory permissions (0755) +//! +//! ## File Locations +//! +//! - **Source**: `{build_dir}/grafana/provisioning/**/*` +//! - **Destination**: `/opt/torrust/storage/grafana/provisioning/**/*` +//! - **Container Mount**: Mounted as `/etc/grafana/provisioning/` (read-only) + +use std::sync::Arc; + +use tracing::{info, instrument}; + +use crate::adapters::ansible::AnsibleClient; +use crate::shared::command::CommandError; + +/// Step that deploys Grafana provisioning configuration to a remote host via Ansible +/// +/// This step copies all rendered Grafana provisioning files (datasources, dashboards, +/// dashboard JSONs) from the build directory to the remote host's Grafana provisioning +/// directory. +pub struct DeployGrafanaProvisioningStep { + ansible_client: Arc, +} + +impl DeployGrafanaProvisioningStep { + /// Create a new Grafana provisioning deployment step + /// + /// # Arguments + /// + /// * `ansible_client` - Ansible client for running playbooks + #[must_use] + pub fn new(ansible_client: Arc) -> Self { + Self { ansible_client } + } + + /// Execute the provisioning deployment + /// + /// Runs the Ansible playbook that deploys Grafana provisioning files. + /// + /// # Errors + /// + /// Returns `CommandError` if: + /// - Ansible playbook execution fails + /// - Directory creation fails + /// - File copying fails + /// - Permission setting fails + #[instrument( + name = "deploy_grafana_provisioning", + skip_all, + fields(step_type = "deployment", component = "grafana", method = "ansible") + )] + pub fn execute(&self) -> Result<(), CommandError> { + info!( + step = "deploy_grafana_provisioning", + action = "deploy_files", + "Deploying Grafana provisioning configuration to remote host" + ); + + match self + .ansible_client + .run_playbook("deploy-grafana-provisioning", &[]) + { + Ok(_) => { + info!( + step = "deploy_grafana_provisioning", + status = "success", + "Grafana provisioning configuration deployed successfully" + ); + Ok(()) + } + Err(e) => { + tracing::error!( + step = "deploy_grafana_provisioning", + error = %e, + "Failed to deploy Grafana provisioning configuration" + ); + Err(e) + } + } + } +} diff --git a/src/application/steps/application/mod.rs b/src/application/steps/application/mod.rs index 62dee423..4f840d56 100644 --- a/src/application/steps/application/mod.rs +++ b/src/application/steps/application/mod.rs @@ -11,6 +11,7 @@ //! - `deploy_tracker_config` - Deploys tracker.toml configuration file to remote host //! - `create_prometheus_storage` - Creates Prometheus storage directory structure on remote host //! - `deploy_prometheus_config` - Deploys prometheus.yml configuration file to remote host +//! - `deploy_grafana_provisioning` - Deploys Grafana provisioning files (datasources/dashboards) to remote host //! - `deploy_compose_files` - Deploys Docker Compose files to remote host via Ansible //! - `start_services` - Starts Docker Compose services via Ansible //! - `run` - Legacy run step (placeholder) @@ -31,6 +32,7 @@ pub mod create_prometheus_storage; pub mod create_tracker_storage; pub mod deploy_compose_files; +pub mod deploy_grafana_provisioning; pub mod deploy_prometheus_config; pub mod deploy_tracker_config; pub mod init_tracker_database; @@ -40,6 +42,7 @@ pub mod start_services; pub use create_prometheus_storage::CreatePrometheusStorageStep; pub use create_tracker_storage::CreateTrackerStorageStep; pub use deploy_compose_files::{DeployComposeFilesStep, DeployComposeFilesStepError}; +pub use deploy_grafana_provisioning::DeployGrafanaProvisioningStep; pub use deploy_prometheus_config::DeployPrometheusConfigStep; pub use deploy_tracker_config::{DeployTrackerConfigStep, DeployTrackerConfigStepError}; pub use init_tracker_database::InitTrackerDatabaseStep; diff --git a/src/application/steps/rendering/ansible_templates.rs b/src/application/steps/rendering/ansible_templates.rs index 47abfa85..85392bbc 100644 --- a/src/application/steps/rendering/ansible_templates.rs +++ b/src/application/steps/rendering/ansible_templates.rs @@ -25,6 +25,7 @@ use thiserror::Error; use tracing::{info, instrument}; use crate::adapters::ssh::credentials::SshCredentials; +use crate::domain::grafana::GrafanaConfig; use crate::domain::tracker::TrackerConfig; use crate::infrastructure::templating::ansible::template::renderer::AnsibleProjectGeneratorError; use crate::infrastructure::templating::ansible::template::wrappers::inventory::{ @@ -87,6 +88,7 @@ pub struct RenderAnsibleTemplatesStep { ssh_credentials: SshCredentials, ssh_socket_addr: SocketAddr, tracker_config: TrackerConfig, + grafana_config: Option, } impl RenderAnsibleTemplatesStep { @@ -96,12 +98,14 @@ impl RenderAnsibleTemplatesStep { ssh_credentials: SshCredentials, ssh_socket_addr: SocketAddr, tracker_config: TrackerConfig, + grafana_config: Option, ) -> Self { Self { ansible_project_generator, ssh_credentials, ssh_socket_addr, tracker_config, + grafana_config, } } @@ -127,7 +131,11 @@ impl RenderAnsibleTemplatesStep { // Use the configuration renderer to handle all template rendering self.ansible_project_generator - .render(&inventory_context, Some(&self.tracker_config)) + .render( + &inventory_context, + Some(&self.tracker_config), + self.grafana_config.as_ref(), + ) .await?; info!( diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 4ad0b762..f48e2660 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -125,8 +125,14 @@ impl RenderDockerComposeTemplatesStep { // Apply Prometheus configuration (independent of database choice) let builder = self.apply_prometheus_config(builder); + + // Apply Grafana configuration (independent of database choice) + let builder = self.apply_grafana_config(builder); let docker_compose_context = builder.build(); + // Apply Grafana credentials to env context + let env_context = self.apply_grafana_env_context(env_context); + let compose_build_dir = generator .render(&env_context, &docker_compose_context) .await?; @@ -210,6 +216,28 @@ impl RenderDockerComposeTemplatesStep { } } + fn apply_grafana_config( + &self, + builder: DockerComposeContextBuilder, + ) -> DockerComposeContextBuilder { + if let Some(grafana_config) = self.environment.grafana_config() { + builder.with_grafana(grafana_config.clone()) + } else { + builder + } + } + + fn apply_grafana_env_context(&self, env_context: EnvContext) -> EnvContext { + if let Some(grafana_config) = self.environment.grafana_config() { + env_context.with_grafana( + grafana_config.admin_user().to_string(), + grafana_config.admin_password().expose_secret().to_string(), + ) + } else { + env_context + } + } + fn extract_tracker_ports(tracker_config: &TrackerConfig) -> (Vec, Vec, u16) { // Extract UDP tracker ports let udp_ports: Vec = tracker_config diff --git a/src/application/steps/rendering/grafana_templates.rs b/src/application/steps/rendering/grafana_templates.rs new file mode 100644 index 00000000..f09cc9fe --- /dev/null +++ b/src/application/steps/rendering/grafana_templates.rs @@ -0,0 +1,142 @@ +//! Grafana template rendering step +//! +//! This module provides the `RenderGrafanaTemplatesStep` which handles rendering +//! of Grafana provisioning templates to the build directory. This step prepares +//! Grafana datasource and dashboard configurations for deployment to the remote host. +//! +//! ## Key Features +//! +//! - Template rendering for Grafana provisioning configurations +//! - Integration with the `GrafanaProjectGenerator` for file generation +//! - Build directory preparation for deployment operations +//! - Comprehensive error handling for template processing +//! +//! ## Usage Context +//! +//! This step is typically executed during the release workflow, after +//! infrastructure provisioning and software installation, to prepare +//! the Grafana provisioning files for deployment. +//! +//! ## Architecture +//! +//! This step follows the three-level architecture: +//! - **Command** (Level 1): `ReleaseCommandHandler` orchestrates the release workflow +//! - **Step** (Level 2): This `RenderGrafanaTemplatesStep` handles template rendering +//! - The templates are rendered locally, no remote action is needed + +use std::path::PathBuf; +use std::sync::Arc; + +use tracing::{info, instrument}; + +use crate::domain::environment::Environment; +use crate::domain::template::TemplateManager; +use crate::infrastructure::templating::grafana::template::renderer::{ + GrafanaProjectGenerator, GrafanaProjectGeneratorError, +}; + +/// Step that renders Grafana provisioning templates to the build directory +/// +/// This step handles the preparation of Grafana provisioning configuration files +/// by rendering templates to the build directory. The rendered files are +/// then ready to be deployed to the remote host. +pub struct RenderGrafanaTemplatesStep { + environment: Arc>, + template_manager: Arc, + build_dir: PathBuf, +} + +impl RenderGrafanaTemplatesStep { + /// Creates a new `RenderGrafanaTemplatesStep` + /// + /// # Arguments + /// + /// * `environment` - The deployment environment + /// * `template_manager` - The template manager for accessing templates + /// * `build_dir` - The build directory where templates will be rendered + #[must_use] + pub fn new( + environment: Arc>, + template_manager: Arc, + build_dir: PathBuf, + ) -> Self { + Self { + environment, + template_manager, + build_dir, + } + } + + /// Execute the template rendering step + /// + /// This will render Grafana provisioning templates to the build directory if Grafana + /// configuration is present in the environment. + /// + /// # Returns + /// + /// Returns the path to the Grafana provisioning build directory on success, or `None` + /// if Grafana is not configured. + /// + /// # Errors + /// + /// Returns an error if: + /// * Template rendering fails + /// * Directory creation fails + /// * File writing fails + #[instrument( + name = "render_grafana_templates", + skip_all, + fields( + step_type = "rendering", + template_type = "grafana", + build_dir = %self.build_dir.display() + ) + )] + pub fn execute(&self) -> Result, GrafanaProjectGeneratorError> { + // Check if Grafana is configured + if self.environment.context().user_inputs.grafana.is_none() { + info!( + step = "render_grafana_templates", + status = "skipped", + reason = "grafana_not_configured", + "Skipping Grafana template rendering - not configured" + ); + return Ok(None); + } + + // Check if Prometheus is configured (required for datasource) + let Some(prometheus_config) = &self.environment.context().user_inputs.prometheus else { + info!( + step = "render_grafana_templates", + status = "skipped", + reason = "prometheus_not_configured", + "Skipping Grafana template rendering - Prometheus datasource requires Prometheus to be configured" + ); + return Ok(None); + }; + + info!( + step = "render_grafana_templates", + templates_dir = %self.template_manager.templates_dir().display(), + build_dir = %self.build_dir.display(), + "Rendering Grafana provisioning templates" + ); + + let generator = + GrafanaProjectGenerator::new(&self.build_dir, self.template_manager.clone()); + + // Render all Grafana provisioning files (datasource + dashboards) + generator.render(prometheus_config)?; + + let grafana_build_dir = self.build_dir.join("grafana/provisioning"); + + info!( + step = "render_grafana_templates", + grafana_build_dir = %grafana_build_dir.display(), + status = "success", + "Grafana provisioning templates rendered successfully" + ); + + Ok(Some(grafana_build_dir)) + } +} diff --git a/src/application/steps/rendering/mod.rs b/src/application/steps/rendering/mod.rs index f9910b81..3965ed93 100644 --- a/src/application/steps/rendering/mod.rs +++ b/src/application/steps/rendering/mod.rs @@ -11,6 +11,7 @@ //! - `docker_compose_templates` - Docker Compose template rendering for deployment //! - `tracker_templates` - Tracker configuration template rendering //! - `prometheus_templates` - Prometheus configuration template rendering +//! - `grafana_templates` - Grafana provisioning template rendering //! //! ## Key Features //! @@ -24,12 +25,14 @@ pub mod ansible_templates; pub mod docker_compose_templates; +pub mod grafana_templates; pub mod opentofu_templates; pub mod prometheus_templates; pub mod tracker_templates; pub use ansible_templates::RenderAnsibleTemplatesStep; pub use docker_compose_templates::RenderDockerComposeTemplatesStep; +pub use grafana_templates::RenderGrafanaTemplatesStep; pub use opentofu_templates::RenderOpenTofuTemplatesStep; pub use prometheus_templates::RenderPrometheusTemplatesStep; pub use tracker_templates::RenderTrackerTemplatesStep; diff --git a/src/application/steps/rendering/prometheus_templates.rs b/src/application/steps/rendering/prometheus_templates.rs index ba74ddc6..7c6b35d1 100644 --- a/src/application/steps/rendering/prometheus_templates.rs +++ b/src/application/steps/rendering/prometheus_templates.rs @@ -195,9 +195,9 @@ mod tests { // Build environment with Prometheus config let (environment, _, _, _temp_dir) = EnvironmentTestBuilder::new() - .with_prometheus_config(Some(PrometheusConfig { - scrape_interval: 30, - })) + .with_prometheus_config(Some(PrometheusConfig::new( + std::num::NonZeroU32::new(30).expect("30 is non-zero"), + ))) .build_with_custom_paths(); let environment = Arc::new(environment); diff --git a/src/bin/e2e_deployment_workflow_tests.rs b/src/bin/e2e_deployment_workflow_tests.rs index ea8c7192..5210a212 100644 --- a/src/bin/e2e_deployment_workflow_tests.rs +++ b/src/bin/e2e_deployment_workflow_tests.rs @@ -288,8 +288,11 @@ async fn run_deployer_workflow( test_runner.release_software()?; // Validate the release (Docker Compose files deployed correctly) - // Note: E2E deployment environment has Prometheus enabled, so we validate it - let services = ServiceValidation { prometheus: true }; + // Note: E2E deployment environment has Prometheus and Grafana enabled + let services = ServiceValidation { + prometheus: true, + grafana: true, + }; run_release_validation(socket_addr, ssh_credentials, Some(services)) .await .map_err(|e| anyhow::anyhow!("{e}"))?; @@ -299,8 +302,11 @@ async fn run_deployer_workflow( test_runner.run_services()?; // Validate services are running using actual mapped ports from runtime environment - // Note: E2E deployment environment has Prometheus enabled, so we validate it - let run_services = RunServiceValidation { prometheus: true }; + // Note: E2E deployment environment has Prometheus and Grafana enabled + let run_services = RunServiceValidation { + prometheus: true, + grafana: true, + }; run_run_validation( socket_addr, ssh_credentials, diff --git a/src/domain/environment/context.rs b/src/domain/environment/context.rs index 62585781..379903d8 100644 --- a/src/domain/environment/context.rs +++ b/src/domain/environment/context.rs @@ -37,6 +37,8 @@ use crate::adapters::ssh::SshCredentials; use crate::domain::environment::{EnvironmentName, InternalConfig, RuntimeOutputs, UserInputs}; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::ProviderConfig; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -186,15 +188,18 @@ impl EnvironmentContext { /// Creates a new environment context with custom tracker configuration /// /// This creates absolute paths for data and build directories by using the - /// provided working directory as the base, and allows specifying a custom - /// tracker configuration instead of using the default. + /// provided working directory as the base, and allows specifying custom + /// tracker, prometheus, and grafana configurations. #[must_use] + #[allow(clippy::too_many_arguments)] // Public API with necessary configuration parameters pub fn with_working_dir_and_tracker( name: &EnvironmentName, provider_config: ProviderConfig, ssh_credentials: SshCredentials, ssh_port: u16, tracker_config: crate::domain::tracker::TrackerConfig, + prometheus_config: Option, + grafana_config: Option, working_dir: &std::path::Path, ) -> Self { Self { @@ -204,6 +209,8 @@ impl EnvironmentContext { ssh_credentials, ssh_port, tracker_config, + prometheus_config, + grafana_config, ), internal_config: InternalConfig::with_working_dir(name, working_dir), runtime_outputs: RuntimeOutputs { @@ -337,10 +344,16 @@ impl EnvironmentContext { /// Returns the Prometheus configuration if enabled #[must_use] - pub fn prometheus_config(&self) -> Option<&crate::domain::prometheus::PrometheusConfig> { + pub fn prometheus_config(&self) -> Option<&PrometheusConfig> { self.user_inputs.prometheus.as_ref() } + /// Returns the Grafana configuration if enabled + #[must_use] + pub fn grafana_config(&self) -> Option<&GrafanaConfig> { + self.user_inputs.grafana.as_ref() + } + /// Returns the build directory #[must_use] pub fn build_dir(&self) -> &PathBuf { diff --git a/src/domain/environment/mod.rs b/src/domain/environment/mod.rs index beb86c68..f2789bcf 100644 --- a/src/domain/environment/mod.rs +++ b/src/domain/environment/mod.rs @@ -134,6 +134,9 @@ pub use crate::domain::tracker::{ // Re-export Prometheus types for convenience pub use crate::domain::prometheus::PrometheusConfig; +// Re-export Grafana types for convenience +pub use crate::domain::grafana::GrafanaConfig; + use crate::adapters::ssh::SshCredentials; use crate::domain::provider::ProviderConfig; use crate::domain::{InstanceName, ProfileName}; @@ -284,16 +287,19 @@ impl Environment { /// Creates a new environment in Created state with custom tracker configuration /// /// This creates absolute paths for data and build directories by using the - /// provided working directory as the base, and allows specifying a custom - /// tracker configuration instead of using the default. + /// provided working directory as the base, and allows specifying custom + /// tracker, prometheus, and grafana configurations. #[must_use] #[allow(clippy::needless_pass_by_value)] // Public API takes ownership for ergonomics + #[allow(clippy::too_many_arguments)] // Public API with necessary configuration parameters pub fn with_working_dir_and_tracker( name: EnvironmentName, provider_config: ProviderConfig, ssh_credentials: SshCredentials, ssh_port: u16, tracker_config: TrackerConfig, + prometheus_config: Option, + grafana_config: Option, working_dir: &std::path::Path, ) -> Environment { let context = EnvironmentContext::with_working_dir_and_tracker( @@ -302,6 +308,8 @@ impl Environment { ssh_credentials, ssh_port, tracker_config, + prometheus_config, + grafana_config, working_dir, ); @@ -448,6 +456,12 @@ impl Environment { self.context.prometheus_config() } + /// Returns the Grafana configuration if enabled + #[must_use] + pub fn grafana_config(&self) -> Option<&GrafanaConfig> { + self.context.grafana_config() + } + /// Returns the SSH username for this environment #[must_use] pub fn ssh_username(&self) -> &Username { @@ -896,7 +910,10 @@ impl Environment { mod tests { use super::*; use crate::adapters::ssh::SshCredentials; + use crate::domain::grafana::GrafanaConfig; + use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::{LxdConfig, ProviderConfig}; + use crate::domain::tracker::TrackerConfig; use crate::domain::EnvironmentName; use std::path::Path; use tempfile::TempDir; @@ -1042,8 +1059,9 @@ mod tests { provider_config, ssh_credentials, ssh_port: 22, - tracker: crate::domain::tracker::TrackerConfig::default(), - prometheus: Some(crate::domain::prometheus::PrometheusConfig::default()), + tracker: TrackerConfig::default(), + prometheus: Some(PrometheusConfig::default()), + grafana: Some(GrafanaConfig::default()), }, internal_config: InternalConfig { data_dir: data_dir.clone(), diff --git a/src/domain/environment/state/release_failed.rs b/src/domain/environment/state/release_failed.rs index 05ff8ff7..f073da4c 100644 --- a/src/domain/environment/state/release_failed.rs +++ b/src/domain/environment/state/release_failed.rs @@ -44,6 +44,10 @@ pub enum ReleaseStep { RenderPrometheusTemplates, /// Deploying Prometheus configuration to the remote host via Ansible DeployPrometheusConfigToRemote, + /// Rendering Grafana provisioning templates to the build directory + RenderGrafanaTemplates, + /// Deploying Grafana provisioning configuration to the remote host via Ansible + DeployGrafanaProvisioning, /// Rendering Docker Compose templates to the build directory RenderDockerComposeTemplates, /// Deploying compose files to the remote host via Ansible @@ -60,6 +64,8 @@ impl fmt::Display for ReleaseStep { Self::CreatePrometheusStorage => "Create Prometheus Storage", Self::RenderPrometheusTemplates => "Render Prometheus Templates", Self::DeployPrometheusConfigToRemote => "Deploy Prometheus Config to Remote", + Self::RenderGrafanaTemplates => "Render Grafana Templates", + Self::DeployGrafanaProvisioning => "Deploy Grafana Provisioning", Self::RenderDockerComposeTemplates => "Render Docker Compose Templates", Self::DeployComposeFilesToRemote => "Deploy Compose Files to Remote", }; diff --git a/src/domain/environment/testing.rs b/src/domain/environment/testing.rs index 40aca6a9..9028ebf8 100644 --- a/src/domain/environment/testing.rs +++ b/src/domain/environment/testing.rs @@ -5,7 +5,10 @@ use super::*; use crate::adapters::ssh::SshCredentials; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::{LxdConfig, ProviderConfig}; +use crate::domain::tracker::TrackerConfig; use crate::domain::EnvironmentName; use crate::shared::Username; use std::path::{Path, PathBuf}; @@ -37,7 +40,7 @@ pub struct EnvironmentTestBuilder { ssh_key_name: String, ssh_username: String, temp_dir: TempDir, - prometheus_config: Option, + prometheus_config: Option, } impl EnvironmentTestBuilder { @@ -53,7 +56,7 @@ impl EnvironmentTestBuilder { ssh_key_name: "test_key".to_string(), ssh_username: "torrust".to_string(), temp_dir: TempDir::new().expect("Failed to create temp directory"), - prometheus_config: Some(crate::domain::prometheus::PrometheusConfig::default()), + prometheus_config: Some(PrometheusConfig::default()), } } @@ -80,10 +83,7 @@ impl EnvironmentTestBuilder { /// Sets the Prometheus configuration #[must_use] - pub fn with_prometheus_config( - mut self, - config: Option, - ) -> Self { + pub fn with_prometheus_config(mut self, config: Option) -> Self { self.prometheus_config = config; self } @@ -144,16 +144,17 @@ impl EnvironmentTestBuilder { let provider_config = ProviderConfig::Lxd(LxdConfig { profile_name }); let context = EnvironmentContext { - user_inputs: crate::domain::environment::UserInputs { + user_inputs: UserInputs { name: env_name, instance_name, provider_config, ssh_credentials, ssh_port: 22, - tracker: crate::domain::tracker::TrackerConfig::default(), + tracker: TrackerConfig::default(), prometheus: self.prometheus_config, + grafana: Some(GrafanaConfig::default()), }, - internal_config: crate::domain::environment::InternalConfig { + internal_config: InternalConfig { data_dir: data_dir.clone(), build_dir: build_dir.clone(), }, diff --git a/src/domain/environment/user_inputs.rs b/src/domain/environment/user_inputs.rs index 2c8f1bdc..c2839f4c 100644 --- a/src/domain/environment/user_inputs.rs +++ b/src/domain/environment/user_inputs.rs @@ -20,6 +20,7 @@ use crate::adapters::ssh::SshCredentials; use crate::domain::environment::EnvironmentName; +use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::tracker::TrackerConfig; @@ -40,6 +41,7 @@ use serde::{Deserialize, Serialize}; /// use torrust_tracker_deployer_lib::domain::environment::user_inputs::UserInputs; /// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig; /// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig; +/// use torrust_tracker_deployer_lib::domain::grafana::GrafanaConfig; /// use torrust_tracker_deployer_lib::shared::Username; /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials; /// use std::path::PathBuf; @@ -60,6 +62,7 @@ use serde::{Deserialize, Serialize}; /// ssh_port: 22, /// tracker: TrackerConfig::default(), /// prometheus: Some(PrometheusConfig::default()), +/// grafana: Some(GrafanaConfig::default()), /// }; /// # Ok::<(), Box>(()) /// ``` @@ -89,6 +92,14 @@ pub struct UserInputs { /// When absent (`None`), Prometheus service is disabled. /// Default: `Some(PrometheusConfig::default())` in generated templates. pub prometheus: Option, + + /// Grafana visualization and dashboard configuration (optional) + /// + /// When present, Grafana service is enabled in the deployment. + /// When absent (`None`), Grafana service is disabled. + /// Requires Prometheus to be enabled - dependency validated at configuration time. + /// Default: `Some(GrafanaConfig::default())` in generated templates. + pub grafana: Option, } impl UserInputs { @@ -156,13 +167,14 @@ impl UserInputs { ssh_port, tracker: TrackerConfig::default(), prometheus: Some(PrometheusConfig::default()), + grafana: Some(GrafanaConfig::default()), } } /// Creates a new `UserInputs` with custom tracker configuration /// - /// This is similar to `new` but allows specifying a custom tracker - /// configuration instead of using the default. + /// This is similar to `new` but allows specifying custom tracker, + /// prometheus, and grafana configurations instead of using defaults. #[must_use] pub fn with_tracker( name: &EnvironmentName, @@ -170,6 +182,8 @@ impl UserInputs { ssh_credentials: SshCredentials, ssh_port: u16, tracker: TrackerConfig, + prometheus: Option, + grafana: Option, ) -> Self { let instance_name = Self::generate_instance_name(name); @@ -180,7 +194,8 @@ impl UserInputs { ssh_credentials, ssh_port, tracker, - prometheus: Some(PrometheusConfig::default()), + prometheus, + grafana, } } diff --git a/src/domain/grafana/config.rs b/src/domain/grafana/config.rs new file mode 100644 index 00000000..ff533a83 --- /dev/null +++ b/src/domain/grafana/config.rs @@ -0,0 +1,141 @@ +//! Grafana configuration domain type + +use serde::{Deserialize, Serialize}; + +use crate::shared::secrets::Password; + +/// Grafana metrics visualization configuration +/// +/// Configures Grafana service for displaying tracker metrics. +/// Grafana requires Prometheus to be enabled for metrics visualization. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GrafanaConfig { + /// Grafana admin username + admin_user: String, + + /// Grafana admin password (should be changed in production) + /// + /// Uses `Password` wrapper from secrecy crate for secure handling: + /// - Automatic redaction in debug output (shows `[REDACTED]`) + /// - Memory zeroing when the value is dropped + /// - Explicit `.expose_secret()` calls required to access plaintext + admin_password: Password, +} + +impl GrafanaConfig { + /// Creates a new Grafana configuration + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::grafana::GrafanaConfig; + /// + /// let config = GrafanaConfig::new("admin".to_string(), "password".to_string()); + /// assert_eq!(config.admin_user(), "admin"); + /// ``` + #[must_use] + pub fn new(admin_user: String, admin_password: String) -> Self { + Self { + admin_user, + admin_password: Password::new(admin_password), + } + } + + /// Returns the admin username + #[must_use] + pub fn admin_user(&self) -> &str { + &self.admin_user + } + + /// Returns the admin password + #[must_use] + pub fn admin_password(&self) -> &Password { + &self.admin_password + } +} + +impl Default for GrafanaConfig { + fn default() -> Self { + Self { + admin_user: "admin".to_string(), + admin_password: Password::new("admin"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_grafana_config_with_default_values() { + let config = GrafanaConfig::default(); + + assert_eq!(config.admin_user, "admin"); + assert_eq!(config.admin_password.expose_secret(), "admin"); + } + + #[test] + fn it_should_create_grafana_config_with_custom_values() { + let config = GrafanaConfig { + admin_user: "custom_admin".to_string(), + admin_password: Password::new("custom_pass"), + }; + + assert_eq!(config.admin_user, "custom_admin"); + assert_eq!(config.admin_password.expose_secret(), "custom_pass"); + } + + #[test] + fn it_should_serialize_grafana_config_to_json() { + let config = GrafanaConfig { + admin_user: "admin".to_string(), + admin_password: Password::new("secret123"), + }; + + let json = serde_json::to_string(&config).expect("Failed to serialize"); + + assert!(json.contains("\"admin_user\":\"admin\"")); + assert!(json.contains("\"admin_password\":\"secret123\"")); + } + + #[test] + fn it_should_deserialize_grafana_config_from_json() { + let json = r#"{"admin_user":"admin","admin_password":"secret123"}"#; + + let config: GrafanaConfig = serde_json::from_str(json).expect("Failed to deserialize"); + + assert_eq!(config.admin_user, "admin"); + assert_eq!(config.admin_password.expose_secret(), "secret123"); + } + + #[test] + fn it_should_redact_password_in_debug_output() { + let config = GrafanaConfig { + admin_user: "admin".to_string(), + admin_password: Password::new("super_secret"), + }; + + let debug_output = format!("{config:?}"); + + assert!(debug_output.contains("admin_user: \"admin\"")); + assert!(debug_output.contains("Password(SecretBox([REDACTED]")); + assert!(!debug_output.contains("super_secret")); + } + + #[test] + fn it_should_clone_grafana_config() { + let config = GrafanaConfig { + admin_user: "admin".to_string(), + admin_password: Password::new("password"), + }; + + let cloned = config.clone(); + + assert_eq!(cloned.admin_user, config.admin_user); + assert_eq!( + cloned.admin_password.expose_secret(), + config.admin_password.expose_secret() + ); + } +} diff --git a/src/domain/grafana/mod.rs b/src/domain/grafana/mod.rs new file mode 100644 index 00000000..e6a8ec11 --- /dev/null +++ b/src/domain/grafana/mod.rs @@ -0,0 +1,5 @@ +//! Grafana metrics visualization service domain types + +pub mod config; + +pub use config::GrafanaConfig; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index b3f3feda..7854e639 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -14,6 +14,7 @@ //! - `template` - Core template domain models and business logic pub mod environment; +pub mod grafana; pub mod instance_name; pub mod profile_name; pub mod prometheus; diff --git a/src/domain/prometheus/config.rs b/src/domain/prometheus/config.rs index 9a20a32c..33aedb9a 100644 --- a/src/domain/prometheus/config.rs +++ b/src/domain/prometheus/config.rs @@ -2,8 +2,16 @@ //! //! Defines the configuration for Prometheus metrics scraping. +use std::num::NonZeroU32; + use serde::{Deserialize, Serialize}; +/// Default scrape interval in seconds +/// +/// This is the recommended interval for most use cases, balancing +/// monitoring frequency with resource usage. +const DEFAULT_SCRAPE_INTERVAL_SECS: u32 = 15; + /// Prometheus metrics collection configuration /// /// Configures how Prometheus scrapes metrics from the tracker. @@ -13,85 +21,126 @@ use serde::{Deserialize, Serialize}; /// # Example /// /// ```rust +/// use std::num::NonZeroU32; /// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig; /// -/// let config = PrometheusConfig { -/// scrape_interval: 15, -/// }; +/// let interval = NonZeroU32::new(15).expect("15 is non-zero"); +/// let config = PrometheusConfig::new(interval); /// ``` /// /// # Default Behavior /// /// - Default scrape interval: 15 seconds -/// - Minimum recommended: 5 seconds (to avoid overwhelming the tracker) -/// - Maximum recommended: 300 seconds (5 minutes) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +/// - Minimum: 1 second (to avoid zero or negative values) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PrometheusConfig { /// Scrape interval in seconds /// - /// How often Prometheus should scrape metrics from the tracker's HTTP API endpoints. - /// Default: 15 seconds - pub scrape_interval: u32, + /// Guaranteed to be non-zero at the type level. + /// The Prometheus template will append 's' suffix. + /// Examples: 15 → "15s", 30 → "30s", 60 → "60s" (1 minute) + scrape_interval_in_secs: NonZeroU32, +} + +impl PrometheusConfig { + /// Creates a new Prometheus configuration with the specified scrape interval + /// + /// # Arguments + /// + /// * `scrape_interval_in_secs` - Non-zero interval in seconds + /// + /// # Examples + /// + /// ```rust + /// use std::num::NonZeroU32; + /// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig; + /// + /// let interval = NonZeroU32::new(30).expect("30 is non-zero"); + /// let config = PrometheusConfig::new(interval); + /// assert_eq!(config.scrape_interval_in_secs(), 30); + /// ``` + #[must_use] + pub const fn new(scrape_interval_in_secs: NonZeroU32) -> Self { + Self { + scrape_interval_in_secs, + } + } + + /// Returns the scrape interval in seconds + /// + /// The value is guaranteed to be non-zero. + #[must_use] + pub fn scrape_interval_in_secs(&self) -> u32 { + self.scrape_interval_in_secs.get() + } } impl Default for PrometheusConfig { fn default() -> Self { Self { - scrape_interval: 15, + // SAFETY: DEFAULT_SCRAPE_INTERVAL_SECS is non-zero + scrape_interval_in_secs: NonZeroU32::new(DEFAULT_SCRAPE_INTERVAL_SECS) + .expect("DEFAULT_SCRAPE_INTERVAL_SECS is non-zero"), } } } #[cfg(test)] mod tests { + use std::num::NonZeroU32; + use super::*; #[test] fn it_should_create_prometheus_config_with_default_values() { let config = PrometheusConfig::default(); - assert_eq!(config.scrape_interval, 15); + assert_eq!( + config.scrape_interval_in_secs(), + DEFAULT_SCRAPE_INTERVAL_SECS + ); } #[test] fn it_should_create_prometheus_config_with_custom_interval() { - let config = PrometheusConfig { - scrape_interval: 30, - }; - assert_eq!(config.scrape_interval, 30); + let interval = NonZeroU32::new(30).expect("30 is non-zero"); + let config = PrometheusConfig::new(interval); + assert_eq!(config.scrape_interval_in_secs(), 30); } #[test] fn it_should_serialize_to_json() { - let config = PrometheusConfig { - scrape_interval: 20, - }; + let interval = NonZeroU32::new(20).expect("20 is non-zero"); + let config = PrometheusConfig::new(interval); let json = serde_json::to_value(&config).unwrap(); - assert_eq!(json["scrape_interval"], 20); + assert_eq!(json["scrape_interval_in_secs"], 20); } #[test] fn it_should_deserialize_from_json() { let json = serde_json::json!({ - "scrape_interval": 25 + "scrape_interval_in_secs": 25 }); let config: PrometheusConfig = serde_json::from_value(json).unwrap(); - assert_eq!(config.scrape_interval, 25); + assert_eq!(config.scrape_interval_in_secs(), 25); } #[test] fn it_should_support_different_scrape_intervals() { - let fast = PrometheusConfig { scrape_interval: 5 }; - let medium = PrometheusConfig { - scrape_interval: 15, - }; - let slow = PrometheusConfig { - scrape_interval: 300, - }; - - assert_eq!(fast.scrape_interval, 5); - assert_eq!(medium.scrape_interval, 15); - assert_eq!(slow.scrape_interval, 300); + let fast = PrometheusConfig::new(NonZeroU32::new(5).expect("5 is non-zero")); + let medium = PrometheusConfig::new(NonZeroU32::new(15).expect("15 is non-zero")); + let slow = PrometheusConfig::new(NonZeroU32::new(300).expect("300 is non-zero")); + + assert_eq!(fast.scrape_interval_in_secs(), 5); + assert_eq!(medium.scrape_interval_in_secs(), 15); + assert_eq!(slow.scrape_interval_in_secs(), 300); + } + + #[test] + fn it_should_reject_zero_interval_at_type_level() { + // Cannot construct NonZeroU32 with 0 + let result = NonZeroU32::new(0); + assert!(result.is_none()); } } diff --git a/src/infrastructure/remote_actions/validators/grafana.rs b/src/infrastructure/remote_actions/validators/grafana.rs new file mode 100644 index 00000000..14407ad6 --- /dev/null +++ b/src/infrastructure/remote_actions/validators/grafana.rs @@ -0,0 +1,233 @@ +//! Grafana smoke test validator for remote instances +//! +//! This module provides the `GrafanaValidator` which performs a smoke test +//! on a running Grafana instance to verify it's operational and accessible. +//! +//! ## Key Features +//! +//! - Validates Grafana web UI is accessible via HTTP +//! - Checks Grafana returns a successful HTTP response +//! - Optionally validates admin credentials work (login test) +//! - Performs validation from inside the VM (not externally exposed by firewall) +//! +//! ## Validation Approach +//! +//! Grafana is exposed on port 3100 via Docker, but validation is performed +//! from inside the VM via SSH for consistency with other service validators: +//! +//! 1. Connect to VM via SSH +//! 2. Execute `curl` command to fetch Grafana homepage +//! 3. Verify successful HTTP response (200 OK) +//! +//! This smoke test confirms Grafana is: +//! - Running and bound to the expected port (3000 internally, 3100 externally) +//! - Responding to HTTP requests +//! - Web UI is functional +//! +//! ## Port Mapping +//! +//! - Internal (container): 3000 (Grafana default) +//! - External (host): 3100 (docker-compose port mapping) +//! - Validation uses: 3100 (tests the published port from inside VM) +//! +//! ## Future Enhancements +//! +//! For more comprehensive validation, consider: +//! +//! 1. **Authentication Validation**: +//! - Test admin login with configured credentials +//! - Verify authentication works correctly +//! - Example: `curl -u admin:password http://localhost:3100/api/health` +//! +//! 2. **Datasource Validation**: +//! - Query Grafana API for configured datasources +//! - Verify Prometheus datasource is configured +//! - Check datasource connectivity to Prometheus +//! - Example: `curl http://localhost:3100/api/datasources | jq` +//! +//! 3. **Dashboard Availability**: +//! - Query for available dashboards +//! - Verify default dashboards are loaded +//! - Check dashboard functionality +//! +//! These enhancements require: +//! - JSON parsing of Grafana API responses +//! - Credential management for authentication tests +//! - More complex error handling +//! +//! The current smoke test provides a good baseline validation that can be +//! extended as needed. + +use std::net::IpAddr; +use std::time::Duration; +use tracing::{info, instrument, warn}; + +use crate::adapters::ssh::SshClient; +use crate::adapters::ssh::SshConfig; +use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; + +/// Default Grafana external port (exposed by docker-compose) +const DEFAULT_GRAFANA_PORT: u16 = 3100; + +/// Maximum retry attempts for Grafana startup +const MAX_RETRIES: u32 = 30; + +/// Delay between retry attempts (in seconds) +const RETRY_DELAY_SECS: u64 = 2; + +/// Action that validates Grafana is running and accessible +pub struct GrafanaValidator { + ssh_client: SshClient, + grafana_port: u16, +} + +impl GrafanaValidator { + /// Create a new `GrafanaValidator` with the specified SSH configuration + /// + /// # Arguments + /// * `ssh_config` - SSH connection configuration containing credentials and host IP + /// * `grafana_port` - Port where Grafana is accessible (defaults to 3100 if None) + #[must_use] + pub fn new(ssh_config: SshConfig, grafana_port: Option) -> Self { + let ssh_client = SshClient::new(ssh_config); + Self { + ssh_client, + grafana_port: grafana_port.unwrap_or(DEFAULT_GRAFANA_PORT), + } + } +} + +impl RemoteAction for GrafanaValidator { + fn name(&self) -> &'static str { + "grafana-smoke-test" + } + + #[instrument( + name = "grafana_smoke_test", + skip(self), + fields( + action_type = "validation", + component = "grafana", + server_ip = %server_ip, + grafana_port = self.grafana_port + ) + )] + async fn execute(&self, server_ip: &IpAddr) -> Result<(), RemoteActionError> { + info!( + action = "grafana_smoke_test", + grafana_port = self.grafana_port, + "Running Grafana smoke test with retry logic (Grafana may take time to start)" + ); + + // Retry logic: Grafana container may take some time to fully start + // We retry for up to 60 seconds (30 attempts * 2 seconds) + for attempt in 1..=MAX_RETRIES { + // Perform smoke test: curl Grafana homepage and check for success + // Using -f flag to make curl fail on HTTP errors (4xx, 5xx) + // Using -s flag for silent mode (no progress bar) + // Using -o /dev/null to discard response body (we only care about status code) + let command = format!( + "curl -f -s -o /dev/null http://localhost:{} && echo 'success'", + self.grafana_port + ); + + match self.ssh_client.execute(&command) { + Ok(output) if output.trim().contains("success") => { + info!( + action = "grafana_smoke_test", + status = "success", + attempt = attempt, + "Grafana is running and responding to HTTP requests" + ); + return Ok(()); + } + Ok(_) | Err(_) => { + if attempt < MAX_RETRIES { + warn!( + action = "grafana_smoke_test", + attempt = attempt, + max_retries = MAX_RETRIES, + retry_delay_secs = RETRY_DELAY_SECS, + "Grafana not ready yet, retrying..." + ); + std::thread::sleep(Duration::from_secs(RETRY_DELAY_SECS)); + } + } + } + } + + // All retries exhausted + Err(RemoteActionError::ValidationFailed { + action_name: self.name().to_string(), + message: format!( + "Grafana smoke test failed after {} retries. Grafana may not be running or accessible on port {}. \ + Check that 'docker compose ps' shows Grafana container as running and healthy. \ + Grafana can take 30-60 seconds to fully start.", + MAX_RETRIES, + self.grafana_port + ), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod grafana_validator { + use super::*; + use std::path::PathBuf; + + #[test] + fn it_should_have_correct_name() { + use crate::adapters::ssh::SshCredentials; + use crate::shared::Username; + use std::net::SocketAddr; + + let credentials = SshCredentials::new( + PathBuf::from("test_key"), + PathBuf::from("test_key.pub"), + Username::new("test").unwrap(), + ); + let ssh_config = SshConfig::new(credentials, SocketAddr::from(([127, 0, 0, 1], 22))); + let validator = GrafanaValidator::new(ssh_config, None); + + assert_eq!(validator.name(), "grafana-smoke-test"); + } + + #[test] + fn it_should_use_default_port_when_none_provided() { + use crate::adapters::ssh::SshCredentials; + use crate::shared::Username; + use std::net::SocketAddr; + + let credentials = SshCredentials::new( + PathBuf::from("test_key"), + PathBuf::from("test_key.pub"), + Username::new("test").unwrap(), + ); + let ssh_config = SshConfig::new(credentials, SocketAddr::from(([127, 0, 0, 1], 22))); + let validator = GrafanaValidator::new(ssh_config, None); + + assert_eq!(validator.grafana_port, DEFAULT_GRAFANA_PORT); + } + + #[test] + fn it_should_use_custom_port_when_provided() { + use crate::adapters::ssh::SshCredentials; + use crate::shared::Username; + use std::net::SocketAddr; + + let credentials = SshCredentials::new( + PathBuf::from("test_key"), + PathBuf::from("test_key.pub"), + Username::new("test").unwrap(), + ); + let ssh_config = SshConfig::new(credentials, SocketAddr::from(([127, 0, 0, 1], 22))); + let custom_port = 4000; + let validator = GrafanaValidator::new(ssh_config, Some(custom_port)); + + assert_eq!(validator.grafana_port, custom_port); + } + } +} diff --git a/src/infrastructure/remote_actions/validators/mod.rs b/src/infrastructure/remote_actions/validators/mod.rs index 91f84950..51f90fab 100644 --- a/src/infrastructure/remote_actions/validators/mod.rs +++ b/src/infrastructure/remote_actions/validators/mod.rs @@ -1,9 +1,11 @@ pub mod cloud_init; pub mod docker; pub mod docker_compose; +pub mod grafana; pub mod prometheus; pub use cloud_init::CloudInitValidator; pub use docker::DockerValidator; pub use docker_compose::DockerComposeValidator; +pub use grafana::GrafanaValidator; pub use prometheus::PrometheusValidator; diff --git a/src/infrastructure/templating/ansible/template/renderer/project_generator.rs b/src/infrastructure/templating/ansible/template/renderer/project_generator.rs index e1d3d66c..d1dd0a44 100644 --- a/src/infrastructure/templating/ansible/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/ansible/template/renderer/project_generator.rs @@ -154,6 +154,7 @@ impl AnsibleProjectGenerator { /// /// * `inventory_context` - Runtime context for inventory template rendering (IP, SSH keys) /// * `tracker_config` - Optional tracker configuration for firewall port extraction + /// * `grafana_config` - Optional Grafana configuration for conditional firewall setup /// /// # Returns /// @@ -171,6 +172,7 @@ impl AnsibleProjectGenerator { &self, inventory_context: &InventoryContext, tracker_config: Option<&crate::domain::tracker::TrackerConfig>, + grafana_config: Option<&crate::domain::grafana::GrafanaConfig>, ) -> Result<(), AnsibleProjectGeneratorError> { tracing::info!( template_type = "ansible", @@ -186,7 +188,8 @@ impl AnsibleProjectGenerator { .map_err(|source| AnsibleProjectGeneratorError::InventoryRenderingFailed { source })?; // Render dynamic variables template with system configuration using collaborator - let variables_context = Self::create_variables_context(inventory_context, tracker_config)?; + let variables_context = + Self::create_variables_context(inventory_context, tracker_config, grafana_config)?; self.variables_renderer .render(&variables_context, &build_ansible_dir) .map_err(|source| AnsibleProjectGeneratorError::VariablesRenderingFailed { source })?; @@ -309,6 +312,7 @@ impl AnsibleProjectGenerator { "deploy-tracker-config.yml", "create-prometheus-storage.yml", "deploy-prometheus-config.yml", + "deploy-grafana-provisioning.yml", "deploy-compose-files.yml", "run-compose-services.yml", ] { @@ -318,7 +322,7 @@ impl AnsibleProjectGenerator { tracing::debug!( "Successfully copied {} static template files", - 16 // ansible.cfg + 15 playbooks + 17 // ansible.cfg + 16 playbooks ); Ok(()) @@ -396,6 +400,7 @@ impl AnsibleProjectGenerator { fn create_variables_context( inventory_context: &InventoryContext, tracker_config: Option<&crate::domain::tracker::TrackerConfig>, + grafana_config: Option<&crate::domain::grafana::GrafanaConfig>, ) -> Result< crate::infrastructure::templating::ansible::template::wrappers::variables::AnsibleVariablesContext, AnsibleProjectGeneratorError, @@ -403,12 +408,15 @@ impl AnsibleProjectGenerator { use crate::infrastructure::templating::ansible::template::wrappers::variables::AnsibleVariablesContext; // Extract SSH port from inventory context and create variables context with tracker config - AnsibleVariablesContext::new(inventory_context.ansible_port(), tracker_config).map_err( - |e| AnsibleProjectGeneratorError::ContextCreationFailed { - context_type: "AnsibleVariables".to_string(), - message: format!("Failed to create variables context: {e}"), - }, + AnsibleVariablesContext::new( + inventory_context.ansible_port(), + tracker_config, + grafana_config, ) + .map_err(|e| AnsibleProjectGeneratorError::ContextCreationFailed { + context_type: "AnsibleVariables".to_string(), + message: format!("Failed to create variables context: {e}"), + }) } } diff --git a/src/infrastructure/templating/ansible/template/renderer/variables.rs b/src/infrastructure/templating/ansible/template/renderer/variables.rs index 4f49cb30..fd6aeacc 100644 --- a/src/infrastructure/templating/ansible/template/renderer/variables.rs +++ b/src/infrastructure/templating/ansible/template/renderer/variables.rs @@ -24,7 +24,7 @@ //! let template_manager = Arc::new(TemplateManager::new("/path/to/templates")); //! let renderer = VariablesRenderer::new(template_manager); //! -//! let variables_context = AnsibleVariablesContext::new(22, None)?; +//! let variables_context = AnsibleVariablesContext::new(22, None, None)?; //! renderer.render(&variables_context, temp_dir.path())?; //! # Ok(()) //! # } @@ -211,7 +211,7 @@ mod tests { /// Helper function to create a test variables context fn create_test_variables_context() -> AnsibleVariablesContext { - AnsibleVariablesContext::new(22, None).expect("Failed to create variables context") + AnsibleVariablesContext::new(22, None, None).expect("Failed to create variables context") } /// Helper function to create a test template directory with variables.yml.tera @@ -307,8 +307,8 @@ ssh_port: {{ ssh_port }} let renderer = VariablesRenderer::new(template_manager); // Use custom SSH port - let variables_context = - AnsibleVariablesContext::new(2222, None).expect("Failed to create variables context"); + let variables_context = AnsibleVariablesContext::new(2222, None, None) + .expect("Failed to create variables context"); let result = renderer.render(&variables_context, &output_dir); diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs index 3febe92b..81a4f987 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use serde::Serialize; use thiserror::Error; +use crate::domain::grafana::GrafanaConfig; use crate::domain::tracker::TrackerConfig; /// Errors that can occur when creating an `AnsibleVariablesContext` @@ -33,6 +34,10 @@ pub struct AnsibleVariablesContext { /// Tracker HTTP API port #[serde(skip_serializing_if = "Option::is_none")] tracker_api_port: Option, + + /// Grafana configuration (optional) + #[serde(skip_serializing_if = "Option::is_none")] + grafana_config: Option, } impl AnsibleVariablesContext { @@ -44,6 +49,7 @@ impl AnsibleVariablesContext { pub fn new( ssh_port: u16, tracker_config: Option<&TrackerConfig>, + grafana_config: Option<&GrafanaConfig>, ) -> Result { // Validate SSH port using existing validation crate::infrastructure::templating::ansible::template::wrappers::inventory::context::AnsiblePort::new(ssh_port)?; @@ -56,6 +62,7 @@ impl AnsibleVariablesContext { tracker_udp_ports, tracker_http_ports, tracker_api_port, + grafana_config: grafana_config.cloned(), }) } @@ -125,7 +132,7 @@ mod tests { #[test] fn it_should_create_context_with_valid_ssh_port() { - let context = AnsibleVariablesContext::new(22, None).unwrap(); + let context = AnsibleVariablesContext::new(22, None, None).unwrap(); assert_eq!(context.ssh_port(), 22); assert!(context.tracker_udp_ports().is_empty()); assert!(context.tracker_http_ports().is_empty()); @@ -134,19 +141,19 @@ mod tests { #[test] fn it_should_create_context_with_custom_ssh_port() { - let context = AnsibleVariablesContext::new(2222, None).unwrap(); + let context = AnsibleVariablesContext::new(2222, None, None).unwrap(); assert_eq!(context.ssh_port(), 2222); } #[test] fn it_should_create_context_with_high_port() { - let context = AnsibleVariablesContext::new(65535, None).unwrap(); + let context = AnsibleVariablesContext::new(65535, None, None).unwrap(); assert_eq!(context.ssh_port(), 65535); } #[test] fn it_should_fail_with_port_zero() { - let result = AnsibleVariablesContext::new(0, None); + let result = AnsibleVariablesContext::new(0, None, None); assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("Invalid SSH port")); @@ -154,21 +161,21 @@ mod tests { #[test] fn it_should_implement_clone() { - let context1 = AnsibleVariablesContext::new(22, None).unwrap(); + let context1 = AnsibleVariablesContext::new(22, None, None).unwrap(); let context2 = context1.clone(); assert_eq!(context1.ssh_port(), context2.ssh_port()); } #[test] fn it_should_serialize_to_json() { - let context = AnsibleVariablesContext::new(8022, None).unwrap(); + let context = AnsibleVariablesContext::new(8022, None, None).unwrap(); let json = serde_json::to_string(&context).unwrap(); assert!(json.contains("\"ssh_port\":8022")); } #[test] fn it_should_display_error_message_correctly() { - let error = AnsibleVariablesContext::new(0, None).unwrap_err(); + let error = AnsibleVariablesContext::new(0, None, None).unwrap_err(); let error_msg = format!("{error}"); assert!(error_msg.contains("Invalid SSH port")); assert!(error_msg.contains("Invalid port number: 0")); @@ -205,7 +212,7 @@ mod tests { }, }; - let context = AnsibleVariablesContext::new(22, Some(&tracker_config)).unwrap(); + let context = AnsibleVariablesContext::new(22, Some(&tracker_config), None).unwrap(); assert_eq!(context.tracker_udp_ports(), &[6868, 6969]); assert_eq!(context.tracker_http_ports(), &[7070]); @@ -233,7 +240,7 @@ mod tests { }, }; - let context = AnsibleVariablesContext::new(22, Some(&tracker_config)).unwrap(); + let context = AnsibleVariablesContext::new(22, Some(&tracker_config), None).unwrap(); assert!(context.tracker_udp_ports().is_empty()); assert!(context.tracker_http_ports().is_empty()); @@ -271,7 +278,7 @@ mod tests { }, }; - let context = AnsibleVariablesContext::new(22, Some(&tracker_config)).unwrap(); + let context = AnsibleVariablesContext::new(22, Some(&tracker_config), None).unwrap(); // All valid ports should be extracted (domain now enforces valid SocketAddr) assert_eq!(context.tracker_udp_ports(), &[6868, 6969]); diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/template.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/template.rs index 0434ea44..5c9c7085 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/template.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/template.rs @@ -47,7 +47,7 @@ mod tests { /// Helper function to create a `AnsibleVariablesContext` with the given SSH port fn create_variables_context(ssh_port: u16) -> AnsibleVariablesContext { - AnsibleVariablesContext::new(ssh_port, None).unwrap() + AnsibleVariablesContext::new(ssh_port, None, None).unwrap() } /// Helper function to create a minimal valid variables template file diff --git a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs index 17d91dbc..dc2dcc6c 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs @@ -352,9 +352,8 @@ mod tests { http_tracker_ports: vec![7070], http_api_port: 1212, }; - let prometheus_config = PrometheusConfig { - scrape_interval: 15, - }; + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(15).expect("15 is non-zero")); let context = DockerComposeContext::builder(ports) .with_prometheus(prometheus_config) .build(); @@ -386,10 +385,10 @@ mod tests { "Should set container name" ); - // Verify port mapping + // Verify port is bound to localhost only (not exposed to external network) assert!( - rendered_content.contains("9090:9090"), - "Should expose Prometheus port 9090" + rendered_content.contains("127.0.0.1:9090:9090"), + "Prometheus port 9090 should be bound to localhost only (not exposed to external network)" ); // Verify volume mount diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs index 6f86b7a6..2833678b 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs @@ -1,6 +1,7 @@ //! Builder for `DockerComposeContext` // Internal crate +use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; @@ -14,6 +15,7 @@ pub struct DockerComposeContextBuilder { ports: TrackerPorts, database: DatabaseConfig, prometheus_config: Option, + grafana_config: Option, } impl DockerComposeContextBuilder { @@ -26,6 +28,7 @@ impl DockerComposeContextBuilder { mysql: None, }, prometheus_config: None, + grafana_config: None, } } @@ -54,6 +57,17 @@ impl DockerComposeContextBuilder { self } + /// Adds Grafana configuration + /// + /// # Arguments + /// + /// * `grafana_config` - Grafana configuration + #[must_use] + pub fn with_grafana(mut self, grafana_config: GrafanaConfig) -> Self { + self.grafana_config = Some(grafana_config); + self + } + /// Builds the `DockerComposeContext` #[must_use] pub fn build(self) -> DockerComposeContext { @@ -61,6 +75,7 @@ impl DockerComposeContextBuilder { database: self.database, ports: self.ports, prometheus_config: self.prometheus_config, + grafana_config: self.grafana_config, } } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs index cd4cb7c5..58684253 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs @@ -7,6 +7,7 @@ use serde::Serialize; // Internal crate +use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; // Submodules @@ -31,6 +32,9 @@ pub struct DockerComposeContext { /// Prometheus configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] pub prometheus_config: Option, + /// Grafana configuration (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub grafana_config: Option, } impl DockerComposeContext { @@ -93,6 +97,12 @@ impl DockerComposeContext { pub fn prometheus_config(&self) -> Option<&PrometheusConfig> { self.prometheus_config.as_ref() } + + /// Get the Grafana configuration if present + #[must_use] + pub fn grafana_config(&self) -> Option<&GrafanaConfig> { + self.grafana_config.as_ref() + } } #[cfg(test)] @@ -230,15 +240,20 @@ mod tests { http_tracker_ports: vec![7070], http_api_port: 1212, }; - let prometheus_config = PrometheusConfig { - scrape_interval: 30, - }; + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); let context = DockerComposeContext::builder(ports) .with_prometheus(prometheus_config) .build(); assert!(context.prometheus_config().is_some()); - assert_eq!(context.prometheus_config().unwrap().scrape_interval, 30); + assert_eq!( + context + .prometheus_config() + .unwrap() + .scrape_interval_in_secs(), + 30 + ); } #[test] @@ -261,15 +276,14 @@ mod tests { http_tracker_ports: vec![7070], http_api_port: 1212, }; - let prometheus_config = PrometheusConfig { - scrape_interval: 20, - }; + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(20).expect("20 is non-zero")); let context = DockerComposeContext::builder(ports) .with_prometheus(prometheus_config) .build(); let serialized = serde_json::to_string(&context).unwrap(); assert!(serialized.contains("prometheus_config")); - assert!(serialized.contains("\"scrape_interval\":20")); + assert!(serialized.contains("\"scrape_interval_in_secs\":20")); } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/env/context.rs b/src/infrastructure/templating/docker_compose/template/wrappers/env/context.rs index 5f0e35bc..2eda4655 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/env/context.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/env/context.rs @@ -37,6 +37,18 @@ pub struct MySqlServiceConfig { pub password: String, } +/// Configuration for the Grafana service +/// +/// Contains environment variables for the Grafana container. +/// Only included when Grafana is enabled. +#[derive(Serialize, Debug, Clone)] +pub struct GrafanaServiceConfig { + /// Grafana admin user + pub admin_user: String, + /// Grafana admin password (exposed from secrecy wrapper) + pub admin_password: String, +} + /// Context for rendering the .env template /// /// Contains all variables needed for the Docker Compose environment configuration, @@ -48,6 +60,9 @@ pub struct EnvContext { /// `MySQL` service configuration (only present when `MySQL` driver is configured) #[serde(skip_serializing_if = "Option::is_none")] pub mysql: Option, + /// Grafana service configuration (only present when Grafana is enabled) + #[serde(skip_serializing_if = "Option::is_none")] + pub grafana: Option, } impl EnvContext { @@ -75,6 +90,7 @@ impl EnvContext { database_driver: "sqlite3".to_string(), }, mysql: None, + grafana: None, } } @@ -122,6 +138,7 @@ impl EnvContext { user: mysql_user, password: mysql_password, }), + grafana: None, } } @@ -137,6 +154,35 @@ impl EnvContext { &self.tracker.database_driver } + /// Adds Grafana configuration + /// + /// Exposes the admin password from the secrecy wrapper for template rendering. + /// + /// # Arguments + /// + /// * `admin_user` - Grafana admin username + /// * `admin_password` - Grafana admin password (plain String, already exposed) + #[must_use] + pub fn with_grafana(mut self, admin_user: String, admin_password: String) -> Self { + self.grafana = Some(GrafanaServiceConfig { + admin_user, + admin_password, + }); + self + } + + /// Get the Grafana admin user (if configured) + #[must_use] + pub fn grafana_admin_user(&self) -> Option<&str> { + self.grafana.as_ref().map(|g| g.admin_user.as_str()) + } + + /// Get the Grafana admin password (if configured) + #[must_use] + pub fn grafana_admin_password(&self) -> Option<&str> { + self.grafana.as_ref().map(|g| g.admin_password.as_str()) + } + /// Get the `MySQL` root password (if configured) #[must_use] pub fn mysql_root_password(&self) -> Option<&str> { diff --git a/src/infrastructure/templating/grafana/mod.rs b/src/infrastructure/templating/grafana/mod.rs new file mode 100644 index 00000000..de715174 --- /dev/null +++ b/src/infrastructure/templating/grafana/mod.rs @@ -0,0 +1,16 @@ +//! Grafana Configuration Management Integration +//! +//! This module provides template rendering for Grafana provisioning configuration, +//! enabling automatic datasource and dashboard configuration. +//! +//! ## Architecture +//! +//! Follows the Project Generator pattern: +//! - `template` - Template renderers for Grafana provisioning files +//! +//! ## Configuration Files Generated +//! +//! - **Datasources**: `datasources/prometheus.yml` - Auto-configures Prometheus as data source +//! - **Dashboards**: Dashboard provider and JSON files for metrics visualization + +pub mod template; diff --git a/src/infrastructure/templating/grafana/template/mod.rs b/src/infrastructure/templating/grafana/template/mod.rs new file mode 100644 index 00000000..58e429b9 --- /dev/null +++ b/src/infrastructure/templating/grafana/template/mod.rs @@ -0,0 +1,13 @@ +//! Grafana Template Rendering +//! +//! Provides template rendering capabilities for Grafana provisioning configuration. +//! +//! ## Components +//! +//! - `renderer` - Project generator and template renderers +//! - `wrapper` - Context and template wrappers for Tera templates + +pub mod renderer; +pub mod wrapper; + +pub use wrapper::datasource::{DatasourceContext, DatasourceTemplate}; diff --git a/src/infrastructure/templating/grafana/template/renderer/datasource.rs b/src/infrastructure/templating/grafana/template/renderer/datasource.rs new file mode 100644 index 00000000..e52fabbd --- /dev/null +++ b/src/infrastructure/templating/grafana/template/renderer/datasource.rs @@ -0,0 +1,172 @@ +//! Datasource configuration renderer +//! +//! Renders prometheus.yml.tera template using `DatasourceContext` and `DatasourceTemplate` wrappers. + +use std::path::Path; +use std::sync::Arc; + +use thiserror::Error; +use tracing::instrument; + +use crate::domain::template::{TemplateManager, TemplateManagerError}; +use crate::infrastructure::templating::grafana::template::wrapper::datasource::{ + template::DatasourceTemplateError, DatasourceContext, DatasourceTemplate, +}; + +/// Errors that can occur during datasource configuration rendering +#[derive(Error, Debug)] +pub enum DatasourceRendererError { + /// Failed to get template path from template manager + #[error("Failed to get template path for 'prometheus.yml.tera': {0}")] + TemplatePathFailed(#[from] TemplateManagerError), + + /// Failed to read template file + #[error("Failed to read template file at '{path}': {source}")] + TemplateReadFailed { + path: String, + #[source] + source: std::io::Error, + }, + + /// Failed to create or render template + #[error("Failed to process datasource template: {0}")] + TemplateProcessingFailed(#[from] DatasourceTemplateError), +} + +/// Renders prometheus.yml.tera template to prometheus.yml datasource configuration file +/// +/// This renderer follows the Project Generator pattern: +/// 1. Loads prometheus.yml.tera from the template manager +/// 2. Creates a `DatasourceTemplate` with `DatasourceContext` +/// 3. Renders the template to an output file +/// +/// The `DatasourceContext` contains: +/// - `prometheus_scrape_interval_in_secs`: Matches Prometheus scrape interval +pub struct DatasourceRenderer { + template_manager: Arc, +} + +impl DatasourceRenderer { + /// Template filename for the datasource Tera template + const DATASOURCE_TEMPLATE_FILE: &'static str = "prometheus.yml.tera"; + + /// Output filename for the rendered datasource config file + const DATASOURCE_OUTPUT_FILE: &'static str = "prometheus.yml"; + + /// Directory path for Grafana datasource templates + const DATASOURCE_TEMPLATE_DIR: &'static str = "grafana/provisioning/datasources"; + + /// Creates a new datasource renderer + /// + /// # Arguments + /// + /// * `template_manager` - The template manager to load templates from + #[must_use] + pub fn new(template_manager: Arc) -> Self { + Self { template_manager } + } + + /// Renders the datasource configuration to a file + /// + /// # Arguments + /// + /// * `context` - The rendering context with `prometheus_scrape_interval_in_secs` + /// * `output_dir` - Directory where prometheus.yml will be written (e.g., build/grafana/provisioning/datasources) + /// + /// # Errors + /// + /// Returns an error if: + /// - Template file cannot be loaded + /// - Template file cannot be read + /// - Template rendering fails + /// - Output file cannot be written + #[instrument(skip(self, context), fields(output_dir = %output_dir.display()))] + pub fn render( + &self, + context: &DatasourceContext, + output_dir: &Path, + ) -> Result<(), DatasourceRendererError> { + // 1. Load template from template manager + let template_path = self.template_manager.get_template_path(&format!( + "{}/{}", + Self::DATASOURCE_TEMPLATE_DIR, + Self::DATASOURCE_TEMPLATE_FILE + ))?; + + // 2. Read template content + let template_content = std::fs::read_to_string(&template_path).map_err(|source| { + DatasourceRendererError::TemplateReadFailed { + path: template_path.display().to_string(), + source, + } + })?; + + // 3. Create template wrapper + let template = DatasourceTemplate::new(template_content, context.clone()); + + // 4. Render to output file + let output_path = output_dir.join(Self::DATASOURCE_OUTPUT_FILE); + template.render_to_file(&output_path)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn create_test_template_manager() -> Arc { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let templates_dir = temp_dir.path().join("templates"); + let datasources_dir = templates_dir.join("grafana/provisioning/datasources"); + + fs::create_dir_all(&datasources_dir).expect("Failed to create datasources dir"); + + let template_content = r#"apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: "{{ prometheus_scrape_interval_in_secs }}s" + httpMethod: POST +"#; + + fs::write( + datasources_dir.join("prometheus.yml.tera"), + template_content, + ) + .expect("Failed to write template"); + + Arc::new(TemplateManager::new(templates_dir)) + } + + #[test] + fn it_should_render_datasource_config() { + let template_manager = create_test_template_manager(); + let renderer = DatasourceRenderer::new(template_manager); + + let context = DatasourceContext::new(15); + let output_dir = TempDir::new().expect("Failed to create output dir"); + + renderer + .render(&context, output_dir.path()) + .expect("Failed to render"); + + let output_file = output_dir.path().join("prometheus.yml"); + assert!(output_file.exists()); + + let rendered_content = fs::read_to_string(output_file).expect("Failed to read output"); + assert!(rendered_content.contains("timeInterval: \"15s\"")); + assert!(rendered_content.contains("name: Prometheus")); + } +} diff --git a/src/infrastructure/templating/grafana/template/renderer/mod.rs b/src/infrastructure/templating/grafana/template/renderer/mod.rs new file mode 100644 index 00000000..7df0edbd --- /dev/null +++ b/src/infrastructure/templating/grafana/template/renderer/mod.rs @@ -0,0 +1,10 @@ +//! Grafana Template Renderers +//! +//! Contains the project generator that orchestrates rendering of all Grafana +//! provisioning configuration templates. + +pub mod datasource; +pub mod project_generator; + +pub use datasource::{DatasourceRenderer, DatasourceRendererError}; +pub use project_generator::{GrafanaProjectGenerator, GrafanaProjectGeneratorError}; diff --git a/src/infrastructure/templating/grafana/template/renderer/project_generator.rs b/src/infrastructure/templating/grafana/template/renderer/project_generator.rs new file mode 100644 index 00000000..064b07b7 --- /dev/null +++ b/src/infrastructure/templating/grafana/template/renderer/project_generator.rs @@ -0,0 +1,253 @@ +//! Grafana Project Generator +//! +//! Orchestrates the rendering of Grafana provisioning configuration templates following +//! the Project Generator pattern. +//! +//! ## Architecture +//! +//! This follows the three-layer Project Generator pattern: +//! - **Context** (`DatasourceContext`) - Defines variables needed by templates +//! - **Renderer** (`DatasourceRenderer`) - Renders specific .tera templates +//! - **`ProjectGenerator`** (this file) - Orchestrates all renderers and static file copying +//! +//! ## Data Flow +//! +//! Prometheus Config → `DatasourceContext` → Template Rendering → Provisioning Files + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use thiserror::Error; +use tracing::instrument; + +use crate::domain::prometheus::PrometheusConfig; +use crate::domain::template::{TemplateManager, TemplateManagerError}; +use crate::infrastructure::templating::grafana::template::{ + renderer::{DatasourceRenderer, DatasourceRendererError}, + DatasourceContext, +}; + +/// Errors that can occur during Grafana project generation +#[derive(Error, Debug)] +pub enum GrafanaProjectGeneratorError { + /// Failed to create the build directory + #[error("Failed to create build directory '{directory}': {source}")] + DirectoryCreationFailed { + directory: String, + #[source] + source: std::io::Error, + }, + + /// Failed to get template path + #[error("Failed to get template path: {0}")] + TemplatePathFailed(#[from] TemplateManagerError), + + /// Failed to render datasource configuration + #[error("Failed to render datasource configuration: {0}")] + DatasourceRendererFailed(#[from] DatasourceRendererError), + + /// Failed to copy static files + #[error("Failed to copy static file from '{from}' to '{to}': {source}")] + FileCopyFailed { + from: String, + to: String, + #[source] + source: std::io::Error, + }, +} + +/// Orchestrates Grafana provisioning configuration template rendering +/// +/// This is the Project Generator that coordinates all Grafana template rendering. +/// It follows the standard pattern: +/// 1. Create build directory structure +/// 2. Render datasource template using `DatasourceRenderer` +/// 3. Copy static dashboard provider configuration +/// 4. Copy static dashboard JSON files +pub struct GrafanaProjectGenerator { + build_dir: PathBuf, + template_manager: Arc, + datasource_renderer: DatasourceRenderer, +} + +impl GrafanaProjectGenerator { + /// Relative path for Grafana provisioning files within build directory + const GRAFANA_BUILD_PATH: &'static str = "grafana/provisioning"; + + /// Static dashboard provider configuration file + const DASHBOARD_PROVIDER_FILE: &'static str = "grafana/provisioning/dashboards/torrust.yml"; + + /// Static dashboard JSON directory + const DASHBOARD_JSON_DIR: &'static str = "grafana/provisioning/dashboards/torrust"; + + /// Output directory for dashboard provider configuration + const DASHBOARD_PROVIDER_OUTPUT_DIR: &'static str = "dashboards"; + + /// Output directory for dashboard JSON files + const DASHBOARD_JSON_OUTPUT_DIR: &'static str = "dashboards/torrust"; + + /// Creates a new Grafana project generator + /// + /// # Arguments + /// + /// * `build_dir` - The destination directory where templates will be rendered + /// * `template_manager` - The template manager to source templates from + #[must_use] + pub fn new>(build_dir: P, template_manager: Arc) -> Self { + let datasource_renderer = DatasourceRenderer::new(Arc::clone(&template_manager)); + + Self { + build_dir: build_dir.as_ref().to_path_buf(), + template_manager, + datasource_renderer, + } + } + + /// Renders Grafana provisioning configuration templates to the build directory + /// Renders Grafana provisioning configuration templates to the build directory + /// + /// This method: + /// 1. Creates the build directory structure for Grafana provisioning + /// 2. Renders prometheus.yml.tera datasource template with the provided context + /// 3. Writes the rendered content to datasources/prometheus.yml + /// 4. Copies static dashboard provider configuration (dashboards/torrust.yml) + /// 5. Copies static dashboard JSON files (dashboards/torrust/*.json) + /// + /// # Arguments + /// + /// * `prometheus_config` - Prometheus configuration containing `scrape_interval` + /// + /// # Errors + /// + /// Returns an error if: + /// - Build directory creation fails + /// - Template loading fails + /// - Template rendering fails + /// - Writing output file fails + /// - Copying static files fails + #[instrument( + name = "grafana_project_generator_render", + skip(self, prometheus_config), + fields( + build_dir = %self.build_dir.display() + ) + )] + pub fn render( + &self, + prometheus_config: &PrometheusConfig, + ) -> Result<(), GrafanaProjectGeneratorError> { + // Create build directory structure + let grafana_build_dir = self.build_dir.join(Self::GRAFANA_BUILD_PATH); + self.create_directory_structure(&grafana_build_dir)?; + + // Build context from Prometheus config + let context = Self::build_context(prometheus_config); + + // Render datasource template + self.render_datasource_template(&context, &grafana_build_dir)?; + + // Copy static dashboard files + self.copy_dashboard_provider(&grafana_build_dir)?; + self.copy_dashboard_json_files(&grafana_build_dir)?; + + Ok(()) + } + + /// Creates the directory structure for Grafana provisioning files + #[allow(clippy::unused_self)] + fn create_directory_structure( + &self, + grafana_build_dir: &Path, + ) -> Result<(), GrafanaProjectGeneratorError> { + let datasources_dir = grafana_build_dir.join("datasources"); + let dashboards_dir = grafana_build_dir.join(Self::DASHBOARD_PROVIDER_OUTPUT_DIR); + let dashboards_torrust_dir = grafana_build_dir.join(Self::DASHBOARD_JSON_OUTPUT_DIR); + + // Create all necessary directories + for dir in [&datasources_dir, &dashboards_dir, &dashboards_torrust_dir] { + fs::create_dir_all(dir).map_err(|source| { + GrafanaProjectGeneratorError::DirectoryCreationFailed { + directory: dir.display().to_string(), + source, + } + })?; + } + + Ok(()) + } + + /// Builds `DatasourceContext` from Prometheus configuration + fn build_context(prometheus_config: &PrometheusConfig) -> DatasourceContext { + DatasourceContext::new(prometheus_config.scrape_interval_in_secs()) + } + + /// Renders the datasource template using `DatasourceRenderer` + fn render_datasource_template( + &self, + context: &DatasourceContext, + grafana_build_dir: &Path, + ) -> Result<(), GrafanaProjectGeneratorError> { + let datasources_dir = grafana_build_dir.join("datasources"); + self.datasource_renderer.render(context, &datasources_dir)?; + Ok(()) + } + + /// Copies the static dashboard provider configuration file + fn copy_dashboard_provider( + &self, + grafana_build_dir: &Path, + ) -> Result<(), GrafanaProjectGeneratorError> { + let provider_source_path = self + .template_manager + .get_template_path(Self::DASHBOARD_PROVIDER_FILE)?; + let provider_dest_path = grafana_build_dir.join(format!( + "{}/torrust.yml", + Self::DASHBOARD_PROVIDER_OUTPUT_DIR + )); + + fs::copy(&provider_source_path, &provider_dest_path).map_err(|source| { + GrafanaProjectGeneratorError::FileCopyFailed { + from: provider_source_path.display().to_string(), + to: provider_dest_path.display().to_string(), + source, + } + })?; + + Ok(()) + } + + /// Copies all static dashboard JSON files + fn copy_dashboard_json_files( + &self, + grafana_build_dir: &Path, + ) -> Result<(), GrafanaProjectGeneratorError> { + let dashboards_torrust_dir = grafana_build_dir.join(Self::DASHBOARD_JSON_OUTPUT_DIR); + + // List of dashboard JSON files to copy + let dashboard_files = ["stats.json", "metrics.json"]; + + for file_name in &dashboard_files { + // Build the relative path for the dashboard JSON file + let relative_path = format!("{}/{}", Self::DASHBOARD_JSON_DIR, file_name); + + // Get the template path (this will extract from embedded resources if needed) + let source_path = self + .template_manager + .get_template_path(&relative_path) + .map_err(GrafanaProjectGeneratorError::TemplatePathFailed)?; + + let dest_path = dashboards_torrust_dir.join(file_name); + + fs::copy(&source_path, &dest_path).map_err(|source| { + GrafanaProjectGeneratorError::FileCopyFailed { + from: source_path.display().to_string(), + to: dest_path.display().to_string(), + source, + } + })?; + } + + Ok(()) + } +} diff --git a/src/infrastructure/templating/grafana/template/wrapper/datasource/context.rs b/src/infrastructure/templating/grafana/template/wrapper/datasource/context.rs new file mode 100644 index 00000000..e372a949 --- /dev/null +++ b/src/infrastructure/templating/grafana/template/wrapper/datasource/context.rs @@ -0,0 +1,90 @@ +//! Datasource template context +//! +//! Defines the variables needed for the prometheus.yml.tera datasource template rendering. + +use serde::Serialize; + +/// Context for rendering prometheus.yml.tera datasource template +/// +/// Contains all variables needed for Grafana Prometheus datasource configuration. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::infrastructure::templating::grafana::template::DatasourceContext; +/// +/// let context = DatasourceContext::new(15); +/// ``` +/// +/// # Data Flow +/// +/// Prometheus Config (`scrape_interval_in_secs`) → Application Layer → `DatasourceContext` +/// +/// - `prometheus_scrape_interval_in_secs`: From `prometheus.scrape_interval_in_secs()` +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct DatasourceContext { + /// Prometheus scrape interval in seconds + /// + /// This matches the Prometheus `scrape_interval` configuration to ensure + /// Grafana's time interval aligns with data collection intervals. + pub prometheus_scrape_interval_in_secs: u32, +} + +impl DatasourceContext { + /// Creates a new `DatasourceContext` + /// + /// # Arguments + /// + /// * `prometheus_scrape_interval_in_secs` - Prometheus scrape interval in seconds + /// + /// # Example + /// + /// ```rust + /// use torrust_tracker_deployer_lib::infrastructure::templating::grafana::template::DatasourceContext; + /// + /// let context = DatasourceContext::new(15); + /// assert_eq!(context.prometheus_scrape_interval_in_secs, 15); + /// ``` + #[must_use] + pub fn new(prometheus_scrape_interval_in_secs: u32) -> Self { + Self { + prometheus_scrape_interval_in_secs, + } + } +} + +impl Default for DatasourceContext { + fn default() -> Self { + Self { + prometheus_scrape_interval_in_secs: 15, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_datasource_context() { + let context = DatasourceContext::new(30); + + assert_eq!(context.prometheus_scrape_interval_in_secs, 30); + } + + #[test] + fn it_should_have_default_values() { + let context = DatasourceContext::default(); + + assert_eq!(context.prometheus_scrape_interval_in_secs, 15); + } + + #[test] + fn it_should_be_serializable_for_tera() { + let context = DatasourceContext::new(20); + + let json = serde_json::to_value(&context).expect("Failed to serialize"); + + assert_eq!(json["prometheus_scrape_interval_in_secs"], 20); + } +} diff --git a/src/infrastructure/templating/grafana/template/wrapper/datasource/mod.rs b/src/infrastructure/templating/grafana/template/wrapper/datasource/mod.rs new file mode 100644 index 00000000..b0ab59a5 --- /dev/null +++ b/src/infrastructure/templating/grafana/template/wrapper/datasource/mod.rs @@ -0,0 +1,9 @@ +//! Datasource template wrapper +//! +//! This module provides the context and template for rendering the prometheus.yml.tera datasource configuration. + +pub mod context; +pub mod template; + +pub use context::DatasourceContext; +pub use template::DatasourceTemplate; diff --git a/src/infrastructure/templating/grafana/template/wrapper/datasource/template.rs b/src/infrastructure/templating/grafana/template/wrapper/datasource/template.rs new file mode 100644 index 00000000..b54a6ed5 --- /dev/null +++ b/src/infrastructure/templating/grafana/template/wrapper/datasource/template.rs @@ -0,0 +1,148 @@ +//! Datasource template +//! +//! Wraps the prometheus.yml.tera template with rendering capabilities. + +use std::path::Path; + +use thiserror::Error; + +use super::DatasourceContext; + +/// Errors that can occur during datasource template processing +#[derive(Error, Debug)] +pub enum DatasourceTemplateError { + /// Failed to initialize Tera template engine + #[error("Failed to initialize Tera engine: {0}")] + TeraInitializationFailed(#[from] tera::Error), + + /// Failed to write rendered template to file + #[error("Failed to write datasource file to '{path}': {source}")] + FileWriteFailed { + path: String, + #[source] + source: std::io::Error, + }, +} + +/// Datasource template wrapper +/// +/// This wraps the prometheus.yml.tera template content and provides rendering capability. +/// +/// # Workflow +/// +/// 1. Load template content from file +/// 2. Create `DatasourceTemplate` with content and context +/// 3. Call `render_to_file()` to write rendered output +pub struct DatasourceTemplate { + content: String, + context: DatasourceContext, +} + +impl DatasourceTemplate { + /// Template name for Tera engine + const TEMPLATE_NAME: &'static str = "prometheus.yml.tera"; + + /// Creates a new datasource template + /// + /// # Arguments + /// + /// * `template_content` - The raw .tera template content + /// * `context` - The context with `prometheus_scrape_interval_in_secs` + #[must_use] + pub fn new(template_content: String, context: DatasourceContext) -> Self { + Self { + content: template_content, + context, + } + } + + /// Renders the template to an output file + /// + /// # Arguments + /// + /// * `output_path` - Where to write the rendered prometheus.yml file + /// + /// # Errors + /// + /// Returns an error if: + /// - Tera initialization fails + /// - Template rendering fails + /// - File write fails + pub fn render_to_file(&self, output_path: &Path) -> Result<(), DatasourceTemplateError> { + // Initialize Tera engine + let mut tera = tera::Tera::default(); + tera.add_raw_template(Self::TEMPLATE_NAME, &self.content)?; + + // Render template with context + let rendered_content = tera.render( + Self::TEMPLATE_NAME, + &tera::Context::from_serialize(&self.context)?, + )?; + + // Write to file + std::fs::write(output_path, rendered_content).map_err(|source| { + DatasourceTemplateError::FileWriteFailed { + path: output_path.display().to_string(), + source, + } + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use tempfile::NamedTempFile; + + use super::*; + + #[test] + fn it_should_render_datasource_template() { + let template_content = r#"apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: "{{ prometheus_scrape_interval_in_secs }}s" + httpMethod: POST +"#; + + let context = DatasourceContext::new(15); + let template = DatasourceTemplate::new(template_content.to_string(), context); + + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + template + .render_to_file(temp_file.path()) + .expect("Failed to render"); + + let rendered_content = + std::fs::read_to_string(temp_file.path()).expect("Failed to read rendered file"); + + assert!(rendered_content.contains(r#"timeInterval: "15s""#)); + assert!(rendered_content.contains("name: Prometheus")); + } + + #[test] + fn it_should_handle_different_scrape_intervals() { + let template_content = r#"timeInterval: "{{ prometheus_scrape_interval_in_secs }}s""#; + + let context = DatasourceContext::new(30); + let template = DatasourceTemplate::new(template_content.to_string(), context); + + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + template + .render_to_file(temp_file.path()) + .expect("Failed to render"); + + let rendered_content = + std::fs::read_to_string(temp_file.path()).expect("Failed to read rendered file"); + + assert_eq!(rendered_content.trim(), r#"timeInterval: "30s""#); + } +} diff --git a/src/infrastructure/templating/grafana/template/wrapper/mod.rs b/src/infrastructure/templating/grafana/template/wrapper/mod.rs new file mode 100644 index 00000000..14337045 --- /dev/null +++ b/src/infrastructure/templating/grafana/template/wrapper/mod.rs @@ -0,0 +1,7 @@ +//! Grafana template wrappers +//! +//! This module provides wrappers for Grafana Tera templates. + +pub mod datasource; + +pub use datasource::{DatasourceContext, DatasourceTemplate}; diff --git a/src/infrastructure/templating/mod.rs b/src/infrastructure/templating/mod.rs index b423a335..ddfc0ac3 100644 --- a/src/infrastructure/templating/mod.rs +++ b/src/infrastructure/templating/mod.rs @@ -24,6 +24,8 @@ //! - `template` - Template renderers for Tracker configuration files //! - `prometheus` - Prometheus metrics collection configuration //! - `template` - Template renderers for Prometheus configuration files +//! - `grafana` - Grafana metrics visualization configuration +//! - `template` - Template renderers for Grafana provisioning files //! //! ## Template Rendering //! @@ -34,6 +36,7 @@ pub mod ansible; pub mod docker_compose; +pub mod grafana; pub mod prometheus; pub mod tofu; pub mod tracker; diff --git a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs index 6c6189e0..b81e5eed 100644 --- a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs @@ -145,14 +145,14 @@ impl PrometheusProjectGenerator { /// # Returns /// /// A `PrometheusContext` with: - /// - `scrape_interval`: From `prometheus_config.scrape_interval` + /// - `scrape_interval`: From `prometheus_config.scrape_interval_in_secs` /// - `api_token`: From `tracker_config.http_api.admin_token` /// - `api_port`: Parsed from `tracker_config.http_api.bind_address` fn build_context( prometheus_config: &PrometheusConfig, tracker_config: &TrackerConfig, ) -> PrometheusContext { - let scrape_interval = prometheus_config.scrape_interval; + let scrape_interval = prometheus_config.scrape_interval_in_secs().to_string(); let api_token = tracker_config .http_api .admin_token @@ -278,9 +278,8 @@ scrape_configs: let template_manager = create_test_template_manager(); let generator = PrometheusProjectGenerator::new(&build_dir, template_manager); - let prometheus_config = PrometheusConfig { - scrape_interval: 30, - }; + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); let tracker_config = create_test_tracker_config(); generator diff --git a/src/infrastructure/templating/prometheus/template/renderer/prometheus_config.rs b/src/infrastructure/templating/prometheus/template/renderer/prometheus_config.rs index 34e6df76..0459ea24 100644 --- a/src/infrastructure/templating/prometheus/template/renderer/prometheus_config.rs +++ b/src/infrastructure/templating/prometheus/template/renderer/prometheus_config.rs @@ -154,7 +154,7 @@ scrape_configs: let template_manager = create_test_template_manager(); let renderer = PrometheusConfigRenderer::new(template_manager); - let context = PrometheusContext::new(15, "test_token".to_string(), 1212); + let context = PrometheusContext::new("15s".to_string(), "test_token".to_string(), 1212); let temp_dir = TempDir::new().expect("Failed to create temp output dir"); let output_dir = temp_dir.path(); @@ -177,7 +177,8 @@ scrape_configs: let template_manager = create_test_template_manager(); let renderer = PrometheusConfigRenderer::new(template_manager); - let context = PrometheusContext::new(30, "admin_token_123".to_string(), 8080); + let context = + PrometheusContext::new("30s".to_string(), "admin_token_123".to_string(), 8080); let temp_dir = TempDir::new().expect("Failed to create temp output dir"); let output_dir = temp_dir.path(); @@ -208,7 +209,7 @@ scrape_configs: let template_manager = Arc::new(TemplateManager::new(&templates_dir)); let renderer = PrometheusConfigRenderer::new(template_manager); - let context = PrometheusContext::new(15, "token".to_string(), 1212); + let context = PrometheusContext::new("15s".to_string(), "token".to_string(), 1212); let output_dir = temp_dir.path(); let result = renderer.render(&context, output_dir); diff --git a/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/context.rs b/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/context.rs index fbcdce61..61613795 100644 --- a/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/context.rs +++ b/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/context.rs @@ -15,7 +15,7 @@ use serde::Serialize; /// use torrust_tracker_deployer_lib::infrastructure::templating::prometheus::PrometheusContext; /// /// let context = PrometheusContext { -/// scrape_interval: 15, +/// scrape_interval: "15s".to_string(), /// api_token: "MyAccessToken".to_string(), /// api_port: 1212, /// }; @@ -25,17 +25,16 @@ use serde::Serialize; /// /// Environment Config (`tracker.http_api`) → Application Layer → `PrometheusContext` /// -/// - `scrape_interval`: From `prometheus.scrape_interval` (default: 15 seconds) +/// - `scrape_interval`: From `prometheus.scrape_interval` (e.g., "15s", "30s", "1m") /// - `api_token`: From `tracker.http_api.admin_token` /// - `api_port`: Parsed from `tracker.http_api.bind_address` (e.g., 1212 from "0.0.0.0:1212") #[derive(Debug, Clone, Serialize, PartialEq)] pub struct PrometheusContext { - /// How often to scrape metrics from tracker (in seconds) + /// How often to scrape metrics from tracker (e.g., "15s", "30s", "1m") /// - /// Default: 15 seconds - /// Minimum: 5 seconds (to avoid overwhelming the tracker) - /// Maximum: 300 seconds (5 minutes) - pub scrape_interval: u32, + /// Default: "15s" + /// Examples: "5s" (minimum to avoid overwhelming), "5m" (maximum reasonable interval) + pub scrape_interval: String, /// Tracker HTTP API admin token for authentication /// @@ -58,7 +57,7 @@ impl PrometheusContext { /// /// # Arguments /// - /// * `scrape_interval` - How often to scrape metrics (in seconds) + /// * `scrape_interval` - How often to scrape metrics (e.g., "15s", "30s", "1m") /// * `api_token` - Tracker HTTP API admin token /// * `api_port` - Tracker HTTP API port /// @@ -67,10 +66,10 @@ impl PrometheusContext { /// ```rust /// use torrust_tracker_deployer_lib::infrastructure::templating::prometheus::PrometheusContext; /// - /// let context = PrometheusContext::new(15, "MyToken".to_string(), 1212); + /// let context = PrometheusContext::new("15s".to_string(), "MyToken".to_string(), 1212); /// ``` #[must_use] - pub fn new(scrape_interval: u32, api_token: String, api_port: u16) -> Self { + pub fn new(scrape_interval: String, api_token: String, api_port: u16) -> Self { Self { scrape_interval, api_token, @@ -82,7 +81,7 @@ impl PrometheusContext { impl Default for PrometheusContext { fn default() -> Self { Self { - scrape_interval: 15, + scrape_interval: "15s".to_string(), api_token: String::new(), api_port: 1212, } @@ -95,9 +94,9 @@ mod tests { #[test] fn it_should_create_prometheus_context() { - let context = PrometheusContext::new(15, "test_token".to_string(), 1212); + let context = PrometheusContext::new("15s".to_string(), "test_token".to_string(), 1212); - assert_eq!(context.scrape_interval, 15); + assert_eq!(context.scrape_interval, "15s"); assert_eq!(context.api_token, "test_token"); assert_eq!(context.api_port, 1212); } @@ -106,34 +105,34 @@ mod tests { fn it_should_create_default_context() { let context = PrometheusContext::default(); - assert_eq!(context.scrape_interval, 15); + assert_eq!(context.scrape_interval, "15s"); assert_eq!(context.api_token, ""); assert_eq!(context.api_port, 1212); } #[test] fn it_should_serialize_to_json() { - let context = PrometheusContext::new(30, "admin_token".to_string(), 8080); + let context = PrometheusContext::new("30s".to_string(), "admin_token".to_string(), 8080); let json = serde_json::to_value(&context).unwrap(); - assert_eq!(json["scrape_interval"], 30); + assert_eq!(json["scrape_interval"], "30s"); assert_eq!(json["api_token"], "admin_token"); assert_eq!(json["api_port"], 8080); } #[test] fn it_should_support_different_scrape_intervals() { - let fast_scrape = PrometheusContext::new(5, "token".to_string(), 1212); - let slow_scrape = PrometheusContext::new(300, "token".to_string(), 1212); + let fast_scrape = PrometheusContext::new("5s".to_string(), "token".to_string(), 1212); + let slow_scrape = PrometheusContext::new("5m".to_string(), "token".to_string(), 1212); - assert_eq!(fast_scrape.scrape_interval, 5); - assert_eq!(slow_scrape.scrape_interval, 300); + assert_eq!(fast_scrape.scrape_interval, "5s"); + assert_eq!(slow_scrape.scrape_interval, "5m"); } #[test] fn it_should_support_different_ports() { - let default_port = PrometheusContext::new(15, "token".to_string(), 1212); - let custom_port = PrometheusContext::new(15, "token".to_string(), 8080); + let default_port = PrometheusContext::new("15s".to_string(), "token".to_string(), 1212); + let custom_port = PrometheusContext::new("15s".to_string(), "token".to_string(), 8080); assert_eq!(default_port.api_port, 1212); assert_eq!(custom_port.api_port, 8080); diff --git a/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/template.rs b/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/template.rs index 0708366a..ff0663d5 100644 --- a/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/template.rs +++ b/src/infrastructure/templating/prometheus/template/wrapper/prometheus_config/template.rs @@ -150,7 +150,7 @@ scrape_configs: #[test] fn it_should_create_prometheus_template_successfully() { let template_content = sample_template_content(); - let ctx = PrometheusContext::new(15, "test_token".to_string(), 1212); + let ctx = PrometheusContext::new("15s".to_string(), "test_token".to_string(), 1212); let template = PrometheusTemplate::new(template_content, ctx); assert!(template.is_ok()); @@ -159,7 +159,7 @@ scrape_configs: #[test] fn it_should_fail_with_invalid_template_syntax() { let invalid_content = "{{ unclosed".to_string(); - let context = PrometheusContext::new(15, "token".to_string(), 1212); + let context = PrometheusContext::new("15s".to_string(), "token".to_string(), 1212); let result = PrometheusTemplate::new(invalid_content, context); assert!(result.is_err()); @@ -168,7 +168,7 @@ scrape_configs: #[test] fn it_should_render_template_with_context() { let template_content = sample_template_content(); - let ctx = PrometheusContext::new(30, "admin_token".to_string(), 8080); + let ctx = PrometheusContext::new("30s".to_string(), "admin_token".to_string(), 8080); let template = PrometheusTemplate::new(template_content, ctx).expect("Failed to create template"); @@ -183,7 +183,7 @@ scrape_configs: #[test] fn it_should_not_contain_template_syntax_after_rendering() { let template_content = sample_template_content(); - let ctx = PrometheusContext::new(15, "token".to_string(), 1212); + let ctx = PrometheusContext::new("15s".to_string(), "token".to_string(), 1212); let template = PrometheusTemplate::new(template_content, ctx).expect("Failed to create template"); @@ -201,7 +201,7 @@ scrape_configs: let output_path = temp_dir.path().join("prometheus.yml"); let template_content = sample_template_content(); - let ctx = PrometheusContext::new(20, "file_token".to_string(), 9090); + let ctx = PrometheusContext::new("20s".to_string(), "file_token".to_string(), 9090); let template = PrometheusTemplate::new(template_content, ctx).expect("Failed to create template"); @@ -223,7 +223,7 @@ scrape_configs: #[test] fn it_should_provide_access_to_content() { let template_content = sample_template_content(); - let ctx = PrometheusContext::new(15, "token".to_string(), 1212); + let ctx = PrometheusContext::new("15s".to_string(), "token".to_string(), 1212); let template = PrometheusTemplate::new(template_content.clone(), ctx) .expect("Failed to create template"); @@ -234,7 +234,7 @@ scrape_configs: #[test] fn it_should_provide_access_to_context() { let template_content = sample_template_content(); - let ctx = PrometheusContext::new(25, "context_token".to_string(), 7070); + let ctx = PrometheusContext::new("25s".to_string(), "context_token".to_string(), 7070); let template = PrometheusTemplate::new(template_content, ctx.clone()) .expect("Failed to create template"); diff --git a/src/testing/e2e/containers/tracker_ports.rs b/src/testing/e2e/containers/tracker_ports.rs index eba45c9e..0b649abd 100644 --- a/src/testing/e2e/containers/tracker_ports.rs +++ b/src/testing/e2e/containers/tracker_ports.rs @@ -106,6 +106,13 @@ impl E2eConfigEnvironment { "bind_address": format!("0.0.0.0:{}", self.tracker_ports.http_api_port), "admin_token": "MyAccessToken" } + }, + "prometheus": { + "scrape_interval_in_secs": 15 + }, + "grafana": { + "admin_user": "admin", + "admin_password": "e2e-test-password" } }) .to_string() diff --git a/src/testing/e2e/tasks/black_box/generate_config.rs b/src/testing/e2e/tasks/black_box/generate_config.rs index 023ac26f..536700b6 100644 --- a/src/testing/e2e/tasks/black_box/generate_config.rs +++ b/src/testing/e2e/tasks/black_box/generate_config.rs @@ -229,6 +229,9 @@ pub fn create_test_environment_config(environment_name: &str) -> String { "bind_address": "0.0.0.0:1212", "admin_token": "MyAccessToken" } + }, + "prometheus": { + "scrape_interval_in_secs": 15 } }) .to_string() diff --git a/src/testing/e2e/tasks/run_create_command.rs b/src/testing/e2e/tasks/run_create_command.rs index a84a011c..ea106624 100644 --- a/src/testing/e2e/tasks/run_create_command.rs +++ b/src/testing/e2e/tasks/run_create_command.rs @@ -100,6 +100,8 @@ pub fn run_create_command( profile_name: format!("lxd-{environment_name}"), }), TrackerSection::default(), + None, + None, ); // Execute the command diff --git a/src/testing/e2e/tasks/run_release_validation.rs b/src/testing/e2e/tasks/run_release_validation.rs index e56800d3..64eea457 100644 --- a/src/testing/e2e/tasks/run_release_validation.rs +++ b/src/testing/e2e/tasks/run_release_validation.rs @@ -32,6 +32,8 @@ use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; pub struct ServiceValidation { /// Whether to validate Prometheus configuration files pub prometheus: bool, + /// Whether to validate Grafana configuration (no separate config files needed) + pub grafana: bool, } /// Default deployment directory for Docker Compose files @@ -299,6 +301,7 @@ pub async fn run_release_validation( socket_addr = %socket_addr, ssh_user = %ssh_credentials.ssh_username, validate_prometheus = services.prometheus, + validate_grafana = services.grafana, "Running release validation tests" ); diff --git a/src/testing/e2e/tasks/run_run_validation.rs b/src/testing/e2e/tasks/run_run_validation.rs index 53e887ff..3bae6fdd 100644 --- a/src/testing/e2e/tasks/run_run_validation.rs +++ b/src/testing/e2e/tasks/run_run_validation.rs @@ -59,7 +59,7 @@ use tracing::info; use crate::adapters::ssh::SshConfig; use crate::adapters::ssh::SshCredentials; use crate::infrastructure::external_validators::RunningServicesValidator; -use crate::infrastructure::remote_actions::validators::PrometheusValidator; +use crate::infrastructure::remote_actions::validators::{GrafanaValidator, PrometheusValidator}; use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; /// Service validation configuration @@ -71,6 +71,8 @@ use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; pub struct ServiceValidation { /// Whether to validate Prometheus is running and accessible pub prometheus: bool, + /// Whether to validate Grafana is running and accessible + pub grafana: bool, } /// Errors that can occur during run validation @@ -95,6 +97,16 @@ Tip: Ensure Prometheus container is running and accessible on port 9090" #[source] source: RemoteActionError, }, + + /// Grafana smoke test failed + #[error( + "Grafana smoke test failed: {source} +Tip: Ensure Grafana container is running and accessible on port 3100" + )] + GrafanaValidationFailed { + #[source] + source: RemoteActionError, + }, } impl RunValidationError { @@ -166,8 +178,39 @@ For more information, see docs/e2e-testing/." - Check scrape targets: curl http://localhost:9090/api/v1/targets | jq 5. Re-deploy if needed: - - Re-run 'run' command: cargo run -- run - - Or manually: cd /opt/torrust && docker compose up -d prometheus + - Release command: cargo run -- release + - Run command: cargo run -- run + +For more information, see docs/e2e-testing/." + } + Self::GrafanaValidationFailed { .. } => { + "Grafana Smoke Test Failed - Detailed Troubleshooting: + +1. Check Grafana container status: + - SSH to instance: ssh user@instance-ip + - Check container: cd /opt/torrust && docker compose ps + - View Grafana logs: docker compose logs grafana + +2. Verify Grafana is accessible: + - Test from inside VM: curl http://localhost:3100 + - Check if port 3100 is listening: ss -tlnp | grep 3100 + +3. Common issues: + - Grafana container failed to start (check logs) + - Port 3100 already in use by another process + - Invalid admin credentials in environment variables + - Insufficient memory for Grafana + - Grafana depends on Prometheus but Prometheus not running + +4. Debug steps: + - Check environment variables: docker compose exec grafana env | grep GF_ + - Restart Grafana: docker compose restart grafana + - Access Grafana UI: http://:3100 (from your browser) + - Check datasources: curl http://localhost:3100/api/datasources | jq + +5. Re-deploy if needed: + - Release command: cargo run -- release + - Run command: cargo run -- run For more information, see docs/e2e-testing/." } @@ -214,6 +257,7 @@ pub async fn run_run_validation( tracker_api_port = tracker_api_port, http_tracker_ports = ?http_tracker_ports, validate_prometheus = services.prometheus, + validate_grafana = services.grafana, "Running 'run' command validation tests" ); @@ -233,6 +277,10 @@ pub async fn run_run_validation( if services.prometheus { validate_prometheus(ip_addr, ssh_credentials, socket_addr.port()).await?; } + // Optionally validate Grafana is running and accessible + if services.grafana { + validate_grafana(ip_addr, ssh_credentials, socket_addr.port()).await?; + } info!( socket_addr = %socket_addr, @@ -301,3 +349,32 @@ async fn validate_prometheus( Ok(()) } + +/// Validate Grafana is running and accessible via smoke test +/// +/// This function performs a smoke test on Grafana by connecting via SSH +/// and executing a curl command to verify the web UI is accessible. +/// +/// # Note +/// +/// Grafana runs on port 3000 inside the container but is exposed on port 3100 +/// on the host via docker-compose port mapping. Docker published ports bypass +/// UFW firewall, so Grafana is accessible externally. However, for consistency +/// with other validators, we test from inside the VM via SSH. +async fn validate_grafana( + ip_addr: IpAddr, + ssh_credentials: &SshCredentials, + port: u16, +) -> Result<(), RunValidationError> { + info!("Validating Grafana is running and accessible"); + + let ssh_config = SshConfig::new(ssh_credentials.clone(), SocketAddr::new(ip_addr, port)); + + let grafana_validator = GrafanaValidator::new(ssh_config, None); + grafana_validator + .execute(&ip_addr) + .await + .map_err(|source| RunValidationError::GrafanaValidationFailed { source })?; + + Ok(()) +} diff --git a/templates/ansible/deploy-grafana-provisioning.yml b/templates/ansible/deploy-grafana-provisioning.yml new file mode 100644 index 00000000..d0326e78 --- /dev/null +++ b/templates/ansible/deploy-grafana-provisioning.yml @@ -0,0 +1,29 @@ +--- +- name: Deploy Grafana provisioning configuration + hosts: all + become: true + vars_files: + - variables.yml + + tasks: + - name: Create Grafana provisioning directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + loop: + - "{{ deploy_dir }}/storage/grafana/provisioning/datasources" + - "{{ deploy_dir }}/storage/grafana/provisioning/dashboards" + - "{{ deploy_dir }}/storage/grafana/provisioning/dashboards/torrust" + when: grafana_enabled | default(false) + + - name: Deploy Grafana provisioning files + ansible.builtin.copy: + src: "{{ playbook_dir }}/../grafana/provisioning/" + dest: "{{ deploy_dir }}/storage/grafana/provisioning/" + mode: "0644" + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + when: grafana_enabled | default(false) diff --git a/templates/ansible/variables.yml.tera b/templates/ansible/variables.yml.tera index 73d0702b..02f57e23 100644 --- a/templates/ansible/variables.yml.tera +++ b/templates/ansible/variables.yml.tera @@ -9,6 +9,12 @@ # System Configuration ssh_port: {{ ssh_port }} +# Deployment Directory +deploy_dir: /opt/torrust + +# Service Enablement Flags +grafana_enabled: {{ grafana_config is defined }} + # Tracker Firewall Configuration {% if tracker_udp_ports is defined and tracker_udp_ports | length > 0 -%} tracker_udp_ports: diff --git a/templates/docker-compose/.env.tera b/templates/docker-compose/.env.tera index 42e4c99a..bb588687 100644 --- a/templates/docker-compose/.env.tera +++ b/templates/docker-compose/.env.tera @@ -28,3 +28,14 @@ MYSQL_DATABASE='{{ mysql.database }}' MYSQL_USER='{{ mysql.user }}' MYSQL_PASSWORD='{{ mysql.password }}' {% endif %} + +{% if grafana %} +# ============================================================================= +# Grafana Service Configuration +# ============================================================================= + +# Grafana admin credentials +# WARNING: Change default credentials in production deployments for security +GF_SECURITY_ADMIN_USER='{{ grafana.admin_user }}' +GF_SECURITY_ADMIN_PASSWORD='{{ grafana.admin_password }}' +{% endif %} diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index ced74f58..764db543 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -66,9 +66,17 @@ services: networks: - backend_network ports: - - "9090:9090" + - "127.0.0.1:9090:9090" # Localhost only - not exposed to external network + # Grafana accesses Prometheus via Docker network: http://prometheus:9090 + # Host can access for validation via: curl http://localhost:9090 volumes: - ./storage/prometheus/etc:/etc/prometheus:Z + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s logging: options: max-size: "10m" @@ -77,6 +85,41 @@ services: - tracker {% endif %} +{% if grafana_config %} + grafana: + image: grafana/grafana:11.4.0 + container_name: grafana + tty: true + restart: unless-stopped + networks: + - backend_network + ports: + - "3100:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GF_SECURITY_ADMIN_USER} + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD} + volumes: + - grafana_data:/var/lib/grafana + - ./storage/grafana/provisioning:/etc/grafana/provisioning:ro + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + logging: + options: + max-size: "10m" + max-file: "10" + depends_on: +{% if prometheus_config %} + prometheus: + condition: service_healthy +{% else %} + - tracker +{% endif %} +{% endif %} + {% if database.driver == "mysql" %} mysql: image: mysql:8.0 @@ -109,8 +152,14 @@ services: networks: backend_network: {} -{% if database.driver == "mysql" %} +{% if database.driver == "mysql" or grafana_config %} volumes: +{%- if database.driver == "mysql" %} mysql_data: driver: local +{%- endif %} +{%- if grafana_config %} + grafana_data: + driver: local +{%- endif %} {% endif %} diff --git a/templates/grafana/provisioning/dashboards/torrust.yml b/templates/grafana/provisioning/dashboards/torrust.yml new file mode 100644 index 00000000..370234ae --- /dev/null +++ b/templates/grafana/provisioning/dashboards/torrust.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: "Torrust Dashboards" + orgId: 1 + folder: "Torrust Tracker" + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards/torrust + foldersFromFilesStructure: false diff --git a/templates/grafana/provisioning/dashboards/torrust/metrics.json b/templates/grafana/provisioning/dashboards/torrust/metrics.json new file mode 100644 index 00000000..c95b981b --- /dev/null +++ b/templates/grafana/provisioning/dashboards/torrust/metrics.json @@ -0,0 +1,1424 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Using metric endpoint:\n\nhttps://tracker.example.com/api/v1/metrics?token=MyAccessToken&format=prometheus", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 3, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "tracker_core_persistent_torrents_downloads_total{job=\"tracker_metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Completed", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "swarm_coordination_registry_torrents_total{job=\"tracker_metrics\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Torrents", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "code", + "expr": "swarm_coordination_registry_peer_connections_total{job=\"tracker_metrics\", peer_role=\"seeder\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Seeders", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "swarm_coordination_registry_peer_connections_total{job=\"tracker_metrics\", peer_role=\"leecher\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Leechers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 19, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_requests_accepted_total{job=\"tracker_metrics\", request_kind=\"connect\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Connections (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 5 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_requests_accepted_total{job=\"tracker_metrics\", request_kind=\"announce\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Announces (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 21, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_requests_accepted_total{job=\"tracker_metrics\", request_kind=\"scrape\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Scrapes (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_errors_total{job=\"tracker_metrics\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Errors (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP4 Average Connect Processing Time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 14 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "avg(udp_tracker_server_performance_avg_processing_time_ns{job=\"tracker_metrics\", request_kind=\"connect\", server_binding_address_ip_family=\"inet\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Average Connect Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP4 Average Announce Processing Time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 14 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "avg(udp_tracker_server_performance_avg_processing_time_ns{job=\"tracker_metrics\", request_kind=\"announce\", server_binding_address_ip_family=\"inet\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Average Announce Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP4 Average Scrape Processing Time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 14 + }, + "id": 18, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "avg(udp_tracker_server_performance_avg_processing_time_ns{job=\"tracker_metrics\", request_kind=\"scrape\", server_binding_address_ip_family=\"inet\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Average Scrape Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP4 Banned Requests (per sec)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 14 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_requests_banned_total{job=\"tracker_metrics\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Banned Requests (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP 4 requests and responses", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 13, + "w": 18, + "x": 0, + "y": 19 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_requests_received_total{job=\"tracker_metrics\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_responses_sent_total{job=\"tracker_metrics\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "UDP4 Requests and Responses (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP Banned IPs", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 19 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(udp_tracker_server_ips_banned_total{job=\"tracker_metrics\"})", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP Banned IPs", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 25 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(udp_tracker_server_requests_aborted_total{job=\"tracker_metrics\", server_binding_address_ip_family=\"inet\"}[15m]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 aborted requests (per second)", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 40, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-3h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Torrust Live Demo Tracker (metrics)", + "uid": "deogmiudufm68d", + "version": 50, + "weekStart": "" +} \ No newline at end of file diff --git a/templates/grafana/provisioning/dashboards/torrust/stats.json b/templates/grafana/provisioning/dashboards/torrust/stats.json new file mode 100644 index 00000000..c53ea60f --- /dev/null +++ b/templates/grafana/provisioning/dashboards/torrust/stats.json @@ -0,0 +1,1420 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Using stats endpoint:\n\nhttps://tracker.example.com/api/v1/stats?token=MyAccessToken&format=prometheus", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "completed{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Completed", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "torrents{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Torrents", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "seeders{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Seeders", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "leechers{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Leechers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 19, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(udp4_connections_handled{job=\"tracker_stats\"}[15m])", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Connections (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 5 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(udp4_announces_handled[15m])", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Announces (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 21, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(udp4_scrapes_handled[15m])", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Scrapes (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request per second in 15 minutes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(udp4_errors_handled[15m])", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP4 Errors (per sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP Average Connect Processing Time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 14 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "udp_avg_connect_processing_time_ns{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP Average Connect Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP Average Announce Processing Time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 14 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "udp_avg_announce_processing_time_ns{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP Average Announce Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP Average Scrape Processing Time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 14 + }, + "id": 18, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "udp_avg_scrape_processing_time_ns{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP Average Scrape Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP banned requests (per second)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 14 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(udp_requests_banned{job=\"tracker_stats\"}[15m])", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP banned requests (per second)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP 4 requests and responses", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 13, + "w": 18, + "x": 0, + "y": 19 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(udp4_requests{job=\"tracker_stats\"}[15m])", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(udp4_responses[15m])", + "hide": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B" + } + ], + "title": "UDP4 requests and responses (per second)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "UDP Banned IPs", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 19 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "udp_banned_ips_total{job=\"tracker_stats\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP Banned IPs", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 25 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(udp_requests_aborted{job=\"tracker_stats\"}[15m])", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "UDP aborted requests (per second)", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 40, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Torrust Live Demo Tracker (stats)", + "uid": "de6lx6hce8fswc", + "version": 93, + "weekStart": "" +} \ No newline at end of file diff --git a/templates/grafana/provisioning/datasources/prometheus.yml.tera b/templates/grafana/provisioning/datasources/prometheus.yml.tera new file mode 100644 index 00000000..1b480452 --- /dev/null +++ b/templates/grafana/provisioning/datasources/prometheus.yml.tera @@ -0,0 +1,13 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: "{{ prometheus_scrape_interval_in_secs }}s" + httpMethod: POST