From 0ce79935d7f0b151ae59de27f3f32015188fc0cd Mon Sep 17 00:00:00 2001 From: Nathan Woodburn Date: Thu, 26 Mar 2026 23:07:05 +1100 Subject: [PATCH] feat: Initial code --- .gitea/workflows/build.yml | 22 ++ README.md | 134 ++++++++++-- TODO.md | 0 collectors/__init__.py | 3 + collectors/base.py | 17 ++ collectors/coolify.py | 146 +++++++++++++ collectors/docker_hosts.py | 152 ++++++++++++++ collectors/nginx_from_agent.py | 80 +++++++ collectors/orchestrator.py | 73 +++++++ collectors/proxmox.py | 243 +++++++++++++++++++++ config.py | 77 +++++++ database.py | 305 +++++++++++++++++++++++++++ docker_agent/Dockerfile | 9 + docker_agent/app.py | 290 ++++++++++++++++++++++++++ docker_agent/docker-compose.yml | 15 ++ docker_agent/requirements.txt | 2 + inventory.db | Bin 0 -> 446464 bytes pyproject.toml | 5 +- server.py | 284 ++++++++++++++++++++++--- services/__init__.py | 3 + services/scheduler.py | 57 +++++ templates/assets/css/index.css | 359 +++++++++++++++++++++++++++++--- templates/index.html | 332 +++++++++++++++++++++++++---- uv.lock | 62 +++--- 24 files changed, 2527 insertions(+), 143 deletions(-) create mode 100644 TODO.md create mode 100644 collectors/__init__.py create mode 100644 collectors/base.py create mode 100644 collectors/coolify.py create mode 100644 collectors/docker_hosts.py create mode 100644 collectors/nginx_from_agent.py create mode 100644 collectors/orchestrator.py create mode 100644 collectors/proxmox.py create mode 100644 config.py create mode 100644 database.py create mode 100644 docker_agent/Dockerfile create mode 100644 docker_agent/app.py create mode 100644 docker_agent/docker-compose.yml create mode 100644 docker_agent/requirements.txt create mode 100644 inventory.db create mode 100644 services/__init__.py create mode 100644 services/scheduler.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 24d24ac..d5f5158 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -43,3 +43,25 @@ jobs: docker push git.woodburn.au/nathanwoodburn/$repo:$tag_num docker tag $repo:$tag_num git.woodburn.au/nathanwoodburn/$repo:$tag docker push git.woodburn.au/nathanwoodburn/$repo:$tag + + - name: Build Docker image for agent + run : | + echo "${{ secrets.DOCKERGIT_TOKEN }}" | docker login git.woodburn.au -u nathanwoodburn --password-stdin + echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + tag=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} + tag=${tag//\//-} + tag_num=${GITHUB_RUN_NUMBER} + echo "tag_num=$tag_num" + if [[ "$tag" == "main" ]]; then + tag="latest" + else + tag_num="${tag}-${tag_num}" + fi + repo="docker-inventory-agent" + echo "container=$repo" + cd docker_agent + docker build -t $repo:$tag_num . + docker tag $repo:$tag_num git.woodburn.au/nathanwoodburn/$repo:$tag_num + docker push git.woodburn.au/nathanwoodburn/$repo:$tag_num + docker tag $repo:$tag_num git.woodburn.au/nathanwoodburn/$repo:$tag + docker push git.woodburn.au/nathanwoodburn/$repo:$tag diff --git a/README.md b/README.md index 2e67207..3223f3c 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,141 @@ -# Python Flask Webserver Template +# Home Lab Inventory -Python3 website template including git actions +Flask inventory system for homelab environments with automatic data collection and a dashboard UI. + +## Features +- SQLite persistence for inventory, source health, and collection history +- Automatic scheduled polling with manual trigger API +- Connectors for: + - Proxmox (VM and LXC) + - Docker hosts + - Coolify instances + - Nginx config ingestion from Docker agents +- Dashboard with topology cards and filterable inventory table ## Requirements +- Python 3.13+ - UV ## Development -1. Install project requirements +1. Install dependencies ```bash uv sync ``` -2. Run the dev server +2. Start app ```bash uv run python3 server.py ``` -3. Alternatively use the virtual environment -```bash -source .venv/bin/activate -``` -You can exit the environment with `deactivate` - -For best development setup, you should install the git hook for pre-commit +3. Optional pre-commit hooks ```bash uv run pre-commit install ``` - ## Production -Run using the main.py file ```bash python3 main.py ``` + +## Environment Variables + +### Core +- `APP_NAME` default: `Home Lab Inventory` +- `BASE_DIR` default: current directory +- `DATABASE_PATH` default: `${BASE_DIR}/inventory.db` +- `SCHEDULER_ENABLED` default: `true` +- `POLL_INTERVAL_SECONDS` default: `300` +- `INITIAL_COLLECT_ON_STARTUP` default: `true` +- `REQUEST_TIMEOUT_SECONDS` default: `10` +- `ADMIN_TOKEN` optional, required in `X-Admin-Token` for manual trigger when set + +### Proxmox +- `PROXMOX_ENABLED` default: `true` +- `PROXMOX_ENDPOINTS` comma-separated URLs +- Token auth: + - `PROXMOX_TOKEN_ID` + - `PROXMOX_TOKEN_SECRET` +- Or username/password auth: + - `PROXMOX_USER` + - `PROXMOX_PASSWORD` +- `PROXMOX_VERIFY_TLS` default: `false` + +### Docker +- `DOCKER_ENABLED` default: `true` +- `DOCKER_HOSTS` comma-separated Docker API endpoints +- `DOCKER_HOST` single Docker endpoint (used if `DOCKER_HOSTS` is empty) +- `DOCKER_BEARER_TOKEN` optional +- `DOCKER_AGENT_ENDPOINTS` comma-separated inventory agent URLs +- `DOCKER_AGENT_TOKEN` bearer token used by inventory agents + +Docker endpoint examples: +```text +DOCKER_HOST=unix:///var/run/docker.sock +DOCKER_HOSTS=tcp://docker-1:2376,tcp://docker-2:2376,https://docker-3.example/api +DOCKER_AGENT_ENDPOINTS=https://docker-a-agent:9090,https://docker-b-agent:9090,https://docker-c-agent:9090 +DOCKER_AGENT_TOKEN=change-me +``` + +### Coolify +- `COOLIFY_ENABLED` default: `true` +- `COOLIFY_ENDPOINTS` comma-separated URLs +- `COOLIFY_API_TOKEN` + +### Nginx (via Docker Agent) +- Nginx config data is collected from each Docker agent endpoint (`/api/v1/nginx-configs`). +- No separate NPM API credentials are required in the inventory app. + +## API Endpoints +- `GET /api/v1/summary` +- `GET /api/v1/topology` +- `GET /api/v1/assets` +- `GET /api/v1/sources` +- `GET /api/v1/health` +- `POST /api/v1/collect/trigger` + +## Docker Notes +- Persist DB with a volume mounted into `BASE_DIR` or set `DATABASE_PATH` +- If you need local Docker socket collection, expose Docker API via a secure endpoint or add your preferred socket proxy + +## Multi-Server Docker Agent Pattern +- For one local Docker host, use `DOCKER_HOST=unix:///var/run/docker.sock`. +- For multiple Docker servers, run a small inventory agent on each Docker server and poll those agent endpoints from this app. +- Agent responsibilities: + - Read local Docker socket (`/var/run/docker.sock`) on that host. + - Optionally read mounted Nginx configuration files. + - Expose read-only inventory JSON over HTTPS. + - Authenticate requests with a bearer token. + - Return container name, image, state, ports, and networks. +- This avoids exposing Docker daemon APIs directly across your network. + +## Docker Agent Quick Start +Use the `docker_agent/` folder to run one agent per Docker server. + +1. On each Docker server, copy the folder and start it: +```bash +cd docker_agent +docker compose up -d --build +``` + +2. Set an agent token on each server (`AGENT_TOKEN`) and expose port `9090` only to your inventory app network. + +Nginx config support in docker agent: +- Mount your Nginx config directory into the agent container. +- Set `NGINX_CONFIG_DIR` to the mounted path. +- Query `GET /api/v1/nginx-configs` on the agent. + +Example compose mount in `docker_agent/docker-compose.yml`: +```yaml +volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /etc/nginx:/mnt/nginx:ro +environment: + NGINX_CONFIG_DIR: /mnt/nginx +``` + +3. In the inventory app `.env`, set: +```text +DOCKER_ENABLED=true +DOCKER_AGENT_ENDPOINTS=http://docker1.local:9090,http://docker2.local:9090,http://docker3.local:9090 +DOCKER_AGENT_TOKEN=change-me +``` + +4. Trigger a collection in the UI and confirm `docker` source reports `ok` in the Sources panel. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e69de29 diff --git a/collectors/__init__.py b/collectors/__init__.py new file mode 100644 index 0000000..88d6f98 --- /dev/null +++ b/collectors/__init__.py @@ -0,0 +1,3 @@ +from .orchestrator import InventoryCollectorOrchestrator + +__all__ = ["InventoryCollectorOrchestrator"] diff --git a/collectors/base.py b/collectors/base.py new file mode 100644 index 0000000..1ee6a1c --- /dev/null +++ b/collectors/base.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Dict, List + + +@dataclass +class CollectionResult: + source: str + assets: List[Dict] + status: str + error: str = "" + + +class BaseCollector: + source_name = "unknown" + + def collect(self) -> CollectionResult: + raise NotImplementedError diff --git a/collectors/coolify.py b/collectors/coolify.py new file mode 100644 index 0000000..999cbd7 --- /dev/null +++ b/collectors/coolify.py @@ -0,0 +1,146 @@ +from typing import Dict, List + +import requests + +from config import AppConfig +from .base import BaseCollector, CollectionResult + + +class CoolifyCollector(BaseCollector): + source_name = "coolify" + + def __init__(self, config: AppConfig): + self.config = config + + def collect(self) -> CollectionResult: + if not self.config.coolify_enabled: + return CollectionResult(source=self.source_name, assets=[], status="disabled") + if not self.config.coolify_endpoints: + return CollectionResult(source=self.source_name, assets=[], status="skipped", error="No COOLIFY_ENDPOINTS configured") + if not self.config.coolify_api_token: + return CollectionResult(source=self.source_name, assets=[], status="skipped", error="No COOLIFY_API_TOKEN configured") + + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.config.coolify_api_token}", + } + + assets: List[Dict] = [] + errors: List[str] = [] + + for endpoint in self.config.coolify_endpoints: + base = endpoint.rstrip("/") + try: + resp = requests.get( + f"{base}/api/v1/applications", + headers=headers, + timeout=self.config.request_timeout_seconds, + ) + resp.raise_for_status() + for app in self._extract_app_list(resp.json()): + app_status = self._derive_status(app) + assets.append( + { + "asset_type": "service", + "external_id": str(app.get("id", app.get("uuid", "unknown-app"))), + "name": app.get("name", "unknown-service"), + "hostname": app.get("fqdn") or app.get("name"), + "status": app_status, + "ip_addresses": [], + "node": endpoint, + "metadata": { + "coolify_uuid": app.get("uuid"), + "environment": app.get("environment_name"), + "repository": app.get("git_repository"), + "raw_status": app.get("status"), + "health": app.get("health"), + "deployment_status": app.get("deployment_status"), + }, + } + ) + except Exception as exc: + errors.append(f"{endpoint}: {exc}") + + if errors and not assets: + return CollectionResult(source=self.source_name, assets=[], status="error", error=" | ".join(errors)) + if errors: + return CollectionResult(source=self.source_name, assets=assets, status="degraded", error=" | ".join(errors)) + return CollectionResult(source=self.source_name, assets=assets, status="ok") + + @staticmethod + def _extract_app_list(payload: object) -> List[Dict]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + + if isinstance(payload, dict): + for key in ("data", "applications", "items", "result"): + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + + return [] + + @staticmethod + def _derive_status(app: Dict) -> str: + candidate_fields = [ + app.get("status"), + app.get("health"), + app.get("deployment_status"), + app.get("current_status"), + app.get("state"), + ] + + for value in candidate_fields: + normalized = CoolifyCollector._normalize_status(value) + if normalized != "unknown": + return normalized + + if app.get("is_running") is True or app.get("running") is True: + return "running" + if app.get("is_running") is False or app.get("running") is False: + return "stopped" + + return "unknown" + + @staticmethod + def _normalize_status(value: object) -> str: + if value is None: + return "unknown" + + text = str(value).strip().lower() + if not text: + return "unknown" + + online = { + "running", + "online", + "healthy", + "up", + "active", + "ready", + "started", + "success", + "completed", + } + offline = { + "stopped", + "offline", + "down", + "unhealthy", + "error", + "failed", + "crashed", + "dead", + "exited", + } + + if text in online: + return "running" + if text in offline: + return "stopped" + if "running" in text or "healthy" in text: + return "running" + if "stop" in text or "fail" in text or "unhealthy" in text: + return "stopped" + + return text diff --git a/collectors/docker_hosts.py b/collectors/docker_hosts.py new file mode 100644 index 0000000..58f6cd0 --- /dev/null +++ b/collectors/docker_hosts.py @@ -0,0 +1,152 @@ +from typing import Dict, List +import importlib + +import requests + +from config import AppConfig +from .base import BaseCollector, CollectionResult + + +class DockerHostsCollector(BaseCollector): + source_name = "docker" + + def __init__(self, config: AppConfig): + self.config = config + + def collect(self) -> CollectionResult: + if not self.config.docker_enabled: + return CollectionResult(source=self.source_name, assets=[], status="disabled") + if not self.config.docker_hosts and not self.config.docker_agent_endpoints: + return CollectionResult( + source=self.source_name, + assets=[], + status="skipped", + error="No DOCKER_HOSTS or DOCKER_AGENT_ENDPOINTS configured", + ) + + assets: List[Dict] = [] + errors: List[str] = [] + headers = {"Accept": "application/json"} + if self.config.docker_bearer_token: + headers["Authorization"] = f"Bearer {self.config.docker_bearer_token}" + + agent_headers = {"Accept": "application/json"} + if self.config.docker_agent_token: + agent_headers["Authorization"] = f"Bearer {self.config.docker_agent_token}" + + for endpoint in self.config.docker_agent_endpoints: + base = endpoint.rstrip("/") + try: + resp = requests.get( + f"{base}/api/v1/containers", + headers=agent_headers, + timeout=self.config.request_timeout_seconds, + ) + resp.raise_for_status() + payload = resp.json() + containers = payload.get("containers", []) if isinstance(payload, dict) else payload + for container in containers: + assets.append( + { + "asset_type": "container", + "external_id": container.get("id", "unknown-container"), + "name": container.get("name", "unknown"), + "hostname": container.get("name", "unknown"), + "status": container.get("state", "unknown"), + "ip_addresses": container.get("ip_addresses", []), + "node": endpoint, + "metadata": { + "image": container.get("image", "unknown"), + "ports": container.get("ports", []), + "networks": container.get("networks", []), + "labels": container.get("labels", {}), + "collected_via": "docker-agent", + }, + } + ) + except Exception as exc: + errors.append(f"{endpoint}: {exc}") + + for host in self.config.docker_hosts: + if host.startswith("unix://") or host.startswith("tcp://"): + try: + assets.extend(self._collect_via_docker_sdk(host)) + except Exception as exc: + errors.append(f"{host}: {exc}") + continue + + base = host.rstrip("/") + try: + resp = requests.get( + f"{base}/containers/json?all=1", + headers=headers, + timeout=self.config.request_timeout_seconds, + ) + resp.raise_for_status() + for container in resp.json(): + ports = container.get("Ports", []) + networks = list((container.get("NetworkSettings", {}) or {}).get("Networks", {}).keys()) + assets.append( + { + "asset_type": "container", + "external_id": container.get("Id", "unknown-container"), + "name": (container.get("Names", ["unknown"])[0] or "unknown").lstrip("/"), + "hostname": container.get("Names", ["unknown"])[0].lstrip("/"), + "status": container.get("State", "unknown"), + "ip_addresses": [], + "node": host, + "metadata": { + "image": container.get("Image"), + "ports": ports, + "networks": networks, + "collected_via": "docker-host-api", + }, + } + ) + except Exception as exc: + errors.append(f"{host}: {exc}") + + if errors and not assets: + return CollectionResult(source=self.source_name, assets=[], status="error", error=" | ".join(errors)) + if errors: + return CollectionResult(source=self.source_name, assets=assets, status="degraded", error=" | ".join(errors)) + return CollectionResult(source=self.source_name, assets=assets, status="ok") + + def _collect_via_docker_sdk(self, host: str) -> List[Dict]: + try: + docker_sdk = importlib.import_module("docker") + except Exception as exc: + raise RuntimeError(f"Docker SDK unavailable: {exc}") from exc + + assets: List[Dict] = [] + client = docker_sdk.DockerClient(base_url=host) + try: + for container in client.containers.list(all=True): + ports = container.attrs.get("NetworkSettings", {}).get("Ports", {}) + networks = list((container.attrs.get("NetworkSettings", {}).get("Networks", {}) or {}).keys()) + state = container.attrs.get("State", {}).get("Status", "unknown") + image_obj = container.image + image_name = "unknown" + if image_obj is not None: + image_tags = image_obj.tags or [] + image_name = image_tags[0] if image_tags else image_obj.id + assets.append( + { + "asset_type": "container", + "external_id": container.id, + "name": container.name, + "hostname": container.name, + "status": state, + "ip_addresses": [], + "node": host, + "metadata": { + "image": image_name, + "ports": ports, + "networks": networks, + "collected_via": "docker-sdk", + }, + } + ) + finally: + client.close() + return assets diff --git a/collectors/nginx_from_agent.py b/collectors/nginx_from_agent.py new file mode 100644 index 0000000..6360484 --- /dev/null +++ b/collectors/nginx_from_agent.py @@ -0,0 +1,80 @@ +from typing import Dict, List + +import requests + +from config import AppConfig +from .base import BaseCollector, CollectionResult + + +class NginxFromAgentCollector(BaseCollector): + source_name = "nginx" + + def __init__(self, config: AppConfig): + self.config = config + + def collect(self) -> CollectionResult: + if not self.config.docker_agent_endpoints: + return CollectionResult(source=self.source_name, assets=[], status="skipped", error="No DOCKER_AGENT_ENDPOINTS configured") + + headers = {"Accept": "application/json"} + if self.config.docker_agent_token: + headers["Authorization"] = f"Bearer {self.config.docker_agent_token}" + + assets: List[Dict] = [] + errors: List[str] = [] + + for endpoint in self.config.docker_agent_endpoints: + base = endpoint.rstrip("/") + try: + resp = requests.get( + f"{base}/api/v1/nginx-configs", + headers=headers, + timeout=self.config.request_timeout_seconds, + ) + resp.raise_for_status() + payload = resp.json() + configs = payload.get("configs", []) if isinstance(payload, dict) else [] + + for config in configs: + path = config.get("path", "unknown.conf") + server_names = config.get("server_names", []) or [] + listens = config.get("listens", []) or [] + proxy_pass = config.get("proxy_pass", []) or [] + proxy_pass_resolved = config.get("proxy_pass_resolved", []) or [] + upstreams = config.get("upstreams", []) or [] + upstream_servers = config.get("upstream_servers", []) or [] + inferred_targets = config.get("inferred_targets", []) or [] + + if not server_names: + server_names = [path] + + for server_name in server_names: + assets.append( + { + "asset_type": "nginx_site", + "external_id": f"{endpoint}:{path}:{server_name}", + "name": server_name, + "hostname": server_name, + "status": "configured", + "ip_addresses": [], + "node": endpoint, + "metadata": { + "path": path, + "listens": listens, + "proxy_pass": proxy_pass, + "proxy_pass_resolved": proxy_pass_resolved, + "upstreams": upstreams, + "upstream_servers": upstream_servers, + "inferred_targets": inferred_targets, + "collected_via": "docker-agent-nginx", + }, + } + ) + except Exception as exc: + errors.append(f"{endpoint}: {exc}") + + if errors and not assets: + return CollectionResult(source=self.source_name, assets=[], status="error", error=" | ".join(errors)) + if errors: + return CollectionResult(source=self.source_name, assets=assets, status="degraded", error=" | ".join(errors)) + return CollectionResult(source=self.source_name, assets=assets, status="ok") diff --git a/collectors/orchestrator.py b/collectors/orchestrator.py new file mode 100644 index 0000000..08a8ac7 --- /dev/null +++ b/collectors/orchestrator.py @@ -0,0 +1,73 @@ +import threading +from dataclasses import dataclass +from typing import Dict, List + +from config import AppConfig +from database import InventoryStore +from .base import CollectionResult +from .coolify import CoolifyCollector +from .docker_hosts import DockerHostsCollector +from .nginx_from_agent import NginxFromAgentCollector +from .proxmox import ProxmoxCollector + + +@dataclass +class RunReport: + run_id: int + status: str + results: List[CollectionResult] + + +class InventoryCollectorOrchestrator: + def __init__(self, config: AppConfig, store: InventoryStore): + self.config = config + self.store = store + self._run_lock = threading.Lock() + self.collectors = [ + ProxmoxCollector(config), + DockerHostsCollector(config), + CoolifyCollector(config), + NginxFromAgentCollector(config), + ] + + self.store.seed_sources( + { + "proxmox": config.proxmox_enabled, + "docker": config.docker_enabled, + "coolify": config.coolify_enabled, + "nginx": bool(config.docker_agent_endpoints), + } + ) + + def collect_once(self) -> RunReport: + if not self._run_lock.acquire(blocking=False): + return RunReport(run_id=-1, status="running", results=[]) + + try: + run_id = self.store.run_start() + results: List[CollectionResult] = [] + errors: List[str] = [] + + for collector in self.collectors: + result = collector.collect() + results.append(result) + self.store.set_source_status(result.source, result.status, result.error) + if result.assets: + self.store.upsert_assets(result.source, result.assets) + if result.status == "error": + errors.append(f"{result.source}: {result.error}") + + overall_status = "error" if errors else "ok" + self.store.run_finish(run_id=run_id, status=overall_status, error_summary=" | ".join(errors)) + return RunReport(run_id=run_id, status=overall_status, results=results) + finally: + self._run_lock.release() + + def current_data(self) -> Dict: + return { + "summary": self.store.summary(), + "topology": self.store.topology(), + "assets": self.store.list_assets(), + "sources": self.store.source_health(), + "last_run": self.store.last_run(), + } diff --git a/collectors/proxmox.py b/collectors/proxmox.py new file mode 100644 index 0000000..6e9ad7b --- /dev/null +++ b/collectors/proxmox.py @@ -0,0 +1,243 @@ +import ipaddress +import re +from typing import Dict, List + +import requests + +from config import AppConfig +from .base import BaseCollector, CollectionResult + + +class ProxmoxCollector(BaseCollector): + source_name = "proxmox" + + def __init__(self, config: AppConfig): + self.config = config + + def collect(self) -> CollectionResult: + if not self.config.proxmox_enabled: + return CollectionResult(source=self.source_name, assets=[], status="disabled") + if not self.config.proxmox_endpoints: + return CollectionResult(source=self.source_name, assets=[], status="skipped", error="No PROXMOX_ENDPOINTS configured") + + assets: List[Dict] = [] + errors: List[str] = [] + + for endpoint in self.config.proxmox_endpoints: + try: + assets.extend(self._collect_endpoint(endpoint)) + except Exception as exc: + errors.append(f"{endpoint}: {exc}") + + if errors and not assets: + return CollectionResult(source=self.source_name, assets=[], status="error", error=" | ".join(errors)) + if errors: + return CollectionResult(source=self.source_name, assets=assets, status="degraded", error=" | ".join(errors)) + return CollectionResult(source=self.source_name, assets=assets, status="ok") + + def _collect_endpoint(self, endpoint: str) -> List[Dict]: + endpoint = endpoint.rstrip("/") + headers = {"Accept": "application/json"} + cookies = None + + if self.config.proxmox_token_id and self.config.proxmox_token_secret: + headers["Authorization"] = ( + f"PVEAPIToken={self.config.proxmox_token_id}={self.config.proxmox_token_secret}" + ) + elif self.config.proxmox_user and self.config.proxmox_password: + token_resp = requests.post( + f"{endpoint}/api2/json/access/ticket", + data={"username": self.config.proxmox_user, "password": self.config.proxmox_password}, + timeout=self.config.request_timeout_seconds, + verify=self.config.proxmox_verify_tls, + ) + token_resp.raise_for_status() + payload = token_resp.json().get("data", {}) + cookies = {"PVEAuthCookie": payload.get("ticket", "")} + csrf = payload.get("CSRFPreventionToken") + if csrf: + headers["CSRFPreventionToken"] = csrf + + nodes_resp = requests.get( + f"{endpoint}/api2/json/nodes", + headers=headers, + cookies=cookies, + timeout=self.config.request_timeout_seconds, + verify=self.config.proxmox_verify_tls, + ) + nodes_resp.raise_for_status() + nodes = nodes_resp.json().get("data", []) + + assets: List[Dict] = [] + for node in nodes: + node_name = node.get("node", "unknown-node") + + qemu_resp = requests.get( + f"{endpoint}/api2/json/nodes/{node_name}/qemu", + headers=headers, + cookies=cookies, + timeout=self.config.request_timeout_seconds, + verify=self.config.proxmox_verify_tls, + ) + qemu_resp.raise_for_status() + + for vm in qemu_resp.json().get("data", []): + vm_id = str(vm.get("vmid", "")) + vm_ips = self._collect_qemu_ips(endpoint, node_name, vm_id, headers, cookies) + assets.append( + { + "asset_type": "vm", + "external_id": str(vm.get("vmid", vm.get("name", "unknown-vm"))), + "name": vm.get("name") or f"vm-{vm.get('vmid', 'unknown')}", + "hostname": vm.get("name"), + "status": vm.get("status", "unknown"), + "ip_addresses": vm_ips, + "node": node_name, + "cpu": vm.get("cpus"), + "memory_mb": (vm.get("maxmem", 0) or 0) / (1024 * 1024), + "disk_gb": (vm.get("maxdisk", 0) or 0) / (1024 * 1024 * 1024), + "metadata": { + "endpoint": endpoint, + "uptime_seconds": vm.get("uptime"), + }, + } + ) + + lxc_resp = requests.get( + f"{endpoint}/api2/json/nodes/{node_name}/lxc", + headers=headers, + cookies=cookies, + timeout=self.config.request_timeout_seconds, + verify=self.config.proxmox_verify_tls, + ) + lxc_resp.raise_for_status() + + for lxc in lxc_resp.json().get("data", []): + lxc_id = str(lxc.get("vmid", "")) + lxc_ips = self._collect_lxc_ips(endpoint, node_name, lxc_id, headers, cookies) + assets.append( + { + "asset_type": "lxc", + "external_id": str(lxc.get("vmid", lxc.get("name", "unknown-lxc"))), + "name": lxc.get("name") or f"lxc-{lxc.get('vmid', 'unknown')}", + "hostname": lxc.get("name"), + "status": lxc.get("status", "unknown"), + "ip_addresses": lxc_ips, + "node": node_name, + "cpu": lxc.get("cpus"), + "memory_mb": (lxc.get("maxmem", 0) or 0) / (1024 * 1024), + "disk_gb": (lxc.get("maxdisk", 0) or 0) / (1024 * 1024 * 1024), + "metadata": { + "endpoint": endpoint, + "uptime_seconds": lxc.get("uptime"), + }, + } + ) + + return assets + + def _collect_qemu_ips(self, endpoint: str, node_name: str, vm_id: str, headers: Dict, cookies: Dict | None) -> List[str]: + ips: List[str] = [] + + # Guest agent provides the most accurate runtime IP list when enabled. + try: + agent_resp = requests.get( + f"{endpoint}/api2/json/nodes/{node_name}/qemu/{vm_id}/agent/network-get-interfaces", + headers=headers, + cookies=cookies, + timeout=self.config.request_timeout_seconds, + verify=self.config.proxmox_verify_tls, + ) + if agent_resp.ok: + data = agent_resp.json().get("data", {}) + interfaces = data.get("result", []) if isinstance(data, dict) else [] + for interface in interfaces: + for addr in interface.get("ip-addresses", []) or []: + value = addr.get("ip-address") + if value: + ips.append(value) + except Exception: + pass + + ips.extend(self._collect_config_ips(endpoint, node_name, "qemu", vm_id, headers, cookies)) + return self._normalize_ips(ips) + + def _collect_lxc_ips(self, endpoint: str, node_name: str, vm_id: str, headers: Dict, cookies: Dict | None) -> List[str]: + ips: List[str] = [] + + # Runtime interfaces capture DHCP-assigned addresses that are not present in static config. + try: + iface_resp = requests.get( + f"{endpoint}/api2/json/nodes/{node_name}/lxc/{vm_id}/interfaces", + headers=headers, + cookies=cookies, + timeout=self.config.request_timeout_seconds, + verify=self.config.proxmox_verify_tls, + ) + if iface_resp.ok: + interfaces = iface_resp.json().get("data", []) + if isinstance(interfaces, list): + for interface in interfaces: + inet_values = interface.get("inet") + if isinstance(inet_values, list): + ips.extend(inet_values) + except Exception: + pass + + ips.extend(self._collect_config_ips(endpoint, node_name, "lxc", vm_id, headers, cookies)) + return self._normalize_ips(ips) + + def _collect_config_ips( + self, + endpoint: str, + node_name: str, + vm_type: str, + vm_id: str, + headers: Dict, + cookies: Dict | None, + ) -> List[str]: + try: + config_resp = requests.get( + f"{endpoint}/api2/json/nodes/{node_name}/{vm_type}/{vm_id}/config", + headers=headers, + cookies=cookies, + timeout=self.config.request_timeout_seconds, + verify=self.config.proxmox_verify_tls, + ) + if not config_resp.ok: + return [] + config = config_resp.json().get("data", {}) + except Exception: + return [] + + values = [] + for key, value in config.items(): + if not isinstance(value, str): + continue + if key.startswith("net") or key in {"ipconfig0", "ipconfig1", "ipconfig2", "ipconfig3"}: + values.append(value) + + ips: List[str] = [] + for value in values: + ips.extend(re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}(?:/\d{1,2})?\b", value)) + return ips + + @staticmethod + def _normalize_ips(values: List[str]) -> List[str]: + normalized: List[str] = [] + seen = set() + for value in values: + candidate = value.strip() + if "/" in candidate: + candidate = candidate.split("/", 1)[0] + try: + ip_obj = ipaddress.ip_address(candidate) + except ValueError: + continue + if ip_obj.is_loopback: + continue + text = str(ip_obj) + if text not in seen: + seen.add(text) + normalized.append(text) + return normalized diff --git a/config.py b/config.py new file mode 100644 index 0000000..904c029 --- /dev/null +++ b/config.py @@ -0,0 +1,77 @@ +import os +from dataclasses import dataclass +from typing import List + + +def _split_csv(value: str) -> List[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +@dataclass(frozen=True) +class AppConfig: + app_name: str + database_path: str + poll_interval_seconds: int + scheduler_enabled: bool + admin_token: str + request_timeout_seconds: int + + proxmox_enabled: bool + proxmox_endpoints: List[str] + proxmox_token_id: str + proxmox_token_secret: str + proxmox_user: str + proxmox_password: str + proxmox_verify_tls: bool + + docker_enabled: bool + docker_hosts: List[str] + docker_bearer_token: str + docker_agent_endpoints: List[str] + docker_agent_token: str + + coolify_enabled: bool + coolify_endpoints: List[str] + coolify_api_token: str + + +def _bool_env(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def load_config() -> AppConfig: + base_dir = os.getenv("BASE_DIR", os.getcwd()) + os.makedirs(base_dir, exist_ok=True) + + docker_hosts = _split_csv(os.getenv("DOCKER_HOSTS", "")) + if not docker_hosts: + single_docker_host = os.getenv("DOCKER_HOST", "").strip() + if single_docker_host: + docker_hosts = [single_docker_host] + + return AppConfig( + app_name=os.getenv("APP_NAME", "Home Lab Inventory"), + database_path=os.getenv("DATABASE_PATH", os.path.join(base_dir, "inventory.db")), + poll_interval_seconds=int(os.getenv("POLL_INTERVAL_SECONDS", "300")), + scheduler_enabled=_bool_env("SCHEDULER_ENABLED", True), + admin_token=os.getenv("ADMIN_TOKEN", ""), + request_timeout_seconds=int(os.getenv("REQUEST_TIMEOUT_SECONDS", "10")), + proxmox_enabled=_bool_env("PROXMOX_ENABLED", True), + proxmox_endpoints=_split_csv(os.getenv("PROXMOX_ENDPOINTS", "")), + proxmox_token_id=os.getenv("PROXMOX_TOKEN_ID", ""), + proxmox_token_secret=os.getenv("PROXMOX_TOKEN_SECRET", ""), + proxmox_user=os.getenv("PROXMOX_USER", ""), + proxmox_password=os.getenv("PROXMOX_PASSWORD", ""), + proxmox_verify_tls=_bool_env("PROXMOX_VERIFY_TLS", False), + docker_enabled=_bool_env("DOCKER_ENABLED", True), + docker_hosts=docker_hosts, + docker_bearer_token=os.getenv("DOCKER_BEARER_TOKEN", ""), + docker_agent_endpoints=_split_csv(os.getenv("DOCKER_AGENT_ENDPOINTS", "")), + docker_agent_token=os.getenv("DOCKER_AGENT_TOKEN", ""), + coolify_enabled=_bool_env("COOLIFY_ENABLED", True), + coolify_endpoints=_split_csv(os.getenv("COOLIFY_ENDPOINTS", "")), + coolify_api_token=os.getenv("COOLIFY_API_TOKEN", ""), + ) diff --git a/database.py b/database.py new file mode 100644 index 0000000..652a82a --- /dev/null +++ b/database.py @@ -0,0 +1,305 @@ +import json +import sqlite3 +from contextlib import closing +from datetime import datetime, timezone +from typing import Dict, Iterable, List, Optional + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class InventoryStore: + def __init__(self, database_path: str): + self.database_path = database_path + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.database_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + def init(self) -> None: + with closing(self._connect()) as conn: + conn.executescript( + """ + PRAGMA journal_mode=WAL; + + CREATE TABLE IF NOT EXISTS sources ( + name TEXT PRIMARY KEY, + enabled INTEGER NOT NULL, + last_status TEXT, + last_error TEXT, + last_success TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS collection_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + error_summary TEXT + ); + + CREATE TABLE IF NOT EXISTS assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + asset_type TEXT NOT NULL, + external_id TEXT NOT NULL, + name TEXT NOT NULL, + hostname TEXT, + status TEXT, + ip_addresses TEXT, + subnet TEXT, + public_ip TEXT, + node TEXT, + parent_id TEXT, + cpu REAL, + memory_mb REAL, + disk_gb REAL, + metadata_json TEXT, + last_seen TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(source, asset_type, external_id) + ); + + CREATE INDEX IF NOT EXISTS idx_assets_source ON assets(source); + CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status); + CREATE INDEX IF NOT EXISTS idx_assets_subnet ON assets(subnet); + """ + ) + conn.commit() + + def seed_sources(self, source_states: Dict[str, bool]) -> None: + now = utc_now() + with closing(self._connect()) as conn: + valid_sources = set(source_states.keys()) + for source, enabled in source_states.items(): + conn.execute( + """ + INSERT INTO sources(name, enabled, updated_at) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + enabled=excluded.enabled, + updated_at=excluded.updated_at + """, + (source, int(enabled), now), + ) + + if valid_sources: + placeholders = ",".join(["?"] * len(valid_sources)) + conn.execute( + f"DELETE FROM sources WHERE name NOT IN ({placeholders})", + tuple(valid_sources), + ) + + conn.commit() + + def run_start(self) -> int: + with closing(self._connect()) as conn: + cursor = conn.execute( + "INSERT INTO collection_runs(started_at, status) VALUES(?, ?)", + (utc_now(), "running"), + ) + conn.commit() + row_id = cursor.lastrowid + if row_id is None: + raise RuntimeError("Failed to create collection run") + return int(row_id) + + def run_finish(self, run_id: int, status: str, error_summary: str = "") -> None: + with closing(self._connect()) as conn: + conn.execute( + """ + UPDATE collection_runs + SET finished_at=?, status=?, error_summary=? + WHERE id=? + """, + (utc_now(), status, error_summary.strip(), run_id), + ) + conn.commit() + + def set_source_status(self, source: str, status: str, error: str = "") -> None: + now = utc_now() + success_ts = now if status == "ok" else None + with closing(self._connect()) as conn: + conn.execute( + """ + UPDATE sources + SET last_status=?, + last_error=?, + last_success=COALESCE(?, last_success), + updated_at=? + WHERE name=? + """, + (status, error.strip(), success_ts, now, source), + ) + conn.commit() + + def upsert_assets(self, source: str, assets: Iterable[Dict]) -> None: + now = utc_now() + with closing(self._connect()) as conn: + for asset in assets: + conn.execute( + """ + INSERT INTO assets( + source, asset_type, external_id, name, hostname, status, + ip_addresses, subnet, public_ip, node, parent_id, + cpu, memory_mb, disk_gb, metadata_json, last_seen, updated_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source, asset_type, external_id) + DO UPDATE SET + name=excluded.name, + hostname=excluded.hostname, + status=excluded.status, + ip_addresses=excluded.ip_addresses, + subnet=excluded.subnet, + public_ip=excluded.public_ip, + node=excluded.node, + parent_id=excluded.parent_id, + cpu=excluded.cpu, + memory_mb=excluded.memory_mb, + disk_gb=excluded.disk_gb, + metadata_json=excluded.metadata_json, + last_seen=excluded.last_seen, + updated_at=excluded.updated_at + """, + ( + source, + asset.get("asset_type", "unknown"), + str(asset.get("external_id", asset.get("name", "unknown"))), + asset.get("name", "unknown"), + asset.get("hostname"), + asset.get("status"), + json.dumps(asset.get("ip_addresses", [])), + asset.get("subnet"), + asset.get("public_ip"), + asset.get("node"), + asset.get("parent_id"), + asset.get("cpu"), + asset.get("memory_mb"), + asset.get("disk_gb"), + json.dumps(asset.get("metadata", {})), + now, + now, + ), + ) + conn.commit() + + def list_assets(self) -> List[Dict]: + with closing(self._connect()) as conn: + rows = conn.execute( + """ + SELECT source, asset_type, external_id, name, hostname, status, + ip_addresses, subnet, public_ip, node, parent_id, + cpu, memory_mb, disk_gb, metadata_json, last_seen, updated_at + FROM assets + ORDER BY source, asset_type, name + """ + ).fetchall() + return [self._row_to_asset(row) for row in rows] + + def source_health(self) -> List[Dict]: + with closing(self._connect()) as conn: + rows = conn.execute( + """ + SELECT name, enabled, last_status, last_error, last_success, updated_at + FROM sources + ORDER BY name + """ + ).fetchall() + return [dict(row) for row in rows] + + def last_run(self) -> Optional[Dict]: + with closing(self._connect()) as conn: + row = conn.execute( + """ + SELECT id, started_at, finished_at, status, error_summary + FROM collection_runs + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + return dict(row) if row else None + + def summary(self) -> Dict: + with closing(self._connect()) as conn: + totals = conn.execute( + """ + SELECT + COUNT(*) AS total_assets, + SUM(CASE WHEN status IN ('running', 'online', 'healthy', 'up') THEN 1 ELSE 0 END) AS online_assets, + SUM(CASE WHEN status IN ('stopped', 'offline', 'down', 'error', 'unhealthy') THEN 1 ELSE 0 END) AS offline_assets, + COUNT(DISTINCT source) AS source_count, + COUNT(DISTINCT subnet) AS subnet_count + FROM assets + """ + ).fetchone() + + by_type = conn.execute( + """ + SELECT asset_type, COUNT(*) AS count + FROM assets + GROUP BY asset_type + ORDER BY count DESC + """ + ).fetchall() + + return { + "total_assets": int(totals["total_assets"] or 0), + "online_assets": int(totals["online_assets"] or 0), + "offline_assets": int(totals["offline_assets"] or 0), + "source_count": int(totals["source_count"] or 0), + "subnet_count": int(totals["subnet_count"] or 0), + "asset_breakdown": [{"asset_type": row["asset_type"], "count": row["count"]} for row in by_type], + } + + def topology(self) -> Dict: + with closing(self._connect()) as conn: + rows = conn.execute( + """ + SELECT + COALESCE(subnet, 'unassigned') AS subnet, + COUNT(*) AS asset_count, + COUNT(DISTINCT source) AS source_count, + GROUP_CONCAT(DISTINCT public_ip) AS public_ips + FROM assets + GROUP BY COALESCE(subnet, 'unassigned') + ORDER BY subnet + """ + ).fetchall() + + networks = [] + for row in rows: + ips = [value for value in (row["public_ips"] or "").split(",") if value] + networks.append( + { + "subnet": row["subnet"], + "asset_count": row["asset_count"], + "source_count": row["source_count"], + "public_ips": ips, + } + ) + return {"networks": networks} + + @staticmethod + def _row_to_asset(row: sqlite3.Row) -> Dict: + return { + "source": row["source"], + "asset_type": row["asset_type"], + "external_id": row["external_id"], + "name": row["name"], + "hostname": row["hostname"], + "status": row["status"], + "ip_addresses": json.loads(row["ip_addresses"] or "[]"), + "subnet": row["subnet"], + "public_ip": row["public_ip"], + "node": row["node"], + "parent_id": row["parent_id"], + "cpu": row["cpu"], + "memory_mb": row["memory_mb"], + "disk_gb": row["disk_gb"], + "metadata": json.loads(row["metadata_json"] or "{}"), + "last_seen": row["last_seen"], + "updated_at": row["updated_at"], + } diff --git a/docker_agent/Dockerfile b/docker_agent/Dockerfile new file mode 100644 index 0000000..6af4537 --- /dev/null +++ b/docker_agent/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.13-alpine + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt +COPY app.py /app/app.py + +EXPOSE 9090 +ENTRYPOINT ["python", "/app/app.py"] diff --git a/docker_agent/app.py b/docker_agent/app.py new file mode 100644 index 0000000..7a0ca5b --- /dev/null +++ b/docker_agent/app.py @@ -0,0 +1,290 @@ +import importlib +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Set + +from flask import Flask, Request, jsonify, request + +app = Flask(__name__) + + +def _authorized(req: Request) -> bool: + token = os.getenv("AGENT_TOKEN", "").strip() + if not token: + return True + header = req.headers.get("Authorization", "") + expected = f"Bearer {token}" + return header == expected + + +def _docker_client(): + docker_sdk = importlib.import_module("docker") + docker_host = os.getenv("DOCKER_HOST", "unix:///var/run/docker.sock") + return docker_sdk.DockerClient(base_url=docker_host) + + +def _container_to_json(container: Any) -> Dict[str, Any]: + attrs = container.attrs or {} + network_settings = attrs.get("NetworkSettings", {}) + networks = network_settings.get("Networks", {}) or {} + + ip_addresses: List[str] = [] + for network in networks.values(): + ip = network.get("IPAddress") + if ip: + ip_addresses.append(ip) + + ports = network_settings.get("Ports", {}) or {} + labels = attrs.get("Config", {}).get("Labels", {}) or {} + state = attrs.get("State", {}).get("Status", "unknown") + + image_obj = container.image + image_name = "unknown" + if image_obj is not None: + image_tags = image_obj.tags or [] + image_name = image_tags[0] if image_tags else image_obj.id + + return { + "id": container.id, + "name": container.name, + "state": state, + "image": image_name, + "ports": ports, + "networks": list(networks.keys()), + "ip_addresses": ip_addresses, + "labels": labels, + } + + +def _nginx_root() -> Path: + return Path(os.getenv("NGINX_CONFIG_DIR", "/mnt/nginx")) + + +def _parse_nginx_file(content: str) -> Dict[str, Any]: + server_names = re.findall(r"server_name\s+([^;]+);", content) + listens = re.findall(r"listen\s+([^;]+);", content) + proxy_pass_targets = re.findall(r"proxy_pass\s+([^;]+);", content) + upstream_blocks: List[str] = [] + includes = re.findall(r"include\s+([^;]+);", content) + set_matches = re.findall(r"set\s+\$([A-Za-z0-9_]+)\s+([^;]+);", content) + upstream_servers: List[str] = [] + + for match in re.finditer(r"\bupstream\b\s+([^\s{]+)\s*\{(.*?)\}", content, flags=re.DOTALL): + upstream_blocks.append(match.group(1).strip()) + block_body = match.group(2) + for upstream_server in re.findall(r"server\s+([^;]+);", block_body): + upstream_servers.append(upstream_server.strip()) + + set_variables: Dict[str, str] = {} + for var_name, value in set_matches: + candidate = value.strip().strip("\"'") + set_variables[var_name] = candidate + if "http://" in candidate or "https://" in candidate or ":" in candidate: + proxy_pass_targets.append(candidate) + + split_values = [] + for value in server_names: + split_values.extend([name.strip() for name in value.split() if name.strip()]) + + inferred_targets: List[str] = [] + forward_host = set_variables.get("forward_host", "").strip() + forward_port = set_variables.get("forward_port", "").strip() + forward_scheme = set_variables.get("forward_scheme", "http").strip() or "http" + + if forward_host: + if forward_port: + inferred_targets.append(f"{forward_scheme}://{forward_host}:{forward_port}") + else: + inferred_targets.append(f"{forward_scheme}://{forward_host}") + + server_var = set_variables.get("server", "").strip() + port_var = set_variables.get("port", "").strip() + if server_var and server_var.startswith("$") is False: + if port_var and port_var.startswith("$") is False: + inferred_targets.append(f"{forward_scheme}://{server_var}:{port_var}") + else: + inferred_targets.append(f"{forward_scheme}://{server_var}") + + return { + "server_names": sorted(set(split_values)), + "listens": sorted(set([value.strip() for value in listens if value.strip()])), + "proxy_pass": sorted(set([value.strip() for value in proxy_pass_targets if value.strip()])), + "upstreams": sorted(set([value.strip() for value in upstream_blocks if value.strip()])), + "upstream_servers": sorted(set([value for value in upstream_servers if value])), + "inferred_targets": sorted(set([value for value in inferred_targets if value])), + "includes": sorted(set([value.strip() for value in includes if value.strip()])), + "set_variables": set_variables, + } + + +def _expand_globbed_includes(root: Path, include_value: str) -> List[Path]: + candidate = include_value.strip().strip("\"'") + if not candidate: + return [] + + if candidate.startswith("/"): + include_path = root.joinpath(candidate.lstrip("/")) + else: + include_path = root.joinpath(candidate) + + matches = [path for path in root.glob(str(include_path.relative_to(root))) if path.is_file()] + return sorted(set(matches)) + + +def _resolve_value(raw: str, variables: Dict[str, str]) -> str: + value = raw.strip().strip("\"'") + + for _ in range(5): + replaced = False + for var_name, var_value in variables.items(): + token = f"${var_name}" + if token in value: + value = value.replace(token, var_value) + replaced = True + if not replaced: + break + + return value + + +def _collect_proxy_targets( + file_path: Path, + parsed_map: Dict[Path, Dict[str, Any]], + root: Path, + inherited_variables: Dict[str, str], + visited: Set[Path], +) -> List[str]: + if file_path in visited: + return [] + visited.add(file_path) + + parsed = parsed_map.get(file_path) + if not parsed: + return [] + + variables = dict(inherited_variables) + variables.update(parsed.get("set_variables", {})) + + targets: List[str] = [] + for proxy_value in parsed.get("proxy_pass", []): + resolved = _resolve_value(proxy_value, variables) + if resolved: + targets.append(resolved) + + for include_value in parsed.get("includes", []): + for include_file in _expand_globbed_includes(root, include_value): + targets.extend(_collect_proxy_targets(include_file, parsed_map, root, variables, visited)) + + return targets + + +def _scan_nginx_configs() -> List[Dict[str, Any]]: + root = _nginx_root() + if not root.exists() or not root.is_dir(): + return [] + + records: List[Dict[str, Any]] = [] + discovered_files: Set[Path] = set() + + for pattern in ("*.conf", "*.vhost", "*.inc"): + for config_file in root.rglob(pattern): + discovered_files.add(config_file) + + for config_file in root.rglob("*"): + if not config_file.is_file(): + continue + if config_file.suffix: + continue + discovered_files.add(config_file) + + parsed_map: Dict[Path, Dict[str, Any]] = {} + + for config_file in sorted(discovered_files): + try: + content = config_file.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + + parsed = _parse_nginx_file(content) + parsed_map[config_file] = parsed + + for config_file in sorted(parsed_map.keys()): + parsed = parsed_map[config_file] + resolved_targets = sorted( + set( + [ + target + for target in _collect_proxy_targets( + config_file, + parsed_map, + root, + parsed.get("set_variables", {}), + set(), + ) + if target + ] + ) + ) + + records.append( + { + "path": str(config_file.relative_to(root)), + "server_names": parsed["server_names"], + "listens": parsed["listens"], + "proxy_pass": parsed["proxy_pass"], + "proxy_pass_resolved": resolved_targets, + "upstreams": parsed["upstreams"], + "upstream_servers": parsed["upstream_servers"], + "inferred_targets": parsed["inferred_targets"], + } + ) + + return records + + +@app.route("/health", methods=["GET"]) +def health() -> Any: + return jsonify( + { + "ok": True, + "service": "docker-inventory-agent", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) + + +@app.route("/api/v1/containers", methods=["GET"]) +def containers() -> Any: + if not _authorized(request): + return jsonify({"error": "Unauthorized"}), 401 + + client = _docker_client() + try: + data = [_container_to_json(container) for container in client.containers.list(all=True)] + finally: + client.close() + + return jsonify({"containers": data}) + + +@app.route("/api/v1/nginx-configs", methods=["GET"]) +def nginx_configs() -> Any: + if not _authorized(request): + return jsonify({"error": "Unauthorized"}), 401 + + root = _nginx_root() + configs = _scan_nginx_configs() + return jsonify( + { + "config_root": str(root), + "exists": root.exists() and root.is_dir(), + "count": len(configs), + "configs": configs, + } + ) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.getenv("PORT", "9090"))) diff --git a/docker_agent/docker-compose.yml b/docker_agent/docker-compose.yml new file mode 100644 index 0000000..f68d306 --- /dev/null +++ b/docker_agent/docker-compose.yml @@ -0,0 +1,15 @@ +services: + docker-inventory-agent: + build: . + container_name: docker-inventory-agent + restart: unless-stopped + environment: + AGENT_TOKEN: change-me + DOCKER_HOST: unix:///var/run/docker.sock + NGINX_CONFIG_DIR: /mnt/nginx + PORT: "9090" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /etc/nginx:/mnt/nginx:ro + ports: + - "9090:9090" diff --git a/docker_agent/requirements.txt b/docker_agent/requirements.txt new file mode 100644 index 0000000..194e6b9 --- /dev/null +++ b/docker_agent/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.1.2 +docker>=7.1.0 diff --git a/inventory.db b/inventory.db new file mode 100644 index 0000000000000000000000000000000000000000..f8f8cefd98413797ee8b0226fef4d9ab80c519a1 GIT binary patch literal 446464 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

EPp5Dn4gubK6HtV}@v#{bPQ`gq-Vst2% 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 QfACj79_Pfyu%`0=0pdbVB>(^b literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 3c068a2..7691938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [project] -name = "python-webserver-template" +name = "inventory" version = "0.1.0" -description = "Add your description here" +description = "Server inventory system" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "docker>=7.1.0", "flask>=3.1.2", "gunicorn>=23.0.0", "python-dotenv>=1.2.1", diff --git a/server.py b/server.py index a1e0e8a..ef3ff35 100644 --- a/server.py +++ b/server.py @@ -1,20 +1,33 @@ -from flask import ( - Flask, - make_response, - request, - jsonify, - render_template, - send_from_directory, - send_file, -) +import logging import os -import requests +from urllib.parse import urlparse from datetime import datetime + import dotenv +import requests +from flask import Flask, Request, jsonify, make_response, render_template, request, send_file, send_from_directory + +from collectors import InventoryCollectorOrchestrator +from config import load_config +from database import InventoryStore +from services import CollectionScheduler, should_autostart_scheduler dotenv.load_dotenv() +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s: %(message)s") +LOGGER = logging.getLogger(__name__) + app = Flask(__name__) +config = load_config() +store = InventoryStore(config.database_path) +store.init() +orchestrator = InventoryCollectorOrchestrator(config, store) +scheduler = CollectionScheduler(config, orchestrator) +if should_autostart_scheduler(): + scheduler.start() +if os.getenv("INITIAL_COLLECT_ON_STARTUP", "true").strip().lower() in {"1", "true", "yes", "on"}: + LOGGER.info("Running initial inventory collection") + orchestrator.collect_once() def find(name, path): @@ -71,13 +84,8 @@ def wellknown(path): # region Main routes @app.route("/") def index(): - # Print the IP address of the requester - print(f"Request from IP: {request.remote_addr}") - # And the headers - print(f"Request headers: {request.headers}") - # Get current time in the format "dd MMM YYYY hh:mm AM/PM" current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p") - return render_template("index.html", datetime=current_datetime) + return render_template("index.html", datetime=current_datetime, app_name=config.app_name) @app.route("/") @@ -107,25 +115,239 @@ def catch_all(path: str): # region API routes -api_requests = 0 + +def _authorized(req: Request) -> bool: + if not config.admin_token: + return True + return req.headers.get("X-Admin-Token", "") == config.admin_token + + +@app.route("/api/v1/summary", methods=["GET"]) +def api_summary(): + payload = { + "app_name": config.app_name, + "summary": store.summary(), + "last_run": store.last_run(), + "timestamp": datetime.now().isoformat(), + } + return jsonify(payload) + + +@app.route("/api/v1/topology", methods=["GET"]) +def api_topology(): + return jsonify(store.topology()) + + +@app.route("/api/v1/assets", methods=["GET"]) +def api_assets(): + assets = store.list_assets() + assets = _link_assets_to_proxmox(assets) + source = request.args.get("source") + status = request.args.get("status") + search = (request.args.get("search") or "").strip().lower() + + filtered = [] + for asset in assets: + if source and asset.get("source") != source: + continue + if status and (asset.get("status") or "") != status: + continue + if search: + haystack = " ".join( + [ + asset.get("name") or "", + asset.get("hostname") or "", + asset.get("asset_type") or "", + asset.get("source") or "", + asset.get("subnet") or "", + asset.get("public_ip") or "", + ] + ).lower() + if search not in haystack: + continue + filtered.append(asset) + + return jsonify({"count": len(filtered), "assets": filtered}) + + +def _extract_target_hosts(asset: dict) -> list[str]: + metadata = asset.get("metadata") or {} + raw_values: list[str] = [] + + for field in ("proxy_pass_resolved", "inferred_targets", "proxy_pass", "upstream_servers"): + values = metadata.get(field) or [] + if isinstance(values, list): + raw_values.extend([str(value) for value in values]) + + if asset.get("hostname"): + raw_values.append(str(asset.get("hostname"))) + + hosts: list[str] = [] + for raw in raw_values: + parts = [part.strip() for part in raw.split(",") if part.strip()] + for part in parts: + parsed_host = "" + if part.startswith("http://") or part.startswith("https://"): + parsed_host = urlparse(part).hostname or "" + else: + candidate = part.split("/", 1)[0] + if ":" in candidate: + candidate = candidate.split(":", 1)[0] + parsed_host = candidate.strip() + if parsed_host: + hosts.append(parsed_host.lower()) + return hosts + + +def _link_assets_to_proxmox(assets: list[dict]) -> list[dict]: + proxmox_assets = [ + asset for asset in assets if asset.get("source") == "proxmox" and asset.get("asset_type") in {"vm", "lxc"} + ] + + by_ip: dict[str, list[dict]] = {} + by_name: dict[str, list[dict]] = {} + + for asset in proxmox_assets: + for ip in asset.get("ip_addresses") or []: + by_ip.setdefault(str(ip).lower(), []).append(asset) + for key in (asset.get("name"), asset.get("hostname")): + if key: + by_name.setdefault(str(key).lower(), []).append(asset) + + for asset in assets: + if asset.get("source") == "proxmox": + continue + + hosts = _extract_target_hosts(asset) + linked = [] + seen = set() + + for host in hosts: + matches = by_ip.get(host, []) + by_name.get(host, []) + for match in matches: + match_key = f"{match.get('source')}:{match.get('asset_type')}:{match.get('external_id')}" + if match_key in seen: + continue + seen.add(match_key) + linked.append( + { + "source": match.get("source"), + "asset_type": match.get("asset_type"), + "external_id": match.get("external_id"), + "name": match.get("name"), + "hostname": match.get("hostname"), + "ip_addresses": match.get("ip_addresses") or [], + } + ) + + metadata = dict(asset.get("metadata") or {}) + metadata["linked_proxmox_assets"] = linked + asset["metadata"] = metadata + + return assets + + +@app.route("/api/v1/sources", methods=["GET"]) +def api_sources(): + return jsonify({"sources": store.source_health()}) + + +@app.route("/api/v1/nginx/routes", methods=["GET"]) +def api_nginx_routes(): + assets = store.list_assets() + nginx_assets = [ + asset + for asset in assets + if asset.get("source") == "nginx" and asset.get("asset_type") == "nginx_site" + ] + + routes = [] + for asset in nginx_assets: + metadata = asset.get("metadata") or {} + inferred_targets = metadata.get("inferred_targets") or [] + proxy_targets_resolved = metadata.get("proxy_pass_resolved") or [] + proxy_targets = metadata.get("proxy_pass") or [] + upstreams = metadata.get("upstreams") or [] + upstream_servers = metadata.get("upstream_servers") or [] + listens = metadata.get("listens") or [] + route_targets = ( + proxy_targets_resolved + if proxy_targets_resolved + else ( + inferred_targets + if inferred_targets + else (proxy_targets if proxy_targets else (upstream_servers if upstream_servers else upstreams)) + ) + ) + + routes.append( + { + "server_name": asset.get("name") or asset.get("hostname") or "unknown", + "target": ", ".join(route_targets) if route_targets else "-", + "listen": ", ".join(listens) if listens else "-", + "source_host": asset.get("node") or "-", + "config_path": metadata.get("path") or "-", + "status": asset.get("status") or "unknown", + } + ) + + routes.sort(key=lambda item: (item["server_name"], item["source_host"])) + return jsonify({"count": len(routes), "routes": routes}) + + +@app.route("/api/v1/health", methods=["GET"]) +def api_health(): + last_run = store.last_run() + healthy = bool(last_run) and last_run.get("status") in {"ok", "running"} + return jsonify( + { + "healthy": healthy, + "last_run": last_run, + "scheduler_enabled": config.scheduler_enabled, + "poll_interval_seconds": config.poll_interval_seconds, + } + ) + + +@app.route("/api/v1/collect/trigger", methods=["POST"]) +def api_collect_trigger(): + if not _authorized(request): + return jsonify({"error": "Unauthorized"}), 401 + + report = orchestrator.collect_once() + status_code = 200 + if report.status == "running": + status_code = 409 + elif report.status == "error": + status_code = 500 + + return ( + jsonify( + { + "run_id": report.run_id, + "status": report.status, + "results": [ + { + "source": result.source, + "status": result.status, + "asset_count": len(result.assets), + "error": result.error, + } + for result in report.results + ], + } + ), + status_code, + ) @app.route("/api/v1/data", methods=["GET"]) def api_data(): - """ - Example API endpoint that returns some data. - You can modify this to return whatever data you need. - """ - - global api_requests - api_requests += 1 - - data = { - "header": "Sample API Response", - "content": f"Hello, this is a sample API response! You have called this endpoint {api_requests} times.", - "timestamp": datetime.now().isoformat(), - } - return jsonify(data) + payload = orchestrator.current_data() + payload["header"] = config.app_name + payload["content"] = "Inventory snapshot" + payload["timestamp"] = datetime.now().isoformat() + return jsonify(payload) # endregion diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..34edc69 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,3 @@ +from .scheduler import CollectionScheduler, should_autostart_scheduler + +__all__ = ["CollectionScheduler", "should_autostart_scheduler"] diff --git a/services/scheduler.py b/services/scheduler.py new file mode 100644 index 0000000..3fafc24 --- /dev/null +++ b/services/scheduler.py @@ -0,0 +1,57 @@ +import fcntl +import logging +import os +import threading + +from collectors import InventoryCollectorOrchestrator +from config import AppConfig + +LOGGER = logging.getLogger(__name__) + + +class CollectionScheduler: + def __init__(self, config: AppConfig, orchestrator: InventoryCollectorOrchestrator): + self.config = config + self.orchestrator = orchestrator + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._leader_file = None + + def start(self) -> bool: + if not self.config.scheduler_enabled: + LOGGER.info("Scheduler disabled via config") + return False + + leader_file = open("/tmp/inventory-scheduler.lock", "w", encoding="utf-8") + try: + fcntl.flock(leader_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + leader_file.close() + LOGGER.info("Another worker owns scheduler lock") + return False + + self._leader_file = leader_file + + self._thread = threading.Thread(target=self._run_loop, name="inventory-scheduler", daemon=True) + self._thread.start() + LOGGER.info("Scheduler started with %s second interval", self.config.poll_interval_seconds) + return True + + def _run_loop(self) -> None: + while not self._stop_event.is_set(): + self.orchestrator.collect_once() + self._stop_event.wait(self.config.poll_interval_seconds) + + def shutdown(self) -> None: + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.0) + if self._leader_file: + fcntl.flock(self._leader_file.fileno(), fcntl.LOCK_UN) + self._leader_file.close() + self._leader_file = None + + +def should_autostart_scheduler() -> bool: + # Do not auto-start on Flask's reloader child process. + return os.getenv("WERKZEUG_RUN_MAIN", "true") != "false" diff --git a/templates/assets/css/index.css b/templates/assets/css/index.css index 3ef53b7..ef954dd 100644 --- a/templates/assets/css/index.css +++ b/templates/assets/css/index.css @@ -1,41 +1,344 @@ +:root { + --bg: #0e1117; + --panel: #171b23; + --ink: #e6edf3; + --muted: #9aa6b2; + --line: #2d3748; + --accent: #2f81f7; + --good: #2ea043; + --bad: #f85149; + --warn: #d29922; +} + +* { + box-sizing: border-box; +} + body { - background-color: #000000; - color: #ffffff; -} -h1 { - font-size: 50px; margin: 0; - padding: 0; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(1200px 600px at 5% -10%, #1f2937 0%, transparent 65%), + radial-gradient(1000px 500px at 95% 0%, #102a43 0%, transparent 55%), + var(--bg); } -.centre { - margin-top: 10%; - text-align: center; + +.dashboard { + max-width: 1240px; + margin: 0 auto; + padding: 2rem 1rem 3rem; } -a { + +.hero { + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; + margin-bottom: 1.25rem; +} + +h1 { + margin: 0; + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + font-size: clamp(1.9rem, 4.5vw, 2.9rem); +} + +.subtitle, +.meta { + margin: 0.2rem 0; + color: var(--muted); +} + +.hero-actions { + display: flex; + flex-direction: column; + align-items: end; + gap: 0.5rem; +} + +button { + border: none; + border-radius: 999px; + padding: 0.65rem 1rem; + background: var(--accent); color: #ffffff; + font-weight: 700; + cursor: pointer; +} + +button:disabled { + opacity: 0.7; + cursor: wait; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; + margin-bottom: 0.9rem; +} + +.stat-card, +.panel, +.topology-card, +.source-card { + background: color-mix(in srgb, var(--panel) 95%, #000000); + border: 1px solid var(--line); + border-radius: 14px; +} + +.stat-card { + padding: 0.85rem; +} + +.stat-card span { + color: var(--muted); + font-size: 0.85rem; +} + +.stat-card strong { + display: block; + font-size: 1.6rem; +} + +.panel { + padding: 0.95rem; + margin-top: 0.9rem; +} + +.panel h2 { + margin: 0 0 0.8rem; + font-size: 1.1rem; +} + +.topology-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.7rem; +} + +.topology-card, +.source-card { + padding: 0.75rem; +} + +.topology-card h3, +.source-card h3 { + margin: 0 0 0.35rem; + font-size: 1rem; +} + +.topology-card p, +.source-card p { + margin: 0.2rem 0; + color: var(--muted); + font-size: 0.9rem; +} + +.source-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.7rem; +} + +.source-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; + flex-wrap: wrap; +} + +.filters { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +input, +select { + border: 1px solid var(--line); + border-radius: 8px; + padding: 0.5rem 0.55rem; + background: #0f141b; + color: var(--ink); +} + +input { + min-width: 220px; +} + +.table-wrap { + overflow-x: auto; + margin-top: 0.7rem; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} + +thead th { + text-align: left; + font-weight: 700; + color: var(--muted); + border-bottom: 1px solid var(--line); + padding: 0.5rem; +} + +tbody td { + border-bottom: 1px solid #202734; + padding: 0.56rem; + font-size: 0.93rem; +} + +.asset-row { + cursor: pointer; +} + +.asset-row:hover td { + background: #1a2230; +} + +.status-badge { + border-radius: 999px; + padding: 0.2rem 0.55rem; + font-size: 0.8rem; + font-weight: 700; + text-transform: lowercase; +} + +.status-online { + background: color-mix(in srgb, var(--good) 20%, #ffffff); + color: var(--good); +} + +.status-offline { + background: color-mix(in srgb, var(--bad) 18%, #ffffff); + color: var(--bad); +} + +.status-unknown { + background: color-mix(in srgb, var(--warn) 22%, #ffffff); + color: var(--warn); +} + +.empty-state { + color: var(--muted); +} + +.hidden { + display: none; +} + +.modal { + position: fixed; + inset: 0; + background: rgba(5, 10, 15, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal.hidden { + display: none; +} + +.modal-card { + width: min(900px, 100%); + max-height: 88vh; + overflow: auto; + overflow-x: hidden; + background: #111722; + border: 1px solid var(--line); + border-radius: 12px; + padding: 1rem; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.modal-header h3 { + margin: 0; +} + +.modal-body h4 { + margin: 1rem 0 0.4rem; +} + +.modal-body { + overflow-wrap: anywhere; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.7rem; +} + +.detail-grid div { + background: #151d2b; + border: 1px solid #293547; + border-radius: 8px; + padding: 0.6rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.detail-grid strong { + color: var(--muted); + font-size: 0.8rem; +} + +.modal pre { + background: #0b111b; + border: 1px solid #243146; + border-radius: 8px; + padding: 0.65rem; + overflow: auto; + color: #c9d7e8; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +table a { + color: #58a6ff; text-decoration: none; } -a:hover { + +table a:hover { text-decoration: underline; } -/* Mike section styling */ -.mike-section { - margin-top: 30px; - max-width: 600px; - margin-left: auto; - margin-right: auto; - padding: 20px; - background-color: rgba(50, 50, 50, 0.3); - border-radius: 8px; -} +@media (max-width: 760px) { + .hero { + flex-direction: column; + align-items: flex-start; + } -.mike-section h2 { - color: #f0f0f0; - margin-top: 0; -} + .hero-actions { + align-items: flex-start; + } -.mike-section p { - line-height: 1.6; - margin-bottom: 15px; + input { + min-width: 100%; + } } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index f3aac2c..800312f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,55 +4,311 @@ - Nathan.Woodburn/ + {{app_name}} -
-
-

Nathan.Woodburn/

- The current date and time is {{datetime}} -
+
+
+
+

{{app_name}}

+

Home lab inventory dashboard

+

Loaded at {{datetime}}

+
+
+ + Last run: waiting +
+
-
-
-

Pulling data

- This is a test content area that will be updated with data from the server. -
-
- -
+
+ +
+

Topology

+
+
+ +
+

Sources

+
+
+ +
+
+

Asset Inventory

+
+ + + +
+
+ +
+ + + + + + + + + + + + + +
NameTypeSourceStatusHostSubnetPublic IP
+
+
+ + +
diff --git a/uv.lock b/uv.lock index ca32f47..7831ec3 100644 --- a/uv.lock +++ b/uv.lock @@ -156,6 +156,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "inventory" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "gunicorn" }, + { name = "python-dotenv" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = ">=3.1.2" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "requests", specifier = ">=2.32.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.4.0" }, + { name = "ruff", specifier = ">=0.14.5" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -281,37 +312,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] -[[package]] -name = "python-webserver-template" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "flask" }, - { name = "gunicorn" }, - { name = "python-dotenv" }, - { name = "requests" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pre-commit" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "flask", specifier = ">=3.1.2" }, - { name = "gunicorn", specifier = ">=23.0.0" }, - { name = "python-dotenv", specifier = ">=1.2.1" }, - { name = "requests", specifier = ">=2.32.5" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pre-commit", specifier = ">=4.4.0" }, - { name = "ruff", specifier = ">=0.14.5" }, -] - [[package]] name = "pyyaml" version = "6.0.3"