Klaster OKD 4.19 (OpenShift) – Projekt monitorujący serwer PVE 9 i klaster OKD morsUsl , aplikacja mors-usl-proxmox

Wstęp

Poniższy opis jest częścią większej całości https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-monitorowanie-serwera-pve-9-i-klastra-okd-projekt-mors/

Wymaga przygotowania klastra OKD 4.19 oraz serwera baz danych PostgreSQL

Prawidłowa kolejność przygotowania projektu MORS na klastrze OKD 4.19

Opis aplikacji mors-usl-proxmox

Aplikacja służy do zasilania danymi przez REST API w bazie danych PostgreSQL: dba_mors pochodzącymi z metryk serwera Proxmox 9.

Struktura aplikacji mors-usl-proxmox

/repo/pakiety/morsusl/1.0.0/mors-usl-proxmox
drwxr-xr-x 2 bastuser bastuser  21 Feb 12 21:25 app
-rw-r--r-- 1 bastuser bastuser 448 Feb 12 21:39 cm-mors-usl-proxmox.yaml
-rw-r--r-- 1 bastuser bastuser 406 Feb 12 21:24 Dockerfile
-rw-r--r-- 1 bastuser bastuser   4 Feb 12 21:47 requirements.txt
-rw-r--r-- 1 bastuser bastuser 155 Feb 12 21:27 secret-mors-usl-api-token.yaml

Przygotowanie Dockerfile na potrzeby zbudowania obrazu aplikacji

FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
WORKDIR /app
# Minimalne zależności (requests)
RUN pip install --no-cache-dir requests
# Skopiuj kod aplikacji
COPY app/main.py /app/main.py
# Użytkownik nie-root (OpenShift i tak uruchamia jako losowy UID, ale to nie szkodzi)
RUN useradd -u 10001 -m appuser && chown -R 10001:0 /app
USER 10001
CMD ["python", "/app/main.py"]

Kod aplikacji python mors-usl-proxmox

#!/usr/bin/env python3
import json
import os
import sys
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
import requests
import warnings
from urllib3.exceptions import InsecureRequestWarning

def env_int(name: str, default: int) -> int:
    raw = os.getenv(name, str(default)).strip()
    try:
        return int(raw)
    except ValueError:
        return default


def env_bool(name: str, default: bool) -> bool:
    raw = os.getenv(name, "1" if default else "0").strip().lower()
    return raw in ("1", "true", "yes", "y", "on")


def log(msg: str, level: str = "INFO") -> None:
    ts = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
    print(f"{ts} [{level}] {msg}", flush=True)


def debug_log(enabled: bool, msg: str) -> None:
    if enabled:
        log(msg, "DEBUG")


def normalize_payload(src: Dict[str, Any]) -> Dict[str, Any]:
    """
    Wejście: JSON z /pve_stats.json
    Wyjście: JSON pod API /rest-api/v1/stats/proxmox/create

    Wymagane pola:
      cpu_usage, cpu_temp_c, sys_load, ram_usage_prc, swap_usage_prc,
      gpu_busy_prc, gpu_temp_c, nvme_temp_c
    """

    def get_required(key: str) -> Any:
        if key not in src:
            raise KeyError(f"Brak pola '{key}' w danych źródłowych.")
        return src[key]

    cpu_usage = get_required("cpu_usage")
    cpu_temp_c = get_required("cpu_temp_c")
    sys_load = get_required("sys_load")
    ram_usage_prc = get_required("ram_usage_prc")
    swap_usage_prc = get_required("swap_usage_prc")
    gpu_busy_percent = get_required("gpu_busy_percent")
    gpu_temp_c = get_required("gpu_temp_c")
    nvme_temp_c = get_required("nvme_temp_c")

    # W Twoim curl przykładzie temperatury były stringami ("59.2", "47.0")
    # więc ujednolicamy do stringów z 1 miejscem po przecinku.
    def temp_to_str(v: Any) -> str:
        try:
            return f"{float(v):.1f}"
        except Exception:
            return str(v)

    payload = {
        "cpu_usage": int(cpu_usage),
        "cpu_temp_c": temp_to_str(cpu_temp_c),
        "sys_load": str(sys_load),
        "ram_usage_prc": int(ram_usage_prc),
        "swap_usage_prc": int(swap_usage_prc),
        "gpu_busy_prc": int(gpu_busy_percent),  # mapowanie nazwy pola
        "gpu_temp_c": temp_to_str(gpu_temp_c),
        "nvme_temp_c": int(nvme_temp_c),
    }
    return payload


def fetch_stats(source_url: str, timeout_s: int, debug: bool) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
    try:
        r = requests.get(source_url, timeout=timeout_s)
        debug_log(debug, f"GET {source_url} -> HTTP {r.status_code}")
        r.raise_for_status()
        return r.json(), None
    except Exception as e:
        return None, f"Nie udało się pobrać danych z SOURCE_URL: {e}"


def post_stats(
    api_url: str,
    api_token: str,
    payload: Dict[str, Any],
    timeout_s: int,
    tls_verify: bool,
    debug: bool,
) -> Optional[str]:
    headers = {
        "Content-Type": "application/json",
        "Authorization": api_token,
    }

    try:
        debug_log(debug, f"POST {api_url} tls_verify={tls_verify} payload={json.dumps(payload, ensure_ascii=False)}")
        r = requests.post(
            api_url,
            headers=headers,
            json=payload,
            timeout=timeout_s,
            verify=tls_verify,
        )
        debug_log(debug, f"POST -> HTTP {r.status_code} body={r.text[:500]}")
        r.raise_for_status()
        return None
    except Exception as e:
        return f"Nie udało się wysłać danych do API: {e}"


def main() -> int:
    # Wymagane/konfigurowalne ENV
    source_url = os.getenv("SOURCE_URL", "http://192.168.8.20:8080/pve_stats.json").strip()
    api_url = os.getenv("API_URL", "").strip()
    api_token = os.getenv("API_TOKEN", "").strip()

    update_interval = env_int("UPDATE_INTERVAL", 5)
    debug = env_bool("DEBUG", False)

    # Odpowiednik curl -k (czyli brak weryfikacji TLS)
    tls_verify = env_bool("API_TLS_VERIFY", False)

    if not tls_verify:
        warnings.simplefilter("ignore", InsecureRequestWarning)

    http_timeout_s = env_int("HTTP_TIMEOUT", 10)

    if not api_url:
        log("Brak zmiennej środowiskowej API_URL (ConfigMap).", "ERROR")
        return 2
    if not api_token:
        log("Brak zmiennej środowiskowej API_TOKEN (Secret).", "ERROR")
        return 2

    log("Start mors-usl-proxmox")
    log(f"SOURCE_URL={source_url}")
    log(f"API_URL={api_url}")
    log(f"UPDATE_INTERVAL={update_interval}s DEBUG={1 if debug else 0} API_TLS_VERIFY={1 if tls_verify else 0}")

    while True:
        src, err = fetch_stats(source_url, http_timeout_s, debug)
        if err:
            log(err, "ERROR")
            time.sleep(update_interval)
            continue

        try:
            payload = normalize_payload(src or {})
        except Exception as e:
            log(f"Błąd normalizacji danych: {e}", "ERROR")
            if debug:
                debug_log(True, f"Źródłowy JSON (fragment): {json.dumps(src, ensure_ascii=False)[:2000]}")
            time.sleep(update_interval)
            continue

        err = post_stats(api_url, api_token, payload, http_timeout_s, tls_verify, debug)
        if err:
            log(err, "ERROR")
        else:
            log("OK: wysłano statystyki do API")

        time.sleep(update_interval)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        log("Stop (KeyboardInterrupt)", "INFO")
        sys.exit(0)

Co robi Service (w praktyce)

Service to „stabilny punkt dostępu” do Podów w klastrze. Pody żyją i umierają, mają zmienne IP. Service ma stały DNS i stały virtual IP (ClusterIP). Service kieruje ruch do Podów przez selektory labeli (np. app=mors-gui-api). W projekcie morsgui-prod jest mors-gui-api. Najlepsza praktyka w klastrze:Inne workloady w klastrze (np. mors-usl-proxmox w morsusl-prod) powinny trafiać do API przez Service, nie przez Route.

Czyli zamiast:

https://mors-gui-api.morsgui-prod.apps… (Route / ingress / TLS / zewnętrzny endpoint)
np. https://mors-gui-api.morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/proxmox/create

powinno się używać:

http://mors-gui-api.morsgui-prod.svc:8080/… (wewnętrzny Service DNS)
np. http://mors-gui-api.morsgui-prod.svc:8080/rest-api/v1/stats/proxmox/create

To ma duże plusy:

omijasz ingress/router, zwykle omijasz TLS (możesz iść po HTTP w sieci klastra, jeśli akceptujesz), unikasz warningów TLS typu InsecureRequestWarning, mniej zależności od certów/route.

Co robi Route

Route wystawia Service „na zewnątrz” klastra przez router/ingress OpenShift. Route mapuje hosta DNS (np. …apps.testcluster…) do Service. Route może robić TLS (edge/passthrough/reencrypt), host-based routing itd.

Route jest potrzebna, jeśli:

Chcesz, żeby API było dostępne z sieci spoza klastra, albo z innych środowisk (np. z laptopa/VM poza OCP).

Kiedy Route nie jest potrzebna

Jeżeli jedynym klientem API jest inny Pod w klastrze (u Ciebie: mors-usl-proxmox), to w wielu przypadkach Route jest zbędna.

Przygotowanie configmap dla aplikacji mors-usl-proxmox

apiVersion: v1
kind: ConfigMap
metadata:
  name: cm-mors-usl-proxmox
  namespace: morsusl-prod
data:
  API_URL: "https://mors-gui-api.morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/proxmox/create"
  SOURCE_URL: "http://192.168.8.20:8080/pve_stats.json"
  UPDATE_INTERVAL: "5"
  DEBUG: "1"
  # 0 = jak curl -k (bez weryfikacji TLS), 1 = normalna weryfikacja certu
  API_TLS_VERIFY: "0"
  # timeouty HTTP (sekundy)
  HTTP_TIMEOUT: "10"

Przygotowanie secrets dla aplikacji mors-usl-proxmox

apiVersion: v1
kind: Secret
metadata:
  name: secret-mors-usl-api-token
  namespace: morsusl-prod
type: Opaque
stringData:
  API_TOKEN: "cxbn762xamnMYt2d"

Uruchomienie aplikacji mors-usl-proxmox w projekcie morsusl-prod

oc new-project morsusl-prod

# Build binarny z Dockerfile
oc new-build --strategy=docker --binary --name=mors-usl-proxmox-build
cd /repo/pakiety/morsusl/1.0.0/mors-usl-proxmox/
oc start-build mors-usl-proxmox-build --from-dir=. --follow

# Konfiguracja (Secret + ConfigMap)
oc apply -f secret-mors-usl-api-token.yaml
oc apply -f cm-mors-usl-proxmox.yaml

# Deployment aplikacji
oc new-app mors-usl-proxmox-build:latest --name=mors-usl-proxmox

oc set env deployment/mors-usl-proxmox --from=configmap/cm-mors-usl-proxmox
oc set env deployment/mors-usl-proxmox --from=secret/secret-mors-usl-api-token

oc rollout restart deployment/mors-usl-proxmox
oc rollout status deployment/mors-usl-proxmox

Weryfikacja działania aplikacji mors-usl-proxmox

[bastuser@bastion mors-usl-proxmox]$ oc logs -f mors-usl-proxmox-849db9554d-qgqlp
2026-02-12T19:40:57+00:00 [INFO] Start mors-usl-proxmox
2026-02-12T19:40:57+00:00 [INFO] SOURCE_URL=http://192.168.8.20:8080/pve_stats.json
2026-02-12T19:40:57+00:00 [INFO] API_URL=https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/proxmox/create
2026-02-12T19:40:57+00:00 [INFO] UPDATE_INTERVAL=5s DEBUG=0 API_TLS_VERIFY=0
2026-02-12T19:40:57+00:00 [INFO] OK: wysłano statystyki do API