Project

General

Profile

PF » History » Version 1

Redmine Admin, 10/29/2026 08:57 AM
deploy-publish.sh

1 1 Redmine Admin
**Version 1.2 — 2026-10-29** · companion of the host cheat sheet (conventions: [[Conventions]], [[NPM]], [[Operations]], [[Git_Workflow]])
2
3
Host `dmz-web` (Rocky Linux 10.2). Everything runs **rootless** under `websvcmaster` (no sudo) as Quadlet units. Design rule: **shared-nothing** — own networks, database, app container and volumes; the only shared piece is Nginx Proxy Manager (NPM), which joins the front network.
4
5
| Item | Value |
6
|---|---|
7
| Public name | `pf.sibernet.com.tr` (DNS A record → FortiGate WAN IP; Let's Encrypt HTTP-01) |
8
| App | `site-pf` — FastAPI/uvicorn on `:8000` (one worker), image `localhost/site-pf:<tag>` built locally from `~/apps/pf`; own login (password + TOTP), no public registration |
9
| Database | `db-pf` — TimescaleDB on PostgreSQL 16, `docker.io/timescale/timescaledb:2.31.0-pg16` |
10
| Networks | `net-pf` (NPM + app, outbound allowed — the app fetches market data) and `net-pf-db` (app + db only, `Internal=true`: the database has no route to the internet and NPM cannot reach it) |
11
| Volume | `db-pf-data` (PostgreSQL data) |
12
| Secrets | `pf-db-password`, `pf-app-secret` (both `openssl rand -hex 32`, created without a trailing newline) |
13
| Installer | `pf-install.sh` in `~/deploy` (this file's procedures are what it does) |
14
| Source | `pf-app-<ver>.tar.gz` (delivered with the release, not part of `dmz-web-deploy`); lives afterwards in its own repo |
15
16
Naming follows the host rule: containers `db-pf` / `site-pf`, secrets `pf-…`. The unit description `Description=Portfolio -- <domain>` is what `check-sites.sh` reads, so `./check-sites.sh | grep pf` works.
17
18
---
19
20
## 1. Install — `pf-install.sh` (websvcmaster)
21
22
First time, from `~/deploy` (shell setup: host cheat sheet §0), with the source tarball next to the script:
23
24
```bash
25
./pf-install.sh --app-src=pf-app-1.1.0.tar.gz --build --access-list=admin-segments
26
```
27
28
* `--app-src=FILE` unpacks the application (Containerfile, pinned `requirements.txt`, `app/`, `tests/`) into `~/apps/pf`; existing files are never overwritten, and an archive with absolute or `../` paths is refused. It is for the **first** install — later code comes from the app's own repo (§2).
29
* `--build` runs `podman build -t localhost/site-pf:1.1.0 ~/apps/pf` (the default tag; `--app-image=localhost/site-pf:1.2.0` for another).
30
* `--access-list=NAME` puts that NPM Access List on the new Proxy Host. The app has its own login (§5), so this is a second layer, not the only one — still recommended while the host is new.
31
* Other options: `--db-image`, `--app-dir`, `--timezone`, `--skip-npm-api`, `--force`; `./pf-install.sh -h`.
32
33
What it does, in order: generates the Quadlet files into `~/deploy/quadlet/` (`net-pf.network`, `net-pf-db.network`, `db-pf.volume`, `db-pf.container`, `site-pf.container`), creates the two secrets, adds `net-pf` to `nginx-proxy-manager.container`, installs the units, starts nets → db (waits until healthy) → app (waits for `/health`), **restarts NPM** (every site is down ~10–20 s — run it off-hours) and creates the Let's Encrypt certificate and Proxy Host through NPM's API.
34
35
Before it changes anything it compares the `Network=` lines of the **live** `nginx-proxy-manager.container` with the repo copy and stops if the live file knows a network the repo copy lacks (copying it over would cut that site off — the 2026-10-25 outage). If it stops: add the missing lines to the repo copy, commit, run again.
36
37
Prerequisite: the DNS name must already resolve to the FortiGate WAN IP, otherwise the certificate request fails (the rest still works; add cert and host in the NPM UI later: Forward Hostname `site-pf`, Forward Port `8000`, Force SSL, HTTP/2, Websockets, Block Common Exploits).
38
39
Then commit the generated units (root): `deploy-commit.sh -m "pf: portfolio stack on pf.sibernet.com.tr"`.
40
41
Create the first user (hidden password prompt) and enrol 2FA — §5. Verify:
42
43
```bash
44
curl -sI --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444 | head -1     # HTTP/2 200
45
curl -s  --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444/health        # {"status":"healthy"}
46
podman exec site-pf curl -s http://127.0.0.1:8000/health/db                                       # {"status":"db ok"}
47
./check-sites.sh | grep pf
48
podman network inspect net-pf net-pf-db | jq '.[].containers | keys'   # net-pf: site, nginx-proxy-manager · net-pf-db: db, site
49
```
50
51
---
52
53
## 2. Application code and images
54
55
The **no-host-packages rule** holds: no Python, pip or libraries on Rocky; everything lives in the image. The `Containerfile` is `python:3.12-slim-bookworm` + `curl` (for the healthcheck) + `pip install --only-binary=:all:` from a **fully pinned** `requirements.txt` (65 wheels) — no compiler in the image, and a dependency without a wheel fails the build loudly instead of compiling something unreviewed.
56
57
Source layout (`~/apps/pf`):
58
59
| File | Job |
60
|---|---|
61
| `app/config.py` | `PF_*` settings; DB URL built with `quote_plus`, so a password with `/ + = @ :` cannot break it |
62
| `app/database.py`, `app/models.py` | SQLAlchemy 2 (async, asyncpg); tables are created at start; `price_history` becomes a TimescaleDB hypertable when the extension is there |
63
| `app/auth.py` | bcrypt (SHA-256 pre-hash), TOTP with replay protection, JWT, per-IP rate limit, per-account lockout |
64
| `app/services/market_data.py` | Yahoo Finance (stocks, crypto, FX, dividends) and TEFAS (funds); raw, not dividend-adjusted prices |
65
| `app/services/drip.py` | dividend reinvestment (§5) |
66
| `app/services/portfolio.py`, `indicators.py` | positions, allocation, rebalancing hints, risk; RSI/MACD/SMA/EMA/Bollinger/ATR (pandas-ta) |
67
| `app/main.py`, `app/schemas.py`, `app/cli.py` | API, validation, admin commands |
68
| `tests/` | 21 tests (`pytest`), see "Tests" below |
69
70
* **Pinning:** `>=` ranges drift (on 2026-10-29 an unpinned install resolves to pandas 3.x and a beta of pandas-ta). The list was resolved for Python 3.12 and import-tested; it includes `greenlet`, which SQLAlchemy's async layer needs and which is easy to miss. Change versions on purpose, build a new tag, test, then switch the unit. Not used on purpose: `python-jose` and `passlib` (unmaintained; passlib breaks with current bcrypt) — the app uses `PyJWT` and `bcrypt` directly.
71
* **Image tags:** one tag per release (`site-pf:1.1.0`, `1.1.1`, …), never `latest` — the previous tag is the rollback.
72
* **Config (`PF_*`, set in `site-pf.container`):** `PF_BASE_CURRENCY` (default `TRY`), `PF_DIVIDEND_WITHHOLDING` (fraction taken at source, default `0` — set it to what your broker really withholds, e.g. `0.10`), `PF_DRIP_DEFAULT` (default `true`), `PF_DIVIDEND_PAY_LAG_DAYS` (default `0`), `PF_SYNC_ENABLED` / `PF_SYNC_INTERVAL_HOURS` (daily), `PF_REQUIRE_2FA` (default `true`), `PF_LOGIN_RATE_LIMIT` (default `5/minute`), `PF_ACCESS_TOKEN_MINUTES` (30), `PF_ENABLE_DOCS` (`0`: hides `/docs`, `/redoc`, `/openapi.json`; `1` only while developing, never behind the public Proxy Host). The app refuses to start when `PF_SECRET_KEY` (secret `pf-app-secret`) is shorter than 32 characters.
73
* **Source code is not in `dmz-web-deploy`.** Keep `~/apps/pf` in its own repository (e.g. a bare repo `/srv/git/pf-app.git`, same procedure as host cheat sheet §3 Git A–E) and tag it with the same number as the image.
74
* **Tables:** `create_all` at start creates what is missing; it never alters an existing table. A later schema change needs an explicit Alembic migration (installed, not wired): `podman exec site-pf alembic upgrade head`, run by hand after the app is up, never from the container start command.
75
* **Tests:** they need a throw-away PostgreSQL, never the production database (`PF_ENV=test PF_TIMESCALE=off PF_SECRET_KEY=<32+ chars> PF_DATABASE_URL_OVERRIDE=postgresql+asyncpg://user:pw@host/pf_test pytest`, with `requirements-dev.txt` installed in a venv away from the host's Python — e.g. on a workstation). Market data is faked in the tests: they prove the logic, not Yahoo/TEFAS behaviour.
76
77
---
78
79
## 3. Operations
80
81
### A. New code (database untouched)
82
83
```bash
84
cd ~/apps/pf                                         # edit / pull
85
podman build -t localhost/site-pf:1.1.1 .
86
# repo copy: change  Image=localhost/site-pf:1.1.1  in ~/deploy/quadlet/site-pf.container
87
# root:      deploy-commit.sh -m "pf: app 1.1.1"
88
cp ~/deploy/quadlet/site-pf.container ~/.config/containers/systemd/
89
systemctl --user daemon-reload && systemctl --user restart site-pf.service
90
~/deploy/check-sites.sh | grep pf
91
```
92
93
Rollback: put the previous tag back, same three lines.
94
95
### B. Logs and shell
96
97
```bash
98
journalctl --user -u site-pf.service -f
99
podman logs -f site-pf          podman logs -f db-pf
100
podman exec -it db-pf psql -U pf_user -d pf_db
101
```
102
103
### C. Backup and restore — TimescaleDB is not plain PostgreSQL
104
105
A plain dump restored the plain way breaks hypertables. Use the custom format and restore with the Timescale pre/post calls:
106
107
```bash
108
# backup (websvcmaster) — then copy the file OFF the host; a dump next to the database is not a backup
109
podman exec db-pf pg_dump -U pf_user -Fc pf_db > ~/tmp/pf_db-$(date +%F).dump
110
111
# restore into an EMPTY database (same TimescaleDB version or newer)
112
podman exec db-pf psql -U pf_user -d postgres -c "create database pf_restore"
113
podman exec db-pf psql -U pf_user -d pf_restore -c "create extension if not exists timescaledb"
114
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_pre_restore()"
115
podman exec -i db-pf pg_restore -U pf_user -d pf_restore --no-owner < ~/tmp/pf_db-2026-10-29.dump
116
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_post_restore()"
117
```
118
119
**Do one restore test before trusting the backups**, and again after each TimescaleDB upgrade. The application volume has nothing to back up (no uploads); the secrets are not needed for a restore (set a new password in the new environment).
120
121
### D. Password rotation
122
123
`POSTGRES_PASSWORD` is read only when the data directory is first created. To rotate: `podman exec db-pf psql -U pf_user -d pf_db -c "alter user pf_user password '<new>'"`, then `podman secret rm pf-db-password`, create it again with the new value (`printf '%s' '<new>' | podman secret create pf-db-password -`), restart `db-pf.service` and `site-pf.service`.
124
125
### E. Upgrading the database image
126
127
Dump first (C). Patch/minor (`2.31.0-pg16` → `2.31.1-pg16`): change the tag in the unit, `cp`, `daemon-reload`, restart `db-pf.service`, then `ALTER EXTENSION timescaledb UPDATE;` in `pf_db` (the first command in a fresh session). A PostgreSQL major jump (pg16 → pg17) is a dump-and-restore into a new volume, not a tag change.
128
129
---
130
131
## 4. Troubleshooting
132
133
* **App restarts in a loop right after boot** → it came up before the database accepted connections. The units wait for `db-pf` (Podman 5: `Notify=healthy`); check `podman healthcheck run db-pf` and `podman logs db-pf`.
134
* **`/health` is 200 but `/health/db` is 503** → the password in the secret is not the password the database was initialised with (secret recreated after first start): rotate as in 3.D, or drop the volume if the database is still empty.
135
* **NPM 502 for the pf host** → NPM is not on `net-pf` (`podman network inspect net-pf | jq '.[0].containers | keys'`); check the `Network=net-pf.network` line in the live `nginx-proxy-manager.container` and that NPM was restarted afterwards. Other sites also 502 → host cheat sheet §7 "NPM says 502".
136
* **Certificate request fails** → the DNS name does not point to the FortiGate WAN IP yet, or TCP 80 does not reach NPM; fix DNS, then request the certificate in the NPM UI.
137
* **App container exits at once, log says `PF_SECRET_KEY ... shorter than 32 characters`** → the `pf-app-secret` secret is missing or empty; recreate it (`openssl rand -hex 32 | tr -d '\n' | podman secret create pf-app-secret -` after `podman secret rm pf-app-secret`) and restart `site-pf.service`. Existing login tokens become invalid (30 minutes anyway).
138
* **Login answers 429** → either the per-IP limit (5 attempts per minute, the answer comes back after a minute) or the account lock (5 wrong passwords or codes lock the account for 15 minutes): `podman exec -it site-pf python -m app.cli unlock NAME`.
139
* **Rate limit hits everybody at once** → every request seems to come from one address (the proxy). Check that NPM passes `X-Forwarded-For` (it does by default) and that the app runs with `--proxy-headers` (it does in the `Containerfile`).
140
* **Authenticator code is always refused** → phone clock drift (turn automatic time on), or the code was already used (each 30-second code works once). Lost phone: `python -m app.cli reset-2fa NAME`, then enrol again.
141
* **A dividend is stuck in `pending_price`** → no price for its pay date yet (pay date in the future, or the data source was down); the daily sync completes it. Force it: `POST /api/v1/dividends/sync`.
142
* **Build fails on a dependency** → no wheel for Python 3.12 (the `--only-binary` guard); pick a version with a wheel, do not install a compiler into the image.
143
144
---
145
146
## 5. Using the application
147
148
### A. Users and two-factor login
149
150
There is no public registration. Create users inside the container (the password is typed at a hidden prompt, never given as an argument — it would land in the shell history):
151
152
```bash
153
podman exec -it site-pf python -m app.cli create-user kerem --admin
154
podman exec -it site-pf python -m app.cli list-users
155
podman exec -it site-pf python -m app.cli set-password kerem      # also: unlock, reset-2fa
156
```
157
158
Password rules: at least 12 characters, upper and lower case, a digit. First login returns a **restricted** token (`"scope": "setup"`) that can only enrol 2FA; after the code is confirmed you get a full token (30 minutes). From then on `login` needs the 6-digit code in the `otp` field.
159
160
```bash
161
U=https://pf.sibernet.com.tr
162
T=$(curl -s $U/api/v1/auth/login -d 'username=kerem' --data-urlencode 'password=...' | jq -r .access_token)
163
curl -s -X POST $U/api/v1/auth/2fa/setup   -H "Authorization: Bearer $T"          # -> secret + otpauth_uri: add to the authenticator app
164
curl -s -X POST $U/api/v1/auth/2fa/confirm -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -d '{"code":"123456"}'
165
# every later login:
166
curl -s $U/api/v1/auth/login -d 'username=kerem' --data-urlencode 'password=...' -d 'otp=123456'
167
```
168
169
Data isolation: every query filters on the logged-in user's id; another user's transactions, dividends and settings answer 404, not 403. Instruments (`/assets`) are a shared catalogue; ownership exists only through transactions.
170
171
### B. Dividend reinvestment (DRIP)
172
173
| Rule | Behaviour |
174
|---|---|
175
| Switch | per holding (`PUT /api/v1/holdings/{asset_id}/reinvest`), else the user default (`PUT /api/v1/auth/profile` `drip_default`), else `PF_DRIP_DEFAULT` (on) |
176
| Entitlement | shares held at the close **before** the ex-date; a purchase on the ex-date earns nothing |
177
| Amount | `shares × per-share × (1 − withholding)` |
178
| Reinvest on | a BUY (`source: drip`, fee 0) is booked at the first close on/after the pay date; quantity = net ÷ price, rounded **down** to 6 decimals; the rest is kept as `cash_residual` |
179
| Reinvest off | the net amount is recorded as cash income (`status: paid`) |
180
| No price yet | `status: pending_price`; the daily sync completes it (the later dividends of that asset wait, so the share count stays right) |
181
| Compounding | reinvested shares are normal shares and earn the next dividend |
182
| Idempotent | one row per user, asset and ex-date — syncing twice does nothing |
183
| Manual entry | `POST /api/v1/dividends` for companies the feed does not know; optional `withholding`, `reinvest`, `reinvest_price` |
184
| Corrections | adding or deleting a manual trade recomputes that asset's dividends; `PUT .../reinvest?apply_to_history=true` and `POST /api/v1/dividends/rebuild/{asset_id}` do it on demand. DRIP purchases cannot be deleted by hand (409) |
185
| Returns | reinvested dividends raise the cost basis but are **not** new money: `money_invested` counts only your own purchases, `total_return = market value + sale proceeds + cash dividends − money invested` |
186
187
Limits to know: Yahoo reports ex-dates only, so the pay date is `ex-date + PF_DIVIDEND_PAY_LAG_DAYS` (0); the dividend is assumed to be in the asset's own currency; TEFAS funds and crypto have no dividend events; withholding is one global rate unless you pass it per manual entry.
188
189
### C. Endpoints (`/api/v1`, bearer token except `login`)
190
191
| Area | Calls |
192
|---|---|
193
| Auth | `POST auth/login`, `auth/2fa/setup`, `auth/2fa/confirm`, `auth/password`; `GET auth/me`; `PUT auth/profile` (`risk_profile`: conservative / balanced / growth, `drip_default`) |
194
| Instruments | `POST assets` (stock/crypto = Yahoo symbol such as `THYAO.IS`, `AAPL`, `BTC-USD`; fund = TEFAS code; looked up on creation, `?verify=false` skips it), `GET assets` |
195
| Trades | `POST transactions` (buy/sell, quantity, price, fee, trade_date; selling more than held is refused), `GET transactions` (`asset_id`, `source`, paging), `DELETE transactions/{id}` |
196
| Dividends | `GET dividends`, `POST dividends`, `POST dividends/sync` (6 per hour), `POST dividends/rebuild/{asset_id}`, `GET/PUT holdings/{asset_id}/reinvest` |
197
| Portfolio | `GET portfolio` (positions, totals in `PF_BASE_CURRENCY`, FX conversion), `portfolio/allocation`, `portfolio/rebalance`, `portfolio/risk` |
198
| Market | `GET market/{asset_id}/indicators`, `market/{asset_id}/history` |
199
200
`portfolio/rebalance` compares the asset-class mix (funds / stocks / crypto) with an illustrative target for the chosen risk profile (conservative 65/30/5, balanced 40/50/10, growth 20/65/15) and names the amount to move for any class more than 5 points off. Indicators and rebalancing are information, not personal investment advice.
201
202
---
203
204
## Document history
205
206
| Version | Date       | Status     | Changes |
207
|---------|------------|------------|---------|
208
| 1.2     | 2026-10-29 | Latest     | The real application replaces the starter: login with TOTP 2FA (rate limit, lockout, replay protection, no public registration), transactions, **dividend reinvestment (DRIP)**, Yahoo/TEFAS market data on a TimescaleDB hypertable, positions and allocation in the base currency, indicators, risk statistics, class-level rebalancing hints, admin CLI. `pf-install.sh` 1.1: `--app-src` instead of `--init-app`, image tag 1.1.0, longer start period. New §5 (users and 2FA, DRIP rules, endpoint list); §2 file map, config variables, tests; §4 entries for secret key, 429, authenticator codes, `pending_price`. Pins: `greenlet` added (SQLAlchemy async), `PyJWT`/`bcrypt` instead of python-jose/passlib. |
209
| 1.1     | 2026-10-29 | Superseded | Rewritten around `pf-install.sh` (one procedure instead of nine manual steps, with the live-vs-repo NPM `Network=` guard). Two networks (`net-pf-db` is `Internal=true`); DB healthcheck and `Notify=healthy`; explicit `.volume` unit; pinned images (`2.31.0-pg16`, `site-pf:<tag>`) and a fully pinned, wheels-only `requirements.txt`; secrets as hex (URL-safe) and the URL built with `quote`; `/docs` off; Access List option; Timescale-aware backup/restore, password rotation and DB upgrade; troubleshooting; history table. Fixes of the 1.0 draft: wrong `diff` expectation (`+` vs `>`), NPM snippet repeating section headers, unpinned `latest` tags. |
210
| 1.0     | 2026-10-29 | Superseded | Draft (manual nine-step procedure). Never committed. |