Skip to content
Draft
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
32 changes: 32 additions & 0 deletions cloud-tracker/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ─────────────────────────────────────────────
# Cloud Tracker – Environment Configuration
# Copy this file to .env and fill in the values
# ─────────────────────────────────────────────

# Database
DB_PASSWORD=change_me_strong_password

# Flask secret key (generate with: python3 -c "import secrets; print(secrets.token_hex(32))")
SECRET_KEY=change_me_flask_secret

# ── IRIS IAM / OIDC ─────────────────────────
# Register your application at https://iris-iam.stfc.ac.uk/
# Redirect URI to register: ${APP_BASE_URL}/auth/callback
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_DISCOVERY_URL=https://iris-iam.stfc.ac.uk/.well-known/openid-configuration

# Public base URL of this application (no trailing slash)
APP_BASE_URL=https://cloud-tracker.example.stfc.ac.uk

# ── Site settings ────────────────────────────
SITE_TITLE=STFC Cloud Tracker

# Comma-separated email addresses that are granted admin rights
ADMIN_EMAILS=admin@stfc.ac.uk,another.admin@stfc.ac.uk

# ── Backup settings ──────────────────────────
# Cron expression for db-backup service (default: 02:00 daily)
BACKUP_SCHEDULE=0 2 * * *
# Number of days to keep backups
BACKUP_RETAIN_DAYS=7
37 changes: 37 additions & 0 deletions cloud-tracker/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Environment secrets
.env
*.env
.env.*
!.env.example

# SSL / certs
ssl/*.key
ssl/*.crt
ssl/*.pem
ssl/*.p12
ssl/*.csr
ssl/*.der

# Python artifacts
__pycache__/
*.pyc
*.pyo
*.pyd

# Editor / IDE
.vscode/
.idea/
*.swp
*~

# OS artifacts
.DS_Store
Thumbs.db

# Local runtime/log files
*.log
*.tmp
*.bak

# Claude Code
.claude/
46 changes: 46 additions & 0 deletions cloud-tracker/app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
FROM ubuntu:22.04

LABEL maintainer="STFC Scientific Computing"
LABEL description="STFC Cloud Tracker – Apache + mod_wsgi + Flask"

ENV DEBIAN_FRONTEND=noninteractive
ENV APACHE_RUN_USER=www-data
ENV APACHE_RUN_GROUP=www-data
ENV APACHE_LOG_DIR=/var/log/apache2

# ── System packages ──────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
apache2 \
libapache2-mod-wsgi-py3 \
python3 \
python3-pip \
python3-dev \
libpq-dev \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*

# ── Enable Apache modules ────────────────────────────────
RUN a2enmod wsgi headers expires

# ── Python dependencies ──────────────────────────────────
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt

# ── Application code ─────────────────────────────────────
WORKDIR /var/www/app
COPY . /var/www/app/
RUN chown -R www-data:www-data /var/www/app

# ── Apache configuration ─────────────────────────────────
COPY apache-app.conf /etc/apache2/sites-available/cloudtracker.conf
RUN a2dissite 000-default.conf && a2ensite cloudtracker.conf

# ── Entrypoint ────────────────────────────────────────────
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

EXPOSE 80

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["apache2ctl", "-D", "FOREGROUND"]
55 changes: 55 additions & 0 deletions cloud-tracker/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from authlib.integrations.flask_client import OAuth

db = SQLAlchemy()
oauth = OAuth()


def create_app():
app = Flask(__name__)

app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL']
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SITE_TITLE'] = os.environ.get('SITE_TITLE', 'STFC Cloud Tracker')
app.config['APP_BASE_URL'] = os.environ.get('APP_BASE_URL', '')
admin_raw = os.environ.get('ADMIN_EMAILS', '')
app.config['ADMIN_EMAILS'] = [e.strip() for e in admin_raw.split(',') if e.strip()]

db.init_app(app)
oauth.init_app(app)

oauth.register(
name='iris_iam',
client_id=os.environ['OIDC_CLIENT_ID'],
client_secret=os.environ['OIDC_CLIENT_SECRET'],
server_metadata_url=os.environ['OIDC_DISCOVERY_URL'],
client_kwargs={
'scope': 'openid email profile',
},
)

from .auth import auth_bp, current_user, is_admin
from .routes import main_bp
from .metrics import metrics_bp
from .quota import quota_bp

app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(quota_bp)

@app.context_processor
def inject_globals():
return {
'site_title': app.config.get('SITE_TITLE', 'STFC Cloud Tracker'),
'current_user': current_user(),
'is_admin': is_admin(),
}

with app.app_context():
db.create_all()

return app
47 changes: 47 additions & 0 deletions cloud-tracker/app/apache-app.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ── Main site (served over plain HTTP; host Apache is the public TLS edge) ──
<VirtualHost *:80>
ServerName _default_

# Security headers (HTTP-appropriate; no HSTS since this vhost isn't TLS)
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';"

# WSGI application
WSGIDaemonProcess cloudtracker \
python-path=/var/www \
user=www-data \
group=www-data \
processes=2 \
threads=5 \
display-name=%{GROUP}

WSGIProcessGroup cloudtracker
WSGIApplicationGroup %{GLOBAL}
WSGIScriptAlias / /var/www/app/wsgi.py
WSGIPassAuthorization On

<Directory /var/www/app>
<Files wsgi.py>
Require all granted
</Files>
</Directory>

# Static files served directly by Apache
Alias /static /var/www/app/static
<Directory /var/www/app/static>
Require all granted
Options -Indexes
ExpiresActive On
ExpiresDefault "access plus 1 week"
</Directory>

# Restrict Prometheus metrics endpoint to internal network only
<Location /metrics>
Require ip 127.0.0.1 ::1 172.16.0.0/12 10.0.0.0/8
</Location>

ErrorLog ${APACHE_LOG_DIR}/cloudtracker_error.log
CustomLog ${APACHE_LOG_DIR}/cloudtracker_access.log combined
</VirtualHost>
63 changes: 63 additions & 0 deletions cloud-tracker/app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from flask import (
Blueprint, redirect, url_for, session,
request, current_app,
)
from functools import wraps
from . import oauth

auth_bp = Blueprint('auth', __name__, url_prefix='/auth')


def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if 'user' not in session:
session['next'] = request.url
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated


def current_user():
return session.get('user')


def is_admin():
user = current_user()
if not user:
return False
return user.get('email', '') in current_app.config.get('ADMIN_EMAILS', [])


@auth_bp.route('/login')
def login():
redirect_uri = current_app.config['APP_BASE_URL'] + url_for('auth.callback')
return oauth.iris_iam.authorize_redirect(redirect_uri)


@auth_bp.route('/callback')
def callback():
token = oauth.iris_iam.authorize_access_token()
userinfo = token.get('userinfo') or oauth.iris_iam.userinfo()
session['user'] = {
'sub': userinfo.get('sub'),
'name': userinfo.get('name', userinfo.get('preferred_username', 'Unknown')),
'email': userinfo.get('email', ''),
}
next_url = session.pop('next', None)
return redirect(next_url or url_for('main.index'))


@auth_bp.route('/logout')
def logout():
session.clear()
# Redirect to OIDC end_session_endpoint if available
try:
meta = oauth.iris_iam.load_server_metadata()
end_session = meta.get('end_session_endpoint')
if end_session:
post_logout = current_app.config['APP_BASE_URL'] + url_for('main.index')
return redirect(f"{end_session}?post_logout_redirect_uri={post_logout}")
except Exception:
pass
return redirect(url_for('main.index'))
19 changes: 19 additions & 0 deletions cloud-tracker/app/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash
set -e

# Wait for database to be ready
echo "Waiting for database..."
until python3 -c "
import os, sys, psycopg2
try:
psycopg2.connect(os.environ['DATABASE_URL'])
print('Database ready.')
except Exception as e:
print(f'Not ready: {e}')
sys.exit(1)
" 2>/dev/null; do
sleep 2
done

echo "Starting Apache..."
exec "$@"
55 changes: 55 additions & 0 deletions cloud-tracker/app/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from flask import Blueprint, Response
from prometheus_client import (
generate_latest, CONTENT_TYPE_LATEST,
Gauge, REGISTRY,
)

metrics_bp = Blueprint('metrics', __name__)

# Custom application metrics
tracked_projects_total = Gauge(
'cloudtracker_tracked_projects_total',
'Total number of projects with a recorded quota snapshot',
)
quota_snapshots_total = Gauge(
'cloudtracker_quota_snapshots_total',
'Total number of quota snapshots recorded',
)
funding_parties_total = Gauge(
'cloudtracker_funding_parties_total',
'Total number of funding parties defined',
)
project_quota = Gauge(
'cloudtracker_project_quota',
'Latest recorded quota value per project and field',
['project', 'field'],
)


@metrics_bp.route('/metrics')
def prometheus_metrics():
"""Prometheus scrape endpoint. Restrict to internal network via Apache config."""
_refresh_metrics()
return Response(generate_latest(REGISTRY), mimetype=CONTENT_TYPE_LATEST)


def _refresh_metrics():
from . import db
from .models import QuotaSnapshot, FundingParty, QUOTA_FIELD_KEYS
try:
quota_snapshots_total.set(QuotaSnapshot.query.count())
funding_parties_total.set(FundingParty.query.count())

project_names = [r[0] for r in db.session.query(QuotaSnapshot.project_name).distinct()]
tracked_projects_total.set(len(project_names))
for project_name in project_names:
latest = (QuotaSnapshot.query
.filter_by(project_name=project_name)
.order_by(QuotaSnapshot.recorded_at.desc())
.first())
for key in QUOTA_FIELD_KEYS:
val = getattr(latest, key)
if val is not None:
project_quota.labels(project=project_name, field=key).set(val)
except Exception:
pass
Loading