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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,4 @@ scratch/
# Surrogate models
surrogatemodels/
utopia_forest.json
deploy/secrets.yaml
110 changes: 24 additions & 86 deletions .s2i/bin/assemble
Original file line number Diff line number Diff line change
@@ -1,98 +1,36 @@
#!/bin/bash

function is_django_installed() {
python -c "import django" &>/dev/null
}

function should_collectstatic() {
is_django_installed && [[ -z "$DISABLE_COLLECTSTATIC" ]]
}

function virtualenv_bin() {
# New versions of Python (>3.6) should use venv module
# from stdlib instead of virtualenv package
python3.12 -m venv $1
}

# Install pipenv or micropipenv to the separate virtualenv to isolate it
# from system Python packages and packages in the main
# virtualenv. Executable is simlinked into ~/.local/bin
# to be accessible. This approach is inspired by pipsi
# (pip script installer).
function install_tool() {
echo "---> Installing $1 packaging tool ..."
VENV_DIR=$HOME/.local/venvs/$1
virtualenv_bin "$VENV_DIR"
# First, try to install the tool without --isolated which means that if you
# have your own PyPI mirror, it will take it from there. If this try fails, try it
# again with --isolated which ignores external pip settings (env vars, config file)
# and installs the tool from PyPI (needs internet connetion).
# $1$2 combines package name with [extras] or version specifier if is defined as $2```
if ! $VENV_DIR/bin/pip install -U $1$2; then
echo "WARNING: Installation of $1 failed, trying again from official PyPI with pip --isolated install"
$VENV_DIR/bin/pip install --isolated -U $1$2 # Combines package name with [extras] or version specifier if is defined as $2```
fi
mkdir -p $HOME/.local/bin
ln -s $VENV_DIR/bin/$1 $HOME/.local/bin/$1
}

# S2I assemble script for DESDEO API (FastAPI / gunicorn + uvicorn).
#
# Key differences from the original script:
# - Uses uv instead of pip for dependency installation.
# - UV_PROJECT_ENVIRONMENT points uv at the S2I-managed virtualenv
# (/opt/app-root) so it does NOT create a separate .venv directory.
# - --frozen → reproduces exactly what is pinned in uv.lock.
# - --no-dev → skips dev-only deps (pytest, ruff, etc.).
# - --group web --group server → pulls in FastAPI, gunicorn, uvicorn, etc.
# - Django collectstatic block removed (not applicable here).
#
set -e

# First of all, check that we don't have disallowed combination of ENVs
if [[ ! -z "$ENABLE_PIPENV" && ! -z "$ENABLE_MICROPIPENV" ]]; then
echo "ERROR: Pipenv and micropipenv cannot be enabled at the same time!"
# podman/buildah does not relay this exit code but it will be fixed hopefuly
# https://github.com/containers/buildah/issues/2305
exit 3
fi

shopt -s dotglob

echo "---> Installing application source ..."
mv /tmp/src/* "$HOME"

# set permissions for any installed artifacts
# Restore permissions after source injection.
fix-permissions /opt/app-root -P

echo "---> Installing uv ..."
pip install -q --upgrade pip
pip install -q uv

if [[ ! -z "$UPGRADE_PIP_TO_LATEST" ]]; then
echo "---> Upgrading pip, setuptools and wheel to latest version ..."
if ! pip install -U pip setuptools wheel; then
echo "WARNING: Installation of the latest pip, setuptools and wheel failed, trying again from official PyPI with pip --isolated install"
pip install --isolated -U pip setuptools wheel
fi
fi

pip install $DESDEO_INSTALL


if should_collectstatic; then
(
echo "---> Collecting Django static files ..."

APP_HOME=$(readlink -f "${APP_HOME:-.}")
# Change the working directory to APP_HOME
PYTHONPATH="$(pwd)${PYTHONPATH:+:$PYTHONPATH}"
cd "$APP_HOME"

# Look for 'manage.py' in the current directory
manage_file=./manage.py

if [[ ! -f "$manage_file" ]]; then
echo "WARNING: seems that you're using Django, but we could not find a 'manage.py' file."
echo "'manage.py collectstatic' ignored."
exit
fi

if ! python $manage_file collectstatic --dry-run --noinput &> /dev/null; then
echo "WARNING: could not run 'manage.py collectstatic'. To debug, run:"
echo " $ python $manage_file collectstatic --noinput"
echo "Ignore this warning if you're not serving static files with Django."
exit
fi
echo "---> Syncing Python dependencies via uv ..."
# UV_PROJECT_ENVIRONMENT: use the existing S2I venv instead of creating .venv.
# UV_PYTHON_PREFERENCE: do not let uv download its own Python interpreter.
UV_PROJECT_ENVIRONMENT="${VIRTUAL_ENV:-/opt/app-root}" \
UV_PYTHON_PREFERENCE=only-system \
uv sync --frozen --no-dev --group web --group server

python $manage_file collectstatic --noinput
)
fi
echo "---> Dependencies installed."

# set permissions for any installed artifacts
# Restore permissions for any artifacts written during install.
fix-permissions /opt/app-root -P
24 changes: 23 additions & 1 deletion .s2i/environment
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# S2I environment variables for the DESDEO API build and runtime.
# These are read both during the S2I *build* (assemble) and at *runtime*.
#
# UPGRADE_PIP_TO_LATEST is no longer needed because the assemble script
# upgrades pip explicitly before installing uv. Kept here as a no-op for
# compatibility with any base-image hooks that check for it.
UPGRADE_PIP_TO_LATEST=1

# Entry point: Gunicorn loads the FastAPI app from this module path.
APP_MODULE=desdeo.api.app:app

# Gunicorn flags.
# --workers=1 Single worker is safe for the current single-pod setup.
# Increase to 2-4 if the pod gets >1 CPU allocated.
# --worker-class uvicorn.workers.UvicornWorker gives async support.
# --bind Must be 0.0.0.0:8080 to match the Service targetPort.
# --access-logfile - Log to stdout so OpenShift can capture it.
GUNICORN_CMD_ARGS=--bind=0.0.0.0:8080 --workers=1 --access-logfile=- --worker-class uvicorn.workers.UvicornWorker
DESDEO_INSTALL=. --group web --group server

# Passed to `uv sync` in the assemble script.
# Format: flags passed after the implicit project root (.).
# --group web --group server: include the FastAPI/gunicorn/uvicorn dependency groups.
# NOTE: This variable is used in assemble, not by pip directly.
DESDEO_INSTALL=--group web --group server

# Runtime default; override via Deployment env or Secret.
DEBUG=false
75 changes: 75 additions & 0 deletions deploy/api-buildconfig.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# deploy/api-buildconfig.yaml
#
# BuildConfig for the DESDEO API using OpenShift S2I strategy.
#
# Builder image: desdeo-builder ImageStream (custom image built from
# desdeo-s2i-buildimage.Dockerfile). This includes Python 3.12 on UBI8
# plus COIN-OR solvers (bonmin, ipopt, cbc) and ca-certificates.
#
# To use the base Python image without solvers instead,
# replace the sourceStrategy.from block with:
# from:
# kind: ImageStreamTag
# name: python:3.12-ubi9
# namespace: openshift
#
# Triggers:
# - ImageChange on desdeo-builder -> rebuilds API when builder is updated
# - GitHub webhook (push to DEPLOY_BRANCH) <- main CI/CD trigger
# - ConfigChange
---
apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
name: desdeo-api
labels:
app: desdeo-api
spec:
source:
type: Git
git:
# Github repo and deploy branch
uri: https://github.com/gialmisi/DESDEO.git
ref: rahti-deploy

strategy:
type: Source
sourceStrategy:
# Reference the custom builder ImageStream produced by builder-buildconfig.yaml.
# This image has Python 3.12 + COIN-OR solvers pre-installed.
from:
kind: ImageStreamTag
name: desdeo-builder:latest
env:
- name: UPGRADE_PIP_TO_LATEST
value: "1"
- name: DESDEO_INSTALL
value: "--group web --group server"
- name: DEBUG
value: "false"

output:
to:
kind: ImageStreamTag
name: desdeo-api:latest

triggers:
# Rebuild API when the builder image is updated (solver or base OS updates).
- type: ImageChange
imageChange: {}
# GitHub webhook, push to DEPLOY_BRANCH triggers a new API build.
- type: GitHub
github:
secretReference:
name: desdeo-webhook-api
- type: ConfigChange

runPolicy: Serial

resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1"
146 changes: 146 additions & 0 deletions deploy/api-deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# deploy/api-deployment.yaml
#
# Deployment, Service, and Route for the DESDEO FastAPI backend.
#
# The Deployment has an ImageChange trigger: whenever the BuildConfig pushes a
# new image to the desdeo-api ImageStream, the pod is replaced with a rolling
# update automatically.
#
# All secrets are consumed as environment variables from the desdeo-secrets
# Secret; no secret values live in this file.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: desdeo-api
labels:
app: desdeo-api
annotations:
# This annotation tells the OpenShift image change controller to update the
# Deployment when a new image is pushed to the ImageStream.
image.openshift.io/triggers: >
[{"from":{"kind":"ImageStreamTag","name":"desdeo-api:latest"},
"fieldPath":"spec.template.spec.containers[0].image"}]
spec:
replicas: 1
selector:
matchLabels:
app: desdeo-api
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: desdeo-api
spec:
containers:
- name: api
# Placeholder: the image change annotation above overwrites this on deploy.
image: desdeo-api:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
protocol: TCP
env:
- name: DESDEO_PRODUCTION
value: "true"
- name: AUTHJWT_SECRET
valueFrom:
secretKeyRef:
name: desdeo-secrets
key: AUTHJWT_SECRET
- name: DB_HOST
valueFrom:
secretKeyRef:
name: desdeo-secrets
key: DB_HOST
- name: DB_PORT
valueFrom:
secretKeyRef:
name: desdeo-secrets
key: DB_PORT
- name: DB_NAME
valueFrom:
secretKeyRef:
name: desdeo-secrets
key: DB_NAME
- name: DB_USER
valueFrom:
secretKeyRef:
name: desdeo-secrets
key: DB_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: desdeo-secrets
key: DB_PASSWORD
# CORS: allow requests from the webui Route.
# Update this if the webui hostname changes.
- name: CORS_ORIGINS
value: '["https://gialmisi-desdeo-webui.rahtiapp.fi"]'
# COOKIE_DOMAIN is intentionally not set.
# With the SvelteKit proxy architecture, cookies are owned by the
# webui host and forwarded server-side. Setting a shared domain here
# is unnecessary and can cause authentication issues.
- name: APP_MODULE
value: "desdeo.api.app:app"
- name: GUNICORN_CMD_ARGS
value: "--bind=0.0.0.0:8080 --workers=1 --access-logfile=- --worker-class uvicorn.workers.UvicornWorker"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 20
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: desdeo-api
labels:
app: desdeo-api
spec:
selector:
app: desdeo-api
ports:
- name: http
port: 8080
targetPort: 8080
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: desdeo-api
labels:
app: desdeo-api
spec:
host: gialmisi-desdeo-api.rahtiapp.fi
to:
kind: Service
name: desdeo-api
port:
targetPort: http
tls:
# edge: TLS terminated at the Rahti HAProxy; traffic to the pod is plain HTTP.
termination: edge
# Redirect any accidental HTTP requests to HTTPS.
insecureEdgeTerminationPolicy: Redirect
18 changes: 18 additions & 0 deletions deploy/api-imagestream.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# deploy/api-imagestream.yaml
#
# ImageStream for the DESDEO API container image.
# The BuildConfig writes new image revisions here; the Deployment reads from here.
# Separating build output from deployment allows rolling updates to trigger
# automatically when a new image is pushed.
---
apiVersion: image.openshift.io/v1
kind: ImageStream
metadata:
name: desdeo-api
labels:
app: desdeo-api
spec:
lookupPolicy:
# Allow Deployments in this namespace to reference the image by its
# ImageStreamTag name without needing the full registry URL.
local: true
Loading
Loading