diff --git a/.dockerignore b/.dockerignore index 7fd795a0..920a34e8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -75,3 +75,7 @@ logs/ # Temporary files tmp/ temp/ + +# Docker +docker/Dockerfile* +docker/docker-compose.yml diff --git a/.env-example b/.env-example new file mode 100644 index 00000000..29ad6ac4 --- /dev/null +++ b/.env-example @@ -0,0 +1,2 @@ +FLASK_DEBUG=1 +CACHE_REDIS_HOST=redis \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index d99f2f30..3b87ee45 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,8 @@ "[python]": { "editor.defaultFormatter": "ms-python.black-formatter" }, - "python.formatting.provider": "none" + "python.formatting.provider": "none", + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda", + "python-envs.pythonProjects": [] } \ No newline at end of file diff --git a/Makefile b/Makefile index 31a985ea..4b1ddd9c 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,26 @@ -install: +.PHONY: help +help: ## Print this message + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-24s\033[0m %s\n", $$1, $$2}' + +install: ## Install Python dependencies pip install -U -e .[dev] -debug: - FLASK_APP=policyengine_household_api.api FLASK_DEBUG=1 flask run --without-threads +debug: ## Run Flask app with FLASK_DEBUG=1 + FLASK_APP=policyengine_household_api.api FLASK_DEBUG=1 flask run --without-threads --host=0.0.0.0 -test: +test: ## Run unit tests pytest -vv --timeout=150 -rP tests/to_refactor tests/unit -test-with-auth: +test-with-auth: ## Run integration tests CONFIG_FILE=config/test_with_auth.yaml pytest -vv --timeout=150 -rP tests/integration_with_auth -debug-test: +debug-test: ## Run tests with FLASK_DEBUG=1 FLASK_DEBUG=1 pytest -vv --durations=0 --timeout=150 -rP tests -format: +format: ## Run black black . -l 79 -deploy: +deploy: ## Deploy to GCP python gcp/export.py gcloud config set app/cloud_build_timeout 1800 cp gcp/policyengine_household_api/* . @@ -25,9 +29,56 @@ deploy: rm Dockerfile rm .gac.json -changelog: +changelog: ## Build changelog build-changelog changelog.yaml --output changelog.yaml --update-last-date --start-from 0.1.0 --append-file changelog_entry.yaml build-changelog changelog.yaml --org PolicyEngine --repo policyengine-household-api --output CHANGELOG.md --template .github/changelog_template.md bump-version changelog.yaml setup.py policyengine_household_api/constants.py rm changelog_entry.yaml || true - touch changelog_entry.yaml \ No newline at end of file + touch changelog_entry.yaml + +COMPOSE_FILE ?= docker/docker-compose.yml +COMPOSE_EXTERNAL_FILE ?= docker/docker-compose.external.yml +DOCKER_IMG ?= policyengine:policyengine-household-api +DOCKER_NAME ?= policyengine-household-api +ifeq (, $(shell which docker)) +DOCKER_CONTAINER_ID := docker-is-not-installed +else +DOCKER_CONTAINER_ID := $(shell docker ps --filter ancestor=$(DOCKER_IMG) --format "{{.ID}}") +endif +DOCKER_NETWORK ?= policyengine-api_default +DOCKER_CONSOLE ?= policyengine-api-console + +.PHONY: docker-build +docker-build: ## Build the docker image + docker compose --file $(COMPOSE_FILE) build --force-rm + +.PHONY: docker-run +docker-run: ## Run the app as docker container with supporting services + docker compose --file $(COMPOSE_FILE) up + +.PHONY: docker-run-external +docker-run-external: ## Run with external network (for multi-service setups) + docker compose --file $(COMPOSE_FILE) --file $(COMPOSE_EXTERNAL_FILE) up + +.PHONY: services-start +services-start: ## Run the docker containers for supporting services (e.g. Redis) + docker compose --file $(COMPOSE_FILE) up -d redis + +.PHONY: services-start-external +services-start-external: ## Start services with external network + docker compose --file $(COMPOSE_FILE) --file $(COMPOSE_EXTERNAL_FILE) up -d redis + +.PHONY: services-stop +services-stop: ## Stop the docker containers for supporting services + docker compose --file $(COMPOSE_FILE) down + +.PHONY: docker-network-create +docker-network-create: ## Create the external Docker network (for multi-service setups) + docker network create $(DOCKER_NETWORK) || true + +.PHONY: docker-console +docker-console: ## Open a one-off container bash session + @docker run -p 8080:5000 -v $(PWD):/code \ + --network $(DOCKER_NETWORK) \ + --rm --name $(DOCKER_CONSOLE) -it \ + $(DOCKER_IMG) bash \ No newline at end of file diff --git a/README.md b/README.md index 20e01c21..da52da01 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,54 @@ A version of the PolicyEngine API that runs the `calculate` endpoint over household object. To debug locally, run `make debug`. +## Local development with Docker Compose + +To run this app locally via Docker Compose: + +``` +% make docker-build +% make docker-run +``` + +and point your browser at http://localhost:8080 to access the API. + +To develop the code locally, you will want to instead start only the Redis docker container and a one-off +API container, with your local filesystem mounted into the running docker container. + +``` +% make services-start +% make docker-console +``` + +Then inside the container, start the Flask service: + +``` +policyapi@[your-docker-id]:/code$ make debug +``` + +and point your browser at http://localhost:8080 to access the API. + +### Running with other PolicyEngine services + +If you're running this alongside other PolicyEngine services (e.g., the main API) and need +containers to communicate across projects, use the external network mode: + +``` +% make docker-network-create # Create shared network (once) +% make docker-run-external # Run with external network +``` + +This connects the household API to a shared `policyengine-api_default` network that other +PolicyEngine docker-compose projects can also join. + +For development with external networking: + +``` +% make docker-network-create +% make services-start-external +% make docker-console +``` + ## Development rules 1. Every endpoint should return a JSON object with at least a "status" and "message" field. diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29b..6dacd217 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,6 @@ +- bump: minor + changes: + added: + - Docker Compose support for local development with Redis + - Makefile targets for building and running Docker containers + - Support for external Docker networks when running alongside other PolicyEngine services diff --git a/config/README.md b/config/README.md index 8870a9fd..543188c3 100644 --- a/config/README.md +++ b/config/README.md @@ -95,6 +95,16 @@ services: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} ``` +Or, alternately, use the `docker/docker-compose.yml` file and create a `.env` file in your root directory +to set all environment variables. Example: + +``` +% cat .env +CONFIG_FILE=/code/config/custom.yaml +DATABASE__PASSWORD=your-secret-password +ANTHROPIC_API_KEY=your-key-here +``` + #### Kubernetes ConfigMap ```yaml apiVersion: v1 diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 00000000..1bfe1bae --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,26 @@ +FROM python:3.12 + +# use root to install pip libs +USER root + +WORKDIR /code + +COPY . /code/ + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# can't use make install because we ignore .github/ via .dockerignore +# and env is set via docker-compose.yml or injection at container run time +RUN pip install -e ".[dev]" --config-settings editable_mode=compat + +# switch to app user +RUN groupadd policyapi && \ + useradd -g policyapi policyapi && \ + apt-get purge -y --auto-remove build-essential && \ + apt-get -y install make && \ + chown -R policyapi:policyapi /code + +RUN chown -R policyapi:policyapi /usr/local/lib/python3.12/site-packages/policyengine* + +USER policyapi \ No newline at end of file diff --git a/docker/docker-compose.external.yml b/docker/docker-compose.external.yml new file mode 100644 index 00000000..7eb1810f --- /dev/null +++ b/docker/docker-compose.external.yml @@ -0,0 +1,17 @@ +# Override file for connecting to an external Docker network. +# Use this when running alongside other PolicyEngine services. +# +# Usage: +# make docker-run-external +# +# Or manually: +# docker compose -f docker/docker-compose.yml -f docker/docker-compose.external.yml up +# +# Prerequisites: +# The external network must exist. Create it with: +# docker network create policyengine-api_default + +networks: + my_network: + name: ${DOCKER_NETWORK:-policyengine-api_default} + external: true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..394934ce --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,33 @@ +services: + redis: + image: "redis:alpine" + expose: + - "6379" + volumes: + - redis-data:/data + networks: + - my_network + + policyengine: + build: + context: ../ + dockerfile: docker/Dockerfile.api + command: ["/bin/bash", "/code/docker/start.sh"] + image: policyengine:policyengine-household-api + depends_on: + - redis + env_file: + - ../.env + expose: + - 8080 + ports: + - "8080:8080" + networks: + - my_network + +volumes: + redis-data: + +networks: + my_network: + name: ${DOCKER_NETWORK:-policyengine-api_default} \ No newline at end of file diff --git a/docker/start.sh b/docker/start.sh new file mode 100644 index 00000000..47e5f335 --- /dev/null +++ b/docker/start.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Environment variables +PORT="${PORT:-8080}" +WORKER_COUNT="${WORKER_COUNT:-3}" +REDIS_PORT="${REDIS_PORT:-6379}" + +# Start the API +gunicorn -b :"$PORT" policyengine_household_api.api --timeout 300 --workers 5 --preload & + +# Keep the script running and handle shutdown gracefully +trap "pkill -P $$; exit 1" INT TERM + +wait