diff --git a/.cruft.json b/.cruft.json index 38d46bc..578d2b8 100644 --- a/.cruft.json +++ b/.cruft.json @@ -1,6 +1,6 @@ { "template": "https://github.com/kjaymiller/cookiecutter-relecloud/", - "commit": "a3794bddf382c4c6fa4add5bf935ca14bca40a71", + "commit": "04ac8410200b9d270bf7f07d1ae0d96837f5b5d9", "checkout": null, "context": { "cookiecutter": { diff --git a/.devcontainer/Dockerfile_dev b/.devcontainer/Dockerfile_dev index ac59663..feaa2e3 100644 --- a/.devcontainer/Dockerfile_dev +++ b/.devcontainer/Dockerfile_dev @@ -4,4 +4,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends postgresql-client \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* + +COPY requirements-dev.txt requirements-dev.txt +COPY src/requirements.txt src/requirements.txt RUN python -m pip install --upgrade pip +RUN python -m pip install -r requirements-dev.txt \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d5ed405..ecd2e4e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,24 +22,10 @@ "ms-azuretools.vscode-bicep", "charliermarsh.ruff", "ms-python.python", - "ms-python.black-formatter", "ms-azuretools.vscode-docker", - "mtxr.sqltools", - "mtxr.sqltools-driver-pg", "bierner.github-markdown-preview" ], "settings": { - "sqltools.connections": [ - { - "name": "Local database", - "driver": "PostgreSQL", - "server": "db", - "port": 5432, - "database": "relecloud", - "username": "postgres", - "password": "postgres" - } - ], "python.defaultInterpreterPath": "/usr/local/bin/python", "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, @@ -47,15 +33,23 @@ ".coverage": true, ".pytest_cache": true, "__pycache__": true + }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": true, + "source.fixAll": true + } } } } }, "features": { - "ghcr.io/azure/azure-dev/azd:latest": {}, // Required for azd to package the app to ACA - "ghcr.io/devcontainers/features/docker-in-docker:2": {} + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/azure/azure-dev/azd:latest": {} }, - "postCreateCommand": "pip install -r requirements-dev.in && pip install -e src && python3 src/fastapi_app/seed_data.py" + "postCreateCommand": "playwright install chromium --with-deps && pip install -e src && python3 src/fastapi_app/seed_data.py" } diff --git a/.devcontainer/docker-compose_dev.yml b/.devcontainer/docker-compose_dev.yml index 5081a8f..96cb96f 100644 --- a/.devcontainer/docker-compose_dev.yml +++ b/.devcontainer/docker-compose_dev.yml @@ -21,8 +21,8 @@ services: app: build: - context: . - dockerfile: Dockerfile_dev + context: .. + dockerfile: ./.devcontainer/Dockerfile_dev depends_on: db: condition: service_healthy diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index ceb9ebd..3013f96 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -9,16 +9,18 @@ on: # GitHub Actions workflow to deploy to Azure using azd # To configure required secrets for connecting to Azure, simply run `azd pipeline config` - + # Set up permissions for deploying with secretless Azure federated credentials # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication permissions: id-token: write contents: read - + jobs: build: runs-on: ubuntu-latest + outputs: + uri: ${{ steps.output.outputs.uri }} env: AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} @@ -27,10 +29,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - + - name: Install azd uses: Azure/setup-azd@v0.1.0 - + - name: Log in with Azure (Federated Credentials) if: ${{ env.AZURE_CLIENT_ID != '' }} run: | @@ -39,13 +41,13 @@ jobs: --federated-credential-provider "github" ` --tenant-id "$Env:AZURE_TENANT_ID" shell: pwsh - + - name: Log in with Azure (Client Credentials) if: ${{ env.AZURE_CREDENTIALS != '' }} run: | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; Write-Host "::add-mask::$($info.clientSecret)" - + azd auth login ` --client-id "$($info.clientId)" ` --client-secret "$($info.clientSecret)" ` @@ -53,17 +55,53 @@ jobs: shell: pwsh env: AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} - + - name: Provision Infrastructure run: azd provision --no-prompt env: AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} - + - name: Deploy Application run: azd deploy --no-prompt env: AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Output Deployment URI + id: output + run: | + azd env get-values > .env + source .env + echo "uri=$BACKEND_URI" >> "$GITHUB_OUTPUT" + + smoketests: + runs-on: ubuntu-latest + needs: build + steps: + + - name: Basic smoke test (curl) + env: + URI: ${{needs.build.outputs.uri}} + run: | + echo "Sleeping 1 minute due to https://github.com/Azure/azure-dev/issues/2669" + sleep 60 + curl -sSf $URI + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: 3.12 + + - name: End-to-end smoke tests (playwright) + env: + URI: ${{needs.build.outputs.uri}} + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements-dev.txt + python3 -m playwright install --with-deps + python3 -m pytest --exitfirst src/tests/smoke/smoketests.py --live-server-url $URI diff --git a/.github/workflows/cruft.yml b/.github/workflows/cruft.yml index 3fce56d..6910276 100644 --- a/.github/workflows/cruft.yml +++ b/.github/workflows/cruft.yml @@ -31,7 +31,7 @@ jobs: python-version: "3.10" - name: Install Cruft - run: pip3 install -r requirements-dev.in + run: pip3 install -r requirements-dev.txt - name: Check if update is available continue-on-error: false diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index d29fe9d..26dcb0f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -23,8 +23,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements-dev.in + pip install -r requirements-dev.txt - name: Lint with ruff - run: ruff . - - name: Check formatting with black - run: black . --check --verbose + run: | + ruff check . + ruff format . diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dac73c5..a35fabb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-20.04"] - python_version: ["3.8", "3.9", "3.10", "3.11"] + python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] services: db: image: postgres:14 @@ -36,13 +36,13 @@ jobs: - name: Install dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install -r requirements-dev.in + python3 -m pip install -r requirements-dev.txt playwright install --with-deps python3 -m pip install -e src - name: Seed data and run Pytest tests run: | python3 src/fastapi_app/seed_data.py - python3 -m pytest + python3 -m pytest env: POSTGRES_HOST: localhost POSTGRES_USERNAME: postgres diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..67d2ae5 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "MD013": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 9824752..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -## [project-title] Changelog - - -# x.y.z (yyyy-mm-dd) - -*Features* -* ... - -*Bug Fixes* -* ... - -*Breaking Changes* -* ... diff --git a/README.md b/README.md index 13737e0..0909470 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ python3 -m uvicorn fastapi_app:app --reload --port=8000 2. Install the development requirements: ```sh - python3 -m pip install -r requirements-dev.in - playwright install --with-deps + python3 -m pip install -r requirements-dev.txt + python3 -m playwright install --with-deps ``` 3. Run the tests: diff --git a/infra/core/host/appservice.bicep b/infra/core/host/appservice.bicep index f61b4a0..1fb9410 100644 --- a/infra/core/host/appservice.bicep +++ b/infra/core/host/appservice.bicep @@ -1,3 +1,4 @@ +metadata description = 'Creates an Azure App Service in an existing Azure App Service plan.' param name string param location string = resourceGroup().location param tags object = {} @@ -23,6 +24,7 @@ param kind string = 'app,linux' param allowedOrigins array = [] param alwaysOn bool = true param appCommandLine string = '' +@secure() param appSettings object = {} param clientAffinityEnabled bool = false param enableOryxBuild bool = contains(kind, 'linux') @@ -63,6 +65,18 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { identity: { type: managedIdentity ? 'SystemAssigned' : 'None' } + resource configAppSettings 'config' = { + name: 'appsettings' + properties: union(appSettings, + { + SCM_DO_BUILD_DURING_DEPLOYMENT: string(scmDoBuildDuringDeployment) + ENABLE_ORYX_BUILD: string(enableOryxBuild) + }, + runtimeName == 'python' ? { PYTHON_ENABLE_GUNICORN_MULTIWORKERS: 'true'} : {}, + !empty(applicationInsightsName) ? { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString } : {}, + !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}) + } + resource configLogs 'config' = { name: 'logs' properties: { @@ -71,11 +85,13 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { failedRequestsTracing: { enabled: true } httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } } + dependsOn: [ + configAppSettings + ] } resource basicPublishingCredentialsPoliciesFtp 'basicPublishingCredentialsPolicies' = { name: 'ftp' - location: location properties: { allow: false } @@ -83,27 +99,12 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { resource basicPublishingCredentialsPoliciesScm 'basicPublishingCredentialsPolicies' = { name: 'scm' - location: location properties: { allow: false } } } -module config 'appservice-appsettings.bicep' = if (!empty(appSettings)) { - name: '${name}-appSettings' - params: { - name: appService.name - appSettings: union(appSettings, - { - SCM_DO_BUILD_DURING_DEPLOYMENT: string(scmDoBuildDuringDeployment) - ENABLE_ORYX_BUILD: string(enableOryxBuild) - }, - !empty(applicationInsightsName) ? { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString } : {}, - !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}) - } -} - resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (!(empty(keyVaultName))) { name: keyVaultName } diff --git a/infra/main.bicep b/infra/main.bicep index 76080f2..e535335 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -128,4 +128,6 @@ output SERVICE_WEB_URI string = web.outputs.SERVICE_WEB_URI output SERVICE_WEB_IMAGE_NAME string = web.outputs.SERVICE_WEB_IMAGE_NAME output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.endpoint output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name -output APPLICATIONINSIGHTS_NAME string = monitoring.outputs.applicationInsightsName \ No newline at end of file +output APPLICATIONINSIGHTS_NAME string = monitoring.outputs.applicationInsightsName + +output BACKEND_URI string = web.outputs.uri \ No newline at end of file diff --git a/infra/web.bicep b/infra/web.bicep index 67aeb4e..dc3af80 100644 --- a/infra/web.bicep +++ b/infra/web.bicep @@ -61,6 +61,10 @@ module app 'core/host/container-app-upsert.bicep' = { name: 'POSTGRES_PASSWORD' secretRef: 'dbserver-password' } + { + name: 'POSTGRES_SSL' + value: 'require' + } { name: 'RUNNING_IN_PRODUCTION' value: 'true' @@ -88,3 +92,5 @@ output SERVICE_WEB_IDENTITY_PRINCIPAL_ID string = webIdentity.properties.princip output SERVICE_WEB_NAME string = app.outputs.name output SERVICE_WEB_URI string = app.outputs.uri output SERVICE_WEB_IMAGE_NAME string = app.outputs.imageName + +output uri string = app.outputs.uri diff --git a/pyproject.toml b/pyproject.toml index d6707c5..4d262c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,15 +2,10 @@ line-length = 120 select = ["E", "F", "I", "UP"] ignore = ["D203"] +extend-exclude = ["src/flaskapp/migrations/"] [tool.ruff.isort] known-first-party = ["fastapi_app"] -[tool.black] -line-length = 120 - [tool.pytest.ini_options] -addopts = "-ra --cov -vv" - -[tool.coverage.report] -show_missing = true +addopts = "-ra -vv" diff --git a/requirements-dev.in b/requirements-dev.txt similarity index 82% rename from requirements-dev.in rename to requirements-dev.txt index 99edb93..c2c4369 100644 --- a/requirements-dev.in +++ b/requirements-dev.txt @@ -5,11 +5,9 @@ cruft pip-tools # Testing Tools -coverage pytest -pytest-cov +ephemeral-port-reserve pytest-playwright # Linters ruff -black diff --git a/src/fastapi_app/app.py b/src/fastapi_app/app.py index 45cc0f5..a5ef613 100644 --- a/src/fastapi_app/app.py +++ b/src/fastapi_app/app.py @@ -14,6 +14,8 @@ app.mount("/mount", StaticFiles(directory=parent_path / "static"), name="static") templates = Jinja2Templates(directory=parent_path / "templates") templates.env.globals["prod"] = os.environ.get("RUNNING_IN_PRODUCTION", False) +# Use relative path for url_for, so that it works behind a proxy like Codespaces +templates.env.globals["url_for"] = app.url_path_for @app.get("/", response_class=HTMLResponse) diff --git a/src/fastapi_app/models.py b/src/fastapi_app/models.py index c64c7b2..da3b655 100644 --- a/src/fastapi_app/models.py +++ b/src/fastapi_app/models.py @@ -9,11 +9,12 @@ POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD") POSTGRES_HOST = os.environ.get("POSTGRES_HOST") POSTGRES_DATABASE = os.environ.get("POSTGRES_DATABASE") +POSTGRES_PORT = os.environ.get("POSTGRES_PORT", 5432) +POSTGRES_SSL = os.environ.get("POSTGRES_SSL") -sql_url = f"postgresql://{POSTGRES_USERNAME}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DATABASE}" - -if os.environ.get("POSTGRES_SSL", "disable") != "disable": - sql_url = f"{sql_url}?sslmode=require" +sql_url = f"postgresql://{POSTGRES_USERNAME}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}" +if POSTGRES_SSL: + sql_url = f"{sql_url}?sslmode={POSTGRES_SSL}" engine = create_engine(sql_url, echo=True) diff --git a/src/fastapi_app/seed_data.py b/src/fastapi_app/seed_data.py index 2464c33..a533a60 100644 --- a/src/fastapi_app/seed_data.py +++ b/src/fastapi_app/seed_data.py @@ -50,6 +50,9 @@ def load_from_json(): def drop_all(): + # Explicitly remove these tables first to avoid cascade errors + SQLModel.metadata.remove(models.Cruise.__table__) + SQLModel.metadata.remove(models.Destination.__table__) SQLModel.metadata.drop_all(models.engine) diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/conftest.py b/src/tests/conftest.py deleted file mode 100644 index 4dc2a8d..0000000 --- a/src/tests/conftest.py +++ /dev/null @@ -1,27 +0,0 @@ -from multiprocessing import Process - -import pytest -import uvicorn - -from fastapi_app import seed_data -from fastapi_app.app import app - - -def run_server(): - uvicorn.run(app) - - -@pytest.fixture(scope="session") -def live_server(): - seed_data.load_from_json() - proc = Process(target=run_server, daemon=True) - proc.start() - yield - proc.kill() - seed_data.drop_all() - - -@pytest.fixture(scope="session") -def live_server_url(live_server): - """Returns the url of the live server""" - return "http://localhost:8000" diff --git a/src/tests/local/__init_.py b/src/tests/local/__init_.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/local/conftest.py b/src/tests/local/conftest.py new file mode 100644 index 0000000..1ac3ea9 --- /dev/null +++ b/src/tests/local/conftest.py @@ -0,0 +1,49 @@ +import time +from multiprocessing import Process + +import ephemeral_port_reserve +import pytest +import requests +import uvicorn + +from fastapi_app import seed_data +from fastapi_app.app import app + + +def wait_for_server_ready(url: str, timeout: float = 10.0, check_interval: float = 0.5) -> bool: + """Make requests to provided url until it responds without error.""" + conn_error = None + for _ in range(int(timeout / check_interval)): + try: + requests.get(url) + except requests.ConnectionError as exc: + time.sleep(check_interval) + conn_error = str(exc) + else: + return True + raise RuntimeError(conn_error) + + +def run_server(port: int): + uvicorn.run(app, port=port) + + +@pytest.fixture(scope="session") +def live_server_url(): + """Returns the url of the live server""" + seed_data.load_from_json() + + # Start the process + hostname = ephemeral_port_reserve.LOCALHOST + free_port = ephemeral_port_reserve.reserve() + proc = Process(target=run_server, args=(free_port,), daemon=True) + proc.start() + + # Return the URL of the live server once it is ready + url = f"http://{hostname}:{free_port}" + wait_for_server_ready(url, timeout=10.0, check_interval=0.5) + yield url + + # Clean up the process and database + proc.kill() + seed_data.drop_all() diff --git a/src/tests/test_playwright.py b/src/tests/local/test_playwright.py similarity index 100% rename from src/tests/test_playwright.py rename to src/tests/local/test_playwright.py diff --git a/src/tests/smoke/README.md b/src/tests/smoke/README.md new file mode 100644 index 0000000..e18fdde --- /dev/null +++ b/src/tests/smoke/README.md @@ -0,0 +1,11 @@ +# Smoke tests + +This directory contains smoke tests for the project. +These tests are meant to be run against a running instance of the project, +not against a local development server. + +See azure-dev.yaml for an example of how to run these tests in GitHub actions. + +``` +python3 -m pytest --exitfirst src/tests/smoke/smoketests.py --live-server-url $URI +``` \ No newline at end of file diff --git a/src/tests/smoke/__init__.py b/src/tests/smoke/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/smoke/conftest.py b/src/tests/smoke/conftest.py new file mode 100644 index 0000000..b1c1db5 --- /dev/null +++ b/src/tests/smoke/conftest.py @@ -0,0 +1,15 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--live-server-url", + action="store", + default="http://localhost:8000", + help="URL for the live server to test against", + ) + + +@pytest.fixture(scope="function") +def live_server_url(request): + return request.config.getoption("--live-server-url") diff --git a/src/tests/smoke/smoketests.py b/src/tests/smoke/smoketests.py new file mode 100644 index 0000000..62fc3bf --- /dev/null +++ b/src/tests/smoke/smoketests.py @@ -0,0 +1,10 @@ +# ruff: noqa: F401 + +# Import only the tests that we want to run for smoke testing +from ..local.test_playwright import ( + test_about, + test_destination_options_have_cruises, + test_destinations, + test_home, + test_request_information, +)