From d32ca65b160d81c604d13f6ff609dbe631fe14a7 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Wed, 5 Aug 2026 13:38:06 +0200 Subject: [PATCH 1/4] introduce nix builds --- .dockerignore | 26 ++ .github/workflows/README.md | 100 +++--- .github/workflows/build-web.yml | 53 ---- .github/workflows/docker-build.yml | 73 ----- .github/workflows/lint-dockerfiles.yml | 44 --- .github/workflows/tests.yml | 368 +++++++++++++--------- .gitignore | 6 +- AGENTS.md | 12 +- README.md | 98 +++++- backend/Dockerfile | 60 ++-- docker-compose.dev.yml | 12 +- flake.lock | 61 ++++ flake.nix | 51 +++ nix/apps.nix | 34 ++ nix/checks.nix | 13 + nix/modules/tasks.nix | 418 +++++++++++++++++++++++++ nix/packages/backend.nix | 85 +++++ nix/packages/default-app.nix | 55 ++++ nix/packages/default.nix | 42 +++ nix/packages/docker.nix | 92 ++++++ nix/packages/mkDockerImage.nix | 45 +++ nix/packages/mkRootfs.nix | 48 +++ nix/packages/proxy.nix | 44 +++ nix/packages/python-env.nix | 30 ++ nix/packages/simulator.nix | 65 ++++ nix/packages/web.nix | 101 ++++++ nix/shells/default.nix | 70 +++++ nix/shells/helpers.nix | 239 ++++++++++++++ proxy/Dockerfile | 30 +- shell.nix | 222 ------------- simulator/Dockerfile | 61 ++-- simulator/README.md | 2 +- web/Dockerfile | 90 ++---- 33 files changed, 1977 insertions(+), 773 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/workflows/build-web.yml delete mode 100644 .github/workflows/docker-build.yml delete mode 100644 .github/workflows/lint-dockerfiles.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/apps.nix create mode 100644 nix/checks.nix create mode 100644 nix/modules/tasks.nix create mode 100644 nix/packages/backend.nix create mode 100644 nix/packages/default-app.nix create mode 100644 nix/packages/default.nix create mode 100644 nix/packages/docker.nix create mode 100644 nix/packages/mkDockerImage.nix create mode 100644 nix/packages/mkRootfs.nix create mode 100644 nix/packages/proxy.nix create mode 100644 nix/packages/python-env.nix create mode 100644 nix/packages/simulator.nix create mode 100644 nix/packages/web.nix create mode 100644 nix/shells/default.nix create mode 100644 nix/shells/helpers.nix delete mode 100644 shell.nix diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..01c3c812 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.git +**/venv +**/.venv +**/node_modules +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +**/build +**/.next +**/result +**/result-* +**/.env +**/.env.* +**/coverage.xml +**/.coverage +**/htmlcov +**/playwright-report +**/test-results +tests +docs +.github +*.md +.idea +.vscode +.fleet +.DS_Store diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 19f92a04..d8ce17d7 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,86 +1,62 @@ # GitHub Actions -## Workflow map +## Pipeline order ```mermaid flowchart TD - subgraph quality["Quality gate — tests.yml (main/develop PRs)"] + subgraph lint["1. Lint"] BL[backend-lint] SL[simulator-lint] - FE[frontend] - BT[backend-tests] - E2E[e2e-tests] - CI[ci] + FE[frontend-lint] + DL[dockerfile-lint] + NF[nix-fmt] + L[lint aggregator] + BL --> L + SL --> L + FE --> L + DL --> L + NF --> L + end - BL --> BT - FE --> E2E - BT --> CI - SL --> CI - FE --> CI - E2E --> CI + subgraph nix["2. Nix"] + N[nix build packages + docker streams + rootfs] end - subgraph compile["Compile checks — path-filtered"] - BW[build-web.yml] - LD[lint-dockerfiles.yml] + subgraph docker["3. Docker"] + D[load streams / push GHCR / image artifacts] end - subgraph release["Container images — main + relevant PRs"] - DB[docker-build.yml] + subgraph tests["4. Tests"] + BT[backend-tests] + FT[frontend-tests] + E2E[e2e-tests] + EP[e2e-proxy] end + + L --> N --> D + D --> BT + D --> FT + D --> E2E + D --> EP + BT --> CI[ci] + FT --> CI + E2E --> CI + EP --> CI + N --> CI + D --> CI ``` -## When each workflow runs +## Workflow | Workflow | Trigger | Purpose | |----------|---------|---------| -| `tests.yml` | Push/PR to `main` or `develop`, manual dispatch | Primary quality gate: lint, unit/integration tests, E2E | -| `build-web.yml` | Push/PR when `web/**` changes | Next.js production build on Node 22 | -| `lint-dockerfiles.yml` | Push/PR when Dockerfiles change | Hadolint | -| `docker-build.yml` | Push to `main`; PR when app/Docker paths change | Build and push GHCR images | +| `tests.yml` | Push/PR to `main` or `develop`, manual dispatch | Full gate: lint → nix → docker → tests | -## `tests.yml` job graph +Images are built with Nix (`streamLayeredImage` from scratch). Dockerfiles are a two-step `nixos/nix` builder → `scratch` rootfs for local `docker compose build`. -Lint and frontend checks start immediately in parallel: - -- `backend-lint` → `backend-tests` (Python 3.11 / 3.12 / 3.13) -- `simulator-lint` -- `frontend` (ESLint, typecheck, translation keys, Vitest) - -`e2e-tests` depends only on `frontend`, not on the backend test matrix. E2E boots its own backend and most data-loading specs mock GraphQL anyway, so waiting for three Python versions added minutes without improving signal. - -`ci` is the single aggregation job for branch protection. It succeeds only when `backend-tests`, `simulator-lint`, `frontend`, and `e2e-tests` all pass. - -Re-run the full suite manually: +## Local act ```bash -gh workflow run tests.yml -``` - -## Running locally with act - -```bash -# List jobs act -l -W .github/workflows/tests.yml - -# Run the full quality gate -act -W .github/workflows/tests.yml - -# Run one job -act -W .github/workflows/tests.yml -j frontend -act -W .github/workflows/tests.yml -j e2e-tests +act -W .github/workflows/tests.yml -j nix ``` - -Prerequisites: Docker running, [act](https://github.com/nektos/act) installed. - -E2E and service-backed jobs need a medium or large act image. Service containers (Postgres, Redis) are started automatically. - -## Removed workflows - -These duplicated `tests.yml` and ran the same checks twice on every PR: - -- `e2e-tests.yml` -- `backend-tests.yml` -- `frontend-tests.yml` -- `lint-python.yml` (flake8; the repo uses ruff in `tests.yml`) -- `build-docker-*.yml` (four files → `docker-build.yml` matrix) diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml deleted file mode 100644 index 7b376876..00000000 --- a/.github/workflows/build-web.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Web build - -env: - NODE_VERSION: 22.19.0 - -on: - push: - branches: ["*"] - tags: ["v*"] - paths: - - "web/**" - - ".github/workflows/build-web.yml" - pull_request: - paths: - - "web/**" - - ".github/workflows/build-web.yml" - -concurrency: - group: web-build-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - defaults: - run: - working-directory: web - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup nodejs - uses: actions/setup-node@v7 - with: - node-version: ${{ env.NODE_VERSION }} - registry-url: "https://registry.npmjs.org" - scope: "@helpwave" - cache: "npm" - cache-dependency-path: web/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Build Next.js - run: npm run build - - - name: Upload Next.js build artifact - uses: actions/upload-artifact@v7 - with: - name: next-build - path: web/.next diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml deleted file mode 100644 index a578989a..00000000 --- a/.github/workflows/docker-build.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Docker Build - -on: - push: - branches: [main] - pull_request: - paths: - - "backend/**" - - "web/**" - - "proxy/**" - - "simulator/**" - - "**/Dockerfile" - - ".github/workflows/docker-build.yml" - -env: - REGISTRY: ghcr.io - -jobs: - build: - name: Build ${{ matrix.component }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - component: backend - context: backend - image: ${{ github.repository }}-backend - - component: web - context: web - image: ${{ github.repository }}-web - - component: proxy - context: proxy - image: ${{ github.repository }}-proxy - - component: simulator - context: simulator - image: ${{ github.repository }}-simulator - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ env.REGISTRY }}/${{ matrix.image }} - tags: | - type=ref,event=pr - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push docker image - uses: docker/build-push-action@v7 - with: - context: ${{ matrix.context }} - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ matrix.component }} - cache-to: type=gha,mode=max,scope=${{ matrix.component }} diff --git a/.github/workflows/lint-dockerfiles.yml b/.github/workflows/lint-dockerfiles.yml deleted file mode 100644 index ecee35a3..00000000 --- a/.github/workflows/lint-dockerfiles.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Lint Dockerfiles - -on: - push: - branches: ["*"] - paths: - - "**/Dockerfile" - - ".github/workflows/lint-dockerfiles.yml" - pull_request: - paths: - - "**/Dockerfile" - - ".github/workflows/lint-dockerfiles.yml" - -jobs: - hadolint: - name: Lint Dockerfiles - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Run hadolint on backend - uses: hadolint/hadolint-action@v3.3.0 - with: - dockerfile: backend/Dockerfile - failure-threshold: warning - - - name: Run hadolint on proxy - uses: hadolint/hadolint-action@v3.3.0 - with: - dockerfile: proxy/Dockerfile - failure-threshold: warning - - - name: Run hadolint on simulator - uses: hadolint/hadolint-action@v3.3.0 - with: - dockerfile: simulator/Dockerfile - failure-threshold: warning - - - name: Run hadolint on web - uses: hadolint/hadolint-action@v3.3.0 - with: - dockerfile: web/Dockerfile - failure-threshold: warning diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d39d8d9..adad504d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -name: Tests +name: CI on: push: @@ -8,89 +8,188 @@ on: workflow_dispatch: concurrency: - group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + REGISTRY: ghcr.io + NIX_CONFIG: "experimental-features = nix-command flakes" + jobs: backend-lint: + name: Lint backend runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v7 + - uses: actions/setup-python@v7 with: python-version: "3.13" - - - name: Install dependencies + - name: Install ruff + run: pip install ruff + - name: Run ruff working-directory: backend - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run linting - working-directory: backend - run: | - pip install ruff - ruff check . --output-format=concise --exclude database/migrations + run: ruff check . --output-format=concise --exclude database/migrations simulator-lint: + name: Lint simulator runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v7 + - uses: actions/setup-python@v7 with: python-version: "3.13" - - - name: Install dependencies - working-directory: simulator - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run linting + - name: Install ruff + run: pip install ruff + - name: Run ruff working-directory: simulator - run: | - pip install ruff - ruff check . --output-format=concise + run: ruff check . --output-format=concise - frontend: + frontend-lint: + name: Lint frontend runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - - name: Set up Node.js - uses: actions/setup-node@v7 + - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: web/package-lock.json - - name: Install dependencies working-directory: web run: npm ci - - name: Lint working-directory: web run: npm run lint - - name: Check translation keys working-directory: web run: npm run check-translations - - name: Unit tests - working-directory: web - run: npm run test + dockerfile-lint: + name: Lint Dockerfiles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: backend/Dockerfile + failure-threshold: warning + - uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: web/Dockerfile + failure-threshold: warning + - uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: simulator/Dockerfile + failure-threshold: warning + - uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: proxy/Dockerfile + failure-threshold: warning + + nix-fmt: + name: Format nix + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: DeterminateSystems/nix-installer-action@v19 + - uses: DeterminateSystems/magic-nix-cache-action@v12 + - name: Check nix formatting + run: | + mapfile -t files < <(find . -name '*.nix' -not -path './result*' ) + nix shell nixpkgs#nixfmt -c nixfmt --check "${files[@]}" + + lint: + name: Lint + needs: [backend-lint, simulator-lint, frontend-lint, dockerfile-lint, nix-fmt] + runs-on: ubuntu-latest + steps: + - run: echo "All lint jobs passed." + + nix: + name: Nix build + needs: [lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: DeterminateSystems/nix-installer-action@v19 + - uses: DeterminateSystems/magic-nix-cache-action@v12 + - name: Build packages + run: | + nix build .#backend .#web .#simulator .#proxy .#default -L + - name: Build scratch image streams + run: | + nix build .#backend-docker .#web-docker .#proxy-docker .#simulator-docker -L + - name: Build rootfs derivations + run: | + nix build .#backend-rootfs .#web-rootfs .#proxy-rootfs .#simulator-rootfs -L + - name: Flake check + run: nix flake check --no-build + + docker: + name: Docker ${{ matrix.component }} + needs: [nix] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - component: backend + flake_attr: backend-docker + image_name: helpwave-tasks-backend + ghcr_image: ${{ github.repository }}-backend + - component: web + flake_attr: web-docker + image_name: helpwave-tasks-web + ghcr_image: ${{ github.repository }}-web + - component: proxy + flake_attr: proxy-docker + image_name: helpwave-tasks-proxy + ghcr_image: ${{ github.repository }}-proxy + - component: simulator + flake_attr: simulator-docker + image_name: helpwave-tasks-simulator + ghcr_image: ${{ github.repository }}-simulator + steps: + - uses: actions/checkout@v7 + - uses: DeterminateSystems/nix-installer-action@v19 + - uses: DeterminateSystems/magic-nix-cache-action@v12 + - uses: docker/setup-buildx-action@v4 + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build nix image stream and load + run: | + nix build ".#${{ matrix.flake_attr }}" -o "./result-${{ matrix.component }}" -L + "./result-${{ matrix.component }}" | docker load + docker tag "${{ matrix.image_name }}:latest" "local/tasks-${{ matrix.component }}:ci" + - name: Push to GHCR + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + run: | + docker tag "${{ matrix.image_name }}:latest" "${{ env.REGISTRY }}/${{ matrix.ghcr_image }}:latest" + docker push "${{ env.REGISTRY }}/${{ matrix.ghcr_image }}:latest" + - name: Save image artifact for tests + run: | + docker save "local/tasks-${{ matrix.component }}:ci" | gzip > "/tmp/tasks-${{ matrix.component }}.tar.gz" + - uses: actions/upload-artifact@v7 + with: + name: image-${{ matrix.component }} + path: /tmp/tasks-${{ matrix.component }}.tar.gz + retention-days: 1 backend-tests: - needs: [backend-lint] + name: Backend tests + needs: [docker] runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13"] - services: postgres: image: postgres:15 @@ -105,7 +204,6 @@ jobs: --health-retries 5 ports: - 5432:5432 - redis: image: redis:7-alpine options: >- @@ -115,40 +213,29 @@ jobs: --health-retries 5 ports: - 6379:6379 - steps: - uses: actions/checkout@v7 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - - - name: Cache pip packages - uses: actions/cache@v6 + - uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements.txt') }} restore-keys: | ${{ runner.os }}-pip- - - name: Install dependencies working-directory: backend run: | python -m pip install --upgrade pip pip install -r requirements.txt - - - name: Run unit tests + - name: Unit tests working-directory: backend - run: | - pytest tests/unit -v --cov=api --cov=database --cov-report=xml --cov-report=term - - - name: Run integration tests + run: pytest tests/unit -v --cov=api --cov=database --cov-report=xml --cov-report=term + - name: Integration tests working-directory: backend - run: | - pytest tests/integration -v - - - name: Upload coverage to Codecov + run: pytest tests/integration -v + - name: Upload coverage if: matrix.python-version == '3.13' uses: codecov/codecov-action@v7 with: @@ -156,10 +243,28 @@ jobs: flags: backend name: backend-coverage - e2e-tests: - needs: [frontend] + frontend-tests: + name: Frontend unit tests + needs: [docker] runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: web/package-lock.json + - name: Install dependencies + working-directory: web + run: npm ci + - name: Unit tests + working-directory: web + run: npm run test + e2e-tests: + name: E2E + needs: [docker] + runs-on: ubuntu-latest services: postgres: image: postgres:15 @@ -174,7 +279,6 @@ jobs: --health-retries 5 ports: - 5432:5432 - redis: image: redis:7-alpine options: >- @@ -184,7 +288,6 @@ jobs: --health-retries 5 ports: - 6379:6379 - env: DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test REDIS_URL: redis://localhost:6379 @@ -199,85 +302,66 @@ jobs: INFLUXDB_BUCKET: test NEXT_PUBLIC_API_URL: http://localhost:8000/graphql E2E_BASE_URL: http://localhost:3000 - steps: - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v7 + - uses: actions/setup-python@v7 with: python-version: "3.13" - - - name: Set up Node.js - uses: actions/setup-node@v7 + - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" cache: "npm" - cache-dependency-path: web/package-lock.json - + cache-dependency-path: | + web/package-lock.json + tests/package-lock.json - name: Install backend dependencies working-directory: backend run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: Install frontend dependencies working-directory: web run: npm ci - - - name: Install E2E test dependencies + - name: Install E2E dependencies working-directory: tests run: npm ci - - name: Install Playwright browsers working-directory: tests run: npx playwright install --with-deps chromium continue-on-error: true - - - name: Run database migrations + - name: Migrate database working-directory: backend run: alembic upgrade head - - - name: Start backend server + - name: Start backend working-directory: backend run: | uvicorn main:app --host 0.0.0.0 --port 8000 > /tmp/backend.log 2>&1 & echo $! > /tmp/backend.pid - - name: Build frontend working-directory: web run: npm run build - - - name: Start frontend server + - name: Start frontend working-directory: web run: | npm start > /tmp/frontend.log 2>&1 & echo $! > /tmp/frontend.pid - - name: Wait for servers run: | - echo "Waiting for backend..." timeout 120 bash -c 'until curl -f -s http://localhost:8000/health > /dev/null 2>&1; do sleep 2; done' - echo "Waiting for frontend..." timeout 120 bash -c 'until curl -f -s http://localhost:3000 > /dev/null 2>&1; do sleep 2; done' - - name: Run E2E tests working-directory: tests env: CI: true run: npx playwright test - - - name: Upload Playwright report + - uses: actions/upload-artifact@v7 if: always() - uses: actions/upload-artifact@v7 with: name: playwright-report path: tests/playwright-report/ retention-days: 30 - - - name: Upload server logs + - uses: actions/upload-artifact@v7 if: failure() - uses: actions/upload-artifact@v7 with: name: server-logs path: | @@ -286,76 +370,56 @@ jobs: retention-days: 7 e2e-proxy: - name: E2E (docker stack via nginx proxy) - needs: [backend-lint, frontend] + name: E2E (proxy stack) + needs: [docker] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Build backend image - uses: docker/build-push-action@v7 + - name: Download backend image + uses: actions/download-artifact@v7 with: - context: backend - push: false - load: true - tags: local/tasks-backend:e2e - cache-from: type=gha,scope=backend - cache-to: type=gha,mode=max,scope=backend - - - name: Build web image - uses: docker/build-push-action@v7 + name: image-backend + path: /tmp/images + - name: Download web image + uses: actions/download-artifact@v7 with: - context: web - push: false - load: true - tags: local/tasks-web:e2e - cache-from: type=gha,scope=web - cache-to: type=gha,mode=max,scope=web - - - name: Build proxy image - uses: docker/build-push-action@v7 + name: image-web + path: /tmp/images + - name: Download proxy image + uses: actions/download-artifact@v7 with: - context: proxy - push: false - load: true - tags: local/tasks-proxy:e2e - cache-from: type=gha,scope=proxy - cache-to: type=gha,mode=max,scope=proxy - + name: image-proxy + path: /tmp/images + - name: Load images + run: | + gunzip -c /tmp/images/tasks-backend.tar.gz | docker load + gunzip -c /tmp/images/tasks-web.tar.gz | docker load + gunzip -c /tmp/images/tasks-proxy.tar.gz | docker load + docker tag local/tasks-backend:ci local/tasks-backend:e2e + docker tag local/tasks-web:ci local/tasks-web:e2e + docker tag local/tasks-proxy:ci local/tasks-proxy:e2e - name: Start the stack env: BACKEND_IMAGE: local/tasks-backend:e2e WEB_IMAGE: local/tasks-web:e2e PROXY_IMAGE: local/tasks-proxy:e2e run: docker compose -f docker-compose.e2e.yml up -d - - - name: Wait for the stack behind the proxy + - name: Wait for the stack run: | - echo "Waiting for keycloak (realm import)..." timeout 180 bash -c 'until curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration > /dev/null; do sleep 3; done' - echo "Waiting for the backend (graphql must answer, 5xx means not routed/up yet)..." timeout 180 bash -c 'until [ "$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "content-type: application/json" -d "{\"query\":\"{ __typename }\"}" http://localhost/graphql)" -lt 500 ]; do sleep 3; done' - echo "Waiting for the frontend..." timeout 180 bash -c 'until curl -fs http://localhost/ > /dev/null; do sleep 3; done' - - - name: Set up Node.js - uses: actions/setup-node@v7 + - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: tests/package-lock.json - - - name: Install E2E test dependencies + - name: Install E2E dependencies working-directory: tests run: npm ci - - name: Install Playwright browsers working-directory: tests run: npx playwright install --with-deps chromium - - name: Run proxied E2E tests working-directory: tests env: @@ -363,22 +427,17 @@ jobs: E2E_PROXY_TARGET: "1" E2E_BASE_URL: http://localhost run: npx playwright test e2e/proxy-fullstack.spec.ts - - name: Dump stack logs if: failure() run: docker compose -f docker-compose.e2e.yml logs --no-color > /tmp/stack.log 2>&1 || true - - - name: Upload Playwright report + - uses: actions/upload-artifact@v7 if: always() - uses: actions/upload-artifact@v7 with: name: playwright-report-proxy path: tests/playwright-report/ retention-days: 30 - - - name: Upload stack logs + - uses: actions/upload-artifact@v7 if: failure() - uses: actions/upload-artifact@v7 with: name: proxy-stack-logs path: /tmp/stack.log @@ -386,17 +445,18 @@ jobs: ci: name: CI - needs: [backend-tests, simulator-lint, frontend, e2e-tests, e2e-proxy] + needs: [backend-tests, frontend-tests, e2e-tests, e2e-proxy, nix, docker] if: always() runs-on: ubuntu-latest steps: - name: Check required jobs run: | if [[ "${{ needs.backend-tests.result }}" != "success" ]] \ - || [[ "${{ needs.simulator-lint.result }}" != "success" ]] \ - || [[ "${{ needs.frontend.result }}" != "success" ]] \ + || [[ "${{ needs.frontend-tests.result }}" != "success" ]] \ || [[ "${{ needs.e2e-tests.result }}" != "success" ]] \ - || [[ "${{ needs.e2e-proxy.result }}" != "success" ]]; then + || [[ "${{ needs.e2e-proxy.result }}" != "success" ]] \ + || [[ "${{ needs.nix.result }}" != "success" ]] \ + || [[ "${{ needs.docker.result }}" != "success" ]]; then echo "One or more required jobs failed or were skipped." exit 1 fi diff --git a/.gitignore b/.gitignore index 47f8cc90..af6bb1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ htmlcov/ /web/profile/ /profile/ +# nix +/result +/result-* + # __pycache__ __pycache__/ -*/__pycache__/ \ No newline at end of file +*/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index e62f67af..44cf0598 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,15 @@ Important paths: ## 4) Local Development Workflow -## Start infrastructure +### Nix (recommended if available) + +```bash +nix develop +(cd web && npm ci) # once +run-dev-all +``` + +### Start infrastructure From repository root: ```bash @@ -140,6 +148,8 @@ Never skip these steps when applicable. Run only what is relevant to your change scope. +CI order on GitHub: **lint → nix build → docker images → tests**. + ### Frontend changes ```bash cd web diff --git a/README.md b/README.md index f508165d..213a3844 100644 --- a/README.md +++ b/README.md @@ -74,23 +74,74 @@ Use this if you prefer managing your own Python and Node versions. python -m simulator ``` -### Option B: Automated Setup (Nix) -Use this to let Nix handle dependencies, environment variables, and helper commands automatically. +### Option B: Nix flake -1. **Enter Shell** - ```bash - nix-shell - ``` +Requires [Nix](https://nixos.org/download/) with flakes enabled. The flake provides pure packages, a side-effect-free `nix develop` shell, and a NixOS module. Infrastructure (Postgres, Redis, Keycloak, InfluxDB) still comes from Docker Compose in the develop shell. -2. **Start Everything** - ```bash - run-dev-all - ``` +#### Develop -3. **Run Simulator** (Optional) - ```bash - run-simulator - ``` +```bash +nix develop +# or from anywhere: +nix develop github:helpwave/tasks +``` + +First time only for the frontend checkout: + +```bash +(cd web && npm ci) +``` + +Then: + +```bash +run-dev-all +# optional: +run-simulator +``` + +Helpers on `PATH`: `run-dev-backend`, `run-dev-web`, `run-dev-all`, `run-alembic`, `run-alembic-upgrade`, `psql-dev`, `redis-cli-dev`, `start-docker`, `stop-docker`, `clean-dev`, `run-simulator`, `lint-dockerfiles`, `run-act`. + +The develop shell does **not** create `venv/` or run `npm install` into the tree. + +#### Run / install from GitHub + +```bash +nix run github:helpwave/tasks # usage help +nix run github:helpwave/tasks#backend +nix run github:helpwave/tasks#web +nix run github:helpwave/tasks#simulator +nix run github:helpwave/tasks#proxy + +nix profile install github:helpwave/tasks#backend +nix profile install github:helpwave/tasks#web +``` + +Standalone packages expect configured Postgres, Redis, Keycloak, and InfluxDB (or use the NixOS module below). + +#### NixOS module + +```nix +{ + inputs.helpwave-tasks.url = "github:helpwave/tasks"; + + outputs = { nixpkgs, helpwave-tasks, ... }: { + nixosConfigurations.example = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + helpwave-tasks.nixosModules.default + { + services.helpwave-tasks.enable = true; + # services.helpwave-tasks.backend.environmentFile = "/run/secrets/tasks.env"; + # services.helpwave-tasks.keycloak.issuerUri = "https://auth.example/realms/tasks"; + } + ]; + }; + }; +} +``` + +Local Postgres and Redis are enabled by default. Optional InfluxDB 2 and Keycloak can be turned on with `influxdb.createLocally` / `keycloak.createLocally`. Put secrets in `backend.environmentFile`. ### Access & Credentials @@ -163,8 +214,25 @@ act -j backend-tests ## Docker Images -All components are containerized and available on GitHub Container Registry: +Images are built from Nix (scratch rootfs). GHCR tags on `main`: + - `ghcr.io/helpwave/tasks-backend:latest` - `ghcr.io/helpwave/tasks-web:latest` - `ghcr.io/helpwave/tasks-simulator:latest` - `ghcr.io/helpwave/tasks-proxy:latest` + +Build locally via stream: + +```bash +nix build .#backend-docker && ./result | docker load +nix build .#web-docker && ./result | docker load +``` + +Or via two-step Dockerfiles (`nixos/nix` → `scratch`), from the repo root: + +```bash +docker build -f backend/Dockerfile -t helpwave-tasks-backend . +docker build -f web/Dockerfile -t helpwave-tasks-web . +docker build -f proxy/Dockerfile -t helpwave-tasks-proxy . +docker build -f simulator/Dockerfile -t helpwave-tasks-simulator . +``` diff --git a/backend/Dockerfile b/backend/Dockerfile index ccc5e98e..5532958f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,51 +1,31 @@ -ARG PY_VERSION="3.13" -ARG PYTHON_ALPINE_DIGEST="sha256:db66119d6609a3a941a9433b225f4e13d33c459cede097cf3ec2fc4d1bd314b2" +# syntax=docker/dockerfile:1 -FROM python:${PY_VERSION}-alpine@${PYTHON_ALPINE_DIGEST} AS builder +FROM nixos/nix:2.28.4 AS builder -ENV PIP_NO_WARN_ABOUT_ROOT_USER=1 +RUN printf '%s\n' \ + 'extra-experimental-features = nix-command flakes' \ + 'filter-syscalls = false' \ + >> /etc/nix/nix.conf WORKDIR /build -RUN apk add --no-cache \ - binutils=2.45.1-r1 \ - gcc=15.2.0-r5 \ - gmp=6.3.0-r4 \ - isl26=0.26-r2 \ - jansson=2.15.0-r0 \ - libatomic=15.2.0-r5 \ - libffi-dev=3.5.2-r1 \ - libgcc=15.2.0-r5 \ - libgomp=15.2.0-r5 \ - libstdc++=15.2.0-r5 \ - linux-headers=7.0.0-r1 \ - mpc1=1.3.1-r1 \ - mpfr4=4.2.2-r0 \ - musl-dev=1.2.6-r2 \ - libcrypto3=3.5.7-r0 \ - libssl3=3.5.7-r0 \ - openssl-dev=3.5.7-r0 \ - pkgconf=2.5.1-r0 \ - zstd-libs=1.5.7-r2 +COPY flake.nix flake.lock ./ +COPY nix ./nix +COPY backend ./backend +COPY scaffold ./scaffold -COPY requirements.txt /build +RUN nix build "path:/build#backend-rootfs" --out-link /rootfs -RUN pip install --prefix=/build/venv --no-cache-dir --upgrade -r requirements.txt +FROM scratch +COPY --from=builder /rootfs/ / -FROM python:${PY_VERSION}-alpine@${PYTHON_ALPINE_DIGEST} +ENV PATH=/bin \ + SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ + HOST=0.0.0.0 \ + PORT=80 \ + ENV=production -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 +EXPOSE 80 -ENV PORT=80 -ENV HOST="0.0.0.0" - -COPY --from=builder /build/venv /usr/local/ -COPY . /app - -WORKDIR /app - -RUN mkdir -p /scaffold && chmod 755 /scaffold - -CMD ["sh", "-c", "alembic upgrade head && uvicorn main:app --proxy-headers --host $HOST --port $PORT"] +ENTRYPOINT ["/bin/docker-entrypoint"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0d9b11ac..0f156e27 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -62,7 +62,9 @@ services: - "influxdb-data:/var/lib/influxdb2" backend: - build: backend + build: + context: . + dockerfile: backend/Dockerfile ports: - "8000:80" environment: @@ -94,7 +96,9 @@ services: - influxdb web: - build: web + build: + context: . + dockerfile: web/Dockerfile ports: - "3000:80" environment: @@ -107,7 +111,9 @@ services: - backend simulator: - build: simulator + build: + context: . + dockerfile: simulator/Dockerfile environment: KEYCLOAK_URL: "http://keycloak:8080" API_URL: "http://backend:80/graphql" diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..541035b9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1785627969, + "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1785734586, + "narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "531670d871c0e29724a02f3cbcac170adc65b58c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1785031560, + "narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..31662ae1 --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "helpwave tasks — healthcare ward and task management"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + flake-parts.url = "github:hercules-ci/flake-parts"; + }; + + outputs = + inputs@{ + self, + flake-parts, + ... + }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + imports = [ + ./nix/packages + ./nix/shells + ./nix/apps.nix + ./nix/checks.nix + ]; + + flake = { + nixosModules.default = + { lib, pkgs, ... }: + { + imports = [ ./nix/modules/tasks.nix ]; + services.helpwave-tasks = { + backend.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.backend; + web.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.web; + simulator.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.simulator; + proxy.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.proxy; + }; + }; + + overlays.default = final: _prev: { + helpwave-tasks-backend = self.packages.${final.stdenv.hostPlatform.system}.backend; + helpwave-tasks-web = self.packages.${final.stdenv.hostPlatform.system}.web; + helpwave-tasks-simulator = self.packages.${final.stdenv.hostPlatform.system}.simulator; + helpwave-tasks-proxy = self.packages.${final.stdenv.hostPlatform.system}.proxy; + }; + }; + }; +} diff --git a/nix/apps.nix b/nix/apps.nix new file mode 100644 index 00000000..be0a5eae --- /dev/null +++ b/nix/apps.nix @@ -0,0 +1,34 @@ +{ ... }: +{ + perSystem = + { config, lib, ... }: + { + apps = { + default = { + type = "app"; + program = lib.getExe config.packages.default; + meta.description = "helpwave tasks flake usage help"; + }; + backend = { + type = "app"; + program = lib.getExe config.packages.backend; + meta.description = "helpwave tasks GraphQL API backend"; + }; + web = { + type = "app"; + program = lib.getExe config.packages.web; + meta.description = "helpwave tasks Next.js frontend"; + }; + simulator = { + type = "app"; + program = lib.getExe config.packages.simulator; + meta.description = "helpwave tasks clinic traffic simulator"; + }; + proxy = { + type = "app"; + program = lib.getExe config.packages.proxy; + meta.description = "helpwave tasks nginx reverse proxy"; + }; + }; + }; +} diff --git a/nix/checks.nix b/nix/checks.nix new file mode 100644 index 00000000..dd87c7b1 --- /dev/null +++ b/nix/checks.nix @@ -0,0 +1,13 @@ +{ ... }: +{ + perSystem = + { config, ... }: + { + checks = { + backend = config.packages.backend; + web = config.packages.web; + simulator = config.packages.simulator; + proxy = config.packages.proxy; + }; + }; +} diff --git a/nix/modules/tasks.nix b/nix/modules/tasks.nix new file mode 100644 index 00000000..66bfc432 --- /dev/null +++ b/nix/modules/tasks.nix @@ -0,0 +1,418 @@ +{ + config, + lib, + ... +}: + +let + cfg = config.services.helpwave-tasks; + inherit (lib) + mkEnableOption + mkIf + mkOption + types + optional + optionals + optionalAttrs + ; +in +{ + options.services.helpwave-tasks = { + enable = mkEnableOption "helpwave tasks"; + + domain = mkOption { + type = types.str; + default = "localhost"; + description = "Public hostname used to derive default URLs."; + }; + + backend = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable the GraphQL API backend."; + }; + + package = mkOption { + type = types.package; + description = "Backend package providing tasks-backend and tasks-alembic."; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = "Port the backend listens on."; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Bind address for the backend."; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Environment file with secrets (DATABASE_URL / DB credentials, REDIS_URL, + CLIENT_SECRET, INFLUXDB_TOKEN, etc.). + ''; + }; + + environment = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Extra environment variables for the backend."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the backend port in the firewall."; + }; + }; + + web = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable the Next.js frontend."; + }; + + package = mkOption { + type = types.package; + description = "Frontend package providing tasks-web."; + }; + + port = mkOption { + type = types.port; + default = 3000; + description = "Port the frontend listens on."; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Bind address for the frontend."; + }; + + runtime = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + RUNTIME_* variables written into env-config.js (and passed to the process). + Defaults are derived from domain / ports when unset. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Optional environment file for the web process."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the web port in the firewall."; + }; + }; + + proxy = { + enable = mkEnableOption "nginx reverse proxy for helpwave tasks"; + + package = mkOption { + type = types.package; + description = "Proxy package providing tasks-proxy."; + }; + + openFirewall = mkOption { + type = types.bool; + default = true; + description = "Open TCP port 80 for the proxy."; + }; + }; + + simulator = { + package = mkOption { + type = types.package; + description = "Simulator package (installed into systemPackages)."; + }; + }; + + database = { + createLocally = mkOption { + type = types.bool; + default = true; + description = "Provision a local PostgreSQL database for the backend."; + }; + + name = mkOption { + type = types.str; + default = "tasks"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "helpwave-tasks"; + description = "Database role (should match the service user for peer auth)."; + }; + }; + + redis = { + createLocally = mkOption { + type = types.bool; + default = true; + description = "Provision a local Redis instance."; + }; + }; + + influxdb = { + createLocally = mkOption { + type = types.bool; + default = false; + description = "Provision a local InfluxDB 2 instance for audit logs."; + }; + + organization = mkOption { + type = types.str; + default = "tasks"; + description = "InfluxDB organization."; + }; + + bucket = mkOption { + type = types.str; + default = "audit"; + description = "InfluxDB bucket."; + }; + + tokenFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to a file containing the InfluxDB admin/API token."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to a file containing the InfluxDB initial admin password."; + }; + }; + + keycloak = { + createLocally = mkOption { + type = types.bool; + default = false; + description = '' + Enable services.keycloak. Realm import from this repository is left to the + operator; set issuerUri to the resulting realm URL. + ''; + }; + + issuerUri = mkOption { + type = types.str; + default = "http://localhost:8080/realms/tasks"; + description = "OIDC issuer URI for the tasks realm."; + }; + }; + }; + + config = + let + publicOrigin = "http://${cfg.domain}"; + graphqlPublic = + if cfg.proxy.enable then + "${publicOrigin}/graphql" + else + "http://${cfg.domain}:${toString cfg.backend.port}/graphql"; + + defaultWebRuntime = { + RUNTIME_GRAPHQL_ENDPOINT = graphqlPublic; + RUNTIME_ISSUER_URI = cfg.keycloak.issuerUri; + RUNTIME_CLIENT_ID = "tasks-web"; + RUNTIME_REDIRECT_URI = + if cfg.proxy.enable then + "${publicOrigin}/auth/callback" + else + "http://${cfg.domain}:${toString cfg.web.port}/auth/callback"; + RUNTIME_POST_LOGOUT_REDIRECT_URI = + if cfg.proxy.enable then "${publicOrigin}/" else "http://${cfg.domain}:${toString cfg.web.port}/"; + }; + + webRuntime = defaultWebRuntime // cfg.web.runtime; + + redisPort = + if cfg.redis.createLocally then config.services.redis.servers.helpwave-tasks.port else 6379; + + backendEnv = { + ENV = "production"; + HOST = cfg.backend.host; + PORT = toString cfg.backend.port; + ISSUER_URI = cfg.keycloak.issuerUri; + PUBLIC_ISSUER_URI = cfg.keycloak.issuerUri; + ALLOWED_ORIGINS = + if cfg.proxy.enable then publicOrigin else "http://${cfg.domain}:${toString cfg.web.port}"; + } + // optionalAttrs cfg.database.createLocally { + DATABASE_HOSTNAME = "/run/postgresql"; + DATABASE_NAME = cfg.database.name; + DATABASE_USERNAME = cfg.database.user; + DATABASE_URL = "postgresql+asyncpg://${cfg.database.user}@/${cfg.database.name}?host=/run/postgresql"; + } + // optionalAttrs cfg.redis.createLocally { + REDIS_HOSTNAME = "127.0.0.1"; + REDIS_PORT = toString redisPort; + REDIS_URL = "redis://127.0.0.1:${toString redisPort}"; + } + // optionalAttrs cfg.influxdb.createLocally { + INFLUXDB_URL = "http://127.0.0.1:8086"; + INFLUXDB_ORG = cfg.influxdb.organization; + INFLUXDB_BUCKET = cfg.influxdb.bucket; + } + // cfg.backend.environment; + in + mkIf cfg.enable { + assertions = [ + { + assertion = + !cfg.influxdb.createLocally + || (cfg.influxdb.tokenFile != null && cfg.influxdb.passwordFile != null); + message = "services.helpwave-tasks.influxdb.tokenFile and passwordFile are required when createLocally is true."; + } + ]; + + users.users.helpwave-tasks = { + isSystemUser = true; + group = "helpwave-tasks"; + description = "helpwave tasks service user"; + }; + users.groups.helpwave-tasks = { }; + + services.postgresql = mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.database.user; + ensureDBOwnership = true; + } + ]; + }; + + services.redis.servers.helpwave-tasks = mkIf cfg.redis.createLocally { + enable = true; + port = 6379; + }; + + services.influxdb2 = mkIf cfg.influxdb.createLocally { + enable = true; + provision = { + enable = true; + initialSetup = { + organization = cfg.influxdb.organization; + bucket = cfg.influxdb.bucket; + passwordFile = cfg.influxdb.passwordFile; + tokenFile = cfg.influxdb.tokenFile; + }; + }; + }; + + services.keycloak = mkIf cfg.keycloak.createLocally { + enable = true; + settings = { + http-port = 8080; + hostname-strict = false; + http-enabled = true; + }; + }; + + systemd.services.helpwave-tasks-backend = mkIf cfg.backend.enable { + description = "helpwave tasks backend"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network-online.target" + ] + ++ optional cfg.database.createLocally "postgresql.service" + ++ optional cfg.redis.createLocally "redis-helpwave-tasks.service" + ++ optional cfg.influxdb.createLocally "influxdb2.service"; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + User = "helpwave-tasks"; + Group = "helpwave-tasks"; + ExecStartPre = "${cfg.backend.package}/bin/tasks-alembic upgrade head"; + ExecStart = "${cfg.backend.package}/bin/tasks-backend"; + Restart = "on-failure"; + RestartSec = 5; + StateDirectory = "helpwave-tasks"; + EnvironmentFile = mkIf (cfg.backend.environmentFile != null) [ cfg.backend.environmentFile ]; + }; + + environment = backendEnv; + }; + + systemd.services.helpwave-tasks-web = mkIf cfg.web.enable { + description = "helpwave tasks web"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network-online.target" + ] + ++ optional cfg.backend.enable "helpwave-tasks-backend.service"; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + User = "helpwave-tasks"; + Group = "helpwave-tasks"; + ExecStart = "${cfg.web.package}/bin/tasks-web"; + Restart = "on-failure"; + RestartSec = 5; + StateDirectory = "helpwave-tasks-web"; + Environment = [ + "TASKS_WEB_RUNTIME_DIR=/var/lib/helpwave-tasks-web" + "PORT=${toString cfg.web.port}" + "HOSTNAME=${cfg.web.host}" + "NODE_ENV=production" + ] + ++ lib.mapAttrsToList (k: v: "${k}=${v}") webRuntime; + EnvironmentFile = mkIf (cfg.web.environmentFile != null) [ cfg.web.environmentFile ]; + }; + }; + + systemd.services.helpwave-tasks-proxy = mkIf cfg.proxy.enable { + description = "helpwave tasks nginx proxy"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network-online.target" + ] + ++ optional cfg.web.enable "helpwave-tasks-web.service" + ++ optional cfg.backend.enable "helpwave-tasks-backend.service"; + + serviceConfig = { + Type = "simple"; + ExecStart = "${cfg.proxy.package}/bin/tasks-proxy"; + Restart = "on-failure"; + RestartSec = 5; + RuntimeDirectory = "helpwave-tasks-proxy"; + Environment = [ + "FRONTEND_HOST=${cfg.web.host}:${toString cfg.web.port}" + "BACKEND_HOST=${cfg.backend.host}:${toString cfg.backend.port}" + "KEYCLOAK_HOST=127.0.0.1:8080" + "NGINX_PREFIX=/run/helpwave-tasks-proxy" + ]; + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + DynamicUser = true; + }; + }; + + networking.firewall.allowedTCPPorts = + optionals cfg.backend.openFirewall [ cfg.backend.port ] + ++ optionals cfg.web.openFirewall [ cfg.web.port ] + ++ optionals (cfg.proxy.enable && cfg.proxy.openFirewall) [ 80 ]; + + environment.systemPackages = [ cfg.simulator.package ]; + }; +} diff --git a/nix/packages/backend.nix b/nix/packages/backend.nix new file mode 100644 index 00000000..b7b2315d --- /dev/null +++ b/nix/packages/backend.nix @@ -0,0 +1,85 @@ +{ + lib, + stdenv, + python313, + writeShellScript, +}: + +let + pythonEnv = import ./python-env.nix { python3 = python313; }; + + backendSrc = lib.cleanSourceWith { + src = ../../backend; + filter = + path: _type: + let + base = baseNameOf path; + in + !(builtins.elem base [ + "venv" + ".venv" + "__pycache__" + ".pytest_cache" + ".ruff_cache" + ".env" + ".env.local" + ]); + }; + + scaffoldSrc = lib.cleanSource ../../scaffold; + + launcher = writeShellScript "tasks-backend-launch" '' + set -euo pipefail + root="@out@/lib/helpwave-tasks-backend" + export SCAFFOLD_DIRECTORY="''${SCAFFOLD_DIRECTORY:-@out@/share/helpwave-tasks/scaffold}" + export PYTHONPATH="$root''${PYTHONPATH:+:$PYTHONPATH}" + cd "$root" + exec ${pythonEnv}/bin/uvicorn main:app \ + --proxy-headers \ + --host "''${HOST:-0.0.0.0}" \ + --port "''${PORT:-8000}" \ + "$@" + ''; + + alembicLauncher = writeShellScript "tasks-alembic-launch" '' + set -euo pipefail + root="@out@/lib/helpwave-tasks-backend" + export PYTHONPATH="$root''${PYTHONPATH:+:$PYTHONPATH}" + cd "$root" + exec ${pythonEnv}/bin/alembic "$@" + ''; +in +stdenv.mkDerivation { + pname = "helpwave-tasks-backend"; + version = "0.1.0"; + + src = backendSrc; + + nativeBuildInputs = [ ]; + buildInputs = [ pythonEnv ]; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/helpwave-tasks-backend $out/share/helpwave-tasks $out/bin + cp -r . $out/lib/helpwave-tasks-backend/ + cp -r ${scaffoldSrc} $out/share/helpwave-tasks/scaffold + + substitute ${launcher} $out/bin/tasks-backend --subst-var-by out $out + substitute ${alembicLauncher} $out/bin/tasks-alembic --subst-var-by out $out + chmod +x $out/bin/tasks-backend $out/bin/tasks-alembic + + runHook postInstall + ''; + + meta = { + description = "helpwave tasks GraphQL API backend"; + homepage = "https://github.com/helpwave/tasks"; + license = lib.licenses.mpl20; + mainProgram = "tasks-backend"; + platforms = lib.platforms.unix; + }; +} diff --git a/nix/packages/default-app.nix b/nix/packages/default-app.nix new file mode 100644 index 00000000..b462de10 --- /dev/null +++ b/nix/packages/default-app.nix @@ -0,0 +1,55 @@ +{ + lib, + writeShellApplication, +}: + +writeShellApplication { + name = "helpwave-tasks"; + text = '' + cat <<'EOF' + helpwave tasks — Nix flake + + Packages + nix build github:helpwave/tasks#backend + nix build github:helpwave/tasks#web + nix build github:helpwave/tasks#simulator + nix build github:helpwave/tasks#proxy + + Scratch images (stream → docker load) + nix build github:helpwave/tasks#backend-docker && ./result | docker load + nix build github:helpwave/tasks#web-docker && ./result | docker load + nix build github:helpwave/tasks#proxy-docker && ./result | docker load + nix build github:helpwave/tasks#simulator-docker && ./result | docker load + + Or two-step Dockerfiles (nix builder → scratch) + docker build -f backend/Dockerfile -t helpwave-tasks-backend . + docker build -f web/Dockerfile -t helpwave-tasks-web . + + Run + nix run github:helpwave/tasks#backend + nix run github:helpwave/tasks#web + + Install + nix profile install github:helpwave/tasks#backend + nix profile install github:helpwave/tasks#web + + Develop + nix develop github:helpwave/tasks + run-dev-all + + NixOS + inputs.helpwave-tasks.url = "github:helpwave/tasks"; + imports = [ helpwave-tasks.nixosModules.default ]; + services.helpwave-tasks.enable = true; + + Docs: https://github.com/helpwave/tasks + EOF + ''; + + meta = { + description = "helpwave tasks flake usage help"; + homepage = "https://github.com/helpwave/tasks"; + license = lib.licenses.mpl20; + mainProgram = "helpwave-tasks"; + }; +} diff --git a/nix/packages/default.nix b/nix/packages/default.nix new file mode 100644 index 00000000..39cf2330 --- /dev/null +++ b/nix/packages/default.nix @@ -0,0 +1,42 @@ +{ ... }: +{ + perSystem = + { pkgs, ... }: + let + backend = pkgs.callPackage ./backend.nix { }; + web = pkgs.callPackage ./web.nix { }; + simulator = pkgs.callPackage ./simulator.nix { }; + proxy = pkgs.callPackage ./proxy.nix { }; + docker = pkgs.callPackage ./docker.nix { + inherit + backend + web + simulator + proxy + ; + }; + in + { + formatter = pkgs.nixfmt; + + packages = { + inherit + backend + web + simulator + proxy + ; + inherit (docker) + backend-rootfs + web-rootfs + simulator-rootfs + proxy-rootfs + backend-docker + web-docker + simulator-docker + proxy-docker + ; + default = pkgs.callPackage ./default-app.nix { }; + }; + }; +} diff --git a/nix/packages/docker.nix b/nix/packages/docker.nix new file mode 100644 index 00000000..c0b513d6 --- /dev/null +++ b/nix/packages/docker.nix @@ -0,0 +1,92 @@ +{ + lib, + pkgs, + writeShellScriptBin, + backend, + web, + simulator, + proxy, +}: + +let + mkRootfs = import ./mkRootfs.nix { inherit pkgs; }; + mkDockerImage = import ./mkDockerImage.nix { inherit lib pkgs; }; + + backendEntrypoint = writeShellScriptBin "docker-entrypoint" '' + set -euo pipefail + tasks-alembic upgrade head + exec tasks-backend "$@" + ''; + + backendExtra = [ backendEntrypoint ]; + backendEnv = [ + "HOST=0.0.0.0" + "PORT=80" + "ENV=production" + ]; + + webEnv = [ + "HOSTNAME=0.0.0.0" + "PORT=80" + "NODE_ENV=production" + "TASKS_WEB_RUNTIME_DIR=/tmp/helpwave-tasks-web" + ]; + + proxyEnv = [ + "FRONTEND_HOST=127.0.0.1:3000" + "BACKEND_HOST=127.0.0.1:8000" + "KEYCLOAK_HOST=127.0.0.1:8080" + "NGINX_PREFIX=/tmp/helpwave-tasks-proxy" + ]; +in +{ + backend-rootfs = mkRootfs { + package = backend; + pname = "helpwave-tasks-backend"; + extraPackages = backendExtra; + }; + + web-rootfs = mkRootfs { + package = web; + pname = "helpwave-tasks-web"; + }; + + simulator-rootfs = mkRootfs { + package = simulator; + pname = "helpwave-tasks-simulator"; + }; + + proxy-rootfs = mkRootfs { + package = proxy; + pname = "helpwave-tasks-proxy"; + }; + + backend-docker = mkDockerImage { + package = backend; + name = "helpwave-tasks-backend"; + extraPackages = backendExtra; + entrypoint = [ "${backendEntrypoint}/bin/docker-entrypoint" ]; + env = backendEnv; + }; + + web-docker = mkDockerImage { + package = web; + name = "helpwave-tasks-web"; + entrypoint = [ "${web}/bin/tasks-web" ]; + env = webEnv; + }; + + simulator-docker = mkDockerImage { + package = simulator; + name = "helpwave-tasks-simulator"; + entrypoint = [ "${simulator}/bin/tasks-simulator" ]; + env = [ ]; + }; + + proxy-docker = mkDockerImage { + package = proxy; + name = "helpwave-tasks-proxy"; + entrypoint = [ "${proxy}/bin/tasks-proxy" ]; + env = proxyEnv; + }; +} diff --git a/nix/packages/mkDockerImage.nix b/nix/packages/mkDockerImage.nix new file mode 100644 index 00000000..e4c1666e --- /dev/null +++ b/nix/packages/mkDockerImage.nix @@ -0,0 +1,45 @@ +{ lib, pkgs }: + +{ + package, + name, + tag ? "latest", + extraPackages ? [ ], + entrypoint, + env ? [ ], + exposedPorts ? [ "80" ], +}: + +let + contents = [ + package + pkgs.bash + pkgs.coreutils + pkgs.gnugrep + pkgs.gnused + pkgs.dockerTools.binSh + pkgs.dockerTools.usrBinEnv + pkgs.dockerTools.caCertificates + pkgs.dockerTools.fakeNss + ] + ++ extraPackages; +in +pkgs.dockerTools.streamLayeredImage { + inherit name tag; + inherit contents; + maxLayers = 100; + config = { + Entrypoint = entrypoint; + Env = [ + "PATH=/bin" + "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" + ] + ++ env; + ExposedPorts = lib.listToAttrs ( + map (port: { + name = "${port}/tcp"; + value = { }; + }) exposedPorts + ); + }; +} diff --git a/nix/packages/mkRootfs.nix b/nix/packages/mkRootfs.nix new file mode 100644 index 00000000..5d155021 --- /dev/null +++ b/nix/packages/mkRootfs.nix @@ -0,0 +1,48 @@ +{ pkgs }: + +{ + package, + pname, + extraPackages ? [ ], +}: + +let + contents = pkgs.buildEnv { + name = "${pname}-image-contents"; + paths = [ + package + pkgs.bash + pkgs.coreutils + pkgs.gnugrep + pkgs.gnused + pkgs.dockerTools.binSh + pkgs.dockerTools.usrBinEnv + pkgs.dockerTools.caCertificates + pkgs.dockerTools.fakeNss + ] + ++ extraPackages; + pathsToLink = [ + "/bin" + "/etc" + "/lib" + "/sbin" + "/share" + "/usr" + ]; + }; + + closureInfo = pkgs.closureInfo { rootPaths = [ contents ]; }; +in +pkgs.runCommand "${pname}-rootfs" { } '' + mkdir -p "$out/nix/store" + while IFS= read -r path; do + cp -a "$path" "$out/nix/store/" + done < ${closureInfo}/store-paths + + cp -a ${contents}/. "$out/" + chmod -R u+w "$out" + + rm -rf "$out/tmp" "$out/var" "$out/run" "$out/proc" "$out/sys" "$out/dev" + mkdir -p "$out/tmp" "$out/var/tmp" "$out/run" "$out/proc" "$out/sys" "$out/dev" + chmod 1777 "$out/tmp" "$out/var/tmp" +'' diff --git a/nix/packages/proxy.nix b/nix/packages/proxy.nix new file mode 100644 index 00000000..36ddb833 --- /dev/null +++ b/nix/packages/proxy.nix @@ -0,0 +1,44 @@ +{ + lib, + writeShellApplication, + nginx, +}: + +writeShellApplication { + name = "tasks-proxy"; + runtimeInputs = [ nginx ]; + text = '' + : "''${FRONTEND_HOST:=127.0.0.1:3000}" + : "''${BACKEND_HOST:=127.0.0.1:8000}" + : "''${KEYCLOAK_HOST:=127.0.0.1:8080}" + : "''${NGINX_PREFIX:=''${XDG_RUNTIME_DIR:-/tmp}/helpwave-tasks-proxy}" + + mkdir -p "$NGINX_PREFIX/logs" "$NGINX_PREFIX/conf" "$NGINX_PREFIX/tmp" + + sed \ + -e "s|\''${FRONTEND_HOST}|$FRONTEND_HOST|g" \ + -e "s|\''${BACKEND_HOST}|$BACKEND_HOST|g" \ + -e "s|\''${KEYCLOAK_HOST}|$KEYCLOAK_HOST|g" \ + -e "s|include mime.types;|include ${nginx}/conf/mime.types;|" \ + "${../../proxy/nginx.conf}" \ + > "$NGINX_PREFIX/conf/nginx.body.conf" + + cat > "$NGINX_PREFIX/conf/nginx.conf" <> "$NGINX_PREFIX/conf/nginx.conf" + + exec nginx -p "$NGINX_PREFIX" -c "$NGINX_PREFIX/conf/nginx.conf" + ''; + + meta = { + description = "helpwave tasks nginx reverse proxy"; + homepage = "https://github.com/helpwave/tasks"; + license = lib.licenses.mpl20; + mainProgram = "tasks-proxy"; + }; +} diff --git a/nix/packages/python-env.nix b/nix/packages/python-env.nix new file mode 100644 index 00000000..134cbe65 --- /dev/null +++ b/nix/packages/python-env.nix @@ -0,0 +1,30 @@ +{ python3 }: + +python3.withPackages ( + ps: with ps; [ + aiosqlite + alembic + asyncpg + cryptography + fastapi + httpx + httptools + influxdb-client + openpyxl + pytest + pytest-asyncio + pytest-cov + python-dotenv + python-jose + python-multipart + redis + requests + sqlalchemy + strawberry-graphql + tzdata + uvicorn + uvloop + watchfiles + websockets + ] +) diff --git a/nix/packages/simulator.nix b/nix/packages/simulator.nix new file mode 100644 index 00000000..ba5c3fd6 --- /dev/null +++ b/nix/packages/simulator.nix @@ -0,0 +1,65 @@ +{ + lib, + stdenv, + python313, + makeWrapper, +}: + +let + pythonEnv = python313.withPackages ( + ps: with ps; [ + python-dotenv + requests + ] + ); + + src = lib.cleanSourceWith { + src = ../../simulator; + filter = + path: _type: + let + base = baseNameOf path; + in + !(builtins.elem base [ + "venv" + ".venv" + "__pycache__" + ".env" + ".env.local" + ]); + }; +in +stdenv.mkDerivation { + pname = "helpwave-tasks-simulator"; + version = "0.1.0"; + + inherit src; + + nativeBuildInputs = [ makeWrapper ]; + buildInputs = [ pythonEnv ]; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/helpwave-tasks-simulator $out/bin + cp -r . $out/lib/helpwave-tasks-simulator/ + + makeWrapper ${pythonEnv}/bin/python $out/bin/tasks-simulator \ + --chdir $out/lib/helpwave-tasks-simulator \ + --prefix PYTHONPATH : $out/lib/helpwave-tasks-simulator \ + --add-flags "$out/lib/helpwave-tasks-simulator/main.py" + + runHook postInstall + ''; + + meta = { + description = "helpwave tasks clinic traffic simulator"; + homepage = "https://github.com/helpwave/tasks"; + license = lib.licenses.mpl20; + mainProgram = "tasks-simulator"; + platforms = lib.platforms.unix; + }; +} diff --git a/nix/packages/web.nix b/nix/packages/web.nix new file mode 100644 index 00000000..6f56a18c --- /dev/null +++ b/nix/packages/web.nix @@ -0,0 +1,101 @@ +{ + lib, + buildNpmPackage, + nodejs_22, + writeShellScript, +}: + +let + launcher = writeShellScript "tasks-web-launch" '' + set -euo pipefail + + app="@out@/lib/helpwave-tasks-web" + runtime="''${TASKS_WEB_RUNTIME_DIR:-''${XDG_RUNTIME_DIR:-/tmp}/helpwave-tasks-web-$$}" + mkdir -p "$runtime" + + cp -a --no-preserve=mode "$app/." "$runtime/" + mkdir -p "$runtime/public" + + { + echo "window.__ENV = {" + env | grep "^RUNTIME_" | while IFS= read -r line; do + key=''${line%%=*} + val=''${line#*=} + val=''${val//\\/\\\\} + val=''${val//\"/\\\"} + printf ' "%s": "%s",\n' "$key" "$val" + done + echo "}" + } > "$runtime/public/env-config.js" + + export NODE_ENV="''${NODE_ENV:-production}" + export PORT="''${PORT:-3000}" + export HOSTNAME="''${HOSTNAME:-0.0.0.0}" + + cd "$runtime" + exec ${nodejs_22}/bin/node server.js "$@" + ''; +in +buildNpmPackage { + pname = "helpwave-tasks-web"; + version = "0.1.0"; + + src = lib.cleanSourceWith { + src = ../../web; + filter = + path: _type: + let + base = baseNameOf path; + in + !(builtins.elem base [ + "node_modules" + "build" + ".next" + ".env" + ".env.local" + "tsconfig.tsbuildinfo" + ]); + }; + + npmDepsHash = "sha256-5dIfshqcfVQk96oDRwuiqpFeKkR4Ly6L0tC8+L8Is/s="; + + nodejs = nodejs_22; + + npmBuildScript = "build"; + + buildPhase = '' + runHook preBuild + npm run build-intl + npx next build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/helpwave-tasks-web $out/bin + + cp -r build/standalone/. $out/lib/helpwave-tasks-web/ + mkdir -p $out/lib/helpwave-tasks-web/build + cp -r build/static $out/lib/helpwave-tasks-web/build/static + cp -r public $out/lib/helpwave-tasks-web/public + + if [ -d node_modules/@img ]; then + mkdir -p $out/lib/helpwave-tasks-web/node_modules + cp -aL node_modules/@img $out/lib/helpwave-tasks-web/node_modules/@img + fi + + substitute ${launcher} $out/bin/tasks-web --subst-var-by out $out + chmod +x $out/bin/tasks-web + + runHook postInstall + ''; + + meta = { + description = "helpwave tasks Next.js frontend"; + homepage = "https://github.com/helpwave/tasks"; + license = lib.licenses.mpl20; + mainProgram = "tasks-web"; + platforms = lib.platforms.unix; + }; +} diff --git a/nix/shells/default.nix b/nix/shells/default.nix new file mode 100644 index 00000000..5890f871 --- /dev/null +++ b/nix/shells/default.nix @@ -0,0 +1,70 @@ +{ ... }: +{ + perSystem = + { + pkgs, + self', + ... + }: + let + pythonEnv = import ../packages/python-env.nix { python3 = pkgs.python313; }; + helpers = import ./helpers.nix pkgs; + in + { + devShells.default = pkgs.mkShell { + name = "helpwave-tasks"; + + packages = [ + pythonEnv + pkgs.nodejs_22 + pkgs.docker + pkgs.docker-compose + pkgs.postgresql_15 + pkgs.redis + pkgs.netcat-gnu + pkgs.gcc + pkgs.hadolint + pkgs.act + pkgs.git + ] + ++ helpers + ++ [ + self'.packages.backend + self'.packages.simulator + ]; + + shellHook = '' + export PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + export DOCKER_COMPOSE_FILE="''${DOCKER_COMPOSE_FILE:-$PROJECT_ROOT/docker-compose.dev.yml}" + + export ENV="''${ENV:-development}" + export POSTGRES_USER="''${POSTGRES_USER:-postgres}" + export POSTGRES_PASSWORD="''${POSTGRES_PASSWORD:-password}" + export POSTGRES_DATABASE="''${POSTGRES_DATABASE:-postgres}" + export POSTGRES_HOST="''${POSTGRES_HOST:-localhost}" + export POSTGRES_PORT="''${POSTGRES_PORT:-5432}" + export DATABASE_URL="''${DATABASE_URL:-postgresql+asyncpg://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DATABASE}" + + export REDIS_HOST="''${REDIS_HOST:-localhost}" + export REDIS_PORT="''${REDIS_PORT:-6379}" + export REDIS_PASSWORD="''${REDIS_PASSWORD:-password}" + export REDIS_URL="''${REDIS_URL:-redis://:$REDIS_PASSWORD@$REDIS_HOST:$REDIS_PORT}" + + export ISSUER_URI="''${ISSUER_URI:-http://localhost:8080/realms/tasks}" + export CLIENT_SECRET="''${CLIENT_SECRET:-tasks-secret}" + export SCAFFOLD_DIRECTORY="''${SCAFFOLD_DIRECTORY:-$PROJECT_ROOT/scaffold}" + + export INFLUXDB_URL="''${INFLUXDB_URL:-http://localhost:8086}" + export INFLUXDB_TOKEN="''${INFLUXDB_TOKEN:-tasks-token-secret}" + export INFLUXDB_ORG="''${INFLUXDB_ORG:-tasks}" + export INFLUXDB_BUCKET="''${INFLUXDB_BUCKET:-audit}" + + echo ">>> helpwave tasks dev shell (side-effect free)" + echo ">>> Commands: run-dev-backend, run-dev-web, run-dev-all, run-alembic," + echo ">>> psql-dev, redis-cli-dev, start-docker, stop-docker," + echo ">>> clean-dev, run-simulator, lint-dockerfiles, run-act" + echo ">>> Frontend: run '(cd web && npm ci)' once if node_modules is missing." + ''; + }; + }; +} diff --git a/nix/shells/helpers.nix b/nix/shells/helpers.nix new file mode 100644 index 00000000..25a5f92e --- /dev/null +++ b/nix/shells/helpers.nix @@ -0,0 +1,239 @@ +pkgs: + +let + inherit (pkgs) writeShellApplication; + pythonEnv = import ../packages/python-env.nix { python3 = pkgs.python313; }; + + start-docker = writeShellApplication { + name = "start-docker"; + runtimeInputs = [ pkgs.docker-compose ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + : "''${DOCKER_COMPOSE_FILE:?DOCKER_COMPOSE_FILE is not set}" + echo ">>> Starting PostgreSQL, Redis, Keycloak and InfluxDB via Docker..." + (cd "$PROJECT_ROOT" && docker-compose -f "$DOCKER_COMPOSE_FILE" up -d postgres redis keycloak influxdb) + ''; + }; + + stop-docker = writeShellApplication { + name = "stop-docker"; + runtimeInputs = [ pkgs.docker-compose ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + : "''${DOCKER_COMPOSE_FILE:?DOCKER_COMPOSE_FILE is not set}" + echo ">>> Stopping PostgreSQL, Redis, Keycloak and InfluxDB..." + (cd "$PROJECT_ROOT" && docker-compose -f "$DOCKER_COMPOSE_FILE" down) + ''; + }; + + clean-dev = writeShellApplication { + name = "clean-dev"; + runtimeInputs = [ pkgs.docker-compose ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + : "''${DOCKER_COMPOSE_FILE:?DOCKER_COMPOSE_FILE is not set}" + echo ">>> Stopping and removing containers and volumes..." + (cd "$PROJECT_ROOT" && docker-compose -f "$DOCKER_COMPOSE_FILE" down -v) + echo ">>> Cleaned environment." + ''; + }; + + run-dev-backend = writeShellApplication { + name = "run-dev-backend"; + runtimeInputs = [ + pkgs.docker-compose + pythonEnv + ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + : "''${DOCKER_COMPOSE_FILE:?DOCKER_COMPOSE_FILE is not set}" + docker-compose -f "$DOCKER_COMPOSE_FILE" stop backend || true + cd "$PROJECT_ROOT/backend" + exec uvicorn main:app --reload + ''; + }; + + run-dev-web = writeShellApplication { + name = "run-dev-web"; + runtimeInputs = [ + pkgs.docker-compose + pkgs.nodejs_22 + ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + : "''${DOCKER_COMPOSE_FILE:?DOCKER_COMPOSE_FILE is not set}" + docker-compose -f "$DOCKER_COMPOSE_FILE" stop web || true + cd "$PROJECT_ROOT/web" + if [ ! -d node_modules ]; then + echo ">>> web/node_modules missing — run: (cd web && npm ci)" + exit 1 + fi + exec npm run dev + ''; + }; + + run-alembic = writeShellApplication { + name = "run-alembic"; + runtimeInputs = [ pythonEnv ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + cd "$PROJECT_ROOT/backend" + exec alembic "$@" + ''; + }; + + run-alembic-upgrade = writeShellApplication { + name = "run-alembic-upgrade"; + runtimeInputs = [ + pkgs.netcat-gnu + pythonEnv + ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + port="''${POSTGRES_PORT:-5432}" + while ! nc -z localhost "$port"; do + echo ">>> Waiting for database on :$port..." + sleep 0.5 + done + sleep 1 + echo ">>> Database is up!" + cd "$PROJECT_ROOT/backend" + exec alembic upgrade head + ''; + }; + + psql-dev = writeShellApplication { + name = "psql-dev"; + runtimeInputs = [ pkgs.postgresql_15 ]; + text = '' + export PGPASSWORD="''${POSTGRES_PASSWORD:-password}" + exec psql \ + -h "''${POSTGRES_HOST:-localhost}" \ + -U "''${POSTGRES_USER:-postgres}" \ + -d "''${POSTGRES_DATABASE:-postgres}" \ + -p "''${POSTGRES_PORT:-5432}" \ + "$@" + ''; + }; + + redis-cli-dev = writeShellApplication { + name = "redis-cli-dev"; + runtimeInputs = [ pkgs.redis ]; + text = '' + exec redis-cli \ + -h "''${REDIS_HOST:-localhost}" \ + -p "''${REDIS_PORT:-6379}" \ + "$@" + ''; + }; + + run-simulator = writeShellApplication { + name = "run-simulator"; + runtimeInputs = [ + (pkgs.python313.withPackages ( + ps: with ps; [ + python-dotenv + requests + ] + )) + ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + cd "$PROJECT_ROOT/simulator" + export KEYCLOAK_URL="''${KEYCLOAK_URL:-http://localhost:8080}" + export API_URL="''${API_URL:-http://localhost:8000/graphql}" + export REALM="''${REALM:-tasks}" + export USE_DIRECT_GRANT="''${USE_DIRECT_GRANT:-true}" + export CLIENT_ID="''${CLIENT_ID:-tasks-web}" + export USERNAME="''${USERNAME:-test}" + export PASSWORD="''${PASSWORD:-test}" + exec python main.py "$@" + ''; + }; + + lint-dockerfiles = writeShellApplication { + name = "lint-dockerfiles"; + runtimeInputs = [ pkgs.hadolint ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + echo ">>> Linting all Dockerfiles with hadolint..." + hadolint --failure-threshold warning \ + "$PROJECT_ROOT/backend/Dockerfile" \ + "$PROJECT_ROOT/simulator/Dockerfile" \ + "$PROJECT_ROOT/web/Dockerfile" \ + "$PROJECT_ROOT/proxy/Dockerfile" + ''; + }; + + run-act = writeShellApplication { + name = "run-act"; + runtimeInputs = [ pkgs.act ]; + text = '' + echo ">>> Running GitHub Actions locally with act..." + exec act "$@" + ''; + }; + + run-dev-all = writeShellApplication { + name = "run-dev-all"; + runtimeInputs = [ + pkgs.docker-compose + pkgs.bash + pkgs.coreutils + pkgs.findutils + start-docker + stop-docker + run-alembic-upgrade + pythonEnv + pkgs.nodejs_22 + ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + : "''${DOCKER_COMPOSE_FILE:?DOCKER_COMPOSE_FILE is not set}" + + docker-compose -f "$DOCKER_COMPOSE_FILE" ps --services \ + | grep -vE "keycloak|postgres|redis|influxdb" \ + | xargs -r docker-compose -f "$DOCKER_COMPOSE_FILE" stop || true + + start-docker + trap 'echo ">>> Stopping all dev services..."; stop-docker; exit' INT TERM + + run-alembic-upgrade + + if [ ! -d "$PROJECT_ROOT/web/node_modules" ]; then + echo ">>> web/node_modules missing — run: (cd web && npm ci)" + exit 1 + fi + + ( + cd "$PROJECT_ROOT/backend" + exec uvicorn main:app --reload + ) & + backend_pid=$! + + ( + cd "$PROJECT_ROOT/web" + exec npm run dev + ) & + web_pid=$! + + wait "$backend_pid" "$web_pid" + stop-docker + ''; + }; +in +[ + start-docker + stop-docker + clean-dev + run-dev-backend + run-dev-web + run-dev-all + run-alembic + run-alembic-upgrade + psql-dev + redis-cli-dev + run-simulator + lint-dockerfiles + run-act +] diff --git a/proxy/Dockerfile b/proxy/Dockerfile index d30b86f5..5413c93b 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,7 +1,31 @@ -FROM nginx:alpine +# syntax=docker/dockerfile:1 -COPY nginx.conf /etc/nginx/nginx.conf.template +FROM nixos/nix:2.28.4 AS builder + +RUN printf '%s\n' \ + 'extra-experimental-features = nix-command flakes' \ + 'filter-syscalls = false' \ + >> /etc/nix/nix.conf + +WORKDIR /build + +COPY flake.nix flake.lock ./ +COPY nix ./nix +COPY proxy ./proxy + +RUN nix build "path:/build#proxy-rootfs" --out-link /rootfs + +FROM scratch + +COPY --from=builder /rootfs/ / + +ENV PATH=/bin \ + SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ + FRONTEND_HOST=127.0.0.1:3000 \ + BACKEND_HOST=127.0.0.1:8000 \ + KEYCLOAK_HOST=127.0.0.1:8080 \ + NGINX_PREFIX=/tmp/helpwave-tasks-proxy EXPOSE 80 -CMD ["/bin/sh", "-c", "envsubst '${FRONTEND_HOST} ${BACKEND_HOST} ${KEYCLOAK_HOST}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && exec nginx -g 'daemon off;'"] +ENTRYPOINT ["/bin/tasks-proxy"] diff --git a/shell.nix b/shell.nix deleted file mode 100644 index ad0d4df3..00000000 --- a/shell.nix +++ /dev/null @@ -1,222 +0,0 @@ -{ - pkgs ? import { }, - postgresUser ? "postgres", - postgresPassword ? "password", - postgresDatabase ? "postgres", - postgresPort ? 5432, - postgresVersion ? 15, - redisHost ? "localhost", - redisPort ? 6379, - redisPassword ? "password", - dockerComposeFile ? "docker-compose.dev.yml", -}: - -let - python = pkgs.python313; - nodejs = pkgs.nodejs_22; - - postgresql = pkgs."postgresql_${toString postgresVersion}"; - redis = pkgs.redis; - dockerCompose = pkgs.docker-compose; - netcat = pkgs.netcat-gnu; - hadolint = pkgs.hadolint; - - libPath = pkgs.lib.makeLibraryPath [ - pkgs.stdenv.cc.cc.lib - pkgs.zlib - pkgs.glib - ]; -in -pkgs.mkShell { - buildInputs = [ - python - python.pkgs.pip - python.pkgs.virtualenv - python.pkgs.requests - python.pkgs.python-dotenv - nodejs - pkgs.docker - dockerCompose - postgresql - redis - netcat - pkgs.gcc - hadolint - pkgs.act - ]; - - venvDir = "./backend/venv"; - - shellHook = '' - export PROJECT_ROOT="$(pwd)" - export DOCKER_COMPOSE_FILE="$PROJECT_ROOT/${dockerComposeFile}" - - export ENV=development - export DATABASE_URL="postgresql+asyncpg://${postgresUser}:${postgresPassword}@localhost:${toString postgresPort}/${postgresDatabase}" - export REDIS_URL="redis://:${redisPassword}@${redisHost}:${toString redisPort}" - export ISSUER_URI="http://localhost:8080/realms/tasks" - export CLIENT_SECRET="tasks-secret" - export SCAFFOLD_DIRECTORY="$PROJECT_ROOT/scaffold" - export INFLUXDB_URL="http://localhost:8086" - export INFLUXDB_TOKEN="tasks-token-secret" - export INFLUXDB_ORG="tasks" - export INFLUXDB_BUCKET="audit" - - export LD_LIBRARY_PATH="${libPath}:$LD_LIBRARY_PATH" - - echo ">>> Activating dev shell..." - - if [ ! -d "$PROJECT_ROOT/$venvDir" ]; then - ${python}/bin/python -m venv "$PROJECT_ROOT/$venvDir" - fi - source "$PROJECT_ROOT/$venvDir/bin/activate" - - if [ -f "$PROJECT_ROOT/backend/requirements.txt" ]; then - req_file="$PROJECT_ROOT/backend/requirements.txt" - req_hash_file="$PROJECT_ROOT/$venvDir/.requirements_hash" - current_hash=$(sha256sum "$req_file" | cut -d " " -f1) - - if [ ! -f "$req_hash_file" ] || [ "$(cat "$req_hash_file")" != "$current_hash" ]; then - echo ">>> Requirements changed." - echo ">>> Updating pip..." - pip install --upgrade pip > /dev/null - echo ">>> Updating requirements..." - pip install -r "$req_file" - echo "$current_hash" > "$req_hash_file" - fi - fi - - if [ -d "$PROJECT_ROOT/web" ]; then - if [ ! -d "$PROJECT_ROOT/web/node_modules" ]; then - (cd "$PROJECT_ROOT/web" && ${nodejs}/bin/npm install) - fi - fi - - run-simulator() { - echo ">>> Running simulator" - if [ ! -d "$PROJECT_ROOT/simulator/venv" ]; then - ${python}/bin/python -m venv "$PROJECT_ROOT/simulator/venv" - fi - source "$PROJECT_ROOT/simulator/venv/bin/activate" - if [ -f "$PROJECT_ROOT/simulator/requirements.txt" ]; then - req_file="$PROJECT_ROOT/simulator/requirements.txt" - req_hash_file="$PROJECT_ROOT/simulator/venv/.requirements_hash" - current_hash=$(sha256sum "$req_file" | cut -d " " -f1) - if [ ! -f "$req_hash_file" ] || [ "$(cat "$req_hash_file")" != "$current_hash" ]; then - echo ">>> Installing simulator requirements..." - pip install --upgrade pip > /dev/null - pip install -r "$req_file" - echo "$current_hash" > "$req_hash_file" - fi - fi - ( - cd "$PROJECT_ROOT/simulator" - export KEYCLOAK_URL="http://localhost:8080" - export API_URL="http://localhost:8000/graphql" - export REALM="tasks" - export USE_DIRECT_GRANT="true" - export CLIENT_ID="tasks-web" - export USERNAME="test" - export PASSWORD="test" - exec python main.py "$@" - ) - } - - start-docker() { - echo ">>> Starting PostgreSQL, Redis, Keycloak and InfluxDB via Docker..." - (cd "$PROJECT_ROOT" && ${dockerCompose}/bin/docker-compose -f $DOCKER_COMPOSE_FILE up -d postgres redis keycloak influxdb) - } - - stop-docker() { - echo ">>> Stopping PostgreSQL, Redis, Keycloak and InfluxDB..." - (cd "$PROJECT_ROOT" && ${dockerCompose}/bin/docker-compose -f $DOCKER_COMPOSE_FILE down) - } - - clean-dev() { - echo ">>> Stopping and removing containers and volumes..." - (cd "$PROJECT_ROOT" && ${dockerCompose}/bin/docker-compose -f $DOCKER_COMPOSE_FILE down -v) - echo ">>> Cleaned environment." - } - - run-dev-backend() { - ${dockerCompose}/bin/docker-compose -f $DOCKER_COMPOSE_FILE stop backend - (cd "$PROJECT_ROOT/backend" && exec uvicorn main:app --reload) - } - - run-dev-web() { - ${dockerCompose}/bin/docker-compose -f $DOCKER_COMPOSE_FILE stop web - (cd "$PROJECT_ROOT/web" && exec ${nodejs}/bin/npm run dev) - } - - run-alembic() { - (cd "$PROJECT_ROOT/backend" && alembic "$@") - } - - run-alembic-upgrade() { - while ! ${netcat}/bin/nc -z localhost ${toString postgresPort}; do - echo ">>> Waiting for database on :${toString postgresPort}..."; - sleep 0.5; - done - sleep 1; - echo ">>> Database is up!" - run-alembic upgrade head - } - - psql-dev() { - PGPASSWORD="${postgresPassword}" ${postgresql}/bin/psql \ - -h localhost \ - -U "${postgresUser}" \ - -d "${postgresDatabase}" \ - -p ${toString postgresPort} - } - - redis-cli-dev() { - ${redis}/bin/redis-cli \ - -h "${redisHost}" \ - -p ${toString redisPort} - } - - run-dev-all() { - ${dockerCompose}/bin/docker-compose -f $DOCKER_COMPOSE_FILE ps --services | grep -vE "keycloak|postgres|redis|influxdb" | xargs ${dockerCompose}/bin/docker-compose -f $DOCKER_COMPOSE_FILE stop - start-docker - trap "echo '>>> Stopping all dev services...'; stop-docker; exit" SIGINT - - run-alembic-upgrade - - bash -c ' - trap "exit" SIGINT - (cd "$PROJECT_ROOT/backend" && exec uvicorn main:app --reload) & - backend_pid=$! - (cd "$PROJECT_ROOT/web" && exec ${nodejs}/bin/npm run dev) & - web_pid=$! - wait $backend_pid $web_pid - ' - stop-docker - } - - lint-dockerfiles() { - echo ">>> Linting all Dockerfiles with hadolint..." - ${hadolint}/bin/hadolint --failure-threshold warning \ - "$PROJECT_ROOT/backend/Dockerfile" \ - "$PROJECT_ROOT/simulator/Dockerfile" \ - "$PROJECT_ROOT/web/Dockerfile" \ - "$PROJECT_ROOT/proxy/Dockerfile" - local exit_code=$? - if [ $exit_code -eq 0 ]; then - echo ">>> All Dockerfiles passed hadolint checks" - else - echo ">>> Some Dockerfiles have warnings or errors" - return $exit_code - fi - } - - run-act() { - echo ">>> Running GitHub Actions locally with act..." - ${pkgs.act}/bin/act "$@" - } - - echo ">>> Environment ready." - echo "Commands: run-dev-backend, run-dev-web, run-dev-all, run-alembic, psql-dev, redis-cli-dev, clean-dev, start-docker, stop-docker, run-simulator, lint-dockerfiles, run-act" - echo "Tip: 'run-simulator --extreme' creates 500 patients at once to stress test the instance." - ''; -} diff --git a/simulator/Dockerfile b/simulator/Dockerfile index 5b968f06..2e44bb28 100644 --- a/simulator/Dockerfile +++ b/simulator/Dockerfile @@ -1,46 +1,25 @@ -ARG PY_VERSION="3.13" -ARG PYTHON_ALPINE_DIGEST="sha256:db66119d6609a3a941a9433b225f4e13d33c459cede097cf3ec2fc4d1bd314b2" +# syntax=docker/dockerfile:1 -FROM python:${PY_VERSION}-alpine@${PYTHON_ALPINE_DIGEST} AS builder +FROM nixos/nix:2.28.4 AS builder -ENV PIP_NO_WARN_ABOUT_ROOT_USER=1 +RUN printf '%s\n' \ + 'extra-experimental-features = nix-command flakes' \ + 'filter-syscalls = false' \ + >> /etc/nix/nix.conf WORKDIR /build -RUN apk add --no-cache \ - binutils=2.45.1-r1 \ - gcc=15.2.0-r5 \ - gmp=6.3.0-r4 \ - isl26=0.26-r2 \ - jansson=2.15.0-r0 \ - libatomic=15.2.0-r5 \ - libffi-dev=3.5.2-r1 \ - libgcc=15.2.0-r5 \ - libgomp=15.2.0-r5 \ - libstdc++=15.2.0-r5 \ - linux-headers=7.0.0-r1 \ - mpc1=1.3.1-r1 \ - mpfr4=4.2.2-r0 \ - musl-dev=1.2.6-r2 \ - libcrypto3=3.5.7-r0 \ - libssl3=3.5.7-r0 \ - openssl-dev=3.5.7-r0 \ - pkgconf=2.5.1-r0 \ - zstd-libs=1.5.7-r2 - -COPY requirements.txt /build - -RUN pip install --prefix=/build/venv --no-cache-dir --upgrade -r requirements.txt - -FROM python:${PY_VERSION}-alpine@${PYTHON_ALPINE_DIGEST} - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -ENV USE_DIRECT_GRANT=true - -COPY --from=builder /build/venv /usr/local/ -COPY . /app - -WORKDIR /app - -CMD ["python", "main.py"] +COPY flake.nix flake.lock ./ +COPY nix ./nix +COPY simulator ./simulator + +RUN nix build "path:/build#simulator-rootfs" --out-link /rootfs + +FROM scratch + +COPY --from=builder /rootfs/ / + +ENV PATH=/bin \ + SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +ENTRYPOINT ["/bin/tasks-simulator"] diff --git a/simulator/README.md b/simulator/README.md index c9906f72..55f358ee 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -58,7 +58,7 @@ The simulator supports two authentication modes: 1. **Using Nix** (Recommended): ```bash - nix-shell + nix develop run-simulator ``` diff --git a/web/Dockerfile b/web/Dockerfile index c75364f0..d843033c 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,61 +1,31 @@ -ARG NODE_ALPINE_DIGEST="sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f" - -FROM node:22-alpine@${NODE_ALPINE_DIGEST} AS base - -FROM base AS deps -RUN apk add --no-cache \ - gcompat=1.1.0-r4 \ - libucontext=1.3.3-r0 \ - musl-obstack=1.2.3-r2 -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci - -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . -RUN npm run build - -FROM base -WORKDIR /app -ENV NODE_ENV=production -ENV PORT=80 -ENV HOSTNAME="0.0.0.0" - -RUN apk add --no-cache \ - libcap=2.78-r0 \ - libcap-getcap=2.78-r0 \ - libcap-setcap=2.78-r0 \ - libcap-utils=2.78-r0 \ - libcap2=2.78-r0 && \ - setcap 'cap_net_bind_service=+ep' /usr/local/bin/node && \ - addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 tasks && \ - mkdir -p /feedback && \ - mkdir -p /profile && \ - chown tasks:nodejs /feedback && \ - chown tasks:nodejs /profile - -ENV FEEDBACK_DIR=/feedback -ENV PROFILE_PICTURE_DIRECTORY=/profile - -COPY --from=builder --chown=tasks:nodejs /app/public ./public -COPY --from=builder --chown=tasks:nodejs /app/build/standalone ./ -COPY --from=builder --chown=tasks:nodejs /app/build/static ./build/static -COPY --from=builder --chown=tasks:nodejs /app/node_modules/@img ./node_modules/@img - -RUN printf "#!/bin/sh\n\ -echo \"window.__ENV = {\" > /app/public/env-config.js\n\ -env | grep \"^RUNTIME_\" | while read -r line; do\n\ - key=\$(echo \"\$line\" | cut -d \"=\" -f 1)\n\ - val=\$(echo \"\$line\" | cut -d \"=\" -f 2-)\n\ - echo \" \\\"\$key\\\": \\\"\$val\\\",\" >> /app/public/env-config.js\n\ -done\n\ -echo \"}\" >> /app/public/env-config.js\n\ -exec \"\$@\"\n" > /app/entrypoint.sh && chmod +x /app/entrypoint.sh - -USER tasks +# syntax=docker/dockerfile:1 + +FROM nixos/nix:2.28.4 AS builder + +RUN printf '%s\n' \ + 'extra-experimental-features = nix-command flakes' \ + 'filter-syscalls = false' \ + >> /etc/nix/nix.conf + +WORKDIR /build + +COPY flake.nix flake.lock ./ +COPY nix ./nix +COPY web ./web + +RUN nix build "path:/build#web-rootfs" --out-link /rootfs + +FROM scratch + +COPY --from=builder /rootfs/ / + +ENV PATH=/bin \ + SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ + HOSTNAME=0.0.0.0 \ + PORT=80 \ + NODE_ENV=production \ + TASKS_WEB_RUNTIME_DIR=/tmp/helpwave-tasks-web + EXPOSE 80 -ENTRYPOINT ["/app/entrypoint.sh"] -CMD ["node", "server.js"] + +ENTRYPOINT ["/bin/tasks-web"] From c0510546f61930fc6c564cf072b20eeb7a1b28d0 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Wed, 5 Aug 2026 14:02:07 +0200 Subject: [PATCH 2/4] switch to linter install from nix --- .github/workflows/tests.yml | 24 +++++++----------------- nix/checks.nix | 15 +++++++++++++-- nix/shells/default.nix | 4 +++- nix/shells/helpers.nix | 13 +++++++++++++ 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index adad504d..65435995 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,28 +21,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.13" - - name: Install ruff - run: pip install ruff + - uses: DeterminateSystems/nix-installer-action@v19 + - uses: DeterminateSystems/magic-nix-cache-action@v12 - name: Run ruff - working-directory: backend - run: ruff check . --output-format=concise --exclude database/migrations + run: nix develop --command ruff check backend --output-format=concise --exclude database/migrations simulator-lint: name: Lint simulator runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.13" - - name: Install ruff - run: pip install ruff + - uses: DeterminateSystems/nix-installer-action@v19 + - uses: DeterminateSystems/magic-nix-cache-action@v12 - name: Run ruff - working-directory: simulator - run: ruff check . --output-format=concise + run: nix develop --command ruff check simulator --output-format=concise frontend-lint: name: Lint frontend @@ -94,9 +86,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@v19 - uses: DeterminateSystems/magic-nix-cache-action@v12 - name: Check nix formatting - run: | - mapfile -t files < <(find . -name '*.nix' -not -path './result*' ) - nix shell nixpkgs#nixfmt -c nixfmt --check "${files[@]}" + run: nix develop --command bash -c 'mapfile -t files < <(find . -name "*.nix" -not -path "./result*"); nixfmt --check "${files[@]}"' lint: name: Lint diff --git a/nix/checks.nix b/nix/checks.nix index dd87c7b1..de7a84d0 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -1,13 +1,24 @@ -{ ... }: +{ inputs, ... }: { perSystem = - { config, ... }: + { pkgs, config, ... }: { checks = { backend = config.packages.backend; web = config.packages.web; simulator = config.packages.simulator; proxy = config.packages.proxy; + ruff = + pkgs.runCommand "ruff-check" + { + nativeBuildInputs = [ pkgs.ruff ]; + src = inputs.self; + } + '' + ruff check "$src/backend" --output-format=concise --exclude database/migrations + ruff check "$src/simulator" --output-format=concise + touch "$out" + ''; }; }; } diff --git a/nix/shells/default.nix b/nix/shells/default.nix index 5890f871..971ddf75 100644 --- a/nix/shells/default.nix +++ b/nix/shells/default.nix @@ -16,6 +16,7 @@ packages = [ pythonEnv + pkgs.ruff pkgs.nodejs_22 pkgs.docker pkgs.docker-compose @@ -26,6 +27,7 @@ pkgs.hadolint pkgs.act pkgs.git + pkgs.nixfmt ] ++ helpers ++ [ @@ -62,7 +64,7 @@ echo ">>> helpwave tasks dev shell (side-effect free)" echo ">>> Commands: run-dev-backend, run-dev-web, run-dev-all, run-alembic," echo ">>> psql-dev, redis-cli-dev, start-docker, stop-docker," - echo ">>> clean-dev, run-simulator, lint-dockerfiles, run-act" + echo ">>> clean-dev, run-simulator, lint-python, lint-dockerfiles, run-act" echo ">>> Frontend: run '(cd web && npm ci)' once if node_modules is missing." ''; }; diff --git a/nix/shells/helpers.nix b/nix/shells/helpers.nix index 25a5f92e..6899c0b8 100644 --- a/nix/shells/helpers.nix +++ b/nix/shells/helpers.nix @@ -165,6 +165,18 @@ let ''; }; + lint-python = writeShellApplication { + name = "lint-python"; + runtimeInputs = [ pkgs.ruff ]; + text = '' + : "''${PROJECT_ROOT:?PROJECT_ROOT is not set}" + echo ">>> Ruff: backend" + ruff check "$PROJECT_ROOT/backend" --output-format=concise --exclude database/migrations + echo ">>> Ruff: simulator" + ruff check "$PROJECT_ROOT/simulator" --output-format=concise + ''; + }; + run-act = writeShellApplication { name = "run-act"; runtimeInputs = [ pkgs.act ]; @@ -235,5 +247,6 @@ in redis-cli-dev run-simulator lint-dockerfiles + lint-python run-act ] From ae4e634828a07f2ca70cf4730f27e7c22a5b816e Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Wed, 5 Aug 2026 15:08:45 +0200 Subject: [PATCH 3/4] switch linting onto nix configuration --- .github/workflows/tests.yml | 45 ++++++++++++++++--- web/components/tables/PatientList.tsx | 2 +- .../views/PatientViewTasksPanel.tsx | 13 +++++- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65435995..6ab6fcce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@v19 - uses: DeterminateSystems/magic-nix-cache-action@v12 - name: Run ruff - run: nix develop --command ruff check backend --output-format=concise --exclude database/migrations + run: nix shell --inputs-from . nixpkgs#ruff -c ruff check backend --output-format=concise --exclude database/migrations simulator-lint: name: Lint simulator @@ -34,7 +34,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@v19 - uses: DeterminateSystems/magic-nix-cache-action@v12 - name: Run ruff - run: nix develop --command ruff check simulator --output-format=concise + run: nix shell --inputs-from . nixpkgs#ruff -c ruff check simulator --output-format=concise frontend-lint: name: Lint frontend @@ -86,7 +86,9 @@ jobs: - uses: DeterminateSystems/nix-installer-action@v19 - uses: DeterminateSystems/magic-nix-cache-action@v12 - name: Check nix formatting - run: nix develop --command bash -c 'mapfile -t files < <(find . -name "*.nix" -not -path "./result*"); nixfmt --check "${files[@]}"' + run: | + mapfile -t files < <(find . -name '*.nix' -not -path './result*') + nix shell --inputs-from . nixpkgs#nixfmt -c nixfmt --check "${files[@]}" lint: name: Lint @@ -396,9 +398,40 @@ jobs: run: docker compose -f docker-compose.e2e.yml up -d - name: Wait for the stack run: | - timeout 180 bash -c 'until curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration > /dev/null; do sleep 3; done' - timeout 180 bash -c 'until [ "$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "content-type: application/json" -d "{\"query\":\"{ __typename }\"}" http://localhost/graphql)" -lt 500 ]; do sleep 3; done' - timeout 180 bash -c 'until curl -fs http://localhost/ > /dev/null; do sleep 3; done' + echo "Waiting for keycloak (realm import)..." + for _ in $(seq 1 60); do + if curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration > /dev/null; then + break + fi + sleep 3 + done + curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration > /dev/null + + echo "Waiting for the backend..." + for _ in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H 'content-type: application/json' \ + -d '{"query":"{ __typename }"}' \ + http://localhost/graphql || true) + if [ "$code" -lt 500 ]; then + break + fi + sleep 3 + done + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H 'content-type: application/json' \ + -d '{"query":"{ __typename }"}' \ + http://localhost/graphql) + test "$code" -lt 500 + + echo "Waiting for the frontend..." + for _ in $(seq 1 60); do + if curl -fs http://localhost/ > /dev/null; then + break + fi + sleep 3 + done + curl -fs http://localhost/ > /dev/null - uses: actions/setup-node@v7 with: node-version: "22" diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index 81989c4f..ed6a4240 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -914,7 +914,7 @@ export const PatientList = forwardRef(({ gateCell(params.row.original.id, (col.cell as (p: unknown) => React.ReactNode)(params)) : undefined, })), - ], [translation, patientPropertyColumnsWithActions, gateCell, formatBirthdate, handleEdit]) + ], [translation, patientPropertyColumnsWithActions, gateCell, formatBirthdate]) const propertyFieldTypeByDefId = useMemo( () => new Map(propertyDefinitionsData?.propertyDefinitions.map(d => [d.id, d.fieldType]) ?? []), diff --git a/web/components/views/PatientViewTasksPanel.tsx b/web/components/views/PatientViewTasksPanel.tsx index be5993c5..b17c6171 100644 --- a/web/components/views/PatientViewTasksPanel.tsx +++ b/web/components/views/PatientViewTasksPanel.tsx @@ -229,7 +229,18 @@ export function PatientViewTasksPanel({ setSearchQuery(baselineSearch) setRelatedColumnVisibility(baselineColumnVisibility) setRelatedColumnOrder(baselineColumnOrder) - }, [persistedRelatedContentKey]) + }, [ + persistedRelatedContentKey, + defaultRelatedFilters, + relatedSortBaseline, + baselineSearch, + baselineColumnVisibility, + baselineColumnOrder, + setRelatedFilters, + setRelatedSorting, + setRelatedColumnVisibility, + setRelatedColumnOrder, + ]) const viewMatchesRelatedBaseline = useMemo( () => tableViewStateMatchesBaseline({ From e0d051c959248d308bb0a60e9056ec33ac60f9bd Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Wed, 5 Aug 2026 15:34:43 +0200 Subject: [PATCH 4/4] fix e2e proxy stack --- .github/workflows/README.md | 5 +- .github/workflows/tests.yml | 206 ++++++------------ nix/packages/docker.nix | 1 - nix/packages/proxy.nix | 116 ++++++++-- .../views/PatientViewTasksPanel.tsx | 21 +- 5 files changed, 175 insertions(+), 174 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d8ce17d7..3e53fb9d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -29,18 +29,15 @@ flowchart TD subgraph tests["4. Tests"] BT[backend-tests] FT[frontend-tests] - E2E[e2e-tests] EP[e2e-proxy] end L --> N --> D D --> BT D --> FT - D --> E2E D --> EP BT --> CI[ci] FT --> CI - E2E --> CI EP --> CI N --> CI D --> CI @@ -52,7 +49,7 @@ flowchart TD |----------|---------|---------| | `tests.yml` | Push/PR to `main` or `develop`, manual dispatch | Full gate: lint → nix → docker → tests | -Images are built with Nix (`streamLayeredImage` from scratch). Dockerfiles are a two-step `nixos/nix` builder → `scratch` rootfs for local `docker compose build`. +Images are Nix `streamLayeredImage` scratch builds. E2E runs only against the proxied docker-compose stack. ## Local act diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6ab6fcce..96af0e12 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -253,114 +253,6 @@ jobs: working-directory: web run: npm run test - e2e-tests: - name: E2E - needs: [docker] - runs-on: ubuntu-latest - services: - postgres: - image: postgres:15 - env: - POSTGRES_USER: test - POSTGRES_PASSWORD: test - POSTGRES_DB: test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - env: - DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test - REDIS_URL: redis://localhost:6379 - ISSUER_URI: http://localhost:8080/realms/tasks - PUBLIC_ISSUER_URI: http://localhost:8080/realms/tasks - CLIENT_ID: tasks-backend - CLIENT_SECRET: tasks-secret - ENV: test - INFLUXDB_URL: http://localhost:8086 - INFLUXDB_TOKEN: test-token - INFLUXDB_ORG: test - INFLUXDB_BUCKET: test - NEXT_PUBLIC_API_URL: http://localhost:8000/graphql - E2E_BASE_URL: http://localhost:3000 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.13" - - uses: actions/setup-node@v7 - with: - node-version: "22" - cache: "npm" - cache-dependency-path: | - web/package-lock.json - tests/package-lock.json - - name: Install backend dependencies - working-directory: backend - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Install frontend dependencies - working-directory: web - run: npm ci - - name: Install E2E dependencies - working-directory: tests - run: npm ci - - name: Install Playwright browsers - working-directory: tests - run: npx playwright install --with-deps chromium - continue-on-error: true - - name: Migrate database - working-directory: backend - run: alembic upgrade head - - name: Start backend - working-directory: backend - run: | - uvicorn main:app --host 0.0.0.0 --port 8000 > /tmp/backend.log 2>&1 & - echo $! > /tmp/backend.pid - - name: Build frontend - working-directory: web - run: npm run build - - name: Start frontend - working-directory: web - run: | - npm start > /tmp/frontend.log 2>&1 & - echo $! > /tmp/frontend.pid - - name: Wait for servers - run: | - timeout 120 bash -c 'until curl -f -s http://localhost:8000/health > /dev/null 2>&1; do sleep 2; done' - timeout 120 bash -c 'until curl -f -s http://localhost:3000 > /dev/null 2>&1; do sleep 2; done' - - name: Run E2E tests - working-directory: tests - env: - CI: true - run: npx playwright test - - uses: actions/upload-artifact@v7 - if: always() - with: - name: playwright-report - path: tests/playwright-report/ - retention-days: 30 - - uses: actions/upload-artifact@v7 - if: failure() - with: - name: server-logs - path: | - /tmp/backend.log - /tmp/frontend.log - retention-days: 7 - e2e-proxy: name: E2E (proxy stack) needs: [docker] @@ -398,40 +290,77 @@ jobs: run: docker compose -f docker-compose.e2e.yml up -d - name: Wait for the stack run: | - echo "Waiting for keycloak (realm import)..." - for _ in $(seq 1 60); do - if curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration > /dev/null; then - break + set -uo pipefail + + dump_logs() { + echo "===== docker compose ps =====" + docker compose -f docker-compose.e2e.yml ps -a || true + echo "===== docker compose logs =====" + docker compose -f docker-compose.e2e.yml logs --no-color || true + } + + wait_http() { + local name="$1" + local url="$2" + local ready=0 + echo "Waiting for ${name}..." + for _ in $(seq 1 60); do + if curl -fs --connect-timeout 2 "$url" > /dev/null; then + ready=1 + break + fi + sleep 3 + done + if [ "$ready" -ne 1 ]; then + echo "${name} never became ready at ${url}" + dump_logs + exit 1 fi - sleep 3 - done - curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration > /dev/null + } - echo "Waiting for the backend..." - for _ in $(seq 1 60); do - code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ - -H 'content-type: application/json' \ - -d '{"query":"{ __typename }"}' \ - http://localhost/graphql || true) - if [ "$code" -lt 500 ]; then - break + wait_proxy_port() { + local ready=0 + echo "Waiting for proxy to accept connections..." + for _ in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 2 http://localhost/ || echo 000) + if [ "$code" != "000" ]; then + ready=1 + break + fi + sleep 3 + done + if [ "$ready" -ne 1 ]; then + echo "Proxy never became reachable on :80" + dump_logs + exit 1 fi - sleep 3 - done - code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ - -H 'content-type: application/json' \ - -d '{"query":"{ __typename }"}' \ - http://localhost/graphql) - test "$code" -lt 500 + } - echo "Waiting for the frontend..." - for _ in $(seq 1 60); do - if curl -fs http://localhost/ > /dev/null; then - break + wait_graphql() { + local ready=0 + echo "Waiting for the backend..." + for _ in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 2 -X POST \ + -H 'content-type: application/json' \ + -d '{"query":"{ __typename }"}' \ + http://localhost/graphql || echo 000) + if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then + ready=1 + break + fi + sleep 3 + done + if [ "$ready" -ne 1 ]; then + echo "Backend GraphQL never became ready" + dump_logs + exit 1 fi - sleep 3 - done - curl -fs http://localhost/ > /dev/null + } + + wait_proxy_port + wait_http "keycloak" "http://localhost/keycloak/realms/tasks/.well-known/openid-configuration" + wait_graphql + wait_http "frontend" "http://localhost/" - uses: actions/setup-node@v7 with: node-version: "22" @@ -468,7 +397,7 @@ jobs: ci: name: CI - needs: [backend-tests, frontend-tests, e2e-tests, e2e-proxy, nix, docker] + needs: [backend-tests, frontend-tests, e2e-proxy, nix, docker] if: always() runs-on: ubuntu-latest steps: @@ -476,7 +405,6 @@ jobs: run: | if [[ "${{ needs.backend-tests.result }}" != "success" ]] \ || [[ "${{ needs.frontend-tests.result }}" != "success" ]] \ - || [[ "${{ needs.e2e-tests.result }}" != "success" ]] \ || [[ "${{ needs.e2e-proxy.result }}" != "success" ]] \ || [[ "${{ needs.nix.result }}" != "success" ]] \ || [[ "${{ needs.docker.result }}" != "success" ]]; then diff --git a/nix/packages/docker.nix b/nix/packages/docker.nix index c0b513d6..f5de25a2 100644 --- a/nix/packages/docker.nix +++ b/nix/packages/docker.nix @@ -36,7 +36,6 @@ let "FRONTEND_HOST=127.0.0.1:3000" "BACKEND_HOST=127.0.0.1:8000" "KEYCLOAK_HOST=127.0.0.1:8080" - "NGINX_PREFIX=/tmp/helpwave-tasks-proxy" ]; in { diff --git a/nix/packages/proxy.nix b/nix/packages/proxy.nix index 36ddb833..ed088af0 100644 --- a/nix/packages/proxy.nix +++ b/nix/packages/proxy.nix @@ -2,37 +2,121 @@ lib, writeShellApplication, nginx, + gnused, + coreutils, + writeTextDir, }: +let + nginxMainConf = writeTextDir "conf/nginx.conf" '' + user root root; + worker_processes auto; + error_log /dev/stderr warn; + pid /tmp/nginx.pid; + daemon off; + + events { + worker_connections 1024; + } + + http { + include ${nginx}/conf/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + client_max_body_size 20M; + client_body_temp_path /tmp/client_body_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + upstream frontend_upstream { + server ''${FRONTEND_HOST}; + } + + upstream backend_upstream { + server ''${BACKEND_HOST}; + } + + upstream keycloak_upstream { + server ''${KEYCLOAK_HOST}; + } + + server { + listen 80; + server_name localhost; + + location ~ ^/(graphql|callback|export(/.*)?)$ { + proxy_pass http://backend_upstream; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /keycloak/ { + proxy_pass http://keycloak_upstream/keycloak/; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + } + + location / { + proxy_pass http://frontend_upstream; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_hide_header Cache-Control; + proxy_hide_header Pragma; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + } + } + ''; +in writeShellApplication { name = "tasks-proxy"; - runtimeInputs = [ nginx ]; + runtimeInputs = [ + nginx + gnused + coreutils + ]; text = '' + set -euo pipefail + : "''${FRONTEND_HOST:=127.0.0.1:3000}" : "''${BACKEND_HOST:=127.0.0.1:8000}" : "''${KEYCLOAK_HOST:=127.0.0.1:8080}" - : "''${NGINX_PREFIX:=''${XDG_RUNTIME_DIR:-/tmp}/helpwave-tasks-proxy}" - mkdir -p "$NGINX_PREFIX/logs" "$NGINX_PREFIX/conf" "$NGINX_PREFIX/tmp" + mkdir -p /tmp/client_body_temp /tmp/proxy_temp /tmp/fastcgi_temp /tmp/uwsgi_temp /tmp/scgi_temp /var/log/nginx + conf=/tmp/helpwave-tasks-nginx.conf sed \ -e "s|\''${FRONTEND_HOST}|$FRONTEND_HOST|g" \ -e "s|\''${BACKEND_HOST}|$BACKEND_HOST|g" \ -e "s|\''${KEYCLOAK_HOST}|$KEYCLOAK_HOST|g" \ - -e "s|include mime.types;|include ${nginx}/conf/mime.types;|" \ - "${../../proxy/nginx.conf}" \ - > "$NGINX_PREFIX/conf/nginx.body.conf" - - cat > "$NGINX_PREFIX/conf/nginx.conf" <> "$NGINX_PREFIX/conf/nginx.conf" + "${nginxMainConf}/conf/nginx.conf" \ + > "$conf" - exec nginx -p "$NGINX_PREFIX" -c "$NGINX_PREFIX/conf/nginx.conf" + exec nginx -c "$conf" ''; meta = { diff --git a/web/components/views/PatientViewTasksPanel.tsx b/web/components/views/PatientViewTasksPanel.tsx index b17c6171..aa503604 100644 --- a/web/components/views/PatientViewTasksPanel.tsx +++ b/web/components/views/PatientViewTasksPanel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react' import { useMutation } from '@apollo/client/react' import { Visibility } from '@helpwave/hightide' import type { ColumnFiltersState } from '@tanstack/react-table' @@ -223,24 +223,17 @@ export function PatientViewTasksPanel({ const [searchQuery, setSearchQuery] = useState(baselineSearch) - useEffect(() => { + const resetRelatedTableState = useEffectEvent(() => { setRelatedFilters(defaultRelatedFilters) setRelatedSorting(relatedSortBaseline) setSearchQuery(baselineSearch) setRelatedColumnVisibility(baselineColumnVisibility) setRelatedColumnOrder(baselineColumnOrder) - }, [ - persistedRelatedContentKey, - defaultRelatedFilters, - relatedSortBaseline, - baselineSearch, - baselineColumnVisibility, - baselineColumnOrder, - setRelatedFilters, - setRelatedSorting, - setRelatedColumnVisibility, - setRelatedColumnOrder, - ]) + }) + + useEffect(() => { + resetRelatedTableState() + }, [persistedRelatedContentKey, resetRelatedTableState]) const viewMatchesRelatedBaseline = useMemo( () => tableViewStateMatchesBaseline({