diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1bc263e57e..4ba50992c5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -23,16 +23,17 @@ RUN apt-get update && apt-get install -y \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Install the Azure CLI, Microsoft ODBC Driver 18 & SQL tools +# Note: Debian Trixie's sqv rejects SHA1 signatures, so we use gpg directly to import the Microsoft key RUN apt-get update && apt-get install -y \ apt-transport-https \ ca-certificates \ gnupg \ lsb-release \ && curl -sL https://packages.microsoft.com/keys/microsoft.asc \ - | gpg --dearmor \ - > /usr/share/keyrings/microsoft-archive-keyring.gpg \ + | gpg --dearmor \ + > /usr/share/keyrings/microsoft-archive-keyring.gpg \ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" \ - > /etc/apt/sources.list.d/microsoft.list \ + > /etc/apt/sources.list.d/microsoft.list \ && apt-get update \ && ACCEPT_EULA=Y apt-get install -y \ msodbcsql18 \ diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml new file mode 100644 index 0000000000..5010859c86 --- /dev/null +++ b/.github/workflows/docker_build.yml @@ -0,0 +1,319 @@ +# Tests Docker image builds for devcontainer and production + +name: docker_build + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + - "release/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + # Stage 1: Build devcontainer base image + build-devcontainer: + name: Build Devcontainer + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build devcontainer image + uses: docker/build-push-action@v5 + with: + context: .devcontainer + file: .devcontainer/Dockerfile + push: false + tags: pyrit-devcontainer:latest + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Save devcontainer image + run: docker save pyrit-devcontainer:latest | gzip > devcontainer.tar.gz + + - name: Upload devcontainer artifact + uses: actions/upload-artifact@v4 + with: + name: devcontainer-image + path: devcontainer.tar.gz + retention-days: 1 + + # Stage 2: Build production images (parallel) + build-production-local: + name: Build Production (local) + runs-on: ubuntu-latest + needs: build-devcontainer + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Download devcontainer image + uses: actions/download-artifact@v4 + with: + name: devcontainer-image + + - name: Load devcontainer image + run: gunzip -c devcontainer.tar.gz | docker load + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: docker + + - name: Build production image (local) + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile + push: false + tags: pyrit:local-test + load: true + build-args: | + BASE_IMAGE=pyrit-devcontainer:latest + PYRIT_SOURCE=local + + - name: Save production image + run: docker save pyrit:local-test | gzip > local.tar.gz + + - name: Upload production artifact + uses: actions/upload-artifact@v4 + with: + name: production-local-image + path: local.tar.gz + retention-days: 1 + + build-production-pypi: + name: Build Production (PyPI) + runs-on: ubuntu-latest + needs: build-devcontainer + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Get latest PyRIT version from PyPI + id: pypi-version + run: | + VERSION=$(pip index versions pyrit 2>/dev/null | head -1 | grep -oP '\(\K[^)]+' || echo "0.10.0") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Latest PyRIT version on PyPI: $VERSION" + + - name: Download devcontainer image + uses: actions/download-artifact@v4 + with: + name: devcontainer-image + + - name: Load devcontainer image + run: gunzip -c devcontainer.tar.gz | docker load + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: docker + + - name: Build production image (PyPI) + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile + push: false + tags: pyrit:pypi-test + load: true + build-args: | + BASE_IMAGE=pyrit-devcontainer:latest + PYRIT_SOURCE=pypi + PYRIT_VERSION=${{ steps.pypi-version.outputs.version }} + + - name: Save production image + run: docker save pyrit:pypi-test | gzip > pypi.tar.gz + + - name: Upload production artifact + uses: actions/upload-artifact@v4 + with: + name: production-pypi-image + path: pypi.tar.gz + retention-days: 1 + + # Stage 3: Test production images (parallel) + test-local-import: + name: Test Import (local) + runs-on: ubuntu-latest + needs: build-production-local + steps: + - name: Download production image + uses: actions/download-artifact@v4 + with: + name: production-local-image + + - name: Load production image + run: gunzip -c local.tar.gz | docker load + + - name: Test PyRIT import + run: | + docker run --rm --entrypoint /opt/venv/bin/python pyrit:local-test -c "import pyrit; print(f'PyRIT version: {pyrit.__version__}')" + + test-local-gui: + name: Test GUI (local) + runs-on: ubuntu-latest + needs: build-production-local + steps: + - name: Download production image + uses: actions/download-artifact@v4 + with: + name: production-local-image + + - name: Load production image + run: gunzip -c local.tar.gz | docker load + + - name: Test GUI mode + run: | + docker run -d --name pyrit-gui-test -e PYRIT_MODE=gui -p 8000:8000 pyrit:local-test + + echo "Waiting for GUI to start..." + sleep 15 + + if ! docker ps | grep -q pyrit-gui-test; then + echo "Container not running! Logs:" + docker logs pyrit-gui-test + exit 1 + fi + + echo "Testing API health endpoint..." + curl -sf http://localhost:8000/api/health || (echo "Health endpoint failed" && docker logs pyrit-gui-test && exit 1) + + echo "Testing frontend is served..." + RESPONSE=$(curl -s http://localhost:8000/) + echo "$RESPONSE" | head -5 + echo "$RESPONSE" | grep -iq '' || (echo "Frontend not served" && docker logs pyrit-gui-test && exit 1) + + echo "✅ GUI mode tests passed" + docker stop pyrit-gui-test && docker rm pyrit-gui-test + + test-local-jupyter: + name: Test Jupyter (local) + runs-on: ubuntu-latest + needs: build-production-local + steps: + - name: Download production image + uses: actions/download-artifact@v4 + with: + name: production-local-image + + - name: Load production image + run: gunzip -c local.tar.gz | docker load + + - name: Test Jupyter mode + run: | + docker run -d --name pyrit-jupyter-test -e PYRIT_MODE=jupyter -p 8888:8888 pyrit:local-test + + echo "Waiting for Jupyter to start..." + sleep 20 + + if ! docker ps | grep -q pyrit-jupyter-test; then + echo "Container not running! Logs:" + docker logs pyrit-jupyter-test + exit 1 + fi + + echo "Testing Jupyter responds..." + curl -sf http://localhost:8888/api || (echo "Jupyter API failed" && docker logs pyrit-jupyter-test && exit 1) + + echo "✅ Jupyter mode tests passed" + docker stop pyrit-jupyter-test && docker rm pyrit-jupyter-test + + test-pypi-import: + name: Test Import (PyPI) + runs-on: ubuntu-latest + needs: build-production-pypi + steps: + - name: Download production image + uses: actions/download-artifact@v4 + with: + name: production-pypi-image + + - name: Load production image + run: gunzip -c pypi.tar.gz | docker load + + - name: Test PyRIT import + run: | + docker run --rm --entrypoint /opt/venv/bin/python pyrit:pypi-test -c "import pyrit; print(f'PyRIT version: {pyrit.__version__}')" + + test-pypi-gui: + name: Test GUI (PyPI) + runs-on: ubuntu-latest + needs: build-production-pypi + steps: + - name: Download production image + uses: actions/download-artifact@v4 + with: + name: production-pypi-image + + - name: Load production image + run: gunzip -c pypi.tar.gz | docker load + + - name: Test GUI mode + run: | + docker run -d --name pyrit-gui-pypi -e PYRIT_MODE=gui -p 8000:8000 pyrit:pypi-test + + echo "Waiting for GUI to start..." + sleep 15 + + if ! docker ps | grep -q pyrit-gui-pypi; then + echo "Container not running! Logs:" + docker logs pyrit-gui-pypi + exit 1 + fi + + curl -sf http://localhost:8000/api/health || (echo "Health endpoint failed" && docker logs pyrit-gui-pypi && exit 1) + + RESPONSE=$(curl -s http://localhost:8000/) + echo "$RESPONSE" | head -5 + echo "$RESPONSE" | grep -iq '' || (echo "Frontend not served" && docker logs pyrit-gui-pypi && exit 1) + + echo "✅ GUI mode tests passed (PyPI)" + docker stop pyrit-gui-pypi && docker rm pyrit-gui-pypi + + test-pypi-jupyter: + name: Test Jupyter (PyPI) + runs-on: ubuntu-latest + needs: build-production-pypi + steps: + - name: Download production image + uses: actions/download-artifact@v4 + with: + name: production-pypi-image + + - name: Load production image + run: gunzip -c pypi.tar.gz | docker load + + - name: Test Jupyter mode + run: | + docker run -d --name pyrit-jupyter-pypi -e PYRIT_MODE=jupyter -p 8888:8888 pyrit:pypi-test + + echo "Waiting for Jupyter to start..." + sleep 20 + + if ! docker ps | grep -q pyrit-jupyter-pypi; then + echo "Container not running! Logs:" + docker logs pyrit-jupyter-pypi + exit 1 + fi + + curl -sf http://localhost:8888/api || (echo "Jupyter API failed" && docker logs pyrit-jupyter-pypi && exit 1) + + echo "✅ Jupyter mode tests passed (PyPI)" + docker stop pyrit-jupyter-pypi && docker rm pyrit-jupyter-pypi diff --git a/build_scripts/prepare_package.py b/build_scripts/prepare_package.py new file mode 100644 index 0000000000..1ed307d5c0 --- /dev/null +++ b/build_scripts/prepare_package.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Script to prepare the PyRIT package for distribution. +This builds the TypeScript/React frontend and copies artifacts into the Python package structure. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + + +def build_frontend(frontend_dir: Path) -> bool: + """ + Build the TypeScript/React frontend using npm. + + Args: + frontend_dir: Path to the frontend directory + + Returns: + True if successful, False otherwise + """ + print("=" * 60) + print("Building TypeScript/React frontend...") + print("=" * 60) + + # Check if npm is available + try: + result = subprocess.run(["npm", "--version"], capture_output=True, text=True, check=True) + print(f"Found npm version: {result.stdout.strip()}") + except (subprocess.CalledProcessError, FileNotFoundError): + print("ERROR: npm is not installed or not in PATH") + print("Please install Node.js 20.x and npm from https://nodejs.org/") + return False + + # Check if package.json exists + package_json = frontend_dir / "package.json" + if not package_json.exists(): + print(f"ERROR: package.json not found at {package_json}") + return False + + # Install dependencies + print("\nInstalling frontend dependencies...") + try: + subprocess.run( + ["npm", "install"], + cwd=frontend_dir, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print("✓ Dependencies installed") + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to install dependencies:\n{e.stdout}") + return False + + # Build the frontend + print("\nBuilding frontend for production...") + try: + subprocess.run( + ["npm", "run", "build"], + cwd=frontend_dir, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print("✓ Frontend built successfully") + return True + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to build frontend:\n{e.stdout}") + return False + + +def copy_frontend_to_package(frontend_dist: Path, backend_frontend: Path) -> bool: + """ + Copy frontend dist to pyrit/backend/frontend for packaging. + + Args: + frontend_dist: Path to frontend/dist + backend_frontend: Path to pyrit/backend/frontend + + Returns: + True if successful, False otherwise + """ + print("\n" + "=" * 60) + print("Copying frontend to Python package...") + print("=" * 60) + + # Check if frontend dist exists + if not frontend_dist.exists(): + print(f"ERROR: Frontend dist directory not found at {frontend_dist}") + return False + + # Remove existing backend/frontend if it exists + if backend_frontend.exists(): + print(f"Removing existing {backend_frontend}") + shutil.rmtree(backend_frontend) + + # Copy frontend dist to backend/frontend + print(f"Copying {frontend_dist} to {backend_frontend}") + shutil.copytree(frontend_dist, backend_frontend) + + # Verify files were copied + index_html = backend_frontend / "index.html" + if index_html.exists(): + print("✓ Frontend successfully copied to package") + return True + else: + print("ERROR: index.html not found after copy") + return False + + +def main(): + """Build frontend and prepare package for distribution.""" + # Define paths + root = Path(__file__).parent.parent + frontend_dir = root / "frontend" + frontend_dist = frontend_dir / "dist" + backend_frontend = root / "pyrit" / "backend" / "frontend" + + print("PyRIT Package Preparation") + print("=" * 60) + print(f"Root directory: {root}") + print(f"Frontend directory: {frontend_dir}") + print(f"Target directory: {backend_frontend}") + print() + + # Check if frontend directory exists + if not frontend_dir.exists(): + print(f"ERROR: Frontend directory not found at {frontend_dir}") + return 1 + + # Build the frontend + if not build_frontend(frontend_dir): + print("\n❌ Failed to build frontend") + return 1 + + # Copy to package + if not copy_frontend_to_package(frontend_dist, backend_frontend): + print("\n❌ Failed to copy frontend to package") + return 1 + + print("\n" + "=" * 60) + print("✅ Package preparation complete!") + print("=" * 60) + print("\nNext steps:") + print(" 1. Build the Python package: python -m build") + print(" 2. Upload to PyPI: python -m twine upload dist/*") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/doc/contributing/11_release_process.md b/doc/contributing/11_release_process.md index 95d54baec7..71a0984835 100644 --- a/doc/contributing/11_release_process.md +++ b/doc/contributing/11_release_process.md @@ -97,7 +97,21 @@ After pushing the branch to remote, check the release branch to make sure it loo ## 5. Build Package You'll need the build package to build the project. If it’s not already installed, install it `pip install build`. +### Build the Frontend +The PyRIT package includes a web-based frontend that must be built before packaging. This requires Node.js and npm to be installed. + +Run the prepare script to build the frontend and copy it into the package structure: + +```bash +python build_scripts/prepare_package.py +``` + +This will: +1. Run `npm install` and `npm run build` in the `frontend/` directory +2. Copy the built assets from `frontend/dist/` to `pyrit/backend/frontend/`. Double check to make sure the files exist after running the `prepare_package.py` script. This should at least include index.html, an `assets` folder with `js` and `css` files. + +### Build the Python Package To build the package wheel and archive for PyPI run ```bash diff --git a/docker/Dockerfile b/docker/Dockerfile index 1467a8dca0..6b713a797b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,61 +1,98 @@ -# Base image from Microsoft Azure with Python 3.12 -FROM mcr.microsoft.com/azureml/minimal-py312-inference:20250310.v1 -LABEL description="Docker container for PyRIT with Jupyter Notebook integration" +# syntax=docker/dockerfile:1.4 +# ============================================================================ +# PyRIT Production Dockerfile +# +# This Dockerfile builds on top of the devcontainer base image to avoid +# duplication. The devcontainer is built first and used as the base. +# +# Build with: +# python docker/build_pyrit_docker.py --source local +# python docker/build_pyrit_docker.py --source pypi --version 0.10.0 +# ============================================================================ -# Set environment variables +# Use the devcontainer as base (built by build_pyrit_docker.py) +ARG BASE_IMAGE=pyrit-devcontainer +FROM ${BASE_IMAGE} AS production + +LABEL description="Docker container for PyRIT with Jupyter Notebook and GUI support" + +# Build arguments for version tracking +ARG PYRIT_SOURCE=pypi +ARG PYRIT_VERSION="" +ARG GIT_COMMIT="" +ARG GIT_MODIFIED=false + +# Production environment variables ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -ENV DEBIAN_FRONTEND=noninteractive +ENV JUPYTER_ENABLE_LAB=yes +ENV CUDA_VISIBLE_DEVICES=-1 +ENV ENABLE_GPU=false +ENV HOME=/home/vscode -# Switch to root user to install packages USER root -# Install system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - curl \ - wget \ - build-essential \ - ca-certificates \ - unixodbc \ - libgl1-mesa-glx \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# Ensure we use the venv from the devcontainer base +ENV PATH="/opt/venv/bin:$PATH" +ENV VIRTUAL_ENV="/opt/venv" # Set up working directory WORKDIR /app -# Copy requirements file -COPY requirements.txt /app/ - -# Install torch, torchvision, torchaudio from PyTorch with CUDA 11.8 support -RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cu118 torch==2.6.0+cu118 torchvision==0.21.0+cu118 torchaudio==2.6.0+cu118 -# Install all Python dependencies at once with pinned versions -RUN pip install --no-cache-dir -r requirements.txt +# For local: copy source, install editable, and build frontend +COPY --chown=vscode:vscode pyproject.toml MANIFEST.in README.md LICENSE /app/ +COPY --chown=vscode:vscode pyrit/ /app/pyrit/ +COPY --chown=vscode:vscode frontend/ /app/frontend/ +COPY --chown=vscode:vscode build_scripts/ /app/build_scripts/ +COPY --chown=vscode:vscode doc/ /app/doc/ -# Install PyRIT from PyPI (the official way) -RUN pip install --no-cache-dir pyrit[dev,all] +# Install PyRIT and create build info (combined to ensure dependencies are available) +# Note: We use 'uv pip' because the devcontainer creates venv with uv (no pip by default) +RUN if [ "$PYRIT_SOURCE" = "pypi" ]; then \ + echo "Installing PyRIT from PyPI version: $PYRIT_VERSION"; \ + uv pip install --python /opt/venv/bin/python pyrit[dev,speech,opencv,fairness_bias,fastapi,playwright]==$PYRIT_VERSION; \ + elif [ "$PYRIT_SOURCE" = "local" ]; then \ + echo "Installing PyRIT from local source"; \ + uv pip install --python /opt/venv/bin/python -e .[dev,speech,opencv,fairness_bias,fastapi,playwright]; \ + echo "Building frontend..."; \ + /opt/venv/bin/python build_scripts/prepare_package.py; \ + fi && \ + echo "Creating build info..." && \ + /opt/venv/bin/python -c "import json; import pyrit; \ +info = { \ + 'source': '$PYRIT_SOURCE', \ + 'version': pyrit.__version__, \ + 'commit': '$GIT_COMMIT' if '$GIT_COMMIT' else None, \ + 'modified': '$GIT_MODIFIED' == 'true', \ + 'display': '$PYRIT_VERSION' if '$PYRIT_SOURCE' == 'pypi' else ('$GIT_COMMIT' + (' + local changes' if '$GIT_MODIFIED' == 'true' else '') if '$GIT_COMMIT' else pyrit.__version__) \ +}; \ +f = open('/app/build_info.json', 'w'); json.dump(info, f); f.close(); \ +print(f'PyRIT version: {pyrit.__version__}')" - -# Create a directory for notebooks and data +# Create directories for notebooks and data RUN mkdir -p /app/notebooks /app/data /app/assets && \ chmod -R 777 /app/notebooks /app/data /app/assets -# Check PyRIT version -RUN python -c "import pyrit; print(f'PyRIT version: {pyrit.__version__}')" +# Create PyRIT config directory for env files (will be mounted at runtime) +RUN mkdir -p /home/vscode/.pyrit && \ + chown -R vscode:vscode /home/vscode/.pyrit + +# Register the Jupyter kernel for the venv +RUN /opt/venv/bin/python -m ipykernel install --user --name pyrit --display-name "PyRIT" -RUN chown -R dockeruser:dockeruser /app +# Copy doc to notebooks for Jupyter mode +RUN if [ -d "/app/doc" ]; then \ + cp -r /app/doc/* /app/notebooks/ || true; \ + fi + +RUN chown -R vscode:vscode /app # Create and set permissions for the startup script -COPY start.sh /app/start.sh +COPY docker/start.sh /app/start.sh RUN chmod +x /app/start.sh -# Switch to non-root user -USER dockeruser - -# Expose port for JupyterLab -EXPOSE 8888 +# Expose ports for JupyterLab (8888) and GUI (8000) +EXPOSE 8888 8000 -# Set the entrypoint to the startup script and default command to launch JupyterLab +# Set the entrypoint to the startup script (mode determined by PYRIT_MODE env var) ENTRYPOINT ["/app/start.sh"] -CMD ["jupyter", "lab", "--ip=0.0.0.0", "--port=8888", "--no-browser", "--allow-root", "--NotebookApp.token=''", "--NotebookApp.password=''", "--notebook-dir=/app/notebooks"] diff --git a/docker/QUICKSTART.md b/docker/QUICKSTART.md new file mode 100644 index 0000000000..2ba28b778c --- /dev/null +++ b/docker/QUICKSTART.md @@ -0,0 +1,82 @@ +# PyRIT Docker - Quick Start Guide + +Docker container for PyRIT with support for both **Jupyter Notebook** and **GUI** modes. + +## Prerequisites +- Docker installed and running +- `.env` file at `~/.pyrit/.env` with API keys +- Optionally, `~/.pyrit/.env.local` for additional environment variables + +## Quick Start + +### 1. Build the Image + +Build from local source (includes frontend): +```bash +python docker/build_pyrit_docker.py --source local +``` + +Build from PyPI version: +```bash +python docker/build_pyrit_docker.py --source pypi --version 0.10.0 +``` + +Rebuild base image (when devcontainer changes): +```bash +python docker/build_pyrit_docker.py --source local --rebuild-base +``` + +> **Note:** The build script automatically builds the devcontainer base image if needed. +> The base image is cached and reused for faster subsequent builds. + +### 2. Run PyRIT + +Jupyter mode (port 8888): +```bash +python docker/run_pyrit_docker.py jupyter +``` + +GUI mode (port 8000): +```bash +python docker/run_pyrit_docker.py gui +``` + +## Image Tags + +Images are tagged with version information: +- PyPI: `pyrit:0.10.0`, `pyrit:latest` +- Local (clean): `pyrit:`, `pyrit:latest` +- Local (modified): `pyrit:-modified`, `pyrit:latest` + +Run specific tag: +```bash +python docker/run_pyrit_docker.py gui --tag abc1234def5678 +``` + +## Version Display + +The GUI shows PyRIT version in a tooltip on the logo: +- PyPI builds: `0.10.0` +- Local builds: `abc1234def5678` or `abc1234def5678 + local changes` + +## Docker Compose + +Use profiles to run specific modes: + +```bash +# Jupyter mode +docker-compose --profile jupyter up + +# GUI mode +docker-compose --profile gui up +``` + +## Troubleshooting + +**Image not found**: Run `python docker/build_pyrit_docker.py --source local` first + +**.env missing**: Create `.env` file at `~/.pyrit/.env` with your API keys + +**GUI frontend missing**: Build with `--source local` (PyPI builds before GUI release won't work) + +For complete documentation, see [docker/README.md](./README.md) diff --git a/docker/build_pyrit_docker.py b/docker/build_pyrit_docker.py new file mode 100644 index 0000000000..406647d84d --- /dev/null +++ b/docker/build_pyrit_docker.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Build PyRIT Docker image with support for both PyPI and local source. + +This script first builds the devcontainer base image, then builds the +production image on top of it to avoid duplication. + +Usage: + python build_pyrit_docker.py --source pypi --version 0.10.0 + python build_pyrit_docker.py --source local +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +DEVCONTAINER_IMAGE = "pyrit-devcontainer" + + +def get_git_info(): + """Get current git commit hash and check for uncommitted changes.""" + try: + # Get commit hash + result = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True) + commit = result.stdout.strip() + + # Check for uncommitted changes + result = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, check=True) + modified = len(result.stdout.strip()) > 0 + + return commit, modified + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to get git info: {e}") + sys.exit(1) + + +def build_devcontainer(root_dir: Path, force_rebuild: bool = False) -> bool: + """Build the devcontainer base image if needed.""" + print("🔧 Building devcontainer base image...") + print(f" Tag: {DEVCONTAINER_IMAGE}") + print() + + # Check if image already exists (skip if not forcing rebuild) + if not force_rebuild: + result = subprocess.run(["docker", "images", "-q", DEVCONTAINER_IMAGE], capture_output=True, text=True) + if result.stdout.strip(): + print(f" ✓ Using existing {DEVCONTAINER_IMAGE} image") + print(" (use --rebuild-base to force rebuild)") + print() + return True + + cmd = [ + "docker", + "build", + "-f", + str(root_dir / ".devcontainer" / "Dockerfile"), + "-t", + DEVCONTAINER_IMAGE, + str(root_dir / ".devcontainer"), + ] + + print(f"Running: {' '.join(cmd)}") + print() + + result = subprocess.run(cmd) + + if result.returncode != 0: + print() + print("❌ Failed to build devcontainer base image") + return False + + print() + print(f" ✓ Devcontainer base image built: {DEVCONTAINER_IMAGE}") + print() + return True + + +def build_image(source, version=None, rebuild_base=False): + """Build the Docker image with appropriate tags.""" + root_dir = Path(__file__).parent.parent + + print("🐳 PyRIT Docker Image Builder") + print("=" * 60) + + # First, build the devcontainer base image + if not build_devcontainer(root_dir, force_rebuild=rebuild_base): + sys.exit(1) + + # Prepare build arguments + build_args = {"PYRIT_SOURCE": source, "BASE_IMAGE": DEVCONTAINER_IMAGE} + + # Determine version and tag + if source == "pypi": + if not version: + print("ERROR: --version is required when --source is pypi") + sys.exit(1) + build_args["PYRIT_VERSION"] = version + image_tag = version + print(f"📦 Building from PyPI version: {version}") + print() + print("⚠️ IMPORTANT WARNINGS:") + print(" 1. GUI mode may not work if this PyPI version doesn't") + print(" include the frontend. Jupyter mode will work.") + print(" 2. Ensure your local branch matches the release version:") + print(f" git checkout releases/v{version}") + print(" This ensures notebooks/docs match the PyRIT version.") + print() + + elif source == "local": + commit, modified = get_git_info() + build_args["GIT_COMMIT"] = commit + build_args["GIT_MODIFIED"] = "true" if modified else "false" + + # Create tag from commit hash + image_tag = f"{commit}" + if modified: + image_tag += "-modified" + + print(f"📦 Building from local source") + print(f" Commit: {commit}") + print(f" Modified: {modified}") + print() + else: + print(f"ERROR: Invalid source '{source}'. Must be 'pypi' or 'local'") + sys.exit(1) + + # Build the Docker image + print("🔨 Building Docker image...") + print(f" Tag: pyrit:{image_tag}") + print(f" Also tagging as: pyrit:latest") + print() + + cmd = [ + "docker", + "build", + "-f", + str(root_dir / "docker" / "Dockerfile"), + "-t", + f"pyrit:{image_tag}", + "-t", + "pyrit:latest", + ] + + # Add build args + for key, value in build_args.items(): + cmd.extend(["--build-arg", f"{key}={value}"]) + + cmd.append(str(root_dir)) + + print(f"Running: {' '.join(cmd)}") + print() + + result = subprocess.run(cmd) + + if result.returncode != 0: + print() + print("❌ Failed to build Docker image") + sys.exit(1) + + print() + print("=" * 60) + print("✅ Docker image built successfully!") + print("=" * 60) + print() + print(f" pyrit:{image_tag}") + print(f" pyrit:latest") + print() + print("Next steps:") + print(f" python docker/run_pyrit_docker.py jupyter") + print(f" python docker/run_pyrit_docker.py gui") + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Build PyRIT Docker image", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Build from PyPI version 0.10.0 + python docker/build_pyrit_docker.py --source pypi --version 0.10.0 + + # Build from local source + python docker/build_pyrit_docker.py --source local + """, + ) + + parser.add_argument( + "--source", required=True, choices=["pypi", "local"], help="Source to build from: 'pypi' or 'local'" + ) + + parser.add_argument("--version", help="PyRIT version to install (required when source=pypi)") + + parser.add_argument("--rebuild-base", action="store_true", help="Force rebuild of the devcontainer base image") + + args = parser.parse_args() + + build_image(args.source, args.version, args.rebuild_base) + + +if __name__ == "__main__": + main() diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 94c954993a..0342a85e52 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,8 +1,17 @@ +# NOTE: You must build the devcontainer base image first: +# docker build -f .devcontainer/Dockerfile -t pyrit-devcontainer .devcontainer +# Or use: python docker/build_pyrit_docker.py --source local +# +# Environment files: ~/.pyrit/.env and ~/.pyrit/.env.local are mounted to the container. + services: - pyrit: + pyrit-jupyter: build: - context: . - dockerfile: Dockerfile + context: .. + dockerfile: docker/Dockerfile + args: + BASE_IMAGE: pyrit-devcontainer + PYRIT_SOURCE: local image: pyrit:latest container_name: pyrit-jupyter ports: @@ -11,10 +20,10 @@ services: - notebooks:/app/notebooks - data:/app/data - ../assets:/app/assets - env_file: - - ~/.pyrit/.env - - ~/.pyrit/.env.local - - .env.container.settings + - ~/.pyrit/.env:/home/vscode/.pyrit/.env:ro + - ~/.pyrit/.env.local:/home/vscode/.pyrit/.env.local:ro + environment: + - PYRIT_MODE=jupyter restart: unless-stopped healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:8888 || exit 1"] @@ -22,6 +31,35 @@ services: timeout: 10s retries: 3 start_period: 40s + profiles: + - jupyter + + pyrit-gui: + build: + context: .. + dockerfile: docker/Dockerfile + args: + BASE_IMAGE: pyrit-devcontainer + PYRIT_SOURCE: local + image: pyrit:latest + container_name: pyrit-gui + ports: + - "8000:8000" + volumes: + - ../assets:/app/assets + - ~/.pyrit/.env:/home/vscode/.pyrit/.env:ro + - ~/.pyrit/.env.local:/home/vscode/.pyrit/.env.local:ro + environment: + - PYRIT_MODE=gui + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + profiles: + - gui volumes: notebooks: diff --git a/docker/requirements.txt b/docker/requirements.txt deleted file mode 100644 index edf4e49e51..0000000000 --- a/docker/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -# JupyterLab and related packages -jupyterlab>=4.3.4,<4.5 -notebook==7.3.2 -ipywidgets==8.1.5 -matplotlib==3.10.1 -pandas==2.2.3 -seaborn==0.13.2 -ipython==9.0.2 diff --git a/docker/run_pyrit_docker.py b/docker/run_pyrit_docker.py new file mode 100644 index 0000000000..63bdcbd150 --- /dev/null +++ b/docker/run_pyrit_docker.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Run PyRIT Docker container in Jupyter or GUI mode. + +Usage: + python run_pyrit_docker.py jupyter + python run_pyrit_docker.py gui + python run_pyrit_docker.py gui --tag abc1234def5678 +""" + +import argparse +import subprocess +import sys +from pathlib import Path + + +def check_image_exists(tag): + """Check if the Docker image exists.""" + result = subprocess.run(["docker", "images", "-q", f"pyrit:{tag}"], capture_output=True, text=True) + return len(result.stdout.strip()) > 0 + + +def run_container(mode, tag="latest"): + """Run the PyRIT container in the specified mode.""" + root_dir = Path(__file__).parent.parent + pyrit_config_dir = Path.home() / ".pyrit" + env_file = pyrit_config_dir / ".env" + env_local_file = pyrit_config_dir / ".env.local" + + print("🐳 PyRIT Docker Runner") + print("=" * 60) + + # Check for .env file + if not env_file.exists(): + print("❌ ERROR: .env file not found!") + print(f" Expected location: {env_file}") + print(" Please create a .env file with your API keys.") + print(" See: https://github.com/Azure/PyRIT/blob/main/doc/setup/setup.md") + sys.exit(1) + + # Check if image exists + if not check_image_exists(tag): + print(f"❌ ERROR: Docker image 'pyrit:{tag}' not found!") + print() + print("Please build the image first:") + print(" python docker/build_pyrit_docker.py --source local") + print(" python docker/build_pyrit_docker.py --source pypi --version X.Y.Z") + sys.exit(1) + + # Determine port based on mode + if mode == "jupyter": + port = "8888" + url = "http://localhost:8888" + container_name = "pyrit-jupyter" + elif mode == "gui": + port = "8000" + url = "http://localhost:8000" + container_name = "pyrit-gui" + else: + print(f"ERROR: Invalid mode '{mode}'. Must be 'jupyter' or 'gui'") + sys.exit(1) + + print(f"🚀 Starting PyRIT in {mode.upper()} mode") + print(f" Image: pyrit:{tag}") + print(f" Port: {port}") + print() + + # Build docker run command + # Mount env files to ~/.pyrit/ where PyRIT expects them + cmd = [ + "docker", + "run", + "--rm", + "--name", + container_name, + "-p", + f"{port}:{port}", + "-e", + f"PYRIT_MODE={mode}", + "-v", + f"{env_file}:/home/vscode/.pyrit/.env:ro", + ] + + # Add .env.local if it exists + if env_local_file.exists(): + print(f" Found .env.local - including it") + cmd.extend(["-v", f"{env_local_file}:/home/vscode/.pyrit/.env.local:ro"]) + + cmd.append(f"pyrit:{tag}") + + print() + print("=" * 60) + print("🌐 Open in your browser:") + print() + print(f" {url}") + print() + print("=" * 60) + print() + print("Press Ctrl+C to stop") + print() + + try: + subprocess.run(cmd) + except KeyboardInterrupt: + print("\n\n🛑 Stopping PyRIT...") + print("✅ Stopped") + + +def main(): + parser = argparse.ArgumentParser( + description="Run PyRIT Docker container", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run in Jupyter mode + python docker/run_pyrit_docker.py jupyter + + # Run in GUI mode + python docker/run_pyrit_docker.py gui + + # Run with specific image tag + python docker/run_pyrit_docker.py gui --tag abc1234def5678 + """, + ) + + parser.add_argument("mode", choices=["jupyter", "gui"], help="Mode to run: 'jupyter' or 'gui'") + + parser.add_argument("--tag", default="latest", help="Docker image tag to use (default: latest)") + + args = parser.parse_args() + + run_container(args.mode, args.tag) + + +if __name__ == "__main__": + main() diff --git a/docker/start.sh b/docker/start.sh index 6bebf6b833..f101ee5c96 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,21 +1,25 @@ #!/bin/bash set -e -# Clone PyRIT repository if not already present -if [ ! -d "/app/PyRIT" ]; then - echo "Cloning PyRIT repository..." - git clone https://github.com/Azure/PyRIT -else - echo "PyRIT repository already exists. Updating..." - cd /app/PyRIT - git pull - cd /app +# Activate the Python virtual environment +source /opt/venv/bin/activate + +# Set HOME to vscode user's home so PyRIT finds env files at ~/.pyrit/ +export HOME=/home/vscode + +echo "=== PyRIT Container Startup ===" +echo "PYRIT_MODE: ${PYRIT_MODE:-not set}" +echo "Python version: $(python --version)" +echo "================================" + +# Check if PYRIT_MODE is set +if [ -z "$PYRIT_MODE" ]; then + echo "ERROR: PYRIT_MODE environment variable is not set!" + echo "Please set PYRIT_MODE to either 'jupyter' or 'gui'" + exit 1 fi -# Copy doc folder to notebooks directory -echo "Copying documentation to notebooks directory..." -cp -r /app/PyRIT/doc/* /app/notebooks/ -rm -rf /app/PyRIT +echo "PYRIT_MODE is set to: $PYRIT_MODE" # Default to CPU mode export CUDA_VISIBLE_DEVICES="-1" @@ -30,7 +34,18 @@ else fi # Print PyRIT version +echo "Checking PyRIT installation..." python -c "import pyrit; print(f'Running PyRIT version: {pyrit.__version__}')" -# Execute the command passed to docker run (or the CMD if none provided) -exec "$@" +# Start the appropriate service based on PYRIT_MODE +if [ "$PYRIT_MODE" = "jupyter" ]; then + echo "Starting JupyterLab on port 8888..." + echo "Note: Notebooks are from the local source at build time" + exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --NotebookApp.token='' --NotebookApp.password='' --notebook-dir=/app/notebooks +elif [ "$PYRIT_MODE" = "gui" ]; then + echo "Starting PyRIT GUI on port 8000..." + exec python -m uvicorn pyrit.backend.main:app --host 0.0.0.0 --port 8000 +else + echo "ERROR: Invalid PYRIT_MODE '$PYRIT_MODE'. Must be 'jupyter' or 'gui'" + exit 1 +fi diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1f1eed3157..59b39e17bf 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -28,5 +28,6 @@ } }, "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/setupTests.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/pyproject.toml b/pyproject.toml index 369c686e8e..aac2f29297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,8 +72,10 @@ dev = [ "jupyter>=1.1.1", "jupyter-book==1.0.4", "jupytext>=1.17.1", + "matplotlib>=3.10.0", "mypy>=1.16.0", "mock-alchemy>=0.2.6", + "pandas>=2.2.0", "pre-commit>=4.2.0", "pytest>=8.3.5", "pytest-asyncio>=1.0.0", @@ -129,7 +131,6 @@ speech = [ "azure-cognitiveservices-speech>=1.46.0", ] - # all includes all functional dependencies excluding the ones from the "dev" extra all = [ "accelerate>=1.7.0", diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py index c703f592cc..08502b849e 100644 --- a/pyrit/backend/main.py +++ b/pyrit/backend/main.py @@ -6,7 +6,6 @@ """ import os -import sys from pathlib import Path from fastapi import FastAPI @@ -52,7 +51,7 @@ async def startup_event_async() -> None: def setup_frontend() -> None: - """Set up frontend static file serving (only called when running as main script).""" + """Set up frontend static file serving.""" frontend_path = Path(__file__).parent / "frontend" if DEV_MODE: @@ -63,12 +62,17 @@ def setup_frontend() -> None: print(f"✅ Serving frontend from {frontend_path}") app.mount("/", StaticFiles(directory=str(frontend_path), html=True), name="frontend") else: - # Production mode but no frontend found - this is an error - print("❌ ERROR: Frontend not found!") + # Production mode but no frontend found - warn but don't exit + # This allows API-only usage + print("⚠️ WARNING: Frontend not found!") print(f" Expected location: {frontend_path}") print(" The frontend must be built and included in the package.") print(" Run: python build_scripts/prepare_package.py") - sys.exit(1) + print(" API endpoints will still work but the UI won't be available.") + + +# Set up frontend at module load time (needed when running via uvicorn) +setup_frontend() @app.exception_handler(Exception) @@ -88,5 +92,4 @@ async def global_exception_handler_async(request: object, exc: Exception) -> JSO if __name__ == "__main__": import uvicorn - setup_frontend() uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")