feat(vaultwarden): add SMTP configuration options and enhance signup settings

- Introduced SMTP settings for Vaultwarden including host, port, security, and authentication details.
- Added variables for signup verification, 2FA settings, password hints, and logging options.
- Updated Vaultwarden deployment to utilize new SMTP configurations.
- Enhanced Grafana module to support dynamic dashboard and datasource provisioning.
- Added LLM proxy configuration for Open Web UI with necessary environment variables.
This commit is contained in:
2026-05-30 23:24:44 +02:00
parent 350650ecc2
commit ec740f458f
89 changed files with 3154 additions and 856 deletions
-31
View File
@@ -1,31 +0,0 @@
- name: Add ROCm repository
become: true
ansible.builtin.dnf_repository:
name: rocm
description: ROCm Repository
baseurl: https://repo.radeon.com/rocm/el9/latest/main/
gpgcheck: yes
gpgkey: https://repo.radeon.com/rocm/rocm.gpg.key
enabled: yes
- name: Add AMD Graphics repository
become: true
ansible.builtin.dnf_repository:
name: rocmgraphics
description: AMD Graphics Repository
baseurl: https://repo.radeon.com/graphics/latest/el/9.6/main/x86_64/
gpgcheck: yes
gpgkey: https://repo.radeon.com/rocm/rocm.gpg.key
enabled: yes
- name: Install rocm-dkms and dependencies
become: true
ansible.builtin.package:
name:
- rocm
- rocm-developer-tools
- hipblas-devel
- hip-devel
- rocwmma-devel
- rocm-opencl-devel
state: present
+21 -40
View File
@@ -88,8 +88,6 @@ duo_users: ["*", "!provision", "!comfyui", "!kokoro", "!ollama"]
duo_api_hostname: api-7cda1654.duosecurity.com
# podman services settings
ollama_home: "/mnt/data/ollama"
llama_home: "/mnt/data/llamacpp"
podman_user_pubkey: "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAHHOPBR9p9kq5Cqzpe4cr3jHnweaYrHPXv5sXNzt+sCXP54uc5rWUBhxW2OQVvQzJ47dEVhEKi4WSC7LcuKS2G5AQDzWXNiasHvYIYQU3F/EknVCZnsiXYqXphYkJA6rJCNRnISZCIC1mocq6PB7J08ONdRFCvjfUBuVRT8BNGXNmQ/zQ=="
# ComfyUI settings
@@ -108,39 +106,18 @@ certbot_domains:
- "gpu-01.lab.alexpires.me"
certbot_cockpit_domain: "gpu-01.lab.alexpires.me"
ollama_models:
# Thinking models
- gpt-oss:latest
- gemma4:e4b
- qwen3.5:9b
- qwen3.5:4b
# Non-thinking models
- ministral-3:3b
- ministral-3:8b
- ministral-3:14b
- granite4.1:3b
- granite4.1:8b
- deepseek-r1:latest
- qwen2.5-coder:3b
# Embedding models
- embeddinggemma:latest
- nomic-embed-text:latest
# Whisper models
- dimavz/whisper-tiny:latest
ollama_model_files:
- file: "ollama/gpt-oss-latest-32k.modelfile"
name: "gpt-oss-32k:latest"
- file: "ollama/gemma4-e4b-32k.modelfile"
name: "gemma4-e4b-32k:latest"
- file: "ollama/qwen3.5-9b-32k.modelfile"
name: "qwen3.5-9b-32k:latest"
- file: "ollama/qwen3.5-9b-64k.modelfile"
name: "qwen3.5-9b-64k:latest"
- file: "ollama/qwen3.5-4b-32k.modelfile"
name: "qwen3.5-4b-32k:latest"
- file: "ollama/qwen3.5-4b-64k.modelfile"
name: "qwen3.5-4b-64k:latest"
llama_models:
# Llama.cpp server settings
llama_server_home: "/mnt/data/llamacpp"
llama_server_user_pubkey: "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAHHOPBR9p9kq5Cqzpe4cr3jHnweaYrHPXv5sXNzt+sCXP54uc5rWUBhxW2OQVvQzJ47dEVhEKi4WSC7LcuKS2G5AQDzWXNiasHvYIYQU3F/EknVCZnsiXYqXphYkJA6rJCNRnISZCIC1mocq6PB7J08ONdRFCvjfUBuVRT8BNGXNmQ/zQ=="
llama_server_port: 11434
llama_server_devices:
- "nvidia.com/gpu=all"
- "/dev/kfd:/dev/kfd:rw"
- "/dev/dri:/dev/dri:rw"
llama_server_models:
# TODO: replace revision values with pinned commit SHAs for strict reproducibility.
- name: "gpt-oss-20b-q4-0"
model_id: "unsloth/gpt-oss-20b-GGUF"
@@ -186,9 +163,13 @@ llama_models:
model_id: "unsloth/Devstral-Small-2-24B-Instruct-2512-GGUF"
quant: "IQ4_XS"
revision: "main"
- name: "qwen3-6-27b-mtp-iq4-xs"
model_id: "unsloth/Qwen3.6-27B-MTP-GGUF"
quant: "IQ4_XS"
revision: "main"
# Optional global model preset options applied under [*] in models.ini.
# Keys map to llama.cpp CLI args without leading dashes, short args, or LLAMA_ARG_* env names.
llama_preset_global:
c: 131072
n-gpu-layers: all
# # Optional global model preset options applied under [*] in models.ini.
# # Keys map to llama.cpp CLI args without leading dashes, short args, or LLAMA_ARG_* env names.
# llama_preset_global:
# c: 131072
# n-gpu-layers: all
+2 -4
View File
@@ -1,7 +1,5 @@
- name: Setup AMD ROCm Container Environment
hosts: amd
tasks:
- name: Setup AMD ROCm Container Environment
ansible.builtin.include_tasks:
file: tasks/amd-rocm-container.yml
roles:
- amd_rocm
+2 -4
View File
@@ -1,7 +1,5 @@
- name: Setup Auto Suspend
hosts: auto_suspend
tasks:
- name: Setup Auto Suspend
ansible.builtin.include_tasks:
file: tasks/auto_suspend.yml
roles:
- auto_suspend
+4
View File
@@ -0,0 +1,4 @@
- name: Setup llama.cpp - Vulkan
hosts: llamacpp
roles:
- llama_server
+2 -4
View File
@@ -1,7 +1,5 @@
- name: Setup NVidia CDI Generator
hosts: nvidia
tasks:
- name: Setup nvidia-cdi-generator
ansible.builtin.include_tasks:
file: tasks/nvidia-cdi-generator.yml
roles:
- nvidia_cdi_generator
+2 -4
View File
@@ -45,7 +45,5 @@
device:
- "nvidia.com/gpu=all"
tasks:
- name: Setup Pods
ansible.builtin.include_tasks:
file: tasks/podman.yml
roles:
- podman
+2 -4
View File
@@ -26,7 +26,5 @@
# device:
# - "nvidia.com/gpu=all"
tasks:
- name: Setup Pods
ansible.builtin.include_tasks:
file: tasks/podman.yml
roles:
- podman
+5 -23
View File
@@ -5,24 +5,11 @@
podman_group: "{{ llama_group | default('llama') }}"
podman_extra_groups: "users,video"
podman_user_home: "{{ llama_home | default('/home/llama') }}"
__llama_models__: "{{ llama_models | default([]) }}"
__llama_router_dir__: "{{ podman_user_home }}/models/.router"
__llama_managed_dir__: "{{ podman_user_home }}/models/managed"
__llama_links_dir__: "{{ podman_user_home }}/models/managed-links"
__llama_manifest_file__: "{{ __llama_router_dir__ }}/manifest.json"
__llama_models_input_file__: "{{ __llama_router_dir__ }}/desired-models.json"
__llama_preset_global__: "{{ llama_preset_global | default({}) }}"
__llama_preset_global_input_file__: "{{ __llama_router_dir__ }}/preset-global.json"
__llama_preset_file__: "{{ __llama_router_dir__ }}/models.ini"
__llama_sync_script__: "{{ __llama_router_dir__ }}/llama_hf_sync.py"
__llama_sync_venv__: "{{ llama_sync_venv | default(__llama_router_dir__ ~ '/.venv') }}"
__llama_sync_python__: "{{ __llama_sync_venv__ }}/bin/python"
__llama_models_max__: "{{ llama_models_max_loaded | default(1) }}"
podman_user_folders:
- models
- .cache
- .cache/llama.cpp
pods:
podman_pods:
- name: llama.cpp
repo:
image: ghcr.io/ggml-org/llama.cpp:server-vulkan
@@ -67,16 +54,11 @@
- "nvidia.com/gpu=all"
- "/dev/kfd:/dev/kfd:rw"
- "/dev/dri:/dev/dri:rw"
roles:
- llama_models_sync
- podman
tasks:
- name: Sync llama models from Hugging Face
ansible.builtin.include_tasks:
file: tasks/llama_models_sync.yml
when: llama_models_sync_enabled | default(true)
- name: Setup Pods
ansible.builtin.include_tasks:
file: tasks/podman.yml
- name: Query llama router model list
ansible.builtin.uri:
url: "http://127.0.0.1:{{ llama_port | default(8012) }}/models?reload=1"
+2 -4
View File
@@ -27,7 +27,5 @@
- "nvidia.com/gpu=all"
- "/dev/kfd:/dev/kfd:rw"
- "/dev/dri:/dev/dri:rw"
tasks:
- name: Setup Pods
ansible.builtin.include_tasks:
file: tasks/podman.yml
roles:
- podman
+3 -4
View File
@@ -28,11 +28,10 @@
- "/dev/kfd:/dev/kfd:rw"
- "/dev/dri:/dev/dri:rw"
tasks:
- name: Setup Pods
ansible.builtin.include_tasks:
file: tasks/podman.yml
roles:
- podman
tasks:
- name: Enable sudo access to podman user (temporary)
become: true
ansible.builtin.lineinfile:
+2 -4
View File
@@ -31,7 +31,5 @@
device:
- "/dev/kfd:/dev/kfd:rw"
- "/dev/dri:/dev/dri:rw"
tasks:
- name: Setup Pods
ansible.builtin.include_tasks:
file: tasks/podman.yml
roles:
- podman
+2 -4
View File
@@ -58,7 +58,5 @@
- "nvidia.com/gpu=all"
- "/dev/kfd:/dev/kfd:rw"
- "/dev/dri:/dev/dri:rw"
tasks:
- name: Setup Pods
ansible.builtin.include_tasks:
file: tasks/podman.yml
roles:
- podman
+2 -4
View File
@@ -1,7 +1,5 @@
- name: Setup Windows Game Optimization
hosts: windows_game
tasks:
- name: Setup Optimizations
ansible.builtin.include_tasks:
file: tasks/windows_game.yml
roles:
- windows_game
+2 -4
View File
@@ -1,7 +1,5 @@
- name: Setup Prometheus on Windows
hosts: windows_prometheus
tasks:
- name: Setup Prometheus
ansible.builtin.include_tasks:
file: tasks/windows_prometheus.yml
roles:
- windows_prometheus
+2 -4
View File
@@ -1,7 +1,5 @@
- name: Setup ssh on Windows
hosts: windows
tasks:
- name: Setup ssh
ansible.builtin.include_tasks:
file: tasks/windows_ssh.yml
roles:
- windows_ssh
+2 -4
View File
@@ -1,7 +1,5 @@
- name: Setup WakeOnLan
hosts: wol
tasks:
- name: Setup WakeOnLan
ansible.builtin.include_tasks:
file: tasks/wol.yml
roles:
- wol
+13
View File
@@ -0,0 +1,13 @@
---
# amd_rocm defaults
rocm_repo_baseurl: "https://repo.radeon.com/rocm/el9/latest/main/"
rocm_repo_gpgkey: "https://repo.radeon.com/rocm/rocm.gpg.key"
rocmgraphics_repo_baseurl: "https://repo.radeon.com/graphics/latest/el/9.6/main/x86_64/"
rocm_packages:
- rocm
- rocm-developer-tools
- hipblas-devel
- hip-devel
- rocwmma-devel
- rocm-opencl-devel
+18
View File
@@ -0,0 +1,18 @@
---
galaxy_info:
author: Alexandre Pires
description: Install AMD ROCm repository and packages for GPU computing
company: A13Labs
role_name: amd_rocm
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: Fedora
versions:
- "39"
- "40"
- "41"
- name: EL
versions:
- "9"
+52
View File
@@ -0,0 +1,52 @@
---
- name: Add ROCm repository (yum)
when: ansible_pkg_mgr == 'yum'
become: true
ansible.builtin.yum_repository:
name: rocm
description: ROCm Repository
baseurl: "{{ rocm_repo_baseurl }}"
gpgcheck: true
gpgkey: "{{ rocm_repo_gpgkey }}"
enabled: true
state: present
- name: Add ROCm repository (dnf)
when: ansible_pkg_mgr == 'dnf'
become: true
ansible.builtin.dnf_repository:
name: rocm
description: ROCm Repository
baseurl: "{{ rocm_repo_baseurl }}"
gpgcheck: yes
gpgkey: "{{ rocm_repo_gpgkey }}"
enabled: yes
- name: Add AMD Graphics repository (yum)
when: ansible_pkg_mgr == 'yum'
become: true
ansible.builtin.yum_repository:
name: rocmgraphics
description: AMD Graphics Repository
baseurl: "{{ rocmgraphics_repo_baseurl }}"
gpgkey: "{{ rocm_repo_gpgkey }}"
gpgcheck: true
enabled: true
state: present
- name: Add AMD Graphics repository (dnf)
when: ansible_pkg_mgr == 'dnf'
become: true
ansible.builtin.dnf_repository:
name: rocmgraphics
description: AMD Graphics Repository
baseurl: "{{ rocmgraphics_repo_baseurl }}"
gpgkey: "{{ rocm_repo_gpgkey }}"
gpgcheck: yes
enabled: yes
- name: Install rocm-dkms and dependencies
become: true
ansible.builtin.package:
name: "{{ rocm_packages }}"
state: present
@@ -0,0 +1,2 @@
---
auto_suspend_grub_config_path_bios: /boot/grub2/grub.cfg
+13
View File
@@ -0,0 +1,13 @@
---
galaxy_info:
author: Alexandre Pires
description: Configure automatic system suspend based on idle time
company: A13Labs
role_name: auto_suspend
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: EL
versions:
- "9"
@@ -1,3 +1,4 @@
---
- name: Install dependencies
become: true
ansible.builtin.package:
@@ -46,18 +46,6 @@ amd_exporter_python_version: "python3"
amd_exporter_sysfs_gpu_pattern: "/sys/class/drm/card?/device/"
# Llama Exporter configuration
llama_exporter_enabled: true
llama_exporter_port: 9550
llama_exporter_user: llama_exporter
llama_exporter_user_home: "/home/llama_exporter"
llama_exporter_install_dir: "/opt/llama_exporter"
llama_exporter_venv_path: "/opt/llama_exporter/.venv"
llama_exporter_scrape_interval: 15
llama_exporter_scrape_timeout: 5
# Llama.cpp scrape targets
llama_exporter_targets: []
# Firewall configuration
prometheus_fw_port: "{{ prometheus_port }}"
prometheus_fw_proto: "tcp"
@@ -1,176 +0,0 @@
#!/usr/bin/env python3
"""LLM inference metrics exporter for Prometheus.
Scrape targets:
- llama.cpp HTTP API servers (standard /metrics or custom health)
"""
import json
import os
import time
import logging
import urllib.request
import urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
POLL_INTERVAL = int(os.environ.get("LLAMA_EXPORTER_INTERVAL", "15"))
SCRAPE_TIMEOUT = int(os.environ.get("LLAMA_EXPORTER_TIMEOUT", "5"))
LLAMA_CPP_DEFAULTS = [
{"name": "llama-cpp-8012", "url": "http://localhost:8012"},
]
def _fetch_json(url, timeout=SCRAPE_TIMEOUT):
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
logger.debug("Failed to fetch %s: %s", url, e)
return None
def _collect_llama_cpp(target):
metrics = {}
def _set_metric(metric_name, labels, value):
label_key = tuple(sorted(labels.items()))
metrics[(metric_name, label_key)] = value
base_url = target["url"].rstrip("/")
# Check health endpoint
health_url = f"{base_url}/health"
health = _fetch_json(health_url)
if health and isinstance(health, dict):
status = health.get("status", "unknown")
_set_metric("llama_server_health", {"server": target["name"], "model": health.get("model", "unknown")}, 1.0 if status == "ok" else 0.0)
else:
_set_metric("llama_server_health", {"server": target["name"], "model": "unknown"}, 0.0)
# Check /metrics endpoint for llama.cpp built-in metrics
metrics_url = f"{base_url}/metrics"
try:
req = urllib.request.Request(metrics_url, headers={"Accept": "text/plain"})
with urllib.request.urlopen(req, timeout=SCRAPE_TIMEOUT) as resp:
body = resp.read().decode("utf-8")
for line in body.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Parse simple key value pairs
if "{" in line:
key_part = line.split("{")[0]
labels_part = line.split("{")[1].rstrip("}").split("}")[0] if "}" in line else ""
value = line.split()[-1] if line.split() else "1"
label_str = ""
if labels_part:
label_str = ",".join(f'{{{k}="{v}"}}' for k, v in (
pair.split("=") for pair in labels_part.split(",") if "=" in pair
))
_set_metric(key_part.strip(), {"server": target["name"], "raw": labels_part}, float(value) if value.replace(".", "").replace("-", "").isdigit() else value)
else:
parts = line.split()
if len(parts) >= 2:
try:
float(parts[1])
_set_metric(parts[0], {"server": target["name"]}, parts[1])
except ValueError:
pass
except Exception as e:
logger.debug("Failed to scrape %s/metrics: %s", metrics_url, e)
# Check /model endpoint for model info
model_url = f"{base_url}/models"
models = _fetch_json(model_url)
if models and isinstance(models, dict):
model_list = models.get("data", [models]) if "data" in models else [models]
for m in model_list:
if not isinstance(m, dict):
continue
model_id = m.get("id", "unknown")
_set_metric("llama_models_loaded", {"server": target["name"], "model": model_id}, 1.0)
ctx_max = m.get("context_size", m.get("n_ctx", 0))
if ctx_max:
_set_metric("llama_model_context_max", {"server": target["name"], "model": model_id}, int(ctx_max))
trained = m.get("train_tokens", m.get("n_train", 0))
if trained:
_set_metric("llama_model_tokens_trained", {"server": target["name"], "model": model_id}, int(trained))
return metrics
class MetricsHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/metrics":
self.send_response(404)
self.end_headers()
return
all_metrics = {}
# Scrape llama.cpp targets
targets = []
env_targets = os.environ.get("LLAMA_TARGETS", "")
if env_targets:
try:
targets = json.loads(env_targets)
except json.JSONDecodeError:
logger.warning("Invalid LLAMA_TARGETS JSON, using defaults")
targets = []
if not targets:
targets = LLAMA_CPP_DEFAULTS
for target in targets:
if "url" not in target or "name" not in target:
target["name"] = target.get("name", "default")
target["url"] = target.get("url", "http://localhost:8012")
try:
all_metrics.update(_collect_llama_cpp(target))
except Exception as e:
logger.error("Error scraping llama.cpp target %s: %s", target.get("name", "unknown"), e)
lines = []
for (metric_type, label_pairs), value in sorted(all_metrics.items()):
labels = dict(label_pairs)
if not labels:
line = f"{metric_type} {value}"
else:
label_str = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
line = f"{metric_type}{{{label_str}}} {value}"
lines.append(line)
body = "\n".join(lines) + "\n" if lines else "# no metrics available\n"
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def log_message(self, format, *args):
logger.debug("%s - - %s", self.address_string(), format % args)
def main():
port = int(os.environ.get("LLAMA_EXPORTER_PORT", "9550"))
host = os.environ.get("LLAMA_EXPORTER_BIND", "0.0.0.0")
server = HTTPServer((host, port), MetricsHandler)
logger.info("Starting Llama Exporter on %s:%d", host, port)
logger.info("Polling every %d seconds, timeout %ds", POLL_INTERVAL, SCRAPE_TIMEOUT)
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("Shutting down")
server.shutdown()
if __name__ == "__main__":
main()
@@ -18,10 +18,3 @@
name: "{{ amd_exporter_service_name }}"
state: restarted
listen: Restart amd_exporter
- name: Restart llama_exporter
become: true
ansible.builtin.systemd:
name: "{{ llama_exporter_service_name }}"
state: restarted
listen: Restart llama_exporter
@@ -8,4 +8,3 @@
name: "gpu_monitoring"
vars:
amd_exporter_enabled: false
llama_exporter_enabled: false
@@ -11,12 +11,6 @@
tags:
- roles::gpu_monitoring::amd_exporter
- name: Include Llama Exporter setup
when: llama_exporter_enabled | default(true) | bool
ansible.builtin.include_tasks: llama_exporter.yml
tags:
- roles::gpu_monitoring::llama_exporter
- name: Include Prometheus setup
ansible.builtin.include_tasks: prometheus.yml
tags:
@@ -1,35 +0,0 @@
[Unit]
Description=LLM Inference Metrics Exporter (llama.cpp + Ollama)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ llama_exporter_user }}
Group={{ llama_exporter_user }}
WorkingDirectory={{ llama_exporter_install_dir }}
Environment="LLAMA_EXPORTER_PORT={{ llama_exporter_port }}"
Environment="LLAMA_EXPORTER_BIND=0.0.0.0"
Environment="LLAMA_EXPORTER_INTERVAL={{ llama_exporter_scrape_interval }}"
Environment="LLAMA_EXPORTER_TIMEOUT={{ llama_exporter_scrape_timeout }}"
{% if llama_exporter_targets and llama_exporter_targets != "" %}
Environment="LLAMA_TARGETS={{ llama_exporter_targets | to_json }}"
{% endif %}
Environment="PATH={{ llama_exporter_venv_bin }}:/usr/bin:/bin"
ExecStart={{ llama_exporter_venv_bin }}/python3 {{ llama_exporter_script_path }}
Restart=always
RestartSec=10
TimeoutStopSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=llama_exporter
# Security hardening
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{ llama_exporter_install_dir }} /var/log
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -21,10 +21,3 @@ scrape_configs:
- targets: ["{{ prometheus_host_gateway }}:{{ amd_exporter_port }}"]
labels:
datacenter: "{{ inventory_hostname }}"
- job_name: llama_exporter
metrics_path: /metrics
static_configs:
- targets: ["{{ prometheus_host_gateway }}:{{ llama_exporter_port }}"]
labels:
datacenter: "{{ inventory_hostname }}"
@@ -12,12 +12,3 @@ nvidia_exporter_service_name: "nvidia_exporter"
amd_exporter_script_path: "{{ amd_exporter_install_dir }}/amd_gpu_exporter.py"
amd_exporter_venv_bin: "{{ amd_exporter_venv_path }}/bin"
amd_exporter_service_name: "amdgpu_exporter"
llama_exporter_script_path: "{{ llama_exporter_install_dir }}/llama_exporter.py"
llama_exporter_venv_bin: "{{ llama_exporter_venv_path }}/bin"
llama_exporter_service_name: "llama_exporter"
# Default llama.cpp targets if none provided
llama_exporter_targets_default:
- name: "llama-cpp-8012"
url: "http://localhost:8012"
@@ -0,0 +1,26 @@
llama_server_user: "llama"
llama_server_group: "llama"
llama_server_home: "/home/llama"
llama_server_pubkey: ""
llama_server_models: []
llama_server_models_max: 1
llama_server_image: "ghcr.io/ggml-org/llama.cpp"
llama_server_tag: "server-vulkan"
llama_server_extra_groups: "users,video"
llama_server_devices: []
llama_server_env: {}
llama_server_port: 8080
llama_server_preset_global: {}
# Llama Exporter configuration
llama_server_exporter_enabled: true
llama_server_exporter_port: 9550
llama_server_exporter_user: llama_exporter
llama_server_exporter_user_home: "/opt/llama_exporter"
llama_server_exporter_install_dir: "/opt/llama_exporter"
llama_server_exporter_venv_path: "/opt/llama_exporter/.venv"
llama_server_exporter_scrape_interval: 15
llama_server_exporter_scrape_timeout: 5
# Llama.cpp scrape targets
llama_server_exporter_targets: []
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""LLM inference metrics exporter for Prometheus.
Background thread scrapes only loaded models to avoid triggering model loads
in llama-server, then the HTTP handler serves the cached data immediately.
Exposes metrics for every known model from /models; unloaded models show
zero values without ever requesting /metrics?model=<unloaded>.
Uses persistent JSON cache on disk to survive restarts and compute counter
deltas for Prometheus rate/irate queries.
"""
import json
import os
import time
import threading
import logging
import urllib.request
import urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
POLL_INTERVAL = int(os.environ.get("LLAMA_EXPORTER_INTERVAL", "15"))
SCRAPE_TIMEOUT = int(os.environ.get("LLAMA_EXPORTER_TIMEOUT", "5"))
CACHE_FILE = os.environ.get(
"LLAMA_EXPORTER_CACHE",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "llama_exporter_cache.json"),
)
LLAMA_CPP_DEFAULTS = [
{"name": "llama.cpp", "url": "http://localhost:11434"},
]
# All llama.cpp metrics we expose per model (counters + gauges).
ALL_METRICS = [
"llamacpp:prompt_tokens_total",
"llamacpp:prompt_seconds_total",
"llamacpp:tokens_predicted_total",
"llamacpp:tokens_predicted_seconds_total",
"llamacpp:n_decode_total",
"llamacpp:n_tokens_max",
"llamacpp:prompt_tokens_seconds",
"llamacpp:predicted_tokens_seconds",
"llamacpp:requests_processing",
"llamacpp:requests_deferred",
"llamacpp:n_busy_slots_per_decode",
]
# Counter metrics that accumulate over time — exposed as _delta for rate() queries.
COUNTER_METRICS = {
"llamacpp:prompt_tokens_total",
"llamacpp:prompt_seconds_total",
"llamacpp:tokens_predicted_total",
"llamacpp:tokens_predicted_seconds_total",
"llamacpp:n_decode_total",
}
class Cache:
"""Persistent, thread-safe metrics cache with delta computation."""
def __init__(self):
self._lock = threading.Lock()
self._data = {} # { model_id: { metric_name: value } }
self._known_models = set()
self._health = None
self._loaded = {}
# Previous state for delta computation: { model_id: { metric_name: value } }
self._prev = {}
self._prev_timestamp = 0.0
self._deltas = {}
# Load persisted state
self._load_cache()
def _load_cache(self):
"""Load previous scrape state from disk."""
try:
with open(CACHE_FILE, "r") as f:
state = json.load(f)
self._prev = state.get("models", {})
self._prev_timestamp = state.get("timestamp", 0.0)
logger.info("Loaded cache from %s (timestamp=%s, models=%d)",
CACHE_FILE, self._prev_timestamp, len(self._prev))
except FileNotFoundError:
logger.info("No cache file found at %s", CACHE_FILE)
except Exception as e:
logger.warning("Failed to load cache: %s", e)
def _save_cache(self, current_data, known_models, loaded):
"""Save current scrape state to disk."""
try:
state = {
"timestamp": time.time(),
"known": list(known_models),
"loaded": list(loaded),
"models": {},
}
for mid, metrics in current_data.items():
state["models"][mid] = dict(metrics)
tmp = CACHE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f)
os.replace(tmp, CACHE_FILE)
except Exception as e:
logger.error("Failed to save cache: %s", e)
def _compute_deltas_for_data(self, current_data):
"""Compute deltas given current data and stored previous state.
Only counter metrics get deltas. On counter reset (value went backward),
delta is silently 0 (no entry added).
"""
deltas = {}
if not self._prev:
return deltas
for mid, prev_metrics in self._prev.items():
mid_deltas = {}
cur_metrics = current_data.get(mid, {})
for mname, prev_val in prev_metrics.items():
# Only compute deltas for counter metrics
if mname not in COUNTER_METRICS:
continue
cur_val = cur_metrics.get(mname)
if cur_val is None:
continue
diff = cur_val - prev_val
# Counter reset: value went backward, delta is 0 (wrapped)
if diff < 0:
continue
if diff > 0:
mid_deltas[mname] = diff
if mid_deltas:
deltas[mid] = mid_deltas
return deltas
def update(self, models_data, metric_lines, known_models=None):
"""Called by the background thread after a successful scrape cycle."""
with self._lock:
# Discover model IDs and loaded status from /models endpoint
known = set()
loaded = {}
if models_data and isinstance(models_data, dict):
model_list = models_data.get("data", [])
for m in model_list:
if not isinstance(m, dict):
continue
mid = m.get("id", "unknown")
known.add(mid)
status_data = m.get("status", {})
if isinstance(status_data, dict) and status_data.get("value") == "loaded":
loaded[mid] = 1.0
self._health = {"status": "ok", "model": mid}
if not known:
self._health = {"status": "error", "model": "unknown"}
# Build new metrics dict for all known models
new_data = {}
for mid in known:
new_data[mid] = {}
# Apply parsed metric values from /metrics?model=<id>
for metric_name, model_id, value in metric_lines:
if model_id in known:
new_data[model_id][metric_name] = value
# Ensure every known model has all ALL_METRICS entries
for mid in known:
for mname in ALL_METRICS:
if mname not in new_data[mid]:
new_data[mid][mname] = 0.0
# Previously known models no longer in the list get zeroed out
for mid in self._known_models - known:
new_data[mid] = {m: 0.0 for m in ALL_METRICS}
# Compute deltas before updating previous state
deltas = self._compute_deltas_for_data(new_data)
# Save previous state for next cycle
self._save_cache(new_data, known, loaded)
# Update state
self._prev = {mid: dict(metrics) for mid, metrics in new_data.items()}
self._known_models = known
self._data = new_data
self._loaded = loaded
self._deltas = deltas
def snapshot(self):
"""Return a frozen copy of the current cache state including deltas."""
with self._lock:
return {
"data": {k: dict(v) for k, v in self._data.items()},
"known": set(self._known_models),
"health": dict(self._health) if self._health else {"status": "error", "model": "unknown"},
"loaded": dict(self._loaded),
"deltas": {k: dict(v) for k, v in self._deltas.items()},
}
cache = Cache()
def _fetch_json(url, timeout=SCRAPE_TIMEOUT):
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
logger.debug("Failed to fetch %s: %s", url, e)
return None
def _fetch_text(url, timeout=SCRAPE_TIMEOUT):
try:
req = urllib.request.Request(url, headers={"Accept": "text/plain"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8")
except Exception as e:
logger.debug("Failed to fetch %s: %s", url, e)
return None
def _scrape_cycle():
"""One full scrape cycle: discover models, then scrape metrics per model."""
targets = []
env_targets = os.environ.get("LLAMA_TARGETS", "")
if env_targets:
try:
targets = json.loads(env_targets)
except json.JSONDecodeError:
targets = []
if not targets:
targets = LLAMA_CPP_DEFAULTS
all_metric_lines = []
models_data = None
known_models = {} # { model_id: base_url, ... }
for target in targets:
base_url = target["url"].rstrip("/")
# Fetch /models to discover models and their status
models_data = _fetch_json(f"{base_url}/models")
if models_data and isinstance(models_data, dict):
model_list = models_data.get("data", [])
for m in model_list:
if not isinstance(m, dict) or "id" not in m:
continue
model_id = m["id"]
known_models[model_id] = base_url
# Only scrape metrics from loaded models to avoid triggering loads
status_data = m.get("status", {})
if isinstance(status_data, dict) and status_data.get("value") != "loaded":
continue
# Scrape /metrics for loaded models only
metrics_url = f"{base_url}/metrics?model={model_id}"
body = _fetch_text(metrics_url)
if body:
for line in body.splitlines():
parsed = _parse_metric_line(line)
if parsed:
metric_name, metric_value = parsed
if metric_name in ALL_METRICS:
all_metric_lines.append((metric_name, model_id, metric_value))
else:
logger.debug("No metrics body for model %s", model_id)
# Update the shared cache
cache.update(models_data, all_metric_lines, known_models)
def _parse_metric_line(line):
"""Parse a single Prometheus metric line. Returns (name, value) or None."""
line = line.strip()
if not line or line.startswith("#"):
return None
try:
# Handle lines with labels: metric_name{label="val"} value
if "{" in line:
parts = line.split("{")
name = parts[0].strip()
rest = parts[1]
# value is the last token after the closing }
value = rest.rsplit("}", 1)[-1].strip().split()[-1] if "}" in rest else rest.strip()
else:
parts = line.split()
name = parts[0]
value = parts[1] if len(parts) >= 2 else "1"
# Try to parse as float
float(value)
return (name, float(value))
except (ValueError, IndexError):
return None
def _background_scrape():
"""Background thread: periodically scrape and update cache."""
logger.info("Background scraper started (interval=%ds)", POLL_INTERVAL)
# Do one immediate scrape on startup
_scrape_cycle()
while True:
try:
time.sleep(POLL_INTERVAL)
_scrape_cycle()
except Exception as e:
logger.error("Scrape cycle error: %s", e)
class MetricsHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/metrics":
self.send_response(404)
self.end_headers()
return
snap = cache.snapshot()
lines = []
def _fmt(metric_name, model_id, value):
return metric_name + '{' + 'server="llama-cpp-11434",model="' + model_id + '"} ' + str(value)
# Health metric
health = snap["health"]
status = health.get("status", "error")
health_model = health.get("model", "unknown")
health_val = 1.0 if status == "ok" else 0.0
lines.append(_fmt("llama_server_health", health_model, health_val))
# Loaded metrics
for mid in snap["loaded"]:
lines.append(_fmt("llama_models_loaded", mid, snap["loaded"][mid]))
# Per-model metrics from cache (absolute values)
for mid in sorted(snap["data"]):
metrics = snap["data"][mid]
for mname in ALL_METRICS:
value = metrics.get(mname, 0.0)
lines.append(_fmt(mname, mid, value))
# Per-model delta metrics (counters as change since last scrape)
deltas = snap.get("deltas", {})
for mid in sorted(deltas):
delta_metrics = deltas[mid]
for mname in sorted(delta_metrics):
value = delta_metrics[mname]
lines.append(_fmt(mname + "_delta", mid, value))
body = "\n".join(lines) + "\n" if lines else "# no metrics available\n"
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def log_message(self, format, *args):
logger.debug("%s - - %s", self.address_string(), format % args)
def main():
port = int(os.environ.get("LLAMA_EXPORTER_PORT", "9550"))
host = os.environ.get("LLAMA_EXPORTER_BIND", "0.0.0.0")
# Start background scraper thread
scraper = threading.Thread(target=_background_scrape, daemon=True)
scraper.start()
# Start HTTP server
server = HTTPServer((host, port), MetricsHandler)
logger.info("Starting Llama Exporter on %s:%d (interval=%ds, timeout=%ds, cache=%s)",
host, port, POLL_INTERVAL, SCRAPE_TIMEOUT, CACHE_FILE)
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("Shutting down")
server.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Tests for llama_exporter cache persistence and delta computation."""
import json
import os
import sys
import tempfile
import shutil
import unittest
# Setup temp cache file before importing the module
TEMP_DIR = tempfile.mkdtemp()
os.environ["LLAMA_EXPORTER_CACHE"] = os.path.join(TEMP_DIR, "test_cache.json")
# Import from parent directory (scripts)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from llama_exporter import Cache
class TestCachePersistenceAndDeltas(unittest.TestCase):
def setUp(self):
# Ensure temp dir exists (pytest may clean it between test methods)
os.makedirs(TEMP_DIR, exist_ok=True)
self.cache = Cache()
def tearDown(self):
shutil.rmtree(TEMP_DIR, ignore_errors=True)
def test_cold_start_no_deltas(self):
"""First scrape should produce no deltas since there's no previous state."""
models_data = {
"data": [
{"id": "model-v1", "status": {"value": "loaded"}},
{"id": "model-v2", "status": {"value": "unloaded"}},
]
}
metric_lines = [
("llamacpp:prompt_tokens_total", "model-v1", 100.0),
("llamacpp:prompt_seconds_total", "model-v1", 2.5),
("llamacpp:n_tokens_max", "model-v1", 4096.0),
]
known_models = {"model-v1": "http://localhost:11434", "model-v2": "http://localhost:11434"}
self.cache.update(models_data, metric_lines, known_models)
snap = self.cache.snapshot()
# No deltas on first scrape
self.assertEqual(snap["deltas"], {})
# Absolute values are correct
self.assertEqual(snap["data"]["model-v1"]["llamacpp:prompt_tokens_total"], 100.0)
# Unloaded model has zeros
self.assertEqual(snap["data"]["model-v2"]["llamacpp:prompt_tokens_total"], 0.0)
# Only loaded model is in loaded set
self.assertIn("model-v1", snap["loaded"])
self.assertNotIn("model-v2", snap["loaded"])
def test_delta_computation(self):
"""Second scrape should produce deltas from first scrape."""
models_data = {
"data": [
{"id": "model-v1", "status": {"value": "loaded"}},
]
}
# First scrape
metric_lines_1 = [
("llamacpp:prompt_tokens_total", "model-v1", 100.0),
("llamacpp:prompt_seconds_total", "model-v1", 2.5),
]
known_models_1 = {"model-v1": "http://localhost:11434"}
self.cache.update(models_data, metric_lines_1, known_models_1)
# Second scrape - values increased
metric_lines_2 = [
("llamacpp:prompt_tokens_total", "model-v1", 250.0),
("llamacpp:prompt_seconds_total", "model-v1", 5.0),
]
self.cache.update(models_data, metric_lines_2, known_models_1)
snap = self.cache.snapshot()
# Deltas should be computed
self.assertIn("model-v1", snap["deltas"])
self.assertEqual(snap["deltas"]["model-v1"]["llamacpp:prompt_tokens_total"], 150.0)
self.assertEqual(snap["deltas"]["model-v1"]["llamacpp:prompt_seconds_total"], 2.5)
def test_counter_reset(self):
"""If a counter value goes backward, delta should be 0."""
models_data = {
"data": [
{"id": "model-v1", "status": {"value": "loaded"}},
]
}
# First scrape
metric_lines_1 = [
("llamacpp:prompt_tokens_total", "model-v1", 100.0),
]
known_models_1 = {"model-v1": "http://localhost:11434"}
self.cache.update(models_data, metric_lines_1, known_models_1)
# Second scrape - counter reset (model was reloaded)
metric_lines_2 = [
("llamacpp:prompt_tokens_total", "model-v1", 10.0),
]
self.cache.update(models_data, metric_lines_2, known_models_1)
snap = self.cache.snapshot()
# On counter reset, delta is implicitly 0 (no entry in deltas)
self.assertNotIn("model-v1", snap["deltas"])
def test_gauge_metrics_no_delta(self):
"""Gauge metrics should not get delta entries (only counters do)."""
models_data = {
"data": [
{"id": "model-v1", "status": {"value": "loaded"}},
]
}
# First scrape
metric_lines_1 = [
("llamacpp:n_tokens_max", "model-v1", 4096.0),
("llamacpp:requests_processing", "model-v1", 2.0),
]
known_models_1 = {"model-v1": "http://localhost:11434"}
self.cache.update(models_data, metric_lines_1, known_models_1)
# Second scrape
metric_lines_2 = [
("llamacpp:n_tokens_max", "model-v1", 8192.0),
("llamacpp:requests_processing", "model-v1", 5.0),
]
self.cache.update(models_data, metric_lines_2, known_models_1)
snap = self.cache.snapshot()
# Gauge metrics should not have deltas
self.assertNotIn("model-v1", snap["deltas"])
def test_persistence_across_restarts(self):
"""Delta computation should work across cache reloads from disk."""
models_data = {
"data": [
{"id": "model-v1", "status": {"value": "loaded"}},
]
}
# First scrape (first process instance)
metric_lines_1 = [
("llamacpp:prompt_tokens_total", "model-v1", 100.0),
("llamacpp:prompt_seconds_total", "model-v1", 2.5),
]
known_models_1 = {"model-v1": "http://localhost:11434"}
self.cache.update(models_data, metric_lines_1, known_models_1)
# Simulate restart: create new Cache instance (loads from disk)
self.cache._save_cache(self.cache._data, self.cache._known_models, self.cache._loaded)
# Create a new cache instance (simulates restart)
new_cache = Cache()
# Second scrape after restart
metric_lines_2 = [
("llamacpp:prompt_tokens_total", "model-v1", 300.0),
("llamacpp:prompt_seconds_total", "model-v1", 8.0),
]
known_models_2 = {"model-v1": "http://localhost:11434"}
new_cache.update(models_data, metric_lines_2, known_models_2)
snap = new_cache.snapshot()
# Deltas should be computed using persisted previous state
self.assertIn("model-v1", snap["deltas"])
self.assertEqual(snap["deltas"]["model-v1"]["llamacpp:prompt_tokens_total"], 200.0)
self.assertEqual(snap["deltas"]["model-v1"]["llamacpp:prompt_seconds_total"], 5.5)
def test_unloaded_model_zero_values(self):
"""Unloaded models should appear with zero values but never trigger /metrics calls."""
models_data = {
"data": [
{"id": "model-v1", "status": {"value": "loaded"}},
{"id": "model-v2", "status": {"value": "unloaded"}},
{"id": "model-v3", "status": {"value": "unloaded"}},
]
}
metric_lines = [
("llamacpp:prompt_tokens_total", "model-v1", 50.0),
]
known_models = {
"model-v1": "http://localhost:11434",
"model-v2": "http://localhost:11434",
"model-v3": "http://localhost:11434",
}
self.cache.update(models_data, metric_lines, known_models)
snap = self.cache.snapshot()
# Loaded model has its actual value
self.assertEqual(snap["data"]["model-v1"]["llamacpp:prompt_tokens_total"], 50.0)
# Unloaded models have zeros for all metrics
for m in ["model-v2", "model-v3"]:
self.assertEqual(snap["data"][m]["llamacpp:prompt_tokens_total"], 0.0)
self.assertEqual(snap["data"][m]["llamacpp:n_tokens_max"], 0.0)
def test_removed_model_zeroed(self):
"""Models removed from llama-server should be zeroed out."""
models_data = {
"data": [
{"id": "model-v1", "status": {"value": "loaded"}},
]
}
# First scrape with two models
metric_lines_1 = [
("llamacpp:prompt_tokens_total", "model-v1", 100.0),
]
known_models_1 = {"model-v1": "http://localhost:11434"}
self.cache.update(models_data, metric_lines_1, known_models_1)
# Second scrape with only model-v1 (model-v2 was removed)
metric_lines_2 = [
("llamacpp:prompt_tokens_total", "model-v1", 200.0),
]
known_models_2 = {"model-v1": "http://localhost:11434"}
self.cache.update(models_data, metric_lines_2, known_models_2)
snap = self.cache.snapshot()
# model-v1 delta should be 100
self.assertEqual(snap["deltas"]["model-v1"]["llamacpp:prompt_tokens_total"], 100.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,13 @@
---
- name: Reload systemd daemon
become: true
ansible.builtin.systemd:
daemon_reload: true
listen: Reload systemd daemon
- name: Restart llama_exporter
become: true
ansible.builtin.systemd:
name: "{{ llama_server_exporter_service_name }}"
state: restarted
listen: Restart llama_exporter
+13
View File
@@ -0,0 +1,13 @@
---
galaxy_info:
author: Alexandre Pires
description: Sync llama models from Hugging Face
company: A13Labs
role_name: llama_models_sync
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: EL
versions:
- "9"
@@ -2,16 +2,16 @@
- name: Create llama_exporter group
become: true
ansible.builtin.group:
name: "{{ llama_exporter_user }}"
name: "{{ llama_server_exporter_user }}"
state: present
system: true
- name: Create llama_exporter user
become: true
ansible.builtin.user:
name: "{{ llama_exporter_user }}"
group: "{{ llama_exporter_user }}"
home: "{{ llama_exporter_user_home }}"
name: "{{ llama_server_exporter_user }}"
group: "{{ llama_server_exporter_user }}"
home: "{{ llama_server_exporter_user_home }}"
create_home: true
shell: /usr/sbin/nologin
system: true
@@ -21,20 +21,20 @@
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ llama_exporter_user }}"
group: "{{ llama_exporter_user }}"
owner: "{{ llama_server_exporter_user }}"
group: "{{ llama_server_exporter_user }}"
mode: "0755"
loop:
- "{{ llama_exporter_install_dir }}"
- "{{ llama_exporter_install_dir }}/logs"
- "{{ llama_server_exporter_install_dir }}"
- "{{ llama_server_exporter_install_dir }}/logs"
- name: Copy llama_exporter script
become: true
ansible.builtin.copy:
src: llama_exporter.py
dest: "{{ llama_exporter_script_path }}"
owner: "{{ llama_exporter_user }}"
group: "{{ llama_exporter_user }}"
src: scripts/llama_exporter.py
dest: "{{ llama_server_exporter_script_path }}"
owner: "{{ llama_server_exporter_user }}"
group: "{{ llama_server_exporter_user }}"
mode: "0755"
notify:
- Restart llama_exporter
@@ -42,31 +42,22 @@
- name: Create Python virtual environment
become: true
ansible.builtin.command:
cmd: "{{ ansible_facts['python']['executable'] }} -m venv {{ llama_exporter_venv_path }}"
creates: "{{ llama_exporter_venv_path }}/bin/activate"
- name: Install Python dependencies in llama_exporter venv
become: true
ansible.builtin.pip:
name:
- prometheus-client
- requests
virtualenv: "{{ llama_exporter_venv_path }}"
state: present
cmd: "{{ ansible_facts['python']['executable'] }} -m venv {{ llama_server_exporter_venv_path }}"
creates: "{{ llama_server_exporter_venv_path }}/bin/activate"
- name: Fix ownership of llama_exporter directory
become: true
ansible.builtin.file:
path: "{{ llama_exporter_install_dir }}"
owner: "{{ llama_exporter_user }}"
group: "{{ llama_exporter_user }}"
path: "{{ llama_server_exporter_install_dir }}"
owner: "{{ llama_server_exporter_user }}"
group: "{{ llama_server_exporter_user }}"
recurse: true
- name: Generate llama_exporter systemd service
become: true
ansible.builtin.template:
src: llama_exporter.service.j2
dest: "/etc/systemd/system/{{ llama_exporter_service_name }}.service"
dest: "/etc/systemd/system/{{ llama_server_exporter_service_name }}.service"
owner: root
group: root
mode: "0644"
@@ -77,6 +68,6 @@
- name: Enable llama_exporter service
become: true
ansible.builtin.systemd:
name: "{{ llama_exporter_service_name }}"
name: "{{ llama_server_exporter_service_name }}"
enabled: true
state: started
+358
View File
@@ -0,0 +1,358 @@
---
- name: Validate llama_server_models schema
ansible.builtin.assert:
that:
- llama_server_models is iterable
fail_msg: "llama_server_models must be a list"
- name: Create group
become: true
ansible.builtin.group:
name: "{{ llama_server_group }}"
system: true
- name: Create user
become: true
ansible.builtin.user:
name: "{{ llama_server_user }}"
groups: "{{ podman_extra_groups | default([]) }}"
shell: /bin/bash
home: "{{ llama_server_home }}"
group: "{{ llama_server_group }}"
- name: Disable password login for user
ansible.builtin.command: passwd -d "{{ llama_server_user }}"
become: true
changed_when: false
- name: Create user home
become: true
ansible.builtin.file:
path: "{{ llama_server_home }}"
state: directory
owner: "{{ llama_server_user }}"
group: "{{ llama_server_group }}"
mode: "0750"
- name: Set fact podman_binary (Debian family)
ansible.builtin.set_fact:
podman_binary: /usr/bin/podman
when: ansible_os_family == 'Debian'
- name: Set fact podman_binary (Red Hat family)
ansible.builtin.set_fact:
podman_binary: /usr/sbin/podman
when: ansible_os_family == 'RedHat'
- name: Create required folders
become: true
ansible.builtin.file:
path: "{{ llama_server_home }}/{{ item }}"
state: directory
owner: "{{ llama_server_user }}"
group: "{{ llama_server_group }}"
mode: "0750"
loop:
- models
- .cache
- .cache/llama.cpp
- name: "Add SSH Authorized key"
become: true
ansible.posix.authorized_key:
user: "{{ llama_server_user }}"
key: "{{ llama_server_pubkey }}"
state: "{{ 'present' if llama_server_pubkey is defined and llama_server_pubkey != '' else 'absent' }}"
when: llama_server_pubkey is defined and llama_server_pubkey != ""
- name: SELinux tasks (RedHat only)
when: ansible_os_family == 'RedHat'
become: true
block:
- name: Persistently set SELinux context
community.general.sefcontext:
target: "{{ llama_server_home }}(/.*)?"
setype: user_home_dir_t
state: present
- name: Persistently set SELinux context (ssh authorized keys)
community.general.sefcontext:
target: "{{ llama_server_home }}/.ssh/authorized_keys"
setype: ssh_home_t
state: present
when: llama_server_pubkey is defined and llama_server_pubkey != ""
- name: Apply SELinux context
ansible.builtin.command: restorecon -Rv {{ llama_server_home }}
changed_when: false
- name: Enable sudo access to llama user (temporary)
become: true
ansible.builtin.lineinfile:
path: "/etc/sudoers.d/ansible_{{ llama_server_user }}_tmp"
create: true
mode: "0440"
line: "{{ ansible_facts['user_id'] }} ALL=({{ llama_server_user }}) NOPASSWD: ALL"
validate: "visudo -cf %s"
- name: Enable lingering for llama user
become: true
ansible.builtin.command: loginctl enable-linger {{ llama_server_user }}
register: linger_status
changed_when: >-
'created' in linger_status.stdout or
(linger_status.rc == 0 and not
('linger file already exists' in linger_status.stderr or
'linger file does not exist' in linger_status.stderr))
failed_when:
- linger_status.rc != 0 and not
('linger file already exists' in linger_status.stderr)
- name: Get current user's UID
ansible.builtin.command: id -u {{ llama_server_user }}
changed_when: false
register: uid
- name: Get current user's GID
ansible.builtin.command: id -g {{ llama_server_user }}
changed_when: false
register: gid
- name: Run tasks as podman user
become: true
become_user: "{{ llama_server_user }}"
block:
- name: Pull the requested images
containers.podman.podman_image:
name: "{{ llama_server_image }}"
tag: "{{ llama_server_tag }}"
state: present
- name: Create pods
containers.podman.podman_container:
name: "llama.cpp"
image: "{{ llama_server_image }}:{{ llama_server_tag }}"
state: started
device: "{{ llama_server_devices | default(omit) }}"
env: "{{ llama_server_env | default(omit) }}"
ports: "0.0.0.0:{{ llama_server_port }}:8080/tcp"
volumes:
- "{{ llama_server_home }}/models:/models:Z"
- "{{ llama_server_home }}/.cache:/app/.cache:Z"
command:
- "-t"
- "{{ llama_server_argv_threads | default(10) }}"
- "-np"
- "{{ llama_server_argv_parallel | default(1) }}"
- "-b"
- "{{ llama_server_argv_batch_size | default(1024) }}"
- "-ub"
- "{{ llama_server_argv_ubatch_size | default(512) }}"
- "-fa"
- "{{ llama_server_argv_flash_attn | default('on') }}"
- "-ctk"
- "{{ llama_server_argv_cache_type_k | default('q4_0') }}"
- "-ctv"
- "{{ llama_server_argv_cache_type_v | default('q8_0') }}"
- "-cram"
- "{{ llama_server_argv_cache_reuse | default(-1) }}"
- "-sm"
- "{{ llama_server_argv_split_mode | default('layer') }}"
- "-dev"
- "{{ llama_server_argv_devices | default('Vulkan1,Vulkan2') }}"
- "-ts"
- "{{ llama_server_argv_tensor_split | default('8,12') }}"
- "-fit"
- "{{ llama_server_argv_fit | default('off') }}"
- "-mg"
- "{{ llama_server_argv_main_gpu | default(1) }}"
- "--mmap"
- "--metrics"
- "--models-preset"
- "/models/.router/models.ini"
- "--models-max"
- "{{ llama_server_models_max }}"
cmd_args:
- "--userns=keep-id"
- "--security-opt=label=disable"
- name: Create systemd service file for pods
become: true
ansible.builtin.template:
src: podman.service.j2
dest: "/etc/systemd/system/podman-llama.cpp.service"
owner: root
group: root
mode: "0644"
- name: Reload systemd daemon
become: true
ansible.builtin.systemd:
daemon_reload: true
- name: Enable containers at boot
become: true
ansible.builtin.systemd:
name: "podman-llama.cpp"
enabled: true
state: "started"
- name: Install Python packages required for llama server model sync
become: true
ansible.builtin.package:
name: "{{ __llama_sync_system_packages__ }}"
state: present
vars:
__llama_sync_system_packages__: >-
{{
{
'Debian': ['python3-pip', 'python3-packaging', 'python3-venv'],
'RedHat': ['python3-pip', 'python3-packaging'],
'Archlinux': ['python-pip', 'python-packaging']
}[ansible_os_family]
}}
- name: Install Hugging Face hub client in llama server sync virtualenv
become: true
become_user: "{{ llama_server_user }}"
ansible.builtin.pip:
name:
- pip
- setuptools
- packaging
- huggingface_hub>=0.32.0
state: present
virtualenv: "{{ llama_server_home }}/.router/.venv"
virtualenv_command: "python3 -m venv"
- name: Create llama server router model directories
become: true
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ llama_server_user }}"
group: "{{ llama_server_group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ llama_server_home }}/models", mode: "0750" }
- { path: "{{ llama_server_home }}/models/managed", mode: "0750" }
- { path: "{{ llama_server_home }}/models/managed-links", mode: "0750" }
- { path: "{{ llama_server_home }}/models/.router", mode: "0750" }
- name: Copy llama server Hugging Face sync helper
become: true
ansible.builtin.copy:
src: scripts/llama_hf_sync.py
dest: "{{ llama_server_home }}/models/.router/llama_hf_sync.py"
owner: "{{ llama_server_user }}"
group: "{{ llama_server_group }}"
mode: "0750"
- name: Write desired llama server model manifest input
become: true
ansible.builtin.copy:
content: "{{ llama_server_models | to_nice_json }}"
dest: "{{ llama_server_home }}/models/.router/desired-models.json"
owner: "{{ llama_server_user }}"
group: "{{ llama_server_group }}"
mode: "0640"
- name: Write global llama server preset options
become: true
ansible.builtin.copy:
content: "{{ llama_server_preset_global | to_nice_json }}"
dest: "{{ llama_server_home }}/models/.router/preset-global.json"
owner: "{{ llama_server_user }}"
group: "{{ llama_server_group }}"
mode: "0640"
- name: Set arguments for llama server model sync
ansible.builtin.set_fact:
__llama_preset_file__:
- "{{ llama_server_home }}/.router/.venv/bin/python"
- "{{ llama_server_home }}/models/.router/llama_hf_sync.py"
- "--models-file"
- "{{ llama_server_home }}/models/.router/desired-models.json"
- "--managed-dir"
- "{{ llama_server_home }}/models/managed"
- "--links-dir"
- "{{ llama_server_home }}/models/managed-links"
- "--manifest-file"
- "{{ llama_server_home }}/models/.router/manifest.json"
- "--preset-file"
- "{{ llama_server_home }}/models/.router/models.ini"
- "--preset-global-file"
- "{{ llama_server_home }}/models/.router/preset-global.json"
- "--container-links-dir"
- "/models/managed-links"
- name: Run llama server model sync from Hugging Face
vars:
__llama_sync_argv__: >-
{{ __llama_preset_file__
+ (['--prune'] if llama_server_models_prune_enabled | default(true) else [])
+ (['--dry-run'] if llama_server_models_dry_run | default(false) else [])
}}
become: true
become_user: "{{ llama_server_user }}"
ansible.builtin.command:
argv: "{{ __llama_sync_argv__ }}"
environment:
HF_TOKEN: "{{ lookup('env', 'HF_TOKEN') | default('', true) }}"
register: __llama_sync_output__
changed_when: false
- name: Parse llama server sync output
ansible.builtin.set_fact:
__llama_sync_result__: "{{ __llama_sync_output__.stdout | from_json }}"
- name: Stat llama router preset file (when not in dry-run mode)
become: true
become_user: "{{ llama_server_user }}"
ansible.builtin.stat:
path: "{{ llama_server_home }}/models/.router/models.ini"
register: __llama_preset_stat__
when: not (llama_server_models_dry_run | default(false))
- name: Ensure router preset file exists when not in dry-run mode
ansible.builtin.assert:
that:
- __llama_preset_stat__.stat.exists
fail_msg: "llama router preset file was not created"
when: not (llama_server_models_dry_run | default(false))
- name: Query llama router model list
ansible.builtin.uri:
url: "http://127.0.0.1:{{ llama_server_port | default(8080) }}/models?reload=1"
method: GET
status_code: 200
register: __llama_router_models__
changed_when: false
when:
- llama_server_models_sync_enabled | default(true)
- not (llama_server_models_dry_run | default(false))
- name: Validate expected model aliases are available in router
ansible.builtin.assert:
that:
- item.name in (__llama_router_models__.json.data | map(attribute='id') | list)
fail_msg: "Model name '{{ item.name }}' not present in llama router /models output"
loop: "{{ __llama_sync_result__.models | default([]) }}"
loop_control:
label: "{{ item.name }}"
when:
- llama_server_models_sync_enabled | default(true)
- not (llama_server_models_dry_run | default(false))
- name: Include Llama Exporter setup
when: llama_server_exporter_enabled | default(true) | bool
ansible.builtin.include_tasks: llama_exporter.yml
tags:
- roles::llama_server::llama_exporter
- name: Remove temporary sudo access
become: true
ansible.builtin.file:
path: "/etc/sudoers.d/ansible_{{ llama_server_user }}_tmp"
state: absent
@@ -0,0 +1,35 @@
[Unit]
Description=LLM Inference Metrics Exporter (llama.cpp + Ollama)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ llama_server_exporter_user }}
Group={{ llama_server_exporter_user }}
WorkingDirectory={{ llama_server_exporter_install_dir }}
Environment="LLAMA_EXPORTER_PORT={{ llama_server_exporter_port }}"
Environment="LLAMA_EXPORTER_BIND=0.0.0.0"
Environment="LLAMA_EXPORTER_INTERVAL={{ llama_server_exporter_scrape_interval }}"
Environment="LLAMA_EXPORTER_TIMEOUT={{ llama_server_exporter_scrape_timeout }}"
{% if llama_server_exporter_targets and llama_server_exporter_targets != "" %}
Environment="LLAMA_TARGETS={{ llama_server_exporter_targets | to_json }}"
{% endif %}
Environment="PATH={{ llama_server_exporter_venv_bin }}:/usr/bin:/bin"
ExecStart={{ llama_server_exporter_venv_bin }}/python3 {{ llama_server_exporter_script_path }}
Restart=always
RestartSec=10
TimeoutStopSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=llama_exporter
# Security hardening
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{ llama_server_exporter_install_dir }} /var/log
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,17 @@
[Unit]
Description=Podman file for llama.cpp server
After=local-fs.target network-online.target nvidia-cdi-generator.service
[Service]
Type=oneshot
User={{ llama_server_user }}
Group={{ llama_server_group }}
RemainAfterExit=true
ExecStart={{ podman_binary }} start podman-llama.cpp
ExecStop={{ podman_binary }} stop podman-llama.cpp
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
---
# Internal role variables (non-overridable)
llama_server_exporter_script_path: "{{ llama_server_exporter_install_dir }}/llama_exporter.py"
llama_server_exporter_venv_bin: "{{ llama_server_exporter_venv_path }}/bin"
llama_server_exporter_service_name: "llama_exporter"
# Default llama.cpp targets if none provided
llama_server_exporter_targets_default:
- name: "llama-cpp-11434"
url: "http://localhost:11434"
@@ -0,0 +1,13 @@
---
galaxy_info:
author: Alexandre Pires
description: Install and configure NVIDIA CDI generator service
company: A13Labs
role_name: nvidia_cdi_generator
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: EL
versions:
- "9"
@@ -1,3 +1,4 @@
---
- name: Install nvidia-cdi-generator
become: true
ansible.builtin.package:
+15
View File
@@ -0,0 +1,15 @@
---
# podman defaults
podman_user: ""
podman_group: ""
podman_user_home: ""
podman_extra_groups: []
podman_user_folders: []
podman_user_pubkey: ""
podman_network_name: "none"
podman_build_files: []
podman_pods: []
# Binary path - will be set by OS family in tasks
# podman_binary is set as a fact in tasks/main.yml
+18
View File
@@ -0,0 +1,18 @@
---
galaxy_info:
author: Alexandre Pires
description: Deploy and manage Podman users, pods, and containers
company: A13Labs
role_name: podman
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: Fedora
versions:
- "39"
- "40"
- "41"
- name: EL
versions:
- "9"
@@ -1,32 +1,90 @@
- name: Check if required vars are set
ansible.builtin.fail:
msg: "Required variables are not set for Podman setup. Please check your playbook variables.)"
when:
- podman_user is not defined
- podman_group is not defined
- podman_user_home is not defined
---
- name: Set fact podman_binary (Debian family)
when: ansible_os_family == 'Debian'
ansible.builtin.set_fact:
podman_binary: /usr/bin/podman
when: ansible_os_family == 'Debian'
- name: Set fact podman_binary (Red Hat family)
when: ansible_os_family == 'RedHat'
ansible.builtin.set_fact:
podman_binary: /usr/sbin/podman
when: ansible_os_family == 'RedHat'
- name: Install Podman and its dependencies
- name: Create group
become: true
ansible.builtin.package:
name:
- podman
- acl
state: present
ansible.builtin.group:
name: "{{ podman_group }}"
system: true
- name: Setup user
ansible.builtin.include_tasks:
file: podman/user.yml
- name: Create user
become: true
ansible.builtin.user:
name: "{{ podman_user }}"
groups: "{{ podman_extra_groups | default([]) }}"
shell: /bin/bash
home: "{{ podman_user_home }}"
group: "{{ podman_group }}"
- name: Disable password login for user
ansible.builtin.command: passwd -d "{{ podman_user }}"
become: true
changed_when: false
- name: Create user home
become: true
ansible.builtin.file:
path: "{{ podman_user_home }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0750"
- name: Create build folders
become: true
ansible.builtin.file:
path: "{{ podman_user_home }}/build"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0750"
- name: Create folders
become: true
ansible.builtin.file:
path: "{{ podman_user_home }}/{{ item }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0750"
loop: "{{ podman_user_folders }}"
- name: "Add SSH Authorized key"
become: true
ansible.posix.authorized_key:
user: "{{ podman_user }}"
key: "{{ podman_user_pubkey }}"
state: "{{ 'present' if podman_user_pubkey is defined and podman_user_pubkey != '' else 'absent' }}"
when: podman_user_pubkey is defined and podman_user_pubkey != ""
- name: SELinux tasks (RedHat only)
when: ansible_os_family == 'RedHat'
become: true
block:
- name: Persistently set SELinux context
community.general.sefcontext:
target: "{{ podman_user_home }}(/.*)?"
setype: user_home_dir_t
state: present
- name: Persistently set SELinux context (ssh authorized keys)
community.general.sefcontext:
target: "{{ podman_user_home }}/.ssh/authorized_keys"
setype: ssh_home_t
state: present
when: podman_user_pubkey is defined and podman_user_pubkey != ""
- name: Apply SELinux context
ansible.builtin.command: restorecon -Rv {{ podman_user_home }}
changed_when: false
- name: Enable sudo access to podman user (temporary)
become: true
@@ -91,7 +149,7 @@
extra_args: "--build-arg UID={{ uid.stdout }} --build-arg GID={{ gid.stdout }} --security-opt=label=disable {{ item.build.args | default('') }}"
tag: "latest"
state: present
loop: "{{ pods }}"
loop: "{{ podman_pods }}"
when: item.build is defined and item.build.dockerfile is defined
- name: Pull the requested images
@@ -99,7 +157,7 @@
name: "{{ item.repo.image }}"
tag: "{{ item.repo.tag | default('latest') }}"
state: present
loop: "{{ pods }}"
loop: "{{ podman_pods }}"
when: item.repo is defined and item.repo.image is defined
- name: Create pods
@@ -116,7 +174,7 @@
cmd_args:
- "--userns=keep-id"
- "--security-opt=label=disable"
loop: "{{ pods }}"
loop: "{{ podman_pods }}"
- name: Create systemd service file for pods
become: true
@@ -126,7 +184,7 @@
owner: root
group: root
mode: "0644"
loop: "{{ pods }}"
loop: "{{ podman_pods }}"
- name: Reload systemd daemon
become: true
@@ -139,7 +197,7 @@
name: "podman-{{ item.name }}"
enabled: "{{ item.systemd.enabled | default(true) }}"
state: "{{ item.systemd.state | default('started') }}"
loop: "{{ pods }}"
loop: "{{ podman_pods }}"
- name: Remove temporary sudo access
become: true
@@ -0,0 +1,17 @@
[Unit]
Description=Podman service for {{ item.name }}
After=local-fs.target network-online.target
[Service]
Type=oneshot
User={{ podman_user }}
Group={{ podman_group }}
RemainAfterExit=true
ExecStart={{ podman_binary }} start {{ item.name }}
ExecStop={{ podman_binary }} stop {{ item.name }}
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
---
galaxy_info:
author: Alexandre Pires
description: Windows game optimization (user creation, auto-login, power plan, service disable)
company: A13Labs
role_name: windows_game
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: windows
@@ -1,3 +1,4 @@
---
- name: Create limited GameUser
ansible.windows.win_user:
name: "{{ game_user }}"
@@ -177,10 +178,3 @@
- Power Management: Prefer Maximum Performance
- Low Latency Mode: Ultra
- Vertical Sync: Off or Fast Sync (depending on monitor)
# - name: Set custom shell to launcher.bat
# ansible.windows.win_regedit:
# path: HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
# name: Shell
# data: C:\GameShell\launcher.bat
# type: string
@@ -0,0 +1,11 @@
---
galaxy_info:
author: Alexandre Pires
description: Install Prometheus and Windows Exporter on Windows
company: A13Labs
role_name: windows_prometheus
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: windows
+11
View File
@@ -0,0 +1,11 @@
---
galaxy_info:
author: Alexandre Pires
description: Install and configure OpenSSH Server on Windows
company: A13Labs
role_name: windows_ssh
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: windows
@@ -4,9 +4,6 @@
# keys:
# - "ssh-ed25519 AAAA..."
# - "ssh-rsa AAAA..."
# - name: bob
# keys:
# - "ssh-rsa AAAA..."
- name: Setting defaults for Windows OpenSSH Server installation
ansible.builtin.set_fact:
__ssh_default_ciphers__:
@@ -108,7 +105,7 @@
Write-Output 'hostkeys-generated'
} else {
Write-Output 'ssh-keygen-not-found'
exit 2
exit(2)
}
register: ssh_keygen
changed_when: "'hostkeys-generated' in ssh_keygen.stdout"
@@ -358,7 +355,7 @@
if ($before -ne $after) {
Write-Output 'admin-auth-keys-acl-changed'
} else {
Write-Output 'admin-auth-keys-acl-unchanged'
Write-Output 'admin-auth-keys-unchanged'
}
} else {
Write-Output 'no-admin-auth-keys'
+13
View File
@@ -0,0 +1,13 @@
---
galaxy_info:
author: Alexandre Pires
description: Configure Wake-on-LAN via systemd-networkd
company: A13Labs
role_name: wol
namespace: a13labs
license: MIT
min_ansible_version: "2.1"
platforms:
- name: EL
versions:
- "9"
@@ -1,3 +1,4 @@
---
- name: Install ethtool
become: true
ansible.builtin.package:
-33
View File
@@ -1,33 +0,0 @@
- name: Add ROCm repository
become: true
ansible.builtin.yum_repository:
name: rocm
description: ROCm Repository
baseurl: https://repo.radeon.com/rocm/el9/latest/main/
gpgcheck: true
gpgkey: https://repo.radeon.com/rocm/rocm.gpg.key
enabled: true
state: present
- name: Add AMD Graphics repository
become: true
ansible.builtin.yum_repository:
name: rocmgraphics
description: AMD Graphics Repository
baseurl: https://repo.radeon.com/graphics/latest/el/9.6/main/x86_64/
gpgkey: https://repo.radeon.com/rocm/rocm.gpg.key
gpgcheck: true
enabled: true
state: present
- name: Install rocm-dkms and dependencies
become: true
ansible.builtin.package:
name:
- rocm
- rocm-developer-tools
- hipblas-devel
- hip-devel
- rocwmma-devel
- rocm-opencl-devel
state: present
-146
View File
@@ -1,146 +0,0 @@
- name: Ensure podman user exists before model sync
ansible.builtin.include_tasks:
file: podman/user.yml
- name: Enable sudo access to podman user (temporary)
become: true
ansible.builtin.lineinfile:
path: "/etc/sudoers.d/ansible_{{ podman_user }}_tmp"
create: true
mode: "0440"
line: "{{ ansible_user_id }} ALL=({{ podman_user }}) NOPASSWD: ALL"
validate: "visudo -cf %s"
- name: Validate llama_models schema
ansible.builtin.assert:
that:
- __llama_models__ is iterable
fail_msg: "llama_models must be a list"
- name: Install Python packages required for llama model sync
become: true
ansible.builtin.package:
name: "{{ __llama_sync_system_packages__ }}"
state: present
vars:
__llama_sync_system_packages__: >-
{{
{
'Debian': ['python3-pip', 'python3-packaging', 'python3-venv'],
'RedHat': ['python3-pip', 'python3-packaging'],
'Archlinux': ['python-pip', 'python-packaging']
}[ansible_os_family]
}}
- name: Install Hugging Face hub client in llama model sync virtualenv
become: true
become_user: "{{ podman_user }}"
ansible.builtin.pip:
name:
- pip
- setuptools
- packaging
- huggingface_hub>=0.32.0
state: present
virtualenv: "{{ __llama_sync_venv__ }}"
virtualenv_command: "python3 -m venv"
- name: Create llama router model directories
become: true
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ podman_user_home }}/models", mode: "0750" }
- { path: "{{ __llama_managed_dir__ }}", mode: "0750" }
- { path: "{{ __llama_links_dir__ }}", mode: "0750" }
- { path: "{{ __llama_router_dir__ }}", mode: "0750" }
- name: Copy llama Hugging Face sync helper
become: true
ansible.builtin.copy:
src: scripts/llama_hf_sync.py
dest: "{{ __llama_sync_script__ }}"
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0750"
- name: Write desired llama model manifest input
become: true
ansible.builtin.copy:
content: "{{ __llama_models__ | to_nice_json }}"
dest: "{{ __llama_models_input_file__ }}"
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0640"
- name: Write global llama preset options
become: true
ansible.builtin.copy:
content: "{{ __llama_preset_global__ | to_nice_json }}"
dest: "{{ __llama_preset_global_input_file__ }}"
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0640"
- name: Run llama model sync from Hugging Face
vars:
__llama_sync_argv__: >-
{{
[
__llama_sync_python__,
__llama_sync_script__,
'--models-file',
__llama_models_input_file__,
'--managed-dir',
__llama_managed_dir__,
'--links-dir',
__llama_links_dir__,
'--manifest-file',
__llama_manifest_file__,
'--preset-file',
__llama_preset_file__,
'--preset-global-file',
__llama_preset_global_input_file__,
'--container-links-dir',
'/models/managed-links'
]
+ (['--prune'] if llama_models_prune_enabled | default(true) else [])
+ (['--dry-run'] if llama_models_dry_run | default(false) else [])
}}
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command:
argv: "{{ __llama_sync_argv__ }}"
environment:
HF_TOKEN: "{{ lookup('env', 'HF_TOKEN') | default('', true) }}"
register: __llama_sync_output__
changed_when: false
- name: Parse llama sync output
ansible.builtin.set_fact:
__llama_sync_result__: "{{ __llama_sync_output__.stdout | from_json }}"
- name: Ensure router preset file exists when not in dry-run mode
become: true
become_user: "{{ podman_user }}"
ansible.builtin.stat:
path: "{{ __llama_preset_file__ }}"
register: __llama_preset_stat__
when: not (llama_models_dry_run | default(false))
- name: Ensure router preset file exists when not in dry-run mode
ansible.builtin.assert:
that:
- __llama_preset_stat__.stat.exists
fail_msg: "llama router preset file was not created"
when: not (llama_models_dry_run | default(false))
- name: Remove temporary sudo access
become: true
ansible.builtin.file:
path: "/etc/sudoers.d/ansible_{{ podman_user }}_tmp"
state: absent
-84
View File
@@ -1,84 +0,0 @@
- name: Check if required vars are set
ansible.builtin.fail:
msg: "Required variables are not set for Podman setup. Please check your playbook variables.)"
when:
- podman_user is not defined
- podman_group is not defined
- podman_user_home is not defined
- name: Create group
become: true
ansible.builtin.group:
name: "{{ podman_group }}"
system: true
- name: Create user
become: true
ansible.builtin.user:
name: "{{ podman_user }}"
groups: "{{ podman_extra_groups | default([]) }}"
shell: /bin/bash
home: "{{ podman_user_home }}"
group: "{{ podman_group }}"
- name: Disable password login for user
ansible.builtin.command: passwd -d "{{ podman_user }}"
become: true
changed_when: false
- name: Create user home
become: true
ansible.builtin.file:
path: "{{ podman_user_home }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0750"
- name: Create build folders
become: true
ansible.builtin.file:
path: "{{ podman_user_home }}/build"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0750"
- name: Create folders
become: true
ansible.builtin.file:
path: "{{ podman_user_home }}/{{ item }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_group }}"
mode: "0750"
loop: "{{ podman_user_folders }}"
- name: "Add SSH Authorized key"
become: true
ansible.posix.authorized_key:
user: "{{ podman_user }}"
key: "{{ podman_user_pubkey }}"
state: "{{ 'present' if podman_user_pubkey is defined else 'absent' }}"
when: podman_user_pubkey is defined and podman_user_pubkey != ""
- name: SELinux tasks (RedHat only)
when: ansible_os_family == 'RedHat'
become: true
block:
- name: Persistently set SELinux context
community.general.sefcontext:
target: "{{ podman_user_home }}(/.*)?"
setype: user_home_dir_t
state: present
- name: Persistently set SELinux context (ssh authorized keys)
community.general.sefcontext:
target: "{{ podman_user_home }}/.ssh/authorized_keys"
setype: ssh_home_t
state: present
when: podman_user_pubkey is defined and podman_user_pubkey != ""
- name: Apply SELinux context
ansible.builtin.command: restorecon -Rv {{ podman_user_home }}
changed_when: false
@@ -338,14 +338,14 @@
"targets": [
{
"editorMode": "code",
"expr": "llama_server_health",
"expr": "llama_server_health{model=~\"$model\"}",
"legendFormat": "health {{server}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "llama_models_loaded",
"expr": "llama_models_loaded{model=~\"$model\"}",
"legendFormat": "models {{server}} {{model}}",
"range": true,
"refId": "B"
@@ -354,6 +354,156 @@
"title": "Llama Server Metrics",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 30
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "rate(llamacpp:tokens_predicted_total{model=~\"$model\"}[5m])",
"legendFormat": "decode {{model}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "rate(llamacpp:prompt_tokens_total{model=~\"$model\"}[5m])",
"legendFormat": "prompt {{model}}",
"range": true,
"refId": "B"
}
],
"title": "Token Throughput",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 30
},
"id": 9,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "rate(llamacpp:tokens_predicted_seconds_total{model=~\"$model\"}[5m])",
"legendFormat": "decode time {{model}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "rate(llamacpp:prompt_seconds_total{model=~\"$model\"}[5m])",
"legendFormat": "prompt time {{model}}",
"range": true,
"refId": "B"
}
],
"title": "Processing Time",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 30
},
"id": 10,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "llamacpp:requests_processing{model=~\"$model\"}",
"legendFormat": "processing {{model}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "llamacpp:requests_deferred{model=~\"$model\"}",
"legendFormat": "deferred {{model}}",
"range": true,
"refId": "B"
}
],
"title": "Request Queue",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
@@ -369,9 +519,95 @@
"h": 8,
"w": 12,
"x": 0,
"y": 30
"y": 38
},
"id": 8,
"id": 11,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "llamacpp:n_busy_slots_per_decode{model=~\"$model\"}",
"legendFormat": "busy slots {{model}}",
"range": true,
"refId": "A"
}
],
"title": "Busy Slots Per Decode",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 38
},
"id": 12,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "llamacpp:n_tokens_max{model=~\"$model\"}",
"legendFormat": "max tokens {{model}}",
"range": true,
"refId": "A"
}
],
"title": "Max Tokens (Context Window)",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 46
},
"id": 13,
"options": {
"legend": {
"calcs": [],
@@ -534,6 +770,30 @@
"regex": "",
"skipUrlSync": false,
"type": "datasource"
},
{
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"definition": "label_values(llamacpp:tokens_predicted_total, model)",
"hide": 0,
"includeAll": true,
"label": "Model",
"multi": false,
"name": "model",
"options": [],
"query": "label_values(llamacpp:tokens_predicted_total, model)",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
+27 -8
View File
@@ -3,7 +3,7 @@ module "open-webui" {
persistent_folder = local.persistent_folder
fqdn = local.open_webui_fqdn
ollama_proxy = local.ollama_proxy
llm_proxy = local.llm_proxy
secret_key = var.webui_secret_key
tag = "v0.9.2-slim"
log_level = local.open_webui_log_level
@@ -43,13 +43,13 @@ module "nextcloud" {
issuer = local.internal_issuer
}
module "comfyui_proxy" {
source = "../../modules/utils/wol_proxy"
# module "comfyui_proxy" {
# source = "../../modules/utils/wol_proxy"
upstream = local.comfyui_proxy
proxy_port = local.comfyui_proxy.port
namespace = "comfyui"
}
# upstream = local.comfyui_proxy
# proxy_port = local.comfyui_proxy.port
# namespace = "comfyui"
# }
module "prometheus" {
source = "../../modules/utils/prometheus"
@@ -73,6 +73,15 @@ module "grafana" {
persistent_folder = "${local.persistent_folder}/monitoring"
fqdn = local.grafana_fqdn
dashboard_folder = {
System = "${path.module}/resources/grafana/system"
Kubernetes = "${path.module}/resources/grafana/kubernetes"
Security = "${path.module}/resources/grafana/security"
GPU = "${path.module}/resources/grafana/gpu"
LLM = "${path.module}/resources/grafana/llm"
Windows = "${path.module}/resources/grafana/windows"
}
datasource_folder = "${path.module}/resources/grafana"
}
module "mstream" {
@@ -99,6 +108,16 @@ module "vaultwarden" {
persistent_folder = local.vaultwarden_folder
fqdn = local.vaultwarden_fqdn
issuer = local.vaultwarden_issuer
tag = "1.33.4"
tag = local.vaultwarden_version
admin_token = var.vaultwarden_admin_token
smtp_host = local.vaultwarden_smtp_host
smtp_port = local.vaultwarden_smtp_port
smtp_security = local.vaultwarden_smtp_security
smtp_from = local.vaultwarden_smtp_from
smtp_from_name = local.vaultwarden_smtp_from_name
smtp_username = local.vaultwarden_smtp_username
# We temporarily enable signup to create the first admin user, then we will disable it and rotate the admin token
# signup_enabled = true
# signup_verify = false
}
+44 -36
View File
@@ -132,22 +132,22 @@ locals {
]
proxy_services = {
"comfyui" = {
http_port = 8080
https_port = 8443
tls = true
fqdn = ["comfyui.lab.alexpires.me"]
upstream = "http://upstream.comfyui.svc.cluster.local:8188"
resolver = "kube-dns.kube-system.svc.cluster.local"
# "comfyui" = {
# http_port = 8080
# https_port = 8443
# tls = true
# fqdn = ["comfyui.lab.alexpires.me"]
# upstream = "http://upstream.comfyui.svc.cluster.local:8188"
# resolver = "kube-dns.kube-system.svc.cluster.local"
locations = [
{
path = "/"
"Upgrade" = "$http_upgrade"
"Connection" = "upgrade"
},
]
}
# locations = [
# {
# path = "/"
# "Upgrade" = "$http_upgrade"
# "Connection" = "upgrade"
# },
# ]
# }
}
streams = {
@@ -171,20 +171,20 @@ locals {
}
}
ollama_proxy = {
llm_proxy = {
enabled = true
mac = "04:7c:16:d6:63:66"
ip = "10.19.4.106"
port = "11435"
port = 11434
scheme = "http"
}
comfyui_proxy = {
mac = "04:7c:16:d6:63:66"
ip = "10.19.4.106"
port = "8188"
scheme = "http"
}
# comfyui_proxy = {
# mac = "04:7c:16:d6:63:66"
# ip = "10.19.4.106"
# port = "8188"
# scheme = "http"
# }
scaleway_smtp_host = "smtp.tem.scw.cloud"
@@ -235,16 +235,16 @@ locals {
grafana_fqdn = "grafana.${local.lab_domain}"
devolo_devices = {
devolo_620 = {
ip = "192.168.0.199"
model = "620"
}
# devolo_326 = {
# ip = "192.168.0.200"
# model = "326"
# }
}
# devolo_devices = {
# devolo_620 = {
# ip = "192.168.0.199"
# model = "620"
# }
# # devolo_326 = {
# # ip = "192.168.0.200"
# # model = "326"
# # }
# }
# mstream
mstream_music_folder = "/mnt/storage/Music"
@@ -256,9 +256,17 @@ locals {
searxng_issuer = "letsencrypt-prod"
# vaultwarden
vaultwarden_folder = "/mnt/md0/vaultwarden"
vaultwarden_fqdn = "vaultwarden.${local.lab_domain}"
vaultwarden_issuer = "internal-ca"
vaultwarden_folder = "/mnt/md0/vaultwarden"
vaultwarden_fqdn = "vaultwarden.${local.lab_domain}"
vaultwarden_issuer = "letsencrypt-prod"
vaultwarden_version = "1.36.0"
vaultwarden_smtp_host = local.scaleway_smtp_host
vaultwarden_smtp_port = 465
vaultwarden_smtp_security = "force_tls"
vaultwarden_smtp_from = "vaultwarden@${local.lab_domain}"
vaultwarden_smtp_from_name = "Vaultwarden"
vaultwarden_smtp_username = var.scaleway_project_id
vaultwarden_smtp_password = local.mail_app_api_secret_key
}
data "terraform_remote_state" "scaleway" {
@@ -0,0 +1,35 @@
apiVersion: 1
datasources:
- name: prometheus-dev-01
type: prometheus
url: http://prometheus.prometheus.svc.cluster.local:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
timeInterval: 15s
- name: prometheus-ci-01
type: prometheus
url: http://ci-01.lab.alexpires.me:9090
isDefault: false
editable: false
jsonData:
httpMethod: POST
timeInterval: 15s
- name: prometheus-gpu-01
type: prometheus
url: http://gpu-01.lab.alexpires.me:9091
isDefault: false
editable: false
jsonData:
httpMethod: POST
timeInterval: 15s
- name: prometheus-prod-01
type: prometheus
url: http://10.5.5.2:9090
isDefault: false
editable: false
jsonData:
httpMethod: POST
timeInterval: 15s
@@ -0,0 +1,500 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": true,
"panels": [
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 6,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "up{job=~\"prometheus|nvidia-gpu|amdgpu_exporter\"}",
"instant": true,
"legendFormat": "{{job}}",
"range": false,
"refId": "A"
}
],
"title": "GPU Exporter Health",
"type": "stat"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_gpu_util_percent|gpu_util_percent\"}",
"legendFormat": "{{__name__}} {{gpu}}",
"range": true,
"refId": "A"
}
],
"title": "GPU Utilization",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "celsius"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_temperature_celsius|temperature_celsius\"}",
"legendFormat": "{{__name__}} {{gpu}}",
"range": true,
"refId": "A"
}
],
"title": "GPU Temperature",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "watt"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 14
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_power_watts|power_watts\"}",
"legendFormat": "{{__name__}} {{gpu}}",
"range": true,
"refId": "A"
}
],
"title": "GPU Power Draw",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 14
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_memory_used_percent\"}",
"legendFormat": "nvidia",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "100 * mem_used_bytes / clamp_min(mem_used_bytes + mem_avail_bytes, 1)",
"legendFormat": "amd {{gpu}}",
"range": true,
"refId": "B"
}
],
"title": "GPU Memory Usage",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 22
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "scrape_duration_seconds{job=~\"nvidia-gpu|amdgpu_exporter|prometheus\"}",
"legendFormat": "{{job}}",
"range": true,
"refId": "A"
}
],
"title": "GPU Scrape Duration",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 22
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_pstate\"}",
"legendFormat": "{{__name__}}",
"range": true,
"refId": "A"
}
],
"title": "NVIDIA P-State",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 30
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_pcie_link_gen_current\"}",
"legendFormat": "pcie gen",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_pcie_link_width_current\"}",
"legendFormat": "pcie width",
"range": true,
"refId": "B"
}
],
"title": "NVIDIA PCIe Link",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"max": 1,
"min": 0,
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 30
},
"id": 9,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_throttle_sw_power_cap\"}",
"legendFormat": "sw power cap",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_throttle_hw_thermal_slowdown\"}",
"legendFormat": "hw thermal slowdown",
"range": true,
"refId": "B"
},
{
"editorMode": "code",
"expr": "{__name__=~\"gpu_.*_throttle_hw_power_brake_slowdown\"}",
"legendFormat": "hw power brake slowdown",
"range": true,
"refId": "C"
}
],
"title": "NVIDIA Throttle Flags",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 39,
"style": "dark",
"tags": [
"gpu",
"monitoring",
"realtime"
],
"templating": {
"list": [
{
"current": {
"selected": true,
"text": "Prometheus",
"value": "Prometheus"
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "GPU Monitoring Realtime",
"uid": "gpu-monitoring-realtime",
"version": 2,
"weekStart": ""
}
@@ -0,0 +1,436 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": true,
"panels": [
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 6,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "up{job=~\"prometheus|llama_exporter\"}",
"instant": true,
"legendFormat": "{{job}}",
"range": false,
"refId": "A"
}
],
"title": "LLM Exporter Health",
"type": "stat"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "llama_server_health{model=~\"$model\"}",
"legendFormat": "health {{server}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "llama_models_loaded{model=~\"$model\"}",
"legendFormat": "models {{server}} {{model}}",
"range": true,
"refId": "B"
}
],
"title": "Llama Server Metrics",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "rate(llamacpp:tokens_predicted_total{model=~\"$model\"}[5m])",
"legendFormat": "decode {{model}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "rate(llamacpp:prompt_tokens_total{model=~\"$model\"}[5m])",
"legendFormat": "prompt {{model}}",
"range": true,
"refId": "B"
}
],
"title": "Token Throughput",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 14
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "rate(llamacpp:tokens_predicted_seconds_total{model=~\"$model\"}[5m])",
"legendFormat": "decode time {{model}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "rate(llamacpp:prompt_seconds_total{model=~\"$model\"}[5m])",
"legendFormat": "prompt time {{model}}",
"range": true,
"refId": "B"
}
],
"title": "Processing Time",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 14
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "llamacpp:requests_processing{model=~\"$model\"}",
"legendFormat": "processing {{model}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "llamacpp:requests_deferred{model=~\"$model\"}",
"legendFormat": "deferred {{model}}",
"range": true,
"refId": "B"
}
],
"title": "Request Queue",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 22
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "llamacpp:n_busy_slots_per_decode{model=~\"$model\"}",
"legendFormat": "busy slots {{model}}",
"range": true,
"refId": "A"
}
],
"title": "Busy Slots Per Decode",
"type": "timeseries"
},
{
"datasource": "$datasource",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 22
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.0.0",
"targets": [
{
"editorMode": "code",
"expr": "llamacpp:n_tokens_max{model=~\"$model\"}",
"legendFormat": "max tokens {{model}}",
"range": true,
"refId": "A"
}
],
"title": "Max Tokens (Context Window)",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 39,
"style": "dark",
"tags": [
"llm",
"monitoring",
"realtime"
],
"templating": {
"list": [
{
"current": {
"selected": true,
"text": "Prometheus",
"value": "Prometheus"
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
},
{
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"definition": "label_values(llamacpp:tokens_predicted_total, model)",
"hide": 0,
"includeAll": true,
"label": "Model",
"multi": false,
"name": "model",
"options": [],
"query": "label_values(llamacpp:tokens_predicted_total, model)",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "LLM Monitoring Realtime",
"uid": "llm-monitoring-realtime",
"version": 1,
"weekStart": ""
}
+1
View File
@@ -39,3 +39,4 @@ variable "vaultwarden_admin_token" {
type = string
sensitive = true
}
+16 -5
View File
@@ -161,9 +161,20 @@ module "auth_exporter" {
module "vaultwarden" {
source = "../../modules/apps/vaultwarden"
persistent_folder = local.vaultwarden_folder
fqdn = local.vaultwarden_fqdn
issuer = local.internal_issuer
tag = "1.33.4"
admin_token = var.vaultwarden_admin_token
persistent_folder = local.vaultwarden_folder
fqdn = local.vaultwarden_fqdn
issuer = local.vaultwarden_issuer
tag = local.vaultwarden_version
admin_token = var.vaultwarden_admin_token
smtp_host = local.vaultwarden_smtp_host
smtp_port = local.vaultwarden_smtp_port
smtp_security = local.vaultwarden_smtp_security
smtp_from = local.vaultwarden_smtp_from
smtp_from_name = local.vaultwarden_smtp_from_name
smtp_username = local.vaultwarden_smtp_username
smtp_password = var.vaultwarden_smtp_password
smtp_timeout = local.vaultwarden_smtp_timeout
smtp_auth_mechanism = local.vaultwarden_smtp_auth_mechanism
smtp_helo_name = local.vaultwarden_smtp_helo_name
smtp_embed_images = local.vaultwarden_smtp_embed_images
}
+14 -3
View File
@@ -211,9 +211,20 @@ locals {
telegram_message = file("${path.module}/resources/telegram.tpl")
# vaultwarden
vaultwarden_folder = "${local.persistent_folder}/vaultwarden"
vaultwarden_fqdn = "vaultwarden.${local.primary_domain}"
internal_issuer = "internal-ca"
vaultwarden_folder = "${local.persistent_folder}/vaultwarden"
vaultwarden_fqdn = "vaultwarden.${local.primary_domain}"
vaultwarden_issuer = "internal-ca"
vaultwarden_version = "1.36.0"
vaultwarden_smtp_host = ""
vaultwarden_smtp_port = 587
vaultwarden_smtp_security = "starttls"
vaultwarden_smtp_from = ""
vaultwarden_smtp_from_name = "Vaultwarden"
vaultwarden_smtp_username = ""
vaultwarden_smtp_timeout = 15
vaultwarden_smtp_auth_mechanism = ""
vaultwarden_smtp_helo_name = ""
vaultwarden_smtp_embed_images = true
# get secrets from remote state
backup_bucket_name = data.terraform_remote_state.scaleway.outputs.backup_bucket_name
+7
View File
@@ -140,3 +140,10 @@ variable "vaultwarden_admin_token" {
type = string
sensitive = true
}
variable "vaultwarden_smtp_password" {
description = "SMTP password for VaultWarden email"
type = string
sensitive = true
default = ""
}
+1
View File
@@ -34,3 +34,4 @@ TF_VAR_wireguard_1_pre_shared_key=$WIREGUARD_1_PRE_SHARED_KEY
TF_VAR_nginx_token_bearer=$NGINX_TOKEN_BEARER
TF_VAR_telegram_bot_token=$TELEGRAM_BOT_TOKEN
TF_VAR_vaultwarden_admin_token=$VAULTWARDEN_ADMIN_TOKEN
TF_VAR_vaultwarden_smtp_password=$VAULTWARDEN_SMTP_PASSWORD
+43 -4
View File
@@ -2,8 +2,47 @@ locals {
app_version = var.tag
monitoring_folder = "${var.persistent_folder}/grafana"
grafana_datasource_config = file("${path.module}/resources/default.yml")
grafana_dashboard_provider = file("${path.module}/resources/dashboard-provider.yml")
grafana_trivy_dashboard = file("${path.module}/resources/trivy-vulnerabilities-dashboard.json")
grafana_gpu_realtime_dashboard = file("${path.module}/resources/gpu-realtime-dashboard.json")
dashboard_resource_names = {
for folder_name in keys(var.dashboard_folder) :
folder_name => "${substr(replace(lower(folder_name), "/[^a-z0-9-]/", "-"), 0, 40)}-${substr(md5(folder_name), 0, 8)}"
}
dashboard_mount_paths = {
for folder_name in keys(var.dashboard_folder) :
folder_name => "/var/lib/grafana/dashboards/${local.dashboard_resource_names[folder_name]}"
}
# Load datasource YAMLs from app root if datasource_folder is provided
datasource_files = {
for f in fileset(var.datasource_folder, "*.{yml,yaml}") : f => file("${var.datasource_folder}/${f}")
}
# Load dashboard JSONs from app root folders grouped by Grafana category name
dashboard_files_by_category = {
for folder_name, folder_path in var.dashboard_folder :
folder_name => {
for f in fileset(folder_path, "*.json") : f => file("${folder_path}/${f}")
}
}
grafana_dashboard_provider = yamlencode({
apiVersion = 1
providers = [
for folder_name in keys(var.dashboard_folder) : {
name = "local-${local.dashboard_resource_names[folder_name]}"
orgId = 1
folder = folder_name
type = "file"
disableDeletion = false
updateIntervalSeconds = 30
allowUiUpdates = true
options = {
path = local.dashboard_mount_paths[folder_name]
}
}
]
})
checksum_datasources = md5(join("", values(local.datasource_files)))
checksum_dashboards = md5("${local.grafana_dashboard_provider}${jsonencode(local.dashboard_files_by_category)}")
}
+22 -17
View File
@@ -4,9 +4,7 @@ resource "kubernetes_config_map" "grafana_datasources" {
namespace = kubernetes_namespace.grafana.metadata[0].name
}
data = {
"default.yml" = replace(local.grafana_datasource_config, "__PROMETHEUS_URL__", var.prometheus_url)
}
data = local.datasource_files
}
resource "kubernetes_config_map" "grafana_dashboard_provider" {
@@ -21,15 +19,14 @@ resource "kubernetes_config_map" "grafana_dashboard_provider" {
}
resource "kubernetes_config_map" "grafana_dashboards" {
for_each = local.dashboard_files_by_category
metadata {
name = "grafana-dashboards"
name = "grafana-dashboards-${local.dashboard_resource_names[each.key]}"
namespace = kubernetes_namespace.grafana.metadata[0].name
}
data = {
"trivy-vulnerabilities-dashboard.json" = local.grafana_trivy_dashboard
"gpu-realtime-dashboard.json" = local.grafana_gpu_realtime_dashboard
}
data = each.value
}
resource "kubernetes_deployment" "grafana" {
@@ -50,7 +47,9 @@ resource "kubernetes_deployment" "grafana" {
template {
metadata {
labels = {
app = "grafana"
app = "grafana"
checksum_datasources = local.checksum_datasources
checksum_dashboards = local.checksum_dashboards
}
}
spec {
@@ -111,10 +110,13 @@ resource "kubernetes_deployment" "grafana" {
mount_path = "/etc/grafana/provisioning/dashboards"
read_only = true
}
volume_mount {
name = "grafana-dashboards"
mount_path = "/var/lib/grafana/dashboards"
read_only = true
dynamic "volume_mount" {
for_each = local.dashboard_mount_paths
content {
name = "grafana-dashboards-${local.dashboard_resource_names[volume_mount.key]}"
mount_path = volume_mount.value
read_only = true
}
}
}
volume {
@@ -135,10 +137,13 @@ resource "kubernetes_deployment" "grafana" {
name = kubernetes_config_map.grafana_dashboard_provider.metadata[0].name
}
}
volume {
name = "grafana-dashboards"
config_map {
name = kubernetes_config_map.grafana_dashboards.metadata[0].name
dynamic "volume" {
for_each = local.dashboard_mount_paths
content {
name = "grafana-dashboards-${local.dashboard_resource_names[volume.key]}"
config_map {
name = kubernetes_config_map.grafana_dashboards[volume.key].metadata[0].name
}
}
}
}
@@ -1,9 +1,9 @@
apiVersion: 1
providers:
- name: trivy-vulnerabilities
- name: local
orgId: 1
folder: Security
folder: Default
type: file
disableDeletion: false
updateIntervalSeconds: 30
@@ -1,9 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: __PROMETHEUS_URL__
isDefault: true
editable: true
+9 -4
View File
@@ -21,8 +21,13 @@ variable "persistent_folder" {
type = string
}
variable "prometheus_url" {
description = "Prometheus service URL used by Grafana datasource provisioning"
type = string
default = "http://prometheus.prometheus.svc.cluster.local:9090"
variable "dashboard_folder" {
description = "Map of Grafana folder name => local folder path containing dashboard JSON files. Each entry becomes a Grafana provider/category and loads all .json files from its path."
type = map(string)
default = {}
}
variable "datasource_folder" {
description = "Path to a folder containing Grafana datasource provisioning YAML files. When set, all .yml/.yaml files in this folder will be loaded and have __PROMETHEUS_URL__ replaced with the actual prometheus_url. If not set, falls back to the default single-prometheus datasource."
type = string
}
@@ -1,22 +1,22 @@
resource "kubernetes_service_account" "ollama_proxy" {
resource "kubernetes_service_account" "llm_proxy" {
count = var.ollama_proxy.enabled ? 1 : 0
count = var.llm_proxy.enabled ? 1 : 0
metadata {
name = "ollama-proxy-sa"
name = "llm-proxy-sa"
namespace = kubernetes_namespace.openwebui.metadata[0].name
}
automount_service_account_token = false
}
resource "kubernetes_deployment" "ollama_proxy" {
resource "kubernetes_deployment" "llm_proxy" {
count = var.ollama_proxy.enabled ? 1 : 0
count = var.llm_proxy.enabled ? 1 : 0
metadata {
name = "ollama-proxy"
name = "llm-proxy"
namespace = kubernetes_namespace.openwebui.metadata[0].name
labels = {
app = "ollama-proxy"
app = "llm-proxy"
}
}
@@ -24,7 +24,7 @@ resource "kubernetes_deployment" "ollama_proxy" {
replicas = 1
selector {
match_labels = {
app = "ollama-proxy"
app = "llm-proxy"
}
}
@@ -35,7 +35,7 @@ resource "kubernetes_deployment" "ollama_proxy" {
template {
metadata {
labels = {
app = "ollama-proxy"
app = "llm-proxy"
}
}
@@ -57,37 +57,37 @@ resource "kubernetes_deployment" "ollama_proxy" {
port {
name = "http"
container_port = var.ollama_proxy.port
container_port = var.llm_proxy.port
}
env {
name = "UPSTREAM_MAC"
value = var.ollama_proxy.mac
value = var.llm_proxy.mac
}
env {
name = "UPSTREAM_IP"
value = var.ollama_proxy.ip
value = var.llm_proxy.ip
}
env {
name = "UPSTREAM_PORT"
value = var.ollama_proxy.port
value = try(var.llm_proxy.upstream_port, var.llm_proxy.port)
}
env {
name = "UPSTREAM_SCHEME"
value = var.ollama_proxy.scheme
value = var.llm_proxy.scheme
}
env {
name = "PROXY_PORT"
value = var.ollama_proxy.port
value = var.llm_proxy.port
}
readiness_probe {
tcp_socket {
port = 11434
port = var.llm_proxy.port
}
initial_delay_seconds = 5
period_seconds = 10
+14 -7
View File
@@ -20,14 +20,15 @@ variable "issuer" {
default = "internal-ca"
}
variable "ollama_proxy" {
description = "Configuration for the Ollama proxy"
variable "llm_proxy" {
description = "Configuration for the llm proxy"
type = object({
enabled = bool
mac = optional(string, "")
ip = optional(string, "")
port = optional(string, "11434")
scheme = optional(string, "http")
enabled = bool
mac = optional(string, "")
ip = optional(string, "")
port = optional(string, "11434")
scheme = optional(string, "http")
upstream_port = optional(string, "11434")
})
default = {
enabled = false
@@ -54,3 +55,9 @@ variable "log_level" {
error_message = "log_level must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL"
}
}
variable "aiohttp_client_timeout" {
description = "The timeout for aiohttp client in seconds (e.g., 300, 600 etc.) (By default the timeout will be set to None, effectively disabling the timeout and allowing the client to wait indefinitely.)"
type = string
default = ""
}
+4 -1
View File
@@ -77,7 +77,6 @@ resource "kubernetes_deployment" "openwebui" {
}
}
container {
name = "app"
image = "ghcr.io/open-webui/open-webui:${var.tag}"
@@ -115,6 +114,10 @@ resource "kubernetes_deployment" "openwebui" {
value = var.log_level
}
env {
name = "AIOHTTP_CLIENT_TIMEOUT"
value = var.aiohttp_client_timeout
}
readiness_probe {
tcp_socket {
+21 -4
View File
@@ -1,6 +1,23 @@
locals {
app_folder = var.persistent_folder
app_version = var.tag
app_fqdn = var.fqdn
db_path = var.db_folder != "" ? var.db_folder : local.app_folder
app_folder = var.persistent_folder
app_version = var.tag
app_fqdn = var.fqdn
db_path = var.db_folder != "" ? var.db_folder : local.app_folder
signup = var.signup_enabled ? "true" : "false"
signup_verify = var.signup_verify ? "true" : "false"
disable_2fa_remember = var.disable_2fa_remember ? "true" : "false"
show_password_hint = var.show_password_hint ? "true" : "false"
password_hints_allowed = var.password_hints_allowed ? "true" : "false"
disable_icon_download = var.disable_icon_download ? "true" : "false"
icon_service = var.icon_service
http_request_block_non_global_ips = var.http_request_block_non_global_ips ? "true" : "false"
extended_logging = var.extended_logging ? "true" : "false"
websocket_enabled = var.websocket_enabled ? "true" : "false"
sends_allowed = var.sends_allowed ? "true" : "false"
trash_auto_delete_days = var.trash_auto_delete_days
events_days_retain = var.events_days_retain
admin_session_lifetime = tostring(var.admin_session_lifetime)
# SMTP locals — only enable mail if host or from is configured
smtp_enabled = var.smtp_host != "" || var.smtp_from != ""
}
+49 -10
View File
@@ -19,11 +19,38 @@ resource "kubernetes_config_map" "vaultwarden_config" {
namespace = kubernetes_namespace.vaultwarden.metadata[0].name
}
data = {
DOMAIN = "https://${local.app_fqdn}/"
SIGNUP = var.signup_enabled ? "true" : "false"
DATABASE_URL = "file:///data/db.sqlite3"
}
data = merge({
DOMAIN = "https://${local.app_fqdn}/"
SIGNUPS_ALLOWED = local.signup
SIGNUPS_VERIFY = local.signup_verify
DATABASE_URL = "/data/db.sqlite3"
ENABLE_WEBSOCKET = local.websocket_enabled
DISABLE_2FA_REMEMBER = local.disable_2fa_remember
SHOW_PASSWORD_HINT = local.show_password_hint
PASSWORD_HINTS_ALLOWED = local.password_hints_allowed
DISABLE_ICON_DOWNLOAD = local.disable_icon_download
ICON_SERVICE = local.icon_service
HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS = local.http_request_block_non_global_ips
EXTENDED_LOGGING = local.extended_logging
SENDS_ALLOWED = local.sends_allowed
TRASH_AUTO_DELETE_DAYS = local.trash_auto_delete_days
EVENTS_DAYS_RETAIN = local.events_days_retain
ADMIN_SESSION_LIFETIME = local.admin_session_lifetime
USE_SENDMAIL = "false"
},
local.smtp_enabled ? {
SMTP_HOST = var.smtp_host
SMTP_PORT = tostring(var.smtp_port)
SMTP_SECURITY = var.smtp_security
SMTP_FROM = var.smtp_from
SMTP_FROM_NAME = var.smtp_from_name
SMTP_TIMEOUT = tostring(var.smtp_timeout)
SMTP_EMBED_IMAGES = var.smtp_embed_images ? "true" : "false"
SMTP_DEBUG = var.smtp_debug ? "true" : "false"
SMTP_ACCEPT_INVALID_CERTS = var.smtp_accept_invalid_certs ? "true" : "false"
SMTP_ACCEPT_INVALID_HOSTNAMES = var.smtp_accept_invalid_hostnames ? "true" : "false"
} : {}
)
}
resource "kubernetes_secret" "vaultwarden_secrets" {
@@ -32,9 +59,14 @@ resource "kubernetes_secret" "vaultwarden_secrets" {
namespace = kubernetes_namespace.vaultwarden.metadata[0].name
}
data = {
data = merge({
ADMIN_TOKEN = var.admin_token
}
},
local.smtp_enabled ? {
SMTP_USERNAME = var.smtp_username
SMTP_PASSWORD = var.smtp_password
} : {}
)
}
resource "kubernetes_deployment" "vaultwarden" {
@@ -62,7 +94,8 @@ resource "kubernetes_deployment" "vaultwarden" {
template {
metadata {
labels = {
app = "vaultwarden"
app = "vaultwarden"
config_checksum = md5(join("", values(kubernetes_config_map.vaultwarden_config.data)))
}
}
@@ -101,6 +134,11 @@ resource "kubernetes_deployment" "vaultwarden" {
}
}
env {
name = "ROCKET_PORT"
value = "8080"
}
security_context {
allow_privilege_escalation = false
privileged = false
@@ -114,7 +152,7 @@ resource "kubernetes_deployment" "vaultwarden" {
port {
name = "http"
container_port = 80
container_port = 8080
protocol = "TCP"
}
@@ -151,6 +189,7 @@ resource "kubernetes_deployment" "vaultwarden" {
name = "vaultwarden-data"
host_path {
path = local.db_path
type = "DirectoryOrCreate"
}
}
@@ -177,7 +216,7 @@ resource "kubernetes_service" "vaultwarden" {
port {
name = "http"
port = 80
target_port = 80
target_port = 8080
}
type = "ClusterIP"
@@ -32,6 +32,84 @@ variable "signup_enabled" {
default = false
}
variable "signup_verify" {
description = "Require email verification on signup (defense-in-depth)"
type = bool
default = true
}
variable "disable_2fa_remember" {
description = "Force 2FA on every login, no 'remember this browser'"
type = bool
default = true
}
variable "show_password_hint" {
description = "Show password hints on web login (no SMTP configured)"
type = bool
default = false
}
variable "password_hints_allowed" {
description = "Allow users to set password hints"
type = bool
default = false
}
variable "disable_icon_download" {
description = "Disable external icon downloads to prevent network leaks"
type = bool
default = true
}
variable "icon_service" {
description = "Icon service to use (internal, bitwarden, duckduckgo, google)"
type = string
default = "internal"
}
variable "http_request_block_non_global_ips" {
description = "Block requests to private/reserved IP addresses"
type = bool
default = true
}
variable "extended_logging" {
description = "Enable extended logging with timestamps for audit"
type = bool
default = true
}
variable "websocket_enabled" {
description = "Enable WebSocket notifications for real-time sync"
type = bool
default = true
}
variable "sends_allowed" {
description = "Allow users to create Bitwarden Sends"
type = bool
default = true
}
variable "trash_auto_delete_days" {
description = "Days before auto-deleting trashed items (empty = never)"
type = string
default = "30"
}
variable "events_days_retain" {
description = "Days to retain event logs (empty = indefinitely)"
type = string
default = "90"
}
variable "admin_session_lifetime" {
description = "Admin session lifetime in minutes"
type = number
default = 20
}
variable "admin_token" {
description = "Admin token for VaultWarden"
type = string
@@ -43,3 +121,90 @@ variable "db_folder" {
type = string
default = ""
}
# --- SMTP / Email settings ---
variable "smtp_host" {
description = "SMTP host for sending emails"
type = string
default = ""
}
variable "smtp_port" {
description = "SMTP port (default: 587 for starttls, 465 for force_tls, 25 for off)"
type = number
default = 587
}
variable "smtp_security" {
description = "SMTP security mode: starttls, force_tls, or off"
type = string
default = "starttls"
}
variable "smtp_from" {
description = "Email address to send from"
type = string
default = ""
}
variable "smtp_from_name" {
description = "Sender name for outgoing emails"
type = string
default = "Vaultwarden"
}
variable "smtp_username" {
description = "SMTP username for authentication"
type = string
default = ""
}
variable "smtp_password" {
description = "SMTP password for authentication"
type = string
sensitive = true
default = ""
}
variable "smtp_timeout" {
description = "SMTP connection timeout in seconds"
type = number
default = 15
}
variable "smtp_auth_mechanism" {
description = "SMTP authentication mechanisms (e.g., Plain, Login, Xoauth2, comma-separated)"
type = string
default = ""
}
variable "smtp_helo_name" {
description = "Server name sent during SMTP HELO"
type = string
default = ""
}
variable "smtp_embed_images" {
description = "Embed images as email attachments"
type = bool
default = true
}
variable "smtp_debug" {
description = "Enable detailed SMTP debug output (warning: may contain sensitive data)"
type = bool
default = false
}
variable "smtp_accept_invalid_certs" {
description = "Accept invalid SMTP server certificates (dangerous, not recommended)"
type = bool
default = false
}
variable "smtp_accept_invalid_hostnames" {
description = "Accept invalid SMTP server hostnames (dangerous, not recommended)"
type = bool
default = false
}