Skip to content
Open
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
65 changes: 65 additions & 0 deletions .github/workflows/server-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Server Docker

on:
push:
branches:
- main
paths:
- "server/**"
- ".github/workflows/server-docker.yml"
pull_request:
paths:
- "server/**"
- ".github/workflows/server-docker.yml"
workflow_dispatch:

concurrency:
group: server-docker-${{ github.ref }}
cancel-in-progress: true

env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/webfl-server

jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=sha-

- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and optionally push server image
uses: docker/build-push-action@v6
with:
context: ./server
file: ./server/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,15 @@ This project is a web application built with React and a Python backend. The pro
5. **Start the Python server**:
```sh
flask run
```
```

### Backend Docker Runtime
Build the server image from the repository root:
```sh
docker build -t webfl-server ./server
```

Run the server container locally:
```sh
docker run --rm -p 5000:5000 webfl-server
```
25 changes: 25 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM python:3.10-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
WEBFL_SERVER_HOST=0.0.0.0 \
WEBFL_SERVER_PORT=5000 \
WEBFL_DEBUG=false

WORKDIR /app

RUN apt-get update \
&& apt-get install --no-install-recommends -y libgomp1 \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .

RUN pip install --upgrade pip \
&& pip install -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]
149 changes: 149 additions & 0 deletions server/tests/test_pipeline_correctness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import os
import re
import yaml
import pytest

REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
DOCKERFILE_PATH = os.path.join(REPO_ROOT, "server", "Dockerfile")
APP_PY_PATH = os.path.join(REPO_ROOT, "server", "app.py")
WORKFLOW_PATH = os.path.join(REPO_ROOT, ".github", "workflows", "server-docker.yml")


@pytest.fixture(scope="module")
def dockerfile_lines():
with open(DOCKERFILE_PATH) as f:
return f.read().splitlines()


@pytest.fixture(scope="module")
def app_py_source():
with open(APP_PY_PATH) as f:
return f.read()


@pytest.fixture(scope="module")
def workflow():
with open(WORKFLOW_PATH) as f:
data = yaml.safe_load(f)
# pyyaml treats bare `on:` as a boolean True key, fix that
if True in data and "on" not in data:
data["on"] = data.pop(True)
return data


@pytest.fixture(scope="module")
def workflow_raw():
with open(WORKFLOW_PATH) as f:
return f.read()


# --- Dockerfile ---

def test_dockerfile_base_image(dockerfile_lines):
# want slim to keep the image size down
assert any(line.strip() == "FROM python:3.10-slim" for line in dockerfile_lines)


def test_dockerfile_installs_dependencies(dockerfile_lines):
has_copy_req = any(
re.match(r"^COPY\s+requirements\.txt", line.strip())
for line in dockerfile_lines
)
has_pip_install = any("pip install -r requirements.txt" in line for line in dockerfile_lines)
assert has_copy_req
assert has_pip_install


def test_dockerfile_workdir_and_copy(dockerfile_lines):
assert any(line.strip() == "WORKDIR /app" for line in dockerfile_lines)
assert any(re.match(r"^COPY\s+\.\s+\.", line.strip()) for line in dockerfile_lines)


def test_dockerfile_expose_and_cmd(dockerfile_lines):
assert any(line.strip() == "EXPOSE 5000" for line in dockerfile_lines)
assert any(
re.match(r'^CMD\s+\["python",\s*"app\.py"\]', line.strip())
for line in dockerfile_lines
)


# --- app.py env config ---

def test_app_reads_env_vars(app_py_source):
# server should pick these up at runtime, not hardcode them
for var in ("WEBFL_SERVER_HOST", "WEBFL_SERVER_PORT", "WEBFL_DEBUG"):
assert f'"{var}"' in app_py_source or f"'{var}'" in app_py_source


# --- workflow triggers ---

def test_workflow_path_filters(workflow):
# only rebuild when server code or the workflow itself changes
expected_paths = {"server/**", ".github/workflows/server-docker.yml"}
for trigger in ("push", "pull_request"):
paths = set(workflow["on"][trigger].get("paths", []))
assert expected_paths.issubset(paths)


def test_workflow_manual_dispatch(workflow):
# handy for forcing a rebuild without a code change
assert "workflow_dispatch" in workflow["on"]


# --- build steps ---

def test_workflow_buildx_step(workflow):
steps = workflow["jobs"]["docker"]["steps"]
assert any("docker/setup-buildx-action" in str(step.get("uses", "")) for step in steps)


def test_workflow_build_cache(workflow):
# GHA cache cuts rebuild time significantly on unchanged layers
steps = workflow["jobs"]["docker"]["steps"]
build_step = next(
(s for s in steps if "docker/build-push-action" in str(s.get("uses", ""))), None
)
assert build_step is not None
with_block = build_step.get("with", {})
assert with_block.get("cache-from") == "type=gha"
assert with_block.get("cache-to") == "type=gha,mode=max"


def test_workflow_build_context_and_file(workflow):
steps = workflow["jobs"]["docker"]["steps"]
build_step = next(
(s for s in steps if "docker/build-push-action" in str(s.get("uses", ""))), None
)
assert build_step is not None
with_block = build_step.get("with", {})
assert with_block.get("context") == "./server"
assert with_block.get("file") == "./server/Dockerfile"


def test_workflow_push_condition(workflow):
# build on PRs for validation, but only push to GHCR on actual merges
steps = workflow["jobs"]["docker"]["steps"]
build_step = next(
(s for s in steps if "docker/build-push-action" in str(s.get("uses", ""))), None
)
assert build_step is not None
assert build_step.get("with", {}).get("push") == "${{ github.event_name != 'pull_request' }}"


# --- image tagging ---

def test_workflow_image_tagging(workflow_raw):
# latest on main, branch name otherwise, pr ref for PRs, sha for traceability
assert "type=raw,value=latest,enable={{is_default_branch}}" in workflow_raw
assert "type=ref,event=branch" in workflow_raw
assert "type=ref,event=pr" in workflow_raw
assert "type=sha,prefix=sha-" in workflow_raw


# --- concurrency ---

def test_workflow_concurrency(workflow):
# cancel stale runs so a fast follow-up push doesn't get blocked
concurrency = workflow.get("concurrency", {})
assert concurrency.get("cancel-in-progress") is True
assert "github.ref" in concurrency.get("group", "")