Skip to content

Commit a9d1bf5

Browse files
committed
feat: improvements, monitoring dashboard, sidebar nav, Docker & CI/CD
Signed-off-by: Damian Skrzyński <polprog.tech@gmail.com>
1 parent 132b042 commit a9d1bf5

41 files changed

Lines changed: 5624 additions & 392 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Ignore build artifacts, caches, local config
2+
__pycache__
3+
*.pyc
4+
*.pyo
5+
.pytest_cache
6+
.ruff_cache
7+
.mypy_cache
8+
*.egg-info
9+
dist/
10+
build/
11+
work/
12+
artifacts/
13+
.git
14+
.github
15+
.venv
16+
venv
17+
node_modules
18+
*.md
19+
!README.md

Dockerfile

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# ── Build stage ──────────────────────────────────────────────
2+
FROM python:3.13-slim AS builder
3+
4+
WORKDIR /build
5+
6+
COPY pyproject.toml README.md ./
7+
COPY src/ src/
8+
9+
RUN pip install --no-cache-dir build \
10+
&& python -m build --wheel --outdir /build/dist
11+
12+
# ── Runtime stage ────────────────────────────────────────────
13+
FROM python:3.13-slim
14+
15+
LABEL maintainer="POLPROG <polprog.tech@gmail.com>"
16+
LABEL org.opencontainers.image.source="https://github.com/polprog-tech/OpsPortal"
17+
LABEL org.opencontainers.image.description="Unified developer operations portal"
18+
19+
# Non-root user for security
20+
RUN groupadd --gid 1000 portal \
21+
&& useradd --uid 1000 --gid portal --create-home portal
22+
23+
WORKDIR /app
24+
25+
# Install the built wheel
26+
COPY --from=builder /build/dist/*.whl /tmp/
27+
RUN pip install --no-cache-dir /tmp/*.whl \
28+
&& rm -rf /tmp/*.whl
29+
30+
# Create data directories
31+
RUN mkdir -p /app/work /app/artifacts \
32+
&& chown -R portal:portal /app
33+
34+
# Copy default manifest if user doesn't mount one
35+
COPY --chown=portal:portal opsportal.yaml /app/opsportal.yaml
36+
37+
USER portal
38+
39+
ENV OPSPORTAL_HOST="0.0.0.0" \
40+
OPSPORTAL_PORT="8080" \
41+
OPSPORTAL_WORK_DIR="/app/work" \
42+
OPSPORTAL_ARTIFACT_DIR="/app/artifacts" \
43+
OPSPORTAL_MANIFEST_PATH="/app/opsportal.yaml"
44+
45+
EXPOSE 8080
46+
47+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
48+
CMD python -c "import httpx; r = httpx.get('http://localhost:8080/api/health'); r.raise_for_status()"
49+
50+
ENTRYPOINT ["opsportal"]
51+
CMD ["serve", "--host", "0.0.0.0", "--port", "8080"]
-31.6 KB
Loading

examples/.gitlab-ci.yml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# =============================================================================
2+
# OpsPortal — GitLab CI/CD Pipeline (example)
3+
# =============================================================================
4+
5+
variables:
6+
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
7+
PYTHON_VERSION: "3.13"
8+
IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
9+
IMAGE_LATEST: "$CI_REGISTRY_IMAGE:latest"
10+
11+
stages:
12+
- lint
13+
- test
14+
- build
15+
- deploy
16+
17+
# ── Cache ────────────────────────────────────────────────────
18+
.python-cache: &python-cache
19+
cache:
20+
key: "$CI_COMMIT_REF_SLUG-pip"
21+
paths:
22+
- .pip-cache/
23+
- .venv/
24+
25+
# ── Lint ─────────────────────────────────────────────────────
26+
lint:
27+
stage: lint
28+
image: python:${PYTHON_VERSION}-slim
29+
<<: *python-cache
30+
before_script:
31+
- python -m venv .venv
32+
- source .venv/bin/activate
33+
- pip install --quiet -e ".[dev]"
34+
script:
35+
- ruff check src/ tests/
36+
- ruff format --check src/ tests/
37+
rules:
38+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
39+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
40+
41+
# ── Tests ────────────────────────────────────────────────────
42+
test:
43+
stage: test
44+
image: python:${PYTHON_VERSION}-slim
45+
<<: *python-cache
46+
before_script:
47+
- python -m venv .venv
48+
- source .venv/bin/activate
49+
- pip install --quiet -e ".[dev]"
50+
script:
51+
- pytest tests/ -v --tb=short --junitxml=report.xml --cov=opsportal --cov-report=xml
52+
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
53+
artifacts:
54+
when: always
55+
reports:
56+
junit: report.xml
57+
coverage_report:
58+
coverage_format: cobertura
59+
path: coverage.xml
60+
rules:
61+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
62+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
63+
64+
# ── Build Docker image ───────────────────────────────────────
65+
build:
66+
stage: build
67+
image: docker:24
68+
services:
69+
- docker:24-dind
70+
variables:
71+
DOCKER_TLS_CERTDIR: "/certs"
72+
before_script:
73+
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
74+
script:
75+
- docker build --tag "$IMAGE_TAG" --tag "$IMAGE_LATEST" .
76+
- docker push "$IMAGE_TAG"
77+
- docker push "$IMAGE_LATEST"
78+
rules:
79+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
80+
81+
# ── Deploy to staging (example — adjust for your infra) ─────
82+
deploy:staging:
83+
stage: deploy
84+
image: alpine:latest
85+
environment:
86+
name: staging
87+
url: https://opsportal-staging.example.com
88+
before_script:
89+
- apk add --no-cache curl
90+
script:
91+
- echo "Deploying $IMAGE_TAG to staging..."
92+
- echo "Deployment complete."
93+
rules:
94+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
95+
when: manual
96+
needs:
97+
- build
98+
- test
99+
100+
# ── Deploy to production (manual gate) ──────────────────────
101+
deploy:production:
102+
stage: deploy
103+
image: alpine:latest
104+
environment:
105+
name: production
106+
url: https://opsportal.example.com
107+
before_script:
108+
- apk add --no-cache curl
109+
script:
110+
- echo "Deploying $IMAGE_TAG to production..."
111+
- echo "Deployment complete."
112+
rules:
113+
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
114+
when: manual
115+
needs:
116+
- build
117+
- test

examples/docker-compose.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# =============================================================================
2+
# OpsPortal — Docker Compose
3+
#
4+
# Usage:
5+
# docker compose up -d # start
6+
# docker compose logs -f # follow logs
7+
# docker compose down # stop
8+
# =============================================================================
9+
10+
services:
11+
opsportal:
12+
build: .
13+
# Or use a pre-built image:
14+
# image: registry.example.com/opsportal:latest
15+
ports:
16+
- "${OPSPORTAL_PORT:-8080}:8080"
17+
volumes:
18+
- ./opsportal.yaml:/app/opsportal.yaml:ro
19+
- opsportal-work:/app/work
20+
- opsportal-artifacts:/app/artifacts
21+
environment:
22+
OPSPORTAL_HOST: "0.0.0.0"
23+
OPSPORTAL_PORT: "8080"
24+
OPSPORTAL_LOG_LEVEL: "info"
25+
# OPSPORTAL_AUTH_USERNAME: admin
26+
# OPSPORTAL_AUTH_PASSWORD: changeme
27+
# OPSPORTAL_WEBHOOK_URLS: "https://hooks.slack.com/services/..."
28+
restart: unless-stopped
29+
healthcheck:
30+
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8080/api/health').raise_for_status()"]
31+
interval: 30s
32+
timeout: 5s
33+
start_period: 10s
34+
retries: 3
35+
36+
volumes:
37+
opsportal-work:
38+
opsportal-artifacts:

src/opsportal/adapters/registry.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ def register(self, adapter: ToolAdapter) -> None:
2222
self._adapters[slug] = adapter
2323
logger.info("Registered adapter: %s (%s)", slug, adapter.integration_mode.value)
2424

25+
def unregister(self, slug: str) -> ToolAdapter | None:
26+
"""Remove and return an adapter from the registry."""
27+
adapter = self._adapters.pop(slug, None)
28+
if adapter:
29+
logger.info("Unregistered adapter: %s", slug)
30+
return adapter
31+
2532
def get(self, slug: str) -> ToolAdapter | None:
2633
return self._adapters.get(slug)
2734

@@ -31,6 +38,9 @@ def all(self) -> list[ToolAdapter]:
3138
def enabled(self) -> list[ToolAdapter]:
3239
return list(self._adapters.values())
3340

41+
def slugs(self) -> list[str]:
42+
return list(self._adapters.keys())
43+
3444
def __len__(self) -> int:
3545
return len(self._adapters)
3646

src/opsportal/app/factory.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from fastapi import FastAPI, Request
88
from fastapi.staticfiles import StaticFiles
99
from starlette.exceptions import HTTPException as StarletteHTTPException
10+
from starlette.responses import JSONResponse
1011
from starlette.templating import Jinja2Templates
1112

1213
from opsportal import __version__
@@ -21,9 +22,18 @@
2122
from opsportal.core.errors import get_logger, setup_logging
2223
from opsportal.core.settings import PortalSettings, get_settings
2324
from opsportal.services.artifact_manager import ArtifactManager
25+
from opsportal.services.audit_log import AuditLog
26+
from opsportal.services.cache import TTLCache
27+
from opsportal.services.config_versioning import ConfigVersionManager
2428
from opsportal.services.log_store import LogStore
29+
from opsportal.services.metrics_collector import MetricsCollector
30+
from opsportal.services.notification_service import NotificationService, WebhookConfig
31+
from opsportal.services.plugin_loader import PluginLoader
2532
from opsportal.services.process_manager import ProcessManager
33+
from opsportal.services.scheduler import Scheduler
34+
from opsportal.services.sla_reporter import SLAReporter
2635
from opsportal.services.tool_installer import ToolInstaller
36+
from opsportal.services.uptime_tracker import UptimeTracker
2737

2838
logger = get_logger("app.factory")
2939

@@ -55,9 +65,54 @@ def create_app(settings: PortalSettings | None = None) -> FastAPI:
5565
app.state.log_store = LogStore(max_entries=settings.log_buffer_size)
5666
app.state.tool_installer = ToolInstaller(settings.tools_work_dir)
5767

68+
# -- New services -------------------------------------------------------
69+
app.state.audit_log = AuditLog(settings.work_dir / "audit.jsonl")
70+
app.state.config_versions = ConfigVersionManager(settings.work_dir / "config_versions")
71+
app.state.metrics_collector = MetricsCollector()
72+
app.state.uptime_tracker = UptimeTracker(settings.uptime_data_dir)
73+
app.state.cache = TTLCache(default_ttl=settings.cache_ttl)
74+
75+
# Notification service
76+
webhooks = []
77+
if settings.webhook_urls:
78+
for url in settings.webhook_urls.split(","):
79+
url = url.strip()
80+
if url:
81+
webhooks.append(WebhookConfig(url=url))
82+
app.state.notification_service = NotificationService(webhooks)
83+
84+
# Scheduler
85+
app.state.scheduler = Scheduler(settings.scheduler_config_path)
86+
87+
# Plugin loader
88+
app.state.plugin_loader = PluginLoader()
89+
app.state.plugin_loader.discover()
90+
91+
# Alert manager
92+
from opsportal.services.alert_manager import AlertManager
93+
94+
app.state.alert_manager = AlertManager(settings.work_dir / "alerts.json")
95+
96+
# Backup service
97+
from opsportal.services.backup_service import BackupService
98+
99+
app.state.backup_service = BackupService(
100+
work_dir=settings.work_dir,
101+
manifest_path=settings.manifest_path,
102+
)
103+
104+
# SLA reporter
105+
app.state.sla_reporter = SLAReporter()
106+
107+
# Auth manager (RBAC)
108+
from opsportal.services.auth_manager import AuthManager
109+
110+
app.state.auth_manager = AuthManager(settings.work_dir / "users.json")
111+
58112
# -- Templates & static -------------------------------------------------
59113
templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
60114
templates.env.globals["portal_version"] = __version__
115+
templates.env.globals["auth_enabled"] = settings.auth_enabled
61116
app.state.templates = templates
62117
if _STATIC_DIR.exists():
63118
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@@ -82,6 +137,11 @@ def create_app(settings: PortalSettings | None = None) -> FastAPI:
82137

83138
@app.exception_handler(StarletteHTTPException)
84139
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
140+
if request.url.path.startswith("/api/"):
141+
return JSONResponse(
142+
{"error": exc.detail},
143+
status_code=exc.status_code,
144+
)
85145
return templates.TemplateResponse(
86146
request,
87147
"error.html",
@@ -92,7 +152,13 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
92152
# -- Middleware ----------------------------------------------------------
93153
# Collect child tool ports so CSP allows framing them
94154
child_ports = [t.port for t in manifest.enabled_tools if t.port]
95-
app.add_middleware(PortalSecurityMiddleware, child_tool_ports=child_ports)
155+
app.add_middleware(
156+
PortalSecurityMiddleware,
157+
child_tool_ports=child_ports,
158+
auth_enabled=settings.auth_enabled,
159+
auth_username=settings.auth_username,
160+
auth_password=settings.auth_password,
161+
)
96162

97163
logger.info(
98164
"OpsPortal ready — %d tool(s) registered",

0 commit comments

Comments
 (0)