fix: production deployment — Docker, Nomad, Consul KV, SHA tags

- 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>
This commit is contained in:
Henrik Jess Nielsen
2026-05-23 02:08:27 +02:00
parent ab591be464
commit bf61790465
6 changed files with 90 additions and 55 deletions

View File

@@ -7,7 +7,7 @@ on:
env: env:
SERVICE_NAME: moneycapp-tink-demo SERVICE_NAME: moneycapp-tink-demo
IMAGE: registry.i80.dk/gitea/moneycapp-tink-demo IMAGE: registry.i80.dk/gitea/tink-demo
jobs: jobs:
deploy: deploy:
@@ -25,28 +25,19 @@ jobs:
run: | run: |
echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | docker login registry.i80.dk -u "robot\$gitserver" --password-stdin 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 - name: Build and push image
run: | 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 docker push ${IMAGE}:latest
echo "IMAGE_TAG=${SHA}" >> $GITHUB_ENV
- name: Validate Nomad job - 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 - 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 - name: Health check
run: | run: |

View File

@@ -6,7 +6,6 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/ COPY src/ src/
COPY .env.production .env
EXPOSE 8000 EXPOSE 8000

View File

@@ -36,12 +36,23 @@ docker compose up
## Deploy til i80/Nomad ## Deploy til i80/Nomad
1. Konfigurer Gitea secrets: `REGISTRY_USER`, `REGISTRY_TOKEN`, `NOMAD_ADDR`, `NOMAD_TOKEN` > **Kun relevant for i80-infrastruktur.** For din egen infra: byg Docker image og kør med env vars.
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
## Tink Console setup 1. Læg credentials i Consul KV:
```bash
consul kv put tink-demo/TINK_CLIENT_ID <din_client_id>
consul kv put tink-demo/TINK_CLIENT_SECRET <din_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) 1. Gå til [console.tink.com](https://console.tink.com)
2. Opret en app → kopiér Client ID + Secret til `.env` 2. Opret en app → kopiér Client ID + Secret til `.env`

View File

@@ -1,4 +1,4 @@
job "moneycapp-tink-demo" { job "tink-demo" {
datacenters = ["dc1"] datacenters = ["dc1"]
type = "service" type = "service"
@@ -10,12 +10,13 @@ job "moneycapp-tink-demo" {
} }
service { service {
name = "moneycapp-tink-demo" name = "tink-demo"
port = "http" port = "http"
tags = ["traefik.enable=true", tags = ["traefik.enable=true",
"traefik.http.routers.tink-demo.rule=Host(`tink-demo.i80.dk`)", "traefik.http.routers.tink-demo.rule=Host(`tink-demo.i80.dk`)",
"traefik.http.routers.tink-demo.tls=true", "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 { check {
type = "http" type = "http"
path = "/" path = "/"
@@ -28,8 +29,21 @@ job "moneycapp-tink-demo" {
driver = "docker" driver = "docker"
config { config {
image = "registry.i80.dk/gitea/moneycapp-tink-demo:latest" image = "registry.i80.dk/gitea/tink-demo:latest"
ports = ["http"] force_pull = true
ports = ["http"]
}
template {
data = <<EOH
TINK_CLIENT_ID="{{ key "tink-demo/TINK_CLIENT_ID" }}"
TINK_CLIENT_SECRET="{{ key "tink-demo/TINK_CLIENT_SECRET" }}"
TINK_REDIRECT_URI="https://tink-demo.i80.dk/callback"
APP_BASE_URL="https://tink-demo.i80.dk"
DEMO_MODE="false"
EOH
destination = "secrets/app.env"
env = true
} }
resources { resources {

View File

@@ -40,7 +40,10 @@ def _session(request: Request) -> dict:
# Server-side token store — keeps JWTs OUT of the session cookie # Server-side token store — keeps JWTs OUT of the session cookie
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead) # (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} _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: def _get_sid(sess: dict) -> str:
@@ -69,16 +72,24 @@ def _ctx(request: Request, extra: dict) -> dict:
def _logger(sess: 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): 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) log.append(entry)
# keep last 50 entries
if len(log) > 50: if len(log) > 50:
sess["api_log"] = log[-50:] store["api_log"] = log[-50:]
return cb return cb
def _get_api_log(sess: dict) -> list:
sid = sess.get("sid", "")
return _token_store.get(sid, {}).get("api_log", [])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Landing # Landing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -108,7 +119,7 @@ async def debug_session(request: Request):
for k, v in sess.items() for k, v in sess.items()
if k != "api_log" 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)) safe["cookie_size_bytes"] = len(str(request.session))
return safe return safe
@@ -402,28 +413,35 @@ async def tink_callback(request: Request, code: Optional[str] = None,
print(f"[CALLBACK] Tink returned error: {error}") print(f"[CALLBACK] Tink returned error: {error}")
return RedirectResponse(f"/demo/step/3?error={error}") return RedirectResponse(f"/demo/step/3?error={error}")
if code: if code:
try: sid = sess.get("sid", "unknown")
s = get_settings() if sid not in _callback_locks:
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}") _callback_locks[sid] = asyncio.Lock()
client = _client(log_cb=_logger(sess)) async with _callback_locks[sid]:
tokens = await client.exchange_code_for_token( if _load_token(sess, "user_token"):
code, redirect_uri=s.tink_redirect_uri print(f"[CALLBACK] Already have user_token — skipping duplicate exchange")
) return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}") try:
user_token = tokens.get("access_token", "") s = get_settings()
if not user_token: print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}") client = _client(log_cb=_logger(sess))
return RedirectResponse( tokens = await client.exchange_code_for_token(
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response", code, redirect_uri=s.tink_redirect_uri
status_code=303,
) )
_store_token(sess, "user_token", user_token) print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)") user_token = tokens.get("access_token", "")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303) if not user_token:
except Exception as e: print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}")
import traceback return RedirectResponse(
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}") f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303) 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") print(f"[CALLBACK] No code — bare redirect to step 3")
return RedirectResponse("/demo/step/3", status_code=303) return RedirectResponse("/demo/step/3", status_code=303)
@@ -617,7 +635,7 @@ async def step6(request: Request):
@router.get("/demo/log", response_class=HTMLResponse) @router.get("/demo/log", response_class=HTMLResponse)
async def api_log(request: Request): async def api_log(request: Request):
sess = _session(request) sess = _session(request)
log = sess.get("api_log", []) log = _get_api_log(sess)
return templates.TemplateResponse("log.html", _ctx(request, { return templates.TemplateResponse("log.html", _ctx(request, {
"log": list(reversed(log)), # newest first "log": list(reversed(log)), # newest first
"log_count": len(log), "log_count": len(log),
@@ -627,7 +645,9 @@ async def api_log(request: Request):
@router.post("/demo/log/clear") @router.post("/demo/log/clear")
async def clear_log(request: Request): async def clear_log(request: Request):
sess = _session(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) return RedirectResponse("/demo/log", status_code=303)

0
src/static/.gitkeep Normal file
View File