Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions fournos-ui/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@

@dataclass(frozen=True)
class Settings:
database_url: str = field(
default_factory=lambda: os.environ["DATABASE_URL"]
)
database_url: str = field(default_factory=lambda: os.environ["DATABASE_URL"])

fournos_namespace: str = field(
default_factory=lambda: os.environ.get("FOURNOS_NAMESPACE", "fournos-jobs")
Expand All @@ -25,7 +23,9 @@ class Settings:
)

projects_config_path: str = field(
default_factory=lambda: os.environ.get("PROJECTS_CONFIG_PATH", "/etc/fournos-dashboard/projects.yaml")
default_factory=lambda: os.environ.get(
"PROJECTS_CONFIG_PATH", "/etc/fournos-dashboard/projects.yaml"
)
)

fournos_api_group: str = "fournos.dev"
Expand All @@ -36,12 +36,12 @@ class Settings:
tekton_api_version: str = "v1"
tekton_pipelinerun_plural: str = "pipelineruns"

log_level: str = field(
default_factory=lambda: os.environ.get("LOG_LEVEL", "INFO")
)
log_level: str = field(default_factory=lambda: os.environ.get("LOG_LEVEL", "INFO"))

forge_github_repo: str = field(
default_factory=lambda: os.environ.get("FORGE_GITHUB_REPO", "openshift-psap/forge")
default_factory=lambda: os.environ.get(
"FORGE_GITHUB_REPO", "openshift-psap/forge"
)
)

k8s_request_timeout_seconds: int = field(
Expand Down
33 changes: 23 additions & 10 deletions fournos-ui/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Any, Sequence
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4

from sqlalchemy import (
Expand All @@ -17,7 +18,8 @@
func,
select,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, insert as pg_insert
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, relationship

Expand All @@ -34,6 +36,7 @@ class Base(DeclarativeBase):
# ORM Models
# ---------------------------------------------------------------------------


class Job(Base):
__tablename__ = "jobs"

Expand All @@ -46,7 +49,7 @@ class Job(Base):
owner = Column(String, default="", index=True)
status = Column(String, default="Pending", index=True)
message = Column(Text, default="")
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
completed_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Float, nullable=True)
mlflow_url = Column(String, default="")
Expand All @@ -59,17 +62,21 @@ class Job(Base):
triggered_by_schedule = Column(String, nullable=True, index=True)
trigger_type = Column(String, default="manual")

events = relationship("JobEvent", back_populates="job", cascade="all, delete-orphan")
events = relationship(
"JobEvent", back_populates="job", cascade="all, delete-orphan"
)


class JobEvent(Base):
__tablename__ = "job_events"

id = Column(String, primary_key=True, default=lambda: str(uuid4()))
job_id = Column(String, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False, index=True)
job_id = Column(
String, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False, index=True
)
phase = Column(String, nullable=False)
message = Column(Text, default="")
timestamp = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
timestamp = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))

job = relationship("Job", back_populates="events")

Expand All @@ -78,7 +85,9 @@ class JobEvent(Base):
# Engine & session factory
# ---------------------------------------------------------------------------

engine = create_async_engine(settings.database_url, echo=False, pool_size=5, max_overflow=10)
engine = create_async_engine(
settings.database_url, echo=False, pool_size=5, max_overflow=10
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


Expand All @@ -93,12 +102,15 @@ async def init_db() -> None:
# Query helpers
# ---------------------------------------------------------------------------


async def upsert_job(session: AsyncSession, **kwargs: Any) -> Job:
"""Insert or update a job record keyed by name (atomic)."""
if "id" not in kwargs:
kwargs["id"] = str(uuid4())

update_cols = {k: v for k, v in kwargs.items() if k not in ("id", "name") and v is not None}
update_cols = {
k: v for k, v in kwargs.items() if k not in ("id", "name") and v is not None
}

stmt = (
pg_insert(Job)
Expand Down Expand Up @@ -165,7 +177,8 @@ async def list_jobs(


async def list_jobs_by_schedule(
session: AsyncSession, schedule_name: str,
session: AsyncSession,
schedule_name: str,
) -> Sequence[Job]:
"""List all jobs triggered by a specific schedule."""
result = await session.execute(
Expand Down
12 changes: 10 additions & 2 deletions fournos-ui/app/forge_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ def _discover_from_repo(projects_dir: Path) -> dict[str, ProjectInfo]:
skip = {"core", "__pycache__"}

for proj_dir in sorted(projects_dir.iterdir()):
if not proj_dir.is_dir() or proj_dir.name.startswith(".") or proj_dir.name in skip:
if (
not proj_dir.is_dir()
or proj_dir.name.startswith(".")
or proj_dir.name in skip
):
continue

orchestration = proj_dir / "orchestration"
Expand Down Expand Up @@ -92,7 +96,11 @@ def _discover_from_configmap() -> dict[str, ProjectInfo]:
result: dict[str, ProjectInfo] = {}
for idx, proj in enumerate(data["projects"]):
if not isinstance(proj, dict):
logger.warning("Skipping malformed project entry at index %d: expected mapping, got %s", idx, type(proj).__name__)
logger.warning(
"Skipping malformed project entry at index %d: expected mapping, got %s",
idx,
type(proj).__name__,
)
continue
name = proj.get("name", "")
if not name:
Expand Down
96 changes: 59 additions & 37 deletions fournos-ui/app/k8s_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import json
import logging
import threading
from datetime import datetime, timezone
from typing import Any, Generator
from collections.abc import Generator
from datetime import UTC, datetime
from typing import Any

import yaml
from kubernetes import client, config, watch
from kubernetes.client.rest import ApiException

Expand Down Expand Up @@ -64,6 +64,7 @@ def is_connected() -> bool:
# FournosJob operations
# ---------------------------------------------------------------------------


def list_fournos_jobs(namespace: str | None = None) -> list[dict]:
"""List all FournosJob CRs in the given namespace."""
_ensure_loaded()
Expand Down Expand Up @@ -119,9 +120,7 @@ def create_fournos_job(body: dict, namespace: str | None = None) -> dict:
)


def patch_fournos_job(
name: str, patch: dict, namespace: str | None = None
) -> dict:
def patch_fournos_job(name: str, patch: dict, namespace: str | None = None) -> dict:
"""Patch a FournosJob (e.g. set spec.shutdown)."""
_ensure_loaded()
if _custom_api is None:
Expand Down Expand Up @@ -166,8 +165,7 @@ def watch_fournos_jobs(
if timeout:
kwargs["timeout_seconds"] = timeout
try:
for event in w.stream(_custom_api.list_namespaced_custom_object, **kwargs):
yield event
yield from w.stream(_custom_api.list_namespaced_custom_object, **kwargs)
except ApiException as exc:
logger.warning("Watch stream ended: %s", exc.reason)

Expand All @@ -176,6 +174,7 @@ def watch_fournos_jobs(
# Tekton PipelineRun operations
# ---------------------------------------------------------------------------


def get_pipelinerun(name: str, namespace: str | None = None) -> dict | None:
"""Get a Tekton PipelineRun by name."""
_ensure_loaded()
Expand Down Expand Up @@ -317,14 +316,16 @@ def extract_pipeline_stages(pipelinerun: dict) -> list[dict]:

display_name = task_name.replace("-", " ").title()

stages.append({
"name": task_name,
"displayName": display_name,
"status": task_phase,
"startTime": start_time,
"completionTime": completion_time,
"finally": task_name in finally_task_names,
})
stages.append(
{
"name": task_name,
"displayName": display_name,
"status": task_phase,
"startTime": start_time,
"completionTime": completion_time,
"finally": task_name in finally_task_names,
}
)

stages.sort(key=lambda s: (s["finally"], s.get("startTime") or "9999"))
return stages
Expand All @@ -334,6 +335,7 @@ def extract_pipeline_stages(pipelinerun: dict) -> list[dict]:
# Pod operations
# ---------------------------------------------------------------------------


def list_pods_for_job(job_name: str, namespace: str | None = None) -> list[dict]:
"""List pods associated with a FournosJob."""
_ensure_loaded()
Expand All @@ -350,7 +352,7 @@ def list_pods_for_job(job_name: str, namespace: str | None = None) -> list[dict]
created = pod.metadata.creation_timestamp
age_minutes = 0
if created:
delta = datetime.now(timezone.utc) - created.replace(tzinfo=timezone.utc)
delta = datetime.now(UTC) - created.replace(tzinfo=UTC)
age_minutes = int(delta.total_seconds() / 60)

container_ready = False
Expand All @@ -364,18 +366,22 @@ def list_pods_for_job(job_name: str, namespace: str | None = None) -> list[dict]
if pod.metadata.name.startswith("affinity-assistant"):
continue

pods.append({
"name": pod.metadata.name,
"phase": pod.status.phase or "Unknown",
"container": (
pod.spec.containers[0].name if pod.spec.containers else "unknown"
),
"ready": container_ready,
"restarts": restarts,
"age_minutes": age_minutes,
"_created": created,
})
pods.sort(key=lambda p: p["_created"] or datetime.min.replace(tzinfo=timezone.utc))
pods.append(
{
"name": pod.metadata.name,
"phase": pod.status.phase or "Unknown",
"container": (
pod.spec.containers[0].name
if pod.spec.containers
else "unknown"
),
"ready": container_ready,
"restarts": restarts,
"age_minutes": age_minutes,
"_created": created,
}
)
pods.sort(key=lambda p: p["_created"] or datetime.min.replace(tzinfo=UTC))
return pods
except ApiException as exc:
logger.error("Failed to list pods for %s: %s", job_name, exc.reason)
Expand All @@ -402,7 +408,9 @@ def read_pod_log(
kwargs["tail_lines"] = tail_lines
try:
if follow:
for line in _core_api.read_namespaced_pod_log(**kwargs, _preload_content=False).stream():
for line in _core_api.read_namespaced_pod_log(
**kwargs, _preload_content=False
).stream():
decoded = line.decode("utf-8", errors="replace").rstrip("\n")
yield decoded
else:
Expand Down Expand Up @@ -601,7 +609,9 @@ def create_cronjob(
if resolver_script:
is_python = resolver_filename.lower().endswith(".py")
default_resolver_img = "python:3.12-slim" if is_python else "alpine:latest"
script_key = resolver_filename or ("resolver.py" if is_python else "resolver.sh")
script_key = resolver_filename or (
"resolver.py" if is_python else "resolver.sh"
)
configmap_name = f"{name}-resolver"

_create_resolver_configmap(configmap_name, script_key, resolver_script, ns)
Expand All @@ -619,7 +629,9 @@ def create_cronjob(
)
volumes = [script_vol, shared_vol]
script_mount = client.V1VolumeMount(
name="resolver-script", mount_path="/resolver", read_only=True,
name="resolver-script",
mount_path="/resolver",
read_only=True,
)
shared_mount = client.V1VolumeMount(name="shared", mount_path="/shared")
submit_container.volume_mounts = [shared_mount]
Expand Down Expand Up @@ -714,13 +726,17 @@ def _create_resolver_configmap(
except ApiException as exc:
if exc.status == 409:
_core_api.replace_namespaced_config_map(
name=cm_name, namespace=namespace, body=cm,
name=cm_name,
namespace=namespace,
body=cm,
)
else:
raise


def get_resolver_script(configmap_name: str, namespace: str | None = None) -> tuple[str, str]:
def get_resolver_script(
configmap_name: str, namespace: str | None = None
) -> tuple[str, str]:
"""Read the resolver script from its ConfigMap. Returns (filename, content)."""
_ensure_loaded()
if _core_api is None:
Expand Down Expand Up @@ -755,13 +771,17 @@ def trigger_cronjob(name: str, namespace: str | None = None) -> str:
ns = namespace or settings.fournos_namespace

cj = _batch_api.read_namespaced_cron_job(name=name, namespace=ns)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
job_name = f"{name}-manual-{ts}"[:63]

job_spec = cj.spec.job_template.spec

trigger_env = client.V1EnvVar(name="FOURNOS_TRIGGER_TYPE", value="manual")
if job_spec.template and job_spec.template.spec and job_spec.template.spec.containers:
if (
job_spec.template
and job_spec.template.spec
and job_spec.template.spec.containers
):
for c in job_spec.template.spec.containers:
if c.env is None:
c.env = []
Expand Down Expand Up @@ -839,7 +859,9 @@ def _cronjob_to_dict(cj: Any) -> dict:
"resolver_image": resolver_image,
"resolver_filename": resolver_filename,
"has_resolver": bool(resolver_configmap),
"created_at": meta.creation_timestamp.isoformat() if meta.creation_timestamp else "",
"created_at": meta.creation_timestamp.isoformat()
if meta.creation_timestamp
else "",
"last_schedule": (
cj.status.last_schedule_time.isoformat()
if cj.status and cj.status.last_schedule_time
Expand Down
Loading
Loading