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:
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: |

View File

@@ -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

View File

@@ -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 <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)
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"]
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,10 +29,23 @@ job "moneycapp-tink-demo" {
driver = "docker"
config {
image = "registry.i80.dk/gitea/moneycapp-tink-demo:latest"
image = "registry.i80.dk/gitea/tink-demo:latest"
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 {
cpu = 256
memory = 256

View File

@@ -40,7 +40,10 @@ def _session(request: Request) -> 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,6 +413,13 @@ 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:
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}")
@@ -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)

0
src/static/.gitkeep Normal file
View File