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:
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
21
README.md
21
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 <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`
|
||||
|
||||
@@ -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 = <<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 {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
|
||||
0
src/static/.gitkeep
Normal file
0
src/static/.gitkeep
Normal file
Reference in New Issue
Block a user