From bf6179046562c4fc3c15de5da072b2829a10a9b6 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sat, 23 May 2026 02:08:27 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20production=20deployment=20=E2=80=94=20Do?= =?UTF-8?q?cker,=20Nomad,=20Consul=20KV,=20SHA=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: multi-stage build, non-root user, src/static tracked with .gitkeep - Nomad job: force_pull=true, Traefik router fixed to tink-demo.i80.dk, loadbalancer.server.port=8000, job renamed from moneycapp-tink-demo - CI/CD: git SHA image tags (deterministic deploys), removed .env.production baking — secrets injected at runtime via Consul KV template stanza - Session security: asyncio lock prevents duplicate code exchange on callback, guard for already-stored token, api_log moved server-side (cookie overflow fix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/deploy.yml | 23 ++++------- Dockerfile | 1 - README.md | 21 +++++++--- moneycapp-tink-demo.nomad | 24 +++++++++--- src/routes/demo.py | 76 +++++++++++++++++++++++-------------- src/static/.gitkeep | 0 6 files changed, 90 insertions(+), 55 deletions(-) create mode 100644 src/static/.gitkeep diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 18b5c66..521e1c6 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -7,7 +7,7 @@ on: env: SERVICE_NAME: moneycapp-tink-demo - IMAGE: registry.i80.dk/gitea/moneycapp-tink-demo + IMAGE: registry.i80.dk/gitea/tink-demo jobs: deploy: @@ -25,28 +25,19 @@ jobs: run: | echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | docker login registry.i80.dk -u "robot\$gitserver" --password-stdin - - name: Write production env - run: | - cat > .env.production << 'ENVEOF' - TINK_CLIENT_ID=${{ secrets.TINK_CLIENT_ID }} - TINK_CLIENT_SECRET=${{ secrets.TINK_CLIENT_SECRET }} - TINK_REDIRECT_URI=https://tink-demo.i80.dk/callback - APP_BASE_URL=https://tink-demo.i80.dk - DEMO_MODE=false - ENVEOF - # Strip leading spaces - sed -i 's/^[[:space:]]*//' .env.production - - name: Build and push image run: | - docker build -t ${IMAGE}:latest . + SHA=$(echo "$GITHUB_SHA" | cut -c1-8) + docker build -t ${IMAGE}:${SHA} -t ${IMAGE}:latest . + docker push ${IMAGE}:${SHA} docker push ${IMAGE}:latest + echo "IMAGE_TAG=${SHA}" >> $GITHUB_ENV - name: Validate Nomad job - run: nomad job validate ${SERVICE_NAME}.nomad + run: sed "s|:latest|:${IMAGE_TAG}|g" ${SERVICE_NAME}.nomad | nomad job validate - - name: Deploy to Nomad - run: nomad job run ${SERVICE_NAME}.nomad + run: sed "s|:latest|:${IMAGE_TAG}|g" ${SERVICE_NAME}.nomad | nomad job run - - name: Health check run: | diff --git a/Dockerfile b/Dockerfile index 5a36183..f195754 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ src/ -COPY .env.production .env EXPOSE 8000 diff --git a/README.md b/README.md index a430f60..f8a2692 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,23 @@ docker compose up ## Deploy til i80/Nomad -1. Konfigurer Gitea secrets: `REGISTRY_USER`, `REGISTRY_TOKEN`, `NOMAD_ADDR`, `NOMAD_TOKEN` -2. Læg Tink credentials i Nomad/Vault: `secret/moneycapp-tink-demo` -3. Tilføj `https://tink-demo.i80.dk/callback` som Redirect URI i Tink Console -4. Push til `main` → Gitea Actions bygger og deployer +> **Kun relevant for i80-infrastruktur.** For din egen infra: byg Docker image og kør med env vars. -## Tink Console setup +1. Læg credentials i Consul KV: + ```bash + consul kv put tink-demo/TINK_CLIENT_ID + consul kv put tink-demo/TINK_CLIENT_SECRET + ``` +2. Tilføj `https://tink-demo.i80.dk/callback` som Redirect URI i Tink Console +3. Push til `main` → Gitea Actions bygger og deployer automatisk + +## Docker (self-hosted) + +```bash +cp .env.example .env +# Udfyld TINK_CLIENT_ID og TINK_CLIENT_SECRET +docker compose up +``` 1. Gå til [console.tink.com](https://console.tink.com) 2. Opret en app → kopiér Client ID + Secret til `.env` diff --git a/moneycapp-tink-demo.nomad b/moneycapp-tink-demo.nomad index 7168b10..32146a9 100644 --- a/moneycapp-tink-demo.nomad +++ b/moneycapp-tink-demo.nomad @@ -1,4 +1,4 @@ -job "moneycapp-tink-demo" { +job "tink-demo" { datacenters = ["dc1"] type = "service" @@ -10,12 +10,13 @@ job "moneycapp-tink-demo" { } service { - name = "moneycapp-tink-demo" + name = "tink-demo" port = "http" tags = ["traefik.enable=true", "traefik.http.routers.tink-demo.rule=Host(`tink-demo.i80.dk`)", "traefik.http.routers.tink-demo.tls=true", - "traefik.http.routers.tink-demo.tls.certresolver=le"] + "traefik.http.routers.tink-demo.tls.certresolver=le", + "traefik.http.services.tink-demo.loadbalancer.server.port=8000"] check { type = "http" path = "/" @@ -28,8 +29,21 @@ job "moneycapp-tink-demo" { driver = "docker" config { - image = "registry.i80.dk/gitea/moneycapp-tink-demo:latest" - ports = ["http"] + image = "registry.i80.dk/gitea/tink-demo:latest" + force_pull = true + ports = ["http"] + } + + template { + data = < dict: # Server-side token store — keeps JWTs OUT of the session cookie # (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead) # --------------------------------------------------------------------------- +import asyncio + _token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str} +_callback_locks: dict[str, asyncio.Lock] = {} # sid → Lock (prevents concurrent code exchange) def _get_sid(sess: dict) -> str: @@ -69,16 +72,24 @@ def _ctx(request: Request, extra: dict) -> dict: def _logger(sess: dict): - """Returns a callback that appends log entries to sess['api_log'].""" + """Returns a callback that appends log entries to server-side store (not cookie).""" def cb(entry: dict): - log = sess.setdefault("api_log", []) + sid = sess.get("sid", "") + if not sid: + return + store = _token_store.setdefault(sid, {}) + log = store.setdefault("api_log", []) log.append(entry) - # keep last 50 entries if len(log) > 50: - sess["api_log"] = log[-50:] + store["api_log"] = log[-50:] return cb +def _get_api_log(sess: dict) -> list: + sid = sess.get("sid", "") + return _token_store.get(sid, {}).get("api_log", []) + + # --------------------------------------------------------------------------- # Landing # --------------------------------------------------------------------------- @@ -108,7 +119,7 @@ async def debug_session(request: Request): for k, v in sess.items() if k != "api_log" } - safe["api_log_count"] = len(sess.get("api_log", [])) + safe["api_log_count"] = len(_get_api_log(sess)) safe["cookie_size_bytes"] = len(str(request.session)) return safe @@ -402,28 +413,35 @@ async def tink_callback(request: Request, code: Optional[str] = None, print(f"[CALLBACK] Tink returned error: {error}") return RedirectResponse(f"/demo/step/3?error={error}") if code: - try: - s = get_settings() - print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}") - client = _client(log_cb=_logger(sess)) - tokens = await client.exchange_code_for_token( - code, redirect_uri=s.tink_redirect_uri - ) - print(f"[CALLBACK] Token response keys: {list(tokens.keys())}") - user_token = tokens.get("access_token", "") - if not user_token: - print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}") - return RedirectResponse( - f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response", - status_code=303, + sid = sess.get("sid", "unknown") + if sid not in _callback_locks: + _callback_locks[sid] = asyncio.Lock() + async with _callback_locks[sid]: + if _load_token(sess, "user_token"): + print(f"[CALLBACK] Already have user_token — skipping duplicate exchange") + return RedirectResponse("/demo/step/3?cb_success=1", status_code=303) + try: + s = get_settings() + print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}") + client = _client(log_cb=_logger(sess)) + tokens = await client.exchange_code_for_token( + code, redirect_uri=s.tink_redirect_uri ) - _store_token(sess, "user_token", user_token) - print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)") - return RedirectResponse("/demo/step/3?cb_success=1", status_code=303) - except Exception as e: - import traceback - print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}") - return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303) + print(f"[CALLBACK] Token response keys: {list(tokens.keys())}") + user_token = tokens.get("access_token", "") + if not user_token: + print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}") + return RedirectResponse( + f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response", + status_code=303, + ) + _store_token(sess, "user_token", user_token) + print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)") + return RedirectResponse("/demo/step/3?cb_success=1", status_code=303) + except Exception as e: + import traceback + print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}") + return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303) print(f"[CALLBACK] No code — bare redirect to step 3") return RedirectResponse("/demo/step/3", status_code=303) @@ -617,7 +635,7 @@ async def step6(request: Request): @router.get("/demo/log", response_class=HTMLResponse) async def api_log(request: Request): sess = _session(request) - log = sess.get("api_log", []) + log = _get_api_log(sess) return templates.TemplateResponse("log.html", _ctx(request, { "log": list(reversed(log)), # newest first "log_count": len(log), @@ -627,7 +645,9 @@ async def api_log(request: Request): @router.post("/demo/log/clear") async def clear_log(request: Request): sess = _session(request) - sess["api_log"] = [] + sid = sess.get("sid", "") + if sid and sid in _token_store: + _token_store[sid].pop("api_log", None) return RedirectResponse("/demo/log", status_code=303) diff --git a/src/static/.gitkeep b/src/static/.gitkeep new file mode 100644 index 0000000..e69de29