diff --git a/agents/bernard/bernard/__init__.py b/agents/bernard/bernard/__init__.py index 275d844..5c1d8d0 100644 --- a/agents/bernard/bernard/__init__.py +++ b/agents/bernard/bernard/__init__.py @@ -1 +1 @@ -"""Bernard dev/PR agent (scaffold).""" +"""Bernard — GitOps PR reviewer & Temporal workflow monitor agent.""" diff --git a/agents/bernard/bernard/dashboard/__init__.py b/agents/bernard/bernard/dashboard/__init__.py new file mode 100644 index 0000000..038fb97 --- /dev/null +++ b/agents/bernard/bernard/dashboard/__init__.py @@ -0,0 +1 @@ +"""Bernard dashboard package.""" diff --git a/agents/bernard/bernard/dashboard/api.py b/agents/bernard/bernard/dashboard/api.py new file mode 100644 index 0000000..abcae48 --- /dev/null +++ b/agents/bernard/bernard/dashboard/api.py @@ -0,0 +1,119 @@ +"""Bernard dashboard API — FastAPI server serving alerts from Postgres.""" +from __future__ import annotations + +import json +import os +from contextlib import asynccontextmanager + +import psycopg +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +DB_DSN: str = "" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global DB_DSN + DB_DSN = os.environ["BERNARD_DB_DSN"] + # Ensure tables exist + conn = await psycopg.AsyncConnection.connect(DB_DSN) + try: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS bernard_alerts ( + id SERIAL PRIMARY KEY, + alert_type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS bernard_reviewed_prs ( + id SERIAL PRIMARY KEY, + pr_key TEXT UNIQUE NOT NULL, + reviewed_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + await conn.commit() + finally: + await conn.close() + yield + + +app = FastAPI(title="Bernard Dashboard API", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/alerts") +async def get_alerts(limit: int = 50, alert_type: str | None = None): + conn = await psycopg.AsyncConnection.connect(DB_DSN) + try: + async with conn.cursor() as cur: + if alert_type: + await cur.execute( + "SELECT id, alert_type, title, body, metadata, created_at FROM bernard_alerts WHERE alert_type=%s ORDER BY created_at DESC LIMIT %s", + (alert_type, limit), + ) + else: + await cur.execute( + "SELECT id, alert_type, title, body, metadata, created_at FROM bernard_alerts ORDER BY created_at DESC LIMIT %s", + (limit,), + ) + rows = await cur.fetchall() + return [ + { + "id": r[0], + "alert_type": r[1], + "title": r[2], + "body": r[3], + "metadata": r[4], + "created_at": r[5].isoformat() if r[5] else None, + } + for r in rows + ] + finally: + await conn.close() + + +@app.get("/api/stats") +async def get_stats(): + conn = await psycopg.AsyncConnection.connect(DB_DSN) + try: + async with conn.cursor() as cur: + await cur.execute("SELECT COUNT(*) FROM bernard_alerts") + total = (await cur.fetchone())[0] + await cur.execute("SELECT COUNT(*) FROM bernard_alerts WHERE alert_type='pr_review'") + pr_reviews = (await cur.fetchone())[0] + await cur.execute("SELECT COUNT(*) FROM bernard_alerts WHERE alert_type='workflow_failure'") + wf_failures = (await cur.fetchone())[0] + await cur.execute("SELECT COUNT(*) FROM bernard_reviewed_prs") + reviewed_prs = (await cur.fetchone())[0] + return { + "total_alerts": total, + "pr_reviews": pr_reviews, + "workflow_failures": wf_failures, + "reviewed_prs": reviewed_prs, + } + finally: + await conn.close() + + +@app.get("/health") +async def health(): + return {"status": "ok", "agent": "bernard"} + + +@app.get("/{full_path:path}") +async def serve_spa(full_path: str): + return FileResponse("/app/static/index.html") diff --git a/agents/bernard/bernard/dashboard/server.py b/agents/bernard/bernard/dashboard/server.py new file mode 100644 index 0000000..d1ec23f --- /dev/null +++ b/agents/bernard/bernard/dashboard/server.py @@ -0,0 +1,12 @@ +"""Bernard dashboard server entrypoint.""" +import os +import uvicorn +from bernard.dashboard.api import app + + +def main() -> None: + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8080))) + + +if __name__ == "__main__": + main() diff --git a/agents/bernard/bernard/dashboard/static/index.html b/agents/bernard/bernard/dashboard/static/index.html new file mode 100644 index 0000000..0ee9025 --- /dev/null +++ b/agents/bernard/bernard/dashboard/static/index.html @@ -0,0 +1,312 @@ + + + + + +Bernard — Agentic OS Monitor + + + + + + + + + + + +
+ + +
+
+
📋
+
Total Alerts
+
+
+
+
🔍
+
PR Reviews
+
+
+
+
⚠️
+
Workflow Failures
+
+
+
+
+
PRs Reviewed
+
+
+
+ +
+ + + +
+ +
+
+
+
+ + + + diff --git a/agents/bernard/bernard/llm.py b/agents/bernard/bernard/llm.py new file mode 100644 index 0000000..68ae796 --- /dev/null +++ b/agents/bernard/bernard/llm.py @@ -0,0 +1,68 @@ +"""Bernard LLM integration — calls LiteLLM for code review and failure analysis.""" +from __future__ import annotations + +import os + +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage, SystemMessage + + +def _llm() -> ChatOpenAI: + return ChatOpenAI( + model=os.environ.get("BERNARD_MODEL", "ollama-qwen"), + openai_api_key=os.environ["LITELLM_API_KEY"], + openai_api_base=os.environ.get("LITELLM_BASE_URL", "http://litellm.ai-core.svc.cluster.local:4000"), + temperature=0.3, + max_tokens=2048, + ) + + +async def review_code_diff(title: str, author: str, diff: str) -> str: + """Review a git diff and return a markdown-formatted code review.""" + llm = _llm() + system = SystemMessage(content="""You are Bernard, an expert code reviewer for a Kubernetes GitOps platform. +Review the provided git diff and provide structured feedback covering: +1. **Security Issues** — Any secrets, vulnerabilities, or auth problems +2. **Infrastructure Risks** — Misconfigurations, missing health probes, resource limits +3. **Code Quality** — Logic errors, anti-patterns, or improvements +4. **Summary** — Overall assessment (Approve / Request Changes / Needs Discussion) + +Be concise and actionable. Format your response in markdown.""") + + # Truncate diff if too large + diff_truncated = diff[:6000] + "\n... [diff truncated]" if len(diff) > 6000 else diff + + user = HumanMessage(content=f"""**PR Title:** {title} +**Author:** {author} + +**Diff:** +```diff +{diff_truncated} +```""") + + response = await llm.ainvoke([system, user]) + return response.content + + +async def analyze_workflow_failure(workflow_id: str, workflow_type: str, error: str) -> str: + """Analyze a Temporal workflow failure and suggest a fix.""" + llm = _llm() + system = SystemMessage(content="""You are Bernard, an expert in Temporal workflow orchestration and LangGraph agents. +Analyze the provided workflow failure and provide: +1. **Root Cause** — What caused the failure +2. **Impact** — What was affected (data loss risk, user impact) +3. **Recommended Fix** — Concrete steps to resolve the issue +4. **Prevention** — How to prevent this in the future + +Be concise and technical. Format your response in markdown.""") + + user = HumanMessage(content=f"""**Workflow ID:** {workflow_id} +**Workflow Type:** {workflow_type} + +**Error:** +``` +{error[:3000]} +```""") + + response = await llm.ainvoke([system, user]) + return response.content diff --git a/agents/bernard/bernard/temporal/__init__.py b/agents/bernard/bernard/temporal/__init__.py new file mode 100644 index 0000000..37b2248 --- /dev/null +++ b/agents/bernard/bernard/temporal/__init__.py @@ -0,0 +1 @@ +"""Bernard temporal package.""" diff --git a/agents/bernard/bernard/temporal/activities.py b/agents/bernard/bernard/temporal/activities.py new file mode 100644 index 0000000..850bf02 --- /dev/null +++ b/agents/bernard/bernard/temporal/activities.py @@ -0,0 +1,229 @@ +"""Bernard Temporal activities — Gitea PR review & Temporal workflow monitoring.""" +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from typing import Any + +import httpx +import psycopg +from temporalio import activity + + +# --------------------------------------------------------------------------- +# Gitea helpers +# --------------------------------------------------------------------------- + +def _gitea_headers() -> dict: + return { + "Authorization": f"token {os.environ['GITEA_TOKEN']}", + "Accept": "application/json", + } + + +def _gitea_base() -> str: + return os.environ.get("GITEA_BASE_URL", "http://gitea.gitea.svc.cluster.local:3000") + + +# --------------------------------------------------------------------------- +# PR Review activities +# --------------------------------------------------------------------------- + +@activity.defn +async def fetch_open_prs(repo_full_name: str) -> list[dict[str, Any]]: + """Return list of open PRs from Gitea for the given repo (owner/repo).""" + url = f"{_gitea_base()}/api/v1/repos/{repo_full_name}/pulls" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_gitea_headers(), params={"state": "open", "limit": 20}) + resp.raise_for_status() + return resp.json() + + +@activity.defn +async def fetch_pr_diff(payload: dict[str, Any]) -> dict[str, Any]: + """Fetch the unified diff for a specific PR.""" + repo = payload["repo"] + pr_number = payload["pr_number"] + url = f"{_gitea_base()}/api/v1/repos/{repo}/pulls/{pr_number}.diff" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_gitea_headers()) + resp.raise_for_status() + pr_url = f"{_gitea_base()}/api/v1/repos/{repo}/pulls/{pr_number}" + pr_resp = await client.get(pr_url, headers=_gitea_headers()) + pr_resp.raise_for_status() + pr_data = pr_resp.json() + return { + "repo": repo, + "pr_number": pr_number, + "title": pr_data.get("title", ""), + "author": pr_data.get("user", {}).get("login", ""), + "diff": resp.text, + } + + +@activity.defn +async def review_pr_with_llm(payload: dict[str, Any]) -> dict[str, Any]: + """Send diff to LiteLLM/Ollama and get a structured code review.""" + from bernard.llm import review_code_diff + review = await review_code_diff( + title=payload["title"], + author=payload["author"], + diff=payload["diff"], + ) + return { + "repo": payload["repo"], + "pr_number": payload["pr_number"], + "title": payload["title"], + "author": payload["author"], + "review": review, + } + + +@activity.defn +async def post_pr_comment(payload: dict[str, Any]) -> None: + """Post the AI review as a comment on the Gitea PR.""" + repo = payload["repo"] + pr_number = payload["pr_number"] + review = payload["review"] + body = f"## 🤖 Bernard AI Code Review\n\n{review}\n\n---\n*Reviewed by Bernard — Agentic OS monitoring agent*" + url = f"{_gitea_base()}/api/v1/repos/{repo}/issues/{pr_number}/comments" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(url, headers=_gitea_headers(), json={"body": body}) + resp.raise_for_status() + + +@activity.defn +async def get_reviewed_prs() -> set[str]: + """Fetch set of already-reviewed PR keys from the bernard DB.""" + dsn = os.environ["BERNARD_DB_DSN"] + conn = await psycopg.AsyncConnection.connect(dsn) + try: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS bernard_reviewed_prs ( + id SERIAL PRIMARY KEY, + pr_key TEXT UNIQUE NOT NULL, + reviewed_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + await conn.commit() + await cur.execute("SELECT pr_key FROM bernard_reviewed_prs") + rows = await cur.fetchall() + return {row[0] for row in rows} + finally: + await conn.close() + + +@activity.defn +async def mark_pr_reviewed(pr_key: str) -> None: + """Record that a PR has been reviewed to avoid duplicate comments.""" + dsn = os.environ["BERNARD_DB_DSN"] + conn = await psycopg.AsyncConnection.connect(dsn) + try: + async with conn.cursor() as cur: + await cur.execute( + "INSERT INTO bernard_reviewed_prs (pr_key) VALUES (%s) ON CONFLICT DO NOTHING", + (pr_key,), + ) + await conn.commit() + finally: + await conn.close() + + +# --------------------------------------------------------------------------- +# Temporal monitoring activities +# --------------------------------------------------------------------------- + +@activity.defn +async def fetch_failed_workflows() -> list[dict[str, Any]]: + """Query Temporal for workflows that failed in the last check window.""" + from temporalio.client import Client + address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233") + namespace = os.environ.get("TEMPORAL_NAMESPACE", "default") + client = await Client.connect(address, namespace=namespace) + + failed = [] + async for wf in client.list_workflows('ExecutionStatus = "Failed"'): + failed.append({ + "workflow_id": wf.id, + "run_id": wf.run_id, + "workflow_type": wf.workflow_type, + "start_time": wf.start_time.isoformat() if wf.start_time else None, + "close_time": wf.close_time.isoformat() if wf.close_time else None, + }) + return failed + + +@activity.defn +async def fetch_workflow_failure_details(payload: dict[str, Any]) -> dict[str, Any]: + """Get the detailed error and history for a failed workflow.""" + from temporalio.client import Client + address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233") + namespace = os.environ.get("TEMPORAL_NAMESPACE", "default") + client = await Client.connect(address, namespace=namespace) + + handle = client.get_workflow_handle(payload["workflow_id"], run_id=payload["run_id"]) + try: + await handle.result() + error_message = "No error (workflow may have recovered)" + except Exception as e: + error_message = str(e) + + return { + "workflow_id": payload["workflow_id"], + "run_id": payload["run_id"], + "workflow_type": payload["workflow_type"], + "error": error_message, + } + + +@activity.defn +async def analyze_failure_with_llm(payload: dict[str, Any]) -> dict[str, Any]: + """Use LiteLLM to analyze the failure and suggest a fix.""" + from bernard.llm import analyze_workflow_failure + analysis = await analyze_workflow_failure( + workflow_id=payload["workflow_id"], + workflow_type=payload["workflow_type"], + error=payload["error"], + ) + return {**payload, "analysis": analysis} + + +# --------------------------------------------------------------------------- +# Shared: persist alert and dashboard update +# --------------------------------------------------------------------------- + +@activity.defn +async def persist_alert(payload: dict[str, Any]) -> str: + """Persist an alert to the bernard DB and return the alert ID.""" + dsn = os.environ["BERNARD_DB_DSN"] + conn = await psycopg.AsyncConnection.connect(dsn) + try: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS bernard_alerts ( + id SERIAL PRIMARY KEY, + alert_type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + await conn.commit() + await cur.execute( + """INSERT INTO bernard_alerts (alert_type, title, body, metadata) + VALUES (%s, %s, %s, %s) RETURNING id""", + ( + payload["alert_type"], + payload["title"], + payload["body"], + json.dumps(payload.get("metadata", {})), + ), + ) + row = await cur.fetchone() + await conn.commit() + return str(row[0]) + finally: + await conn.close() diff --git a/agents/bernard/bernard/temporal/worker.py b/agents/bernard/bernard/temporal/worker.py new file mode 100644 index 0000000..06ae67c --- /dev/null +++ b/agents/bernard/bernard/temporal/worker.py @@ -0,0 +1,46 @@ +"""Bernard Temporal worker entrypoint.""" +from __future__ import annotations + +import asyncio +import os + +from temporalio.client import Client +from temporalio.worker import Worker + +from bernard.temporal import activities +from bernard.temporal.workflows import PRReviewWorkflow, TemporalMonitorWorkflow + + +async def _main() -> None: + address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233") + namespace = os.environ.get("TEMPORAL_NAMESPACE", "default") + task_queue = os.environ.get("BERNARD_TASK_QUEUE", "bernard") + + client = await Client.connect(address, namespace=namespace) + worker = Worker( + client, + task_queue=task_queue, + workflows=[PRReviewWorkflow, TemporalMonitorWorkflow], + activities=[ + activities.fetch_open_prs, + activities.fetch_pr_diff, + activities.review_pr_with_llm, + activities.post_pr_comment, + activities.get_reviewed_prs, + activities.mark_pr_reviewed, + activities.fetch_failed_workflows, + activities.fetch_workflow_failure_details, + activities.analyze_failure_with_llm, + activities.persist_alert, + ], + ) + print(f"Bernard worker started on task queue '{task_queue}'") + await worker.run() + + +def main() -> None: + asyncio.run(_main()) + + +if __name__ == "__main__": + main() diff --git a/agents/bernard/bernard/temporal/workflows.py b/agents/bernard/bernard/temporal/workflows.py new file mode 100644 index 0000000..4752caf --- /dev/null +++ b/agents/bernard/bernard/temporal/workflows.py @@ -0,0 +1,133 @@ +"""Bernard Temporal workflows — PR review & Temporal health monitor.""" +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from bernard.temporal.activities import ( + analyze_failure_with_llm, + fetch_failed_workflows, + fetch_open_prs, + fetch_pr_diff, + fetch_workflow_failure_details, + get_reviewed_prs, + mark_pr_reviewed, + persist_alert, + post_pr_comment, + review_pr_with_llm, + ) + +_ACT_TIMEOUT = timedelta(minutes=10) +_REPO = "deepkoluguri/agentic-os" + + +# --------------------------------------------------------------------------- +# PR Review Workflow — scans open PRs, reviews any that haven't been reviewed +# --------------------------------------------------------------------------- + +@workflow.defn +class PRReviewWorkflow: + """Scan all open PRs in the agentic-os repo and post AI reviews.""" + + @workflow.run + async def run(self) -> dict: + reviewed_keys = await workflow.execute_activity( + get_reviewed_prs, start_to_close_timeout=_ACT_TIMEOUT + ) + + open_prs = await workflow.execute_activity( + fetch_open_prs, _REPO, start_to_close_timeout=_ACT_TIMEOUT + ) + + results = [] + for pr in open_prs: + pr_key = f"{_REPO}#{pr['number']}" + if pr_key in reviewed_keys: + continue + + diff_payload = await workflow.execute_activity( + fetch_pr_diff, + {"repo": _REPO, "pr_number": pr["number"]}, + start_to_close_timeout=_ACT_TIMEOUT, + ) + + review_payload = await workflow.execute_activity( + review_pr_with_llm, diff_payload, start_to_close_timeout=_ACT_TIMEOUT + ) + + await workflow.execute_activity( + post_pr_comment, review_payload, start_to_close_timeout=_ACT_TIMEOUT + ) + + alert_id = await workflow.execute_activity( + persist_alert, + { + "alert_type": "pr_review", + "title": f"PR #{pr['number']}: {pr.get('title', '')}", + "body": review_payload["review"], + "metadata": { + "repo": _REPO, + "pr_number": pr["number"], + "author": pr.get("user", {}).get("login", ""), + }, + }, + start_to_close_timeout=_ACT_TIMEOUT, + ) + + await workflow.execute_activity( + mark_pr_reviewed, pr_key, start_to_close_timeout=_ACT_TIMEOUT + ) + + results.append({"pr_key": pr_key, "alert_id": alert_id}) + + return {"reviewed": len(results), "prs": results} + + +# --------------------------------------------------------------------------- +# Temporal Monitor Workflow — runs on cron, scans for failed workflows +# --------------------------------------------------------------------------- + +@workflow.defn +class TemporalMonitorWorkflow: + """Check for failed Temporal workflows and post AI root-cause analysis.""" + + @workflow.run + async def run(self) -> dict: + failed_workflows = await workflow.execute_activity( + fetch_failed_workflows, start_to_close_timeout=_ACT_TIMEOUT + ) + + results = [] + for wf in failed_workflows: + details = await workflow.execute_activity( + fetch_workflow_failure_details, wf, start_to_close_timeout=_ACT_TIMEOUT + ) + + analysis = await workflow.execute_activity( + analyze_failure_with_llm, details, start_to_close_timeout=_ACT_TIMEOUT + ) + + alert_id = await workflow.execute_activity( + persist_alert, + { + "alert_type": "workflow_failure", + "title": f"❌ Workflow Failed: {wf['workflow_type']} ({wf['workflow_id']})", + "body": analysis["analysis"], + "metadata": { + "workflow_id": wf["workflow_id"], + "run_id": wf["run_id"], + "workflow_type": wf["workflow_type"], + "error": details["error"], + }, + }, + start_to_close_timeout=_ACT_TIMEOUT, + ) + + results.append({ + "workflow_id": wf["workflow_id"], + "alert_id": alert_id, + }) + + return {"failures_analyzed": len(results), "alerts": results} diff --git a/agents/bernard/pyproject.toml b/agents/bernard/pyproject.toml new file mode 100644 index 0000000..5dfe84a --- /dev/null +++ b/agents/bernard/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "bernard" +version = "0.1.0" +description = "Bernard — GitOps PR reviewer and Temporal workflow monitor" +requires-python = ">=3.11" + +dependencies = [ + "temporalio>=1.7.0", + "langchain-openai>=0.2.0", + "langchain-core>=0.3.0", + "psycopg[binary]>=3.1.0", + "httpx>=0.27.0", + "fastapi>=0.115.0", + "uvicorn>=0.30.0", +] + +[project.scripts] +bernard-worker = "bernard.temporal.worker:main" +bernard-dashboard = "bernard.dashboard.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/agents/k8s/bernard-deployment.yaml b/agents/k8s/bernard-deployment.yaml new file mode 100644 index 0000000..4b856b8 --- /dev/null +++ b/agents/k8s/bernard-deployment.yaml @@ -0,0 +1,184 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bernard-worker + namespace: ai-agents-bernard + labels: + app.kubernetes.io/name: bernard-worker + agentic-os.io/agent: bernard +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: bernard-worker + template: + metadata: + labels: + app.kubernetes.io/name: bernard-worker + agentic-os.io/agent: bernard + spec: + initContainers: + - name: git-clone + image: alpine/git:latest + command: + - sh + - -c + - | + git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace + volumeMounts: + - name: workspace + mountPath: /workspace + containers: + - name: bernard-worker + image: python:3.11-slim + command: + - sh + - -c + - | + cd /workspace/agents/bernard && pip install -e . -q && bernard-worker + volumeMounts: + - name: workspace + mountPath: /workspace + env: + - name: TEMPORAL_ADDRESS + value: "temporal-frontend.ai-core.svc.cluster.local:7233" + - name: TEMPORAL_NAMESPACE + value: "default" + - name: BERNARD_TASK_QUEUE + value: "bernard" + - name: GITEA_BASE_URL + value: "http://gitea-http.gitea.svc.cluster.local:3000" + - name: GITEA_TOKEN + valueFrom: + secretKeyRef: + name: bernard-secrets + key: GITEA_TOKEN + - name: LITELLM_API_KEY + valueFrom: + secretKeyRef: + name: bernard-secrets + key: LITELLM_API_KEY + - name: LITELLM_BASE_URL + value: "http://litellm.ai-core.svc.cluster.local:4000" + - name: BERNARD_DB_DSN + valueFrom: + secretKeyRef: + name: bernard-secrets + key: BERNARD_DB_DSN + - name: BERNARD_MODEL + value: "ollama-qwen" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: workspace + emptyDir: {} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bernard-dashboard + namespace: ai-agents-bernard + labels: + app.kubernetes.io/name: bernard-dashboard + agentic-os.io/agent: bernard +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: bernard-dashboard + template: + metadata: + labels: + app.kubernetes.io/name: bernard-dashboard + agentic-os.io/agent: bernard + spec: + initContainers: + - name: git-clone + image: alpine/git:latest + command: + - sh + - -c + - | + git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace + volumeMounts: + - name: workspace + mountPath: /workspace + containers: + - name: bernard-dashboard + image: python:3.11-slim + command: + - sh + - -c + - | + cd /workspace/agents/bernard && pip install -e . -q && bernard-dashboard + volumeMounts: + - name: workspace + mountPath: /workspace + env: + - name: PORT + value: "8080" + - name: BERNARD_DB_DSN + valueFrom: + secretKeyRef: + name: bernard-secrets + key: BERNARD_DB_DSN + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + volumes: + - name: workspace + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: bernard-dashboard + namespace: ai-agents-bernard +spec: + selector: + app.kubernetes.io/name: bernard-dashboard + ports: + - port: 8080 + targetPort: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: bernard-ingress + namespace: ai-agents-bernard +spec: + ingressClassName: apisix + rules: + - host: bernard.applaude.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: bernard-dashboard + port: + number: 8080 diff --git a/agents/k8s/bernard-externalsecret.yaml b/agents/k8s/bernard-externalsecret.yaml new file mode 100644 index 0000000..9688bff --- /dev/null +++ b/agents/k8s/bernard-externalsecret.yaml @@ -0,0 +1,22 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: bernard-secrets + namespace: ai-agents-bernard +spec: + refreshInterval: 1h + secretStoreRef: + name: infisical + kind: ClusterSecretStore + target: + name: bernard-secrets + data: + - secretKey: GITEA_TOKEN + remoteRef: + key: BERNARD_GITEA_TOKEN + - secretKey: LITELLM_API_KEY + remoteRef: + key: LITELLM_MASTER_KEY + - secretKey: BERNARD_DB_DSN + remoteRef: + key: BERNARD_DB_DSN diff --git a/agents/k8s/kustomization.yaml b/agents/k8s/kustomization.yaml index 714841c..c1cd68d 100644 --- a/agents/k8s/kustomization.yaml +++ b/agents/k8s/kustomization.yaml @@ -6,3 +6,5 @@ resources: - static-demo.yaml - gumbo-schema-init.yaml - gumbo-worker-deployment.yaml + - bernard-externalsecret.yaml + - bernard-deployment.yaml diff --git a/platform/data/cnpg/databases.yaml b/platform/data/cnpg/databases.yaml index ef5c1dc..d645b9f 100644 --- a/platform/data/cnpg/databases.yaml +++ b/platform/data/cnpg/databases.yaml @@ -52,3 +52,14 @@ spec: owner: agentic_os cluster: name: agentic-os-pg +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: bernard + namespace: platform-data +spec: + name: bernard + owner: agentic_os + cluster: + name: agentic-os-pg