diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4f809db..1bd49cc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,5 @@ # GitLab CI/CD Configuration for Towerops Agent -# Builds and publishes Docker image to GitLab Container Registry +# Builds and publishes Docker image to Docker Hub stages: - test @@ -7,9 +7,9 @@ stages: - release variables: - # GitLab Container Registry - REGISTRY: registry.gitlab.com - IMAGE_NAME: towerops/towerops-agent + # Docker Hub Registry + REGISTRY: docker.io + IMAGE_NAME: gmcintire/towerops-agent # Docker BuildKit for better caching DOCKER_BUILDKIT: 1 DOCKER_TLS_CERTDIR: "/certs" @@ -17,6 +17,8 @@ variables: # Test stage - compile and check code test: stage: test + tags: + - vntx image: rust:1.83-alpine before_script: - apk add --no-cache musl-dev @@ -37,11 +39,13 @@ test: # Build Docker image for branches (test build) build:test: stage: build + tags: + - vntx image: docker:24-dind services: - docker:24-dind before_script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN script: - | docker build \ @@ -59,11 +63,13 @@ build:test: # Build and push for main branch build:main: stage: build + tags: + - vntx image: docker:24-dind services: - docker:24-dind before_script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN script: - | docker build \ @@ -82,11 +88,13 @@ build:main: # Release build for tags (versioned releases) release: stage: release + tags: + - vntx image: docker:24-dind services: - docker:24-dind before_script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN script: - | # Extract version from tag (assumes tags like v0.1.0) @@ -114,11 +122,13 @@ release: build:multiarch: stage: build + tags: + - vntx image: docker:24-dind services: - docker:24-dind before_script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN # Enable buildx for multi-arch - docker buildx create --use --name multiarch-builder - docker buildx inspect --bootstrap diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ddaf83a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,390 @@ +# Towerops Agent - Development Notes + +This file provides context for Claude Code when working on the Rust agent. + +## Project Overview + +Lightweight Rust agent for remote SNMP polling. Deployed on customer networks to poll local SNMP devices and report metrics to Towerops API via HTTPS. + +## What's Complete ✅ + +### Architecture & Design +- [x] Complete module structure (12 source files) +- [x] Configuration types matching Phoenix API responses +- [x] Metric types (SensorReading, InterfaceStat) with proper serialization +- [x] Event loop with 5 concurrent tasks (tokio::select!) +- [x] SQLite buffering with 24-hour retention +- [x] Error types and result handling throughout + +### Core Functionality +- [x] **API Client** (`api_client.rs`) + - fetch_config() - GET /api/v1/agent/config + - submit_metrics() - POST /api/v1/agent/metrics + - heartbeat() - POST /api/v1/agent/heartbeat + - Uses reqwest with rustls-tls (30s timeout) + +- [x] **Storage** (`buffer/storage.rs`) + - store_metric() - Save metrics to SQLite + - get_pending_metrics() - Retrieve unsent metrics + - mark_metrics_sent() - Track submission + - cleanup_old_metrics() - Remove old data + - Last poll time tracking per equipment + +- [x] **Scheduler** (`poller/scheduler.rs`) + - Config refresh every 5 minutes + - Metrics flush every 30 seconds + - Heartbeat every 60 seconds + - Cleanup every hour + - Poll check every 5 seconds + +- [x] **Executor** (`poller/executor.rs`) + - poll_sensors() - Poll configured sensors + - poll_interfaces() - Poll interface statistics + - Parallel polling with tokio::join! + - Applies sensor divisors + +- [x] **Main** (`main.rs`) + - CLI with clap (--api-url, --token, etc.) + - Environment variable support + - Logging with tracing + - Graceful startup/shutdown + +### Build & Deployment +- [x] Cargo.toml with optimized release profile + - opt-level = "z" + - lto = true + - codegen-units = 1 + - strip = true +- [x] Multi-stage Dockerfile (Alpine, ~10-20 MB) +- [x] docker-compose.example.yml +- [x] README with user documentation +- [x] .gitignore and .dockerignore + +### Build Status +```bash +✅ cargo build --release - SUCCESS +⚠️ 5 warnings (unused code - expected) +📦 Target size optimized for minimal footprint +``` + +## What's Incomplete 🔄 + +### CRITICAL: SNMP Library Integration + +**Location**: `src/snmp/client.rs` + +**Problem**: The `snmp` crate v0.2 API is different than initially designed. Current implementation: +- Validates IP addresses +- Returns error for actual SNMP operations +- Compiles successfully but doesn't poll + +**Current Code**: +```rust +pub async fn get(...) -> SnmpResult { + // Validates IP + // Returns: "SNMP implementation incomplete" +} + +pub async fn walk(...) -> SnmpResult> { + // Validates IP + // Returns: empty vec +} +``` + +**What's Needed**: + +1. Research the correct `snmp` crate 0.2 API: +```bash +cargo doc --open --package snmp +``` + +2. Implement actual SNMP operations: + - Create session with proper parameters + - Perform GET request for single OID + - Perform GETNEXT/WALK for OID subtrees + - Handle response parsing + - Map errors (timeout, auth failure, etc.) + +3. Key questions to answer: + - How to create SyncSession? (signature changed) + - How to send GET request? (method name/signature) + - How to send GETNEXT? (for walk implementation) + - How to parse responses? (varbinds format) + - How to handle errors? (error enum changed) + +4. Reference implementation: + - Check `snmp` crate examples directory + - Look at crate tests for usage patterns + - May need to read source code directly + +**Alternatives if `snmp` v0.2 doesn't work**: +- `snmp-mp` crate (more actively maintained) +- `snmp-parser` crate (lower level, more control) +- `rasn-snmp` crate (ASN.1 based) + +**Estimated Effort**: 4-8 hours + +### Testing Gaps + +- [ ] Unit tests for SNMP client (depends on SNMP integration) +- [ ] Unit tests for storage (SQLite operations) +- [ ] Unit tests for API client (mock server) +- [ ] Integration test with real SNMP device + +## Development Workflow + +### Quick Start + +1. **Build the agent**: +```bash +cargo build --release +``` + +2. **Run locally** (needs Phoenix backend running): +```bash +cargo run -- \ + --api-url http://localhost:4000 \ + --token \ + --database-path ./test.db +``` + +3. **Watch logs**: +```bash +RUST_LOG=debug cargo run -- ... +``` + +### Testing Changes + +1. **Check compilation**: +```bash +cargo check +``` + +2. **Run tests**: +```bash +cargo test +``` + +3. **Format code**: +```bash +cargo fmt +``` + +4. **Check for issues**: +```bash +cargo clippy +``` + +### Docker Testing + +1. **Build image**: +```bash +docker build -t towerops-agent:test . +``` + +2. **Run container**: +```bash +docker run --rm \ + -e TOWEROPS_API_URL=http://host.docker.internal:4000 \ + -e TOWEROPS_AGENT_TOKEN= \ + -e RUST_LOG=info \ + -v $(pwd)/data:/data \ + towerops-agent:test +``` + +### CI/CD Pipeline + +**Automated builds** via GitLab CI (`.gitlab-ci.yml`): +- Push to branch → test + build with branch tag +- Push to main → test + build + tag as `latest` +- Create tag (e.g., `v0.1.0`) → test + build + release + +**Registry**: `registry.gitlab.com/towerops/towerops-agent` + +**See**: `DEPLOYMENT.md` for complete CI/CD documentation + +## Integration with Phoenix Backend + +### API Endpoints (from agent perspective) + +**GET /api/v1/agent/config** +- Headers: `Authorization: Bearer ` +- Response: Equipment list with sensors and interfaces +- Called every 5 minutes + +**POST /api/v1/agent/metrics** +- Headers: `Authorization: Bearer ` +- Body: `{"metrics": [...]}` +- Response: `{"status": "accepted", "received": N}` +- Called every 30 seconds with pending metrics + +**POST /api/v1/agent/heartbeat** +- Headers: `Authorization: Bearer ` +- Body: `{"version": "0.1.0", "hostname": "...", "uptime_seconds": 3600}` +- Response: `{"status": "ok"}` +- Called every 60 seconds + +### Getting a Test Token + +1. Start Phoenix: `mix phx.server` +2. Navigate to: `http://localhost:4000/orgs/:slug/agents` +3. Click "Create New Agent" +4. Copy the token (shown only once) +5. Use in agent: `--token ` + +## Architecture Decisions + +### Why Tokio? +- Async event loop for efficient I/O +- Multiple concurrent timers (config, metrics, heartbeat) +- Non-blocking SNMP operations via spawn_blocking + +### Why SQLite? +- Embedded, no external dependencies +- Persist metrics during API outages +- Small footprint (~100 MB for 24h of metrics) +- No configuration needed + +### Why Rust? +- Small binary size (~10-20 MB with Alpine) +- Low memory usage (<256 MB typical) +- Cross-compile to multiple architectures +- Strong type safety for reliability + +### Why Simplified SNMP? +- The `snmp` crate v0.2 API required research +- Chose to ship architecture first, SNMP second +- Allows testing of everything except SNMP polling +- Can be completed independently + +## Common Issues + +### "SNMP implementation incomplete" Error +**Expected**: This is the TODO for SNMP integration. The agent runs but can't poll devices yet. + +### "Failed to fetch config" Error +**Check**: +1. Is Phoenix backend running? +2. Is the token valid (not revoked)? +3. Is the API URL correct? +4. Is there network connectivity? + +### High Memory Usage +**Check**: +1. Database size: `ls -lh /data/towerops-agent.db` +2. Are metrics being submitted? (check logs) +3. Is cleanup running? (should see log every hour) + +### Agent Not Showing as Online +**Check**: +1. Is heartbeat working? (check Phoenix logs) +2. Check `last_seen_at` in database: `SELECT last_seen_at FROM agent_tokens WHERE token_hash = ...` +3. Time sync between agent and server + +## File Organization + +``` +towerops-agent/ +├── src/ +│ ├── main.rs # Entry point, CLI, initialization +│ ├── config.rs # Types matching API responses +│ ├── api_client.rs # HTTP client for Towerops API +│ ├── metrics/ +│ │ └── mod.rs # Metric types (SensorReading, InterfaceStat) +│ ├── snmp/ +│ │ ├── mod.rs # Module exports +│ │ ├── client.rs # ⚠️ INCOMPLETE - needs SNMP integration +│ │ └── types.rs # SNMP types and errors +│ ├── buffer/ +│ │ ├── mod.rs # Module exports +│ │ └── storage.rs # SQLite buffering +│ └── poller/ +│ ├── mod.rs # Module exports +│ ├── executor.rs # Poll execution logic +│ └── scheduler.rs # Main event loop +├── Cargo.toml # Dependencies and build config +├── Dockerfile # Multi-stage build +├── README.md # User documentation +└── CLAUDE.md # This file +``` + +## Dependencies + +**Key Crates**: +- `tokio` - Async runtime with full features +- `reqwest` - HTTP client (rustls-tls, no default features) +- `rusqlite` - SQLite (bundled) +- `serde` + `serde_json` - Serialization +- `snmp` - SNMP operations (v0.2) ⚠️ needs integration +- `tracing` + `tracing-subscriber` - Logging +- `clap` - CLI argument parsing +- `chrono` - Timestamps +- `anyhow` + `thiserror` - Error handling +- `hostname` - Get system hostname + +## Next Actions + +**Immediate** (to make agent functional): +1. Research `snmp` crate v0.2 API documentation +2. Update `src/snmp/client.rs` with working implementation +3. Test with a real SNMP device or simulator +4. Add unit tests for SNMP operations + +**Short-term** (for production readiness): +1. Add more comprehensive unit tests +2. Integration test with mock SNMP device +3. Load test with 100 devices +4. Stability test (7+ days continuous) + +**Long-term** (nice to have): +1. SNMPv3 support +2. Agent-side threshold filtering +3. Configurable sampling rates +4. Agent health metrics endpoint + +## Resources + +- **Main Implementation Doc**: `/Users/graham/dev/towerops/AGENT_IMPLEMENTATION.md` +- **Next Steps Guide**: `/Users/graham/dev/towerops/AGENT_NEXT_STEPS.md` +- **SNMP Crate Docs**: https://docs.rs/snmp/0.2.2/snmp/ +- **SNMP Crate Source**: https://github.com/hroi/snmp-rs + +## Success Criteria + +Agent is production-ready when: +- [x] Compiles successfully +- [x] Docker image builds +- [x] API client works (config, metrics, heartbeat) +- [x] SQLite buffering works +- [x] Event loop runs without panics +- [ ] **SNMP polling works** ← ONLY REMAINING ITEM +- [ ] Metrics appear in Phoenix database +- [ ] Survives 24h API outage +- [ ] Uses <256 MB memory with 50 devices +- [ ] Runs for 7+ days without issues + +## Notes for Future Development + +### Adding New Metric Types +1. Add variant to `Metric` enum in `src/metrics/mod.rs` +2. Update `metric_type()` and `timestamp()` methods +3. Update Phoenix API to accept new type +4. Add serialization test + +### Adding New Configuration Fields +1. Update structs in `src/config.rs` +2. Update Phoenix API `build_equipment_config/1` +3. Consider backwards compatibility + +### Debugging SNMP Issues +- Set `RUST_LOG=debug` to see all SNMP operations +- Check IP reachability: `ping ` +- Test SNMP manually: `snmpget -v2c -c public ` +- Verify community string is correct +- Check firewall rules (UDP port 161) + +--- + +**Last Updated**: January 9, 2026 +**Status**: Architecture complete, SNMP integration pending +**Version**: 0.1.0 (pre-release) diff --git a/Cargo.lock b/Cargo.lock index 42a7057..271cded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "base64" version = "0.22.1" @@ -85,6 +91,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "cc" version = "1.2.52" @@ -158,6 +170,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "env_filter" version = "0.1.4" @@ -181,6 +199,22 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -193,12 +227,24 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -208,6 +254,18 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -217,13 +275,19 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -345,12 +409,31 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -398,6 +481,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -416,6 +505,12 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "once_cell" version = "1.21.3" @@ -434,6 +529,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -470,6 +575,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -479,6 +594,58 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "quote" version = "1.0.43" @@ -488,6 +655,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "regex" version = "1.12.2" @@ -531,6 +704,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "serde" version = "1.0.228" @@ -626,6 +812,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -685,6 +884,9 @@ dependencies = [ "env_logger", "hostname", "log", + "prost", + "prost-build", + "prost-types", "rusqlite", "serde", "serde_json", @@ -750,6 +952,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -765,6 +976,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 6659c6c..f87886a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,11 @@ env_logger = "0.11" thiserror = "1.0" clap = { version = "4.0", features = ["derive", "env"] } hostname = "0.4" +prost = "0.13" +prost-types = "0.13" + +[build-dependencies] +prost-build = "0.13" [profile.release] opt-level = "z" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..235d5e1 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + prost_build::compile_protos(&["proto/agent.proto"], &["proto/"]).unwrap(); +} diff --git a/proto/agent.proto b/proto/agent.proto new file mode 100644 index 0000000..bacdbce --- /dev/null +++ b/proto/agent.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package towerops.agent; + +// Configuration received from the API +message AgentConfig { + string version = 1; + uint32 poll_interval_seconds = 2; + repeated Equipment equipment = 3; +} + +message Equipment { + string id = 1; + string name = 2; + string ip_address = 3; + SnmpConfig snmp = 4; + uint32 poll_interval_seconds = 5; + repeated Sensor sensors = 6; + repeated Interface interfaces = 7; +} + +message SnmpConfig { + bool enabled = 1; + string version = 2; + string community = 3; + uint32 port = 4; +} + +message Sensor { + string id = 1; + string type = 2; + string oid = 3; + double divisor = 4; + string unit = 5; + map metadata = 6; +} + +message Interface { + string id = 1; + uint32 if_index = 2; + string if_name = 3; +} + +// Metrics submitted to the API +message MetricBatch { + repeated Metric metrics = 1; +} + +message Metric { + oneof metric_type { + SensorReading sensor_reading = 1; + InterfaceStat interface_stat = 2; + } +} + +message SensorReading { + string sensor_id = 1; + double value = 2; + string status = 3; + int64 timestamp = 4; // Unix timestamp in seconds +} + +message InterfaceStat { + string interface_id = 1; + int64 if_in_octets = 2; + int64 if_out_octets = 3; + int64 if_in_errors = 4; + int64 if_out_errors = 5; + int64 if_in_discards = 6; + int64 if_out_discards = 7; + int64 timestamp = 8; // Unix timestamp in seconds +} + +// Heartbeat metadata +message HeartbeatMetadata { + string version = 1; + string hostname = 2; + uint64 uptime_seconds = 3; +} + +message HeartbeatResponse { + string status = 1; +} diff --git a/src/api_client.rs b/src/api_client.rs index 2d73123..5170749 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -1,5 +1,7 @@ use crate::config::{AgentConfig, HeartbeatMetadata}; use crate::metrics::Metric; +use crate::proto::agent; +use prost::Message; use serde_json::json; use std::time::Duration; use thiserror::Error; @@ -63,7 +65,7 @@ impl ApiClient { Ok(config) } - /// Submit metrics to the API + /// Submit metrics to the API using Protocol Buffers pub async fn submit_metrics(&self, metrics: Vec) -> Result<()> { if metrics.is_empty() { return Ok(()); @@ -73,10 +75,28 @@ impl ApiClient { let token = self.token.clone(); tokio::task::spawn_blocking(move || { + // Convert metrics to protobuf + let proto_metrics: Vec = metrics + .into_iter() + .map(|m| convert_metric_to_proto(&m)) + .collect(); + + let batch = agent::MetricBatch { + metrics: proto_metrics, + }; + + // Encode to bytes + let mut buf = Vec::new(); + batch + .encode(&mut buf) + .map_err(|e| ApiError::RequestFailed(format!("Protobuf encoding error: {}", e)))?; + + // Send as protobuf let response = ureq::post(&url) .set("Authorization", &format!("Bearer {}", token)) + .set("Content-Type", "application/x-protobuf") .timeout(Duration::from_secs(30)) - .send_json(json!({ "metrics": metrics })) + .send_bytes(&buf) .map_err(|e| ApiError::RequestFailed(e.to_string()))?; let status = response.status(); @@ -117,3 +137,35 @@ impl ApiClient { Ok(()) } } + +/// Convert internal Metric type to protobuf Metric +fn convert_metric_to_proto(metric: &Metric) -> agent::Metric { + use crate::metrics::Metric as M; + + match metric { + M::SensorReading(sr) => agent::Metric { + metric_type: Some(agent::metric::MetricType::SensorReading( + agent::SensorReading { + sensor_id: sr.sensor_id.clone(), + value: sr.value, + status: sr.status.clone(), + timestamp: sr.timestamp.to_unix_timestamp(), + }, + )), + }, + M::InterfaceStat(is) => agent::Metric { + metric_type: Some(agent::metric::MetricType::InterfaceStat( + agent::InterfaceStat { + interface_id: is.interface_id.clone(), + if_in_octets: is.if_in_octets, + if_out_octets: is.if_out_octets, + if_in_errors: is.if_in_errors, + if_out_errors: is.if_out_errors, + if_in_discards: is.if_in_discards, + if_out_discards: is.if_out_discards, + timestamp: is.timestamp.to_unix_timestamp(), + }, + )), + }, + } +} diff --git a/src/main.rs b/src/main.rs index 2165875..06a9af7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod buffer; mod config; mod metrics; mod poller; +mod proto; mod snmp; use clap::Parser; diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 6cc6090..8db631f 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -21,6 +21,10 @@ impl Timestamp { Self::now().secs - self.secs } + pub fn to_unix_timestamp(&self) -> i64 { + self.secs + } + pub fn to_rfc3339(self) -> String { // Convert Unix timestamp to RFC3339 format // This is a simplified implementation that produces UTC timestamps diff --git a/src/proto.rs b/src/proto.rs new file mode 100644 index 0000000..b4cbc09 --- /dev/null +++ b/src/proto.rs @@ -0,0 +1,4 @@ +// Generated protobuf code +pub mod agent { + include!(concat!(env!("OUT_DIR"), "/towerops.agent.rs")); +}