Klaster OKD 4.19 (OpenShift) – Projekt monitorujący serwer PVE 9 i klaster OKD morsGui, aplikacja mors-gui-api

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-gui-api

Aplikacja służy do zasilania danymi przez REST API w bazie danych PostgreSQL: dba_mors generowanymi przez 2 aplikacje

Aplikacja wystawia 4 endpointy, służące do odczytu (read) i dodawania nowych rekordów (create)

  • https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/okd/read/1
  • https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/proxmox/read/1
  • https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/okd/create
  • https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/proxmox/create

Struktura aplikacji mors-gui-web

/repo/pakiety/morsgui/1.0.0/mors-gui-api
drwxr-xr-x 2 bastuser bastuser  21 Feb 12 21:05 app
-rw-r--r-- 1 bastuser bastuser 192 Feb 12 21:54 cm-mors-gui-api.yaml
-rw-r--r-- 1 bastuser bastuser 508 Feb 12 21:11 Dockerfile
-rw-r--r-- 1 bastuser bastuser  84 Feb 12 21:20 requirements.txt
-rw-r--r-- 1 bastuser bastuser 176 Feb 12 21:54 secret-mors-gui-api.yaml

Przygotowanie schematu bazy danych dba_mors

Po wcześniejszym przygotowaniu serwera baz danych PostgreSQL wg opisu https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-na-proxmox-9-opis-postgresql-baza-danych-dla-aplikacji-openshift/ można przystąpić do utworzenia 2 tabel: okd i proxmox oraz nadania wybranych uprawnień dla użytkownika usr_mors.

# zalogowanie do serwera PSQL
[root@database-1 ~]# sudo -iu postgres psql

CREATE DATABASE dba_mors;

-- Dalej uruchom już po połączeniu do bazy dba_mors (np. w psql: \c dba_mors)
CREATE TABLE public.proxmox (
  entry_id       int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  created_at     timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  cpu_usage      integer NOT NULL,
  cpu_temp_c     varchar(5) NOT NULL,
  sys_load       varchar(24) NOT NULL,
  ram_usage_prc  integer NOT NULL,
  swap_usage_prc integer NOT NULL,
  gpu_busy_prc   integer NOT NULL,
  gpu_temp_c     varchar(24) NOT NULL,
  nvme_temp_c    integer NOT NULL
);

CREATE TABLE public.okd (
  entry_id           int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  created_at         timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  compute_1_cpu_prc  integer NOT NULL,
  compute_2_cpu_prc  integer NOT NULL,
  compute_3_cpu_prc  integer NOT NULL,
  control_1_cpu_prc  integer NOT NULL,
  control_2_cpu_prc  integer NOT NULL,
  control_3_cpu_prc  integer NOT NULL,
  compute_1_ram_prc  integer NOT NULL,
  compute_2_ram_prc  integer NOT NULL,
  compute_3_ram_prc  integer NOT NULL,
  control_1_ram_prc  integer NOT NULL,
  control_2_ram_prc  integer NOT NULL,
  control_3_ram_prc  integer NOT NULL
);

CREATE ROLE usr_mors
  WITH LOGIN
  PASSWORD 'MorSdaT2578!';

GRANT CONNECT ON DATABASE dba_mors TO usr_mors;
GRANT USAGE ON SCHEMA public TO usr_mors;

GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.proxmox TO usr_mors;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.okd TO usr_mors;

Przygotowanie Dockerfile na potrzeby zbudowania obrazu aplikacji

FROM python:3.13-slim
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
WORKDIR /app
# zależności systemowe (minimalne) – psycopg potrzebuje libpq
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

Kod aplikacji python mors-gui-api

from __future__ import annotations

import os
from typing import Optional, List, Dict, Any, Literal

from fastapi import FastAPI, Header, HTTPException, Path
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field, constr
import psycopg


# =========================================
# ENV (ConfigMap cm-mors-gui-api)
# =========================================
DBA_SERVER = os.getenv("DBA_SERVER", "127.0.0.1")
DBA_PORT = int(os.getenv("DBA_PORT", "5432"))
DBA_NAME = os.getenv("DBA_NAME", "postgres")
DBA_USER = os.getenv("DBA_USER", "postgres")
DBA_PASS = os.getenv("DBA_PASS", "")
API_TOKEN = (os.getenv("API_TOKEN") or "").strip()

APP_TITLE = "mors-gui-api"
APP_VERSION = "1.0.0"


def db_conn_str() -> str:
    return (
        f"host={DBA_SERVER} port={DBA_PORT} dbname={DBA_NAME} "
        f"user={DBA_USER} password={DBA_PASS}"
    )


# =========================================
# Auth
# =========================================
def normalize_auth_header(value: Optional[str]) -> str:
    if not value:
        return ""
    v = value.strip()
    # akceptuj "Bearer <token>" albo sam token
    if v.lower().startswith("bearer "):
        return v.split(" ", 1)[1].strip()
    return v


def require_token(authorization: Optional[str]) -> None:
    if not API_TOKEN:
        raise HTTPException(status_code=500, detail="API token is not configured (API_TOKEN missing)")
    got = normalize_auth_header(authorization)
    if got != API_TOKEN:
        raise HTTPException(status_code=401, detail="Unauthorized")


# =========================================
# Pydantic models (dopasowane do kolumn)
# =========================================

# public.proxmox:
# cpu_temp_c varchar(5), sys_load varchar(24), gpu_temp_c varchar(24)
# trzymamy to jako string (żeby nie wywaliło się na "59.2" vs "59,2" itp.)
class ProxmoxPayload(BaseModel):
    cpu_usage: int = Field(..., ge=0)
    cpu_temp_c: constr(max_length=5)  # np. "59.2"
    sys_load: constr(max_length=24)   # np. "6.17,5.68,5.46"
    ram_usage_prc: int = Field(..., ge=0, le=100)
    swap_usage_prc: int = Field(..., ge=0, le=100)
    gpu_busy_prc: int = Field(..., ge=0, le=100)
    gpu_temp_c: constr(max_length=24)  # wg DDL varchar(24)
    nvme_temp_c: int = Field(..., ge=-100, le=200)  # arbitralnie szerzej, żeby nie blokować


# public.okd:
# wszystkie pola integer NOT NULL
class OkdPayload(BaseModel):
    compute_1_cpu_prc: int = Field(..., ge=0, le=100)
    compute_2_cpu_prc: int = Field(..., ge=0, le=100)
    compute_3_cpu_prc: int = Field(..., ge=0, le=100)
    control_1_cpu_prc: int = Field(..., ge=0, le=100)
    control_2_cpu_prc: int = Field(..., ge=0, le=100)
    control_3_cpu_prc: int = Field(..., ge=0, le=100)

    compute_1_ram_prc: int = Field(..., ge=0, le=100)
    compute_2_ram_prc: int = Field(..., ge=0, le=100)
    compute_3_ram_prc: int = Field(..., ge=0, le=100)
    control_1_ram_prc: int = Field(..., ge=0, le=100)
    control_2_ram_prc: int = Field(..., ge=0, le=100)
    control_3_ram_prc: int = Field(..., ge=0, le=100)


StatsKind = Literal["okd", "proxmox"]


# =========================================
# DB operations (bez DDL)
# =========================================
def insert_proxmox(payload: ProxmoxPayload) -> None:
    sql = """
        INSERT INTO public.proxmox
        (cpu_usage, cpu_temp_c, sys_load, ram_usage_prc, swap_usage_prc,
         gpu_busy_prc, gpu_temp_c, nvme_temp_c)
        VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
    """
    with psycopg.connect(db_conn_str()) as conn:
        with conn.cursor() as cur:
            cur.execute(
                sql,
                (
                    payload.cpu_usage,
                    payload.cpu_temp_c,
                    payload.sys_load,
                    payload.ram_usage_prc,
                    payload.swap_usage_prc,
                    payload.gpu_busy_prc,
                    payload.gpu_temp_c,
                    payload.nvme_temp_c,
                ),
            )
        conn.commit()


def insert_okd(payload: OkdPayload) -> None:
    sql = """
        INSERT INTO public.okd
        (compute_1_cpu_prc, compute_2_cpu_prc, compute_3_cpu_prc,
         control_1_cpu_prc, control_2_cpu_prc, control_3_cpu_prc,
         compute_1_ram_prc, compute_2_ram_prc, compute_3_ram_prc,
         control_1_ram_prc, control_2_ram_prc, control_3_ram_prc)
        VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
    """
    with psycopg.connect(db_conn_str()) as conn:
        with conn.cursor() as cur:
            cur.execute(
                sql,
                (
                    payload.compute_1_cpu_prc,
                    payload.compute_2_cpu_prc,
                    payload.compute_3_cpu_prc,
                    payload.control_1_cpu_prc,
                    payload.control_2_cpu_prc,
                    payload.control_3_cpu_prc,
                    payload.compute_1_ram_prc,
                    payload.compute_2_ram_prc,
                    payload.compute_3_ram_prc,
                    payload.control_1_ram_prc,
                    payload.control_2_ram_prc,
                    payload.control_3_ram_prc,
                ),
            )
        conn.commit()


def read_last_proxmox(limit: int) -> List[Dict[str, Any]]:
    sql = """
        SELECT entry_id, created_at, cpu_usage, cpu_temp_c, sys_load,
               ram_usage_prc, swap_usage_prc, gpu_busy_prc, gpu_temp_c, nvme_temp_c
        FROM public.proxmox
        ORDER BY entry_id DESC
        LIMIT %s
    """
    with psycopg.connect(db_conn_str()) as conn:
        with conn.cursor() as cur:
            cur.execute(sql, (limit,))
            rows = cur.fetchall()

    out: List[Dict[str, Any]] = []
    for r in rows:
        out.append(
            {
                "entry_id": r[0],
                "created_at": r[1].strftime("%Y-%m-%d %H:%M:%S"),
                "cpu_usage": r[2],
                "cpu_temp_c": r[3],     # varchar(5)
                "sys_load": r[4],       # varchar(24)
                "ram_usage_prc": r[5],
                "swap_usage_prc": r[6],
                "gpu_busy_prc": r[7],
                "gpu_temp_c": r[8],     # varchar(24)
                "nvme_temp_c": r[9],
            }
        )
    return out


def read_last_okd(limit: int) -> List[Dict[str, Any]]:
    sql = """
        SELECT entry_id, created_at,
               compute_1_cpu_prc, compute_2_cpu_prc, compute_3_cpu_prc,
               control_1_cpu_prc, control_2_cpu_prc, control_3_cpu_prc,
               compute_1_ram_prc, compute_2_ram_prc, compute_3_ram_prc,
               control_1_ram_prc, control_2_ram_prc, control_3_ram_prc
        FROM public.okd
        ORDER BY entry_id DESC
        LIMIT %s
    """
    with psycopg.connect(db_conn_str()) as conn:
        with conn.cursor() as cur:
            cur.execute(sql, (limit,))
            rows = cur.fetchall()

    out: List[Dict[str, Any]] = []
    for r in rows:
        out.append(
            {
                "entry_id": r[0],
                "created_at": r[1].strftime("%Y-%m-%d %H:%M:%S"),

                "compute_1_cpu_prc": r[2],
                "compute_2_cpu_prc": r[3],
                "compute_3_cpu_prc": r[4],
                "control_1_cpu_prc": r[5],
                "control_2_cpu_prc": r[6],
                "control_3_cpu_prc": r[7],

                "compute_1_ram_prc": r[8],
                "compute_2_ram_prc": r[9],
                "compute_3_ram_prc": r[10],
                "control_1_ram_prc": r[11],
                "control_2_ram_prc": r[12],
                "control_3_ram_prc": r[13],
            }
        )
    return out


def db_smoke_test() -> None:
    # szybki test połączenia (bez DDL)
    with psycopg.connect(db_conn_str()) as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT 1;")
            cur.fetchone()


# =========================================
# FastAPI endpoints
# =========================================
app = FastAPI(title=APP_TITLE, version=APP_VERSION)


@app.on_event("startup")
def startup() -> None:
    try:
        db_smoke_test()
    except Exception as e:
        raise RuntimeError(f"Database connection failed: {e}") from e


@app.get("/healthz", response_class=PlainTextResponse)
def healthz():
    return "OK"


# POST proxmox create
@app.post("/rest-api/v1/stats/proxmox/create", response_class=PlainTextResponse)
def proxmox_create(
    payload: ProxmoxPayload,
    authorization: Optional[str] = Header(default=None, alias="Authorization"),
):
    require_token(authorization)
    try:
        insert_proxmox(payload)
        return "OK"
    except Exception:
        return PlainTextResponse("ERROR", status_code=500)


# POST okd create
@app.post("/rest-api/v1/stats/okd/create", response_class=PlainTextResponse)
def okd_create(
    payload: OkdPayload,
    authorization: Optional[str] = Header(default=None, alias="Authorization"),
):
    require_token(authorization)
    try:
        insert_okd(payload)
        return "OK"
    except Exception:
        return PlainTextResponse("ERROR", status_code=500)


# GET proxmox read
@app.get("/rest-api/v1/stats/proxmox/read/{limit}")
def proxmox_read(
    limit: int = Path(..., ge=1, le=10000),
    authorization: Optional[str] = Header(default=None, alias="Authorization"),
):
    require_token(authorization)
    try:
        return read_last_proxmox(limit)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Read failed: {e}")


# GET okd read
@app.get("/rest-api/v1/stats/okd/read/{limit}")
def okd_read(
    limit: int = Path(..., ge=1, le=10000),
    authorization: Optional[str] = Header(default=None, alias="Authorization"),
):
    require_token(authorization)
    try:
        return read_last_okd(limit)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Read failed: {e}")

Przygotowanie projektu morsgui-prod

oc new-project morsgui-prod
oc new-build --strategy=docker --binary --name=mors-gui-api-build

cd /repo/pakiety/morsgui/1.0.0/mors-gui-api/
oc start-build mors-gui-api-build --from-dir=. --follow

# tylko w celach testowych, współczesną dobrą praktyką jest configmapa i secret jako manifest YAML tak jak poniżej: mors-gui-api.yaml, secret-mors-gui-api.yaml
oc create configmap cm-mors-gui-api \
  --from-literal=DBA_SERVER=192.168.40.25 \
  --from-literal=DBA_NAME=dba_mors \
  --from-literal=DBA_USER=usr_mors \
  --from-literal=DBA_PASS='MorSdaT2578!' \
  --from-literal=DBA_PORT=5432 \
  --from-literal=API_TOKEN=cxbn762xamnMYt2d

Przygotowanie configmap dla aplikacji mors-gui-api

cd /repo/pakiety/morsgui/1.0.0/mors-gui-api
oc apply -f cm-mors-gui-api.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cm-mors-gui-api
  namespace: morsgui-prod
data:
  DBA_SERVER: "192.168.40.25"
  DBA_PORT: "5432"
  DBA_NAME: "dba_mors"
  DBA_USER: "usr_mors"

Przygotowanie secrets dla aplikacji mors-gui-api

cd /repo/pakiety/morsgui/1.0.0/mors-gui-api
oc apply -f secret-mors-gui-api.yaml
apiVersion: v1
kind: Secret
metadata:
  name: secret-mors-gui-api
  namespace: morsgui-prod
type: Opaque
stringData:
  DBA_PASS: "MorSdaT2578!"
  API_TOKEN: "cxbn762xamnMYt2d"

Uruchomienie aplikacji mors-gui-api w projekcie morsgui-prod

# Utworzenie aplikacji z obrazu buildu (Deployment)
oc new-app mors-gui-api-build:latest --name=mors-gui-api

# Podpięcie env do Deployment
oc set env deployment/mors-gui-api --from=configmap/cm-mors-gui-api
oc set env deployment/mors-gui-api --from=secret/secret-mors-gui-api

# Jeśli Service już istnieje
oc get svc mors-gui-api -o wide

NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE   SELECTOR
mors-gui-api   ClusterIP   172.30.40.251   <none>        8080/TCP   31s   deployment=mors-gui-api
# Jeśli serwis by nie istniał to należy go utworzyć (jeśli OpenShift nie wykrył automatycznie)
oc expose deployment/mors-gui-api --port=8080

# Uwtórz zwykłą Route http
oc expose svc/mors-gui-api
NAME           HOST/PORT                                                 PATH   SERVICES       PORT       TERMINATION   WILDCARD
mors-gui-api   mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local          mors-gui-api   8080-tcp                 None

# lub zdecydowanie lepiej Route https edge TLS
oc create route edge mors-gui-api --service=mors-gui-api --port=8080
NAME           HOST/PORT                                                 PATH   SERVICES       PORT   TERMINATION   WILDCARD
mors-gui-api   mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local          mors-gui-api   8080   edge          None

# zmiana ze zwykłej na edget TLS
oc patch route mors-gui-api --type=merge -p '{"spec":{"tls":{"termination":"edge"}}}'

# Rollout / restart po zmianach. W Deployment nie ma rollout latest, tylko np. restart:
oc rollout restart deployment/mors-gui-api
oc rollout status deployment/mors-gui-api

# Logi poda
oc get pods

oc logs -f mors-gui-api-6f65797d45-q6dxp
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)

Co dokładnie robi Route z edge TLS

Bez TLS (to co masz po oc expose svc/mors-gui-api). Route jest HTTP (terminacja: None). Ruch idzie: klient → router OpenShift → service → pod. Po drodze nic nie jest szyfrowane na wejściu (połączenie klient↔router jest plain HTTP)

Konsekwencje:

token w nagłówku Authorization leci w sieci jawnie (to jest realnie słabe, jeśli to nie jest sieć w pełni zaufana). Przeglądarki i część klientów będą kręcić nosem (brak HTTPS)

Z TLS edge. Klient łączy się HTTPS do routera. Router robi terminację TLS (odszyfrowuje) i dalej do Twojego serwisu/poda idzie już HTTP. Schemat: klient (HTTPS) → router (TLS termination) → service (HTTP) → pod.

Co to daje:

szyfrowanie na odcinku klient↔router (najważniejszy odcinek). Sensowniejsze bezpieczeństwo (token, payload, itp.). Łatwiej potem wpiąć normalne certyfikaty i działać „jak człowiek” produkcyjnie

Weryfikacja działania aplikacji mors-gui-api

Z dowolnej maszyny sieci OKDLAB 192.168.40.0/24 np. maszyny bastion:

[bastuser@bastion mors-gui-api]$ curl -s -k -X GET -H "Content-Type: application/json" -H "Authorization: cxbn762xamnMYt2d" https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/okd/read/1 | jq '.'
[
  {
    "entry_id": 1,
    "created_at": "2026-02-12 21:26:38",
    "compute_1_cpu_prc": 8,
    "compute_2_cpu_prc": 12,
    "compute_3_cpu_prc": 4,
    "control_1_cpu_prc": 25,
    "control_2_cpu_prc": 15,
    "control_3_cpu_prc": 17,
    "compute_1_ram_prc": 31,
    "compute_2_ram_prc": 27,
    "compute_3_ram_prc": 18,
    "control_1_ram_prc": 71,
    "control_2_ram_prc": 43,
    "control_3_ram_prc": 42
  }
]

w logach poda aplikacji powinno być widać reakcję na zapytanie do API

oc logs -f mors-gui-api-6f65797d45-q6dxp
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
INFO:     10.129.2.2:37972 - "GET /rest-api/v1/stats/okd/read/1 HTTP/1.1" 200 OK
INFO:     10.129.2.2:44244 - "GET /rest-api/v1/stats/okd/read/1 HTTP/1.1" 401 Unauthorized
INFO:     10.130.0.2:52108 - "GET /rest-api/v1/stats/okd/read/1 HTTP/1.1" 200 OK
[bastuser@bastion mors-usl-proxmox]$ curl -s -k -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: cxbn762xamnMYt2d" \
  -d '{
    "compute_1_cpu_prc": 12,
    "compute_2_cpu_prc": 9,
    "compute_3_cpu_prc": 15,
    "control_1_cpu_prc": 7,
    "control_2_cpu_prc": 11,
    "control_3_cpu_prc": 6,
    "compute_1_ram_prc": 55,
    "compute_2_ram_prc": 49,
    "compute_3_ram_prc": 62,
    "control_1_ram_prc": 38,
    "control_2_ram_prc": 41,
    "control_3_ram_prc": 35
  }' \
  https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/okd/create
oc logs -f mors-gui-api-6f65797d45-q6dxp
INFO:     10.129.2.2:60622 - "POST /rest-api/v1/stats/okd/create HTTP/1.1" 200 OK
  curl -s -k -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: cxbn762xamnMYt2d" \
  -d '{
    "cpu_usage": 17,
    "cpu_temp_c": "59.2",
    "sys_load": "6.17,5.68,5.46",
    "ram_usage_prc": 61,
    "swap_usage_prc": 0,
    "gpu_busy_prc": 0,
    "gpu_temp_c": "47.0",
    "nvme_temp_c": 39
  }' \
  https://mors-gui-api-morsgui-prod.apps.testcluster.okdlab.local/rest-api/v1/stats/proxmox/create