Compare commits
33 Commits
3a8dd6ba00
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4d116592c | ||
|
|
c45f5d747a | ||
|
|
b5fae9d528 | ||
|
|
d6bd5d927c | ||
|
|
ba62214bbd | ||
|
|
a79c1cec7b | ||
|
|
890fb0e693 | ||
|
|
260c9058b3 | ||
|
|
9201a4dca9 | ||
|
|
79ce3f2913 | ||
|
|
d14990bb01 | ||
|
|
9ea4f09600 | ||
|
|
c8b86569ff | ||
|
|
42c1309b20 | ||
|
|
c5a218490e | ||
|
|
52bd731fc3 | ||
|
|
e85d0eab63 | ||
|
|
e872ba0204 | ||
|
|
8d1dbace0f | ||
|
|
cddcd4463e | ||
|
|
efb5a165e7 | ||
|
|
d58fa67238 | ||
|
|
66d484955d | ||
|
|
2e928de156 | ||
|
|
1de5d69906 | ||
|
|
dd07b0218b | ||
|
|
a5d2a61cfb | ||
|
|
3faa29d8e5 | ||
|
|
2d79627ca9 | ||
|
|
33c8b4edbd | ||
|
|
5e22f2e1ae | ||
|
|
37b48d8f1c | ||
|
|
dee0600ee8 |
@@ -1,6 +1,6 @@
|
|||||||
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
||||||
_commit: v0.19.0
|
_commit: v0.19.0
|
||||||
_src_path: /home/Deeman/Projects/quart_saas_boilerplate
|
_src_path: git@gitlab.com:deemanone/materia_saas_boilerplate.master.git
|
||||||
author_email: hendrik@beanflows.coffee
|
author_email: hendrik@beanflows.coffee
|
||||||
author_name: Hendrik Deeman
|
author_name: Hendrik Deeman
|
||||||
base_url: https://beanflows.coffee
|
base_url: https://beanflows.coffee
|
||||||
|
|||||||
@@ -36,3 +36,10 @@ RATE_LIMIT_WINDOW=60
|
|||||||
# Waitlist (set to true to enable waitlist gate on /auth/signup)
|
# Waitlist (set to true to enable waitlist gate on /auth/signup)
|
||||||
WAITLIST_MODE=false
|
WAITLIST_MODE=false
|
||||||
RESEND_AUDIENCE_WAITLIST=
|
RESEND_AUDIENCE_WAITLIST=
|
||||||
|
|
||||||
|
# R2 Backup (optional — enables materia-backup.timer when all three are set)
|
||||||
|
# Get from: Cloudflare Dashboard → R2 → Manage R2 API Tokens
|
||||||
|
# R2_ENDPOINT format: https://<account_id>.r2.cloudflarestorage.com
|
||||||
|
R2_ACCESS_KEY_ID=
|
||||||
|
R2_SECRET_ACCESS_KEY=
|
||||||
|
R2_ENDPOINT=
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ DEBUG=ENC[AES256_GCM,data:ntBp/hw=,iv:906FN6bz3SHoEclG7MquCNUhHa9wDD2PyhxTDCVFUG
|
|||||||
ADMIN_EMAILS=ENC[AES256_GCM,data:W7kmtrgck47tGpiHy4bIoF7TZouqjNGPHK+zQoZvxT9iz1reuHbP6bXUfuMzsh0=,iv:GXkKRbComRXAVLzif8DV14IySjzRkAg/U9DUj4ytEjE=,tag:6iKYsgbhDgjDQbwZM6hSNg==,type:str]
|
ADMIN_EMAILS=ENC[AES256_GCM,data:W7kmtrgck47tGpiHy4bIoF7TZouqjNGPHK+zQoZvxT9iz1reuHbP6bXUfuMzsh0=,iv:GXkKRbComRXAVLzif8DV14IySjzRkAg/U9DUj4ytEjE=,tag:6iKYsgbhDgjDQbwZM6hSNg==,type:str]
|
||||||
#ENC[AES256_GCM,data:tIhB0x4AbNjs,iv:fkmVB5Cfa11g4YVXGEXPPnGDznhoMm+J108L/ZRkCn8=,tag:y7tqZ7cQ64A3ArM/MmfTlw==,type:comment]
|
#ENC[AES256_GCM,data:tIhB0x4AbNjs,iv:fkmVB5Cfa11g4YVXGEXPPnGDznhoMm+J108L/ZRkCn8=,tag:y7tqZ7cQ64A3ArM/MmfTlw==,type:comment]
|
||||||
DATABASE_PATH=ENC[AES256_GCM,data:Rzif9KAhrVn/F3U=,iv:VgXwn8b38/dFkiTYHDiKe660eWtGPdbeMPC4Xc2RPHk=,tag:OSlbuCeQHcVigj0zxnH+5Q==,type:str]
|
DATABASE_PATH=ENC[AES256_GCM,data:Rzif9KAhrVn/F3U=,iv:VgXwn8b38/dFkiTYHDiKe660eWtGPdbeMPC4Xc2RPHk=,tag:OSlbuCeQHcVigj0zxnH+5Q==,type:str]
|
||||||
DUCKDB_PATH=ENC[AES256_GCM,data:UWMI9RTAHBNgb9EOxnmKUZovyGedu/xz5/yoOFpd,iv:oWVAoDtboVAC+SCTf+b/mQ+zzCGSRTrf3fjt1femqng=,tag:B46K6jTM0iVWQvL1FJlbyg==,type:str]
|
#ENC[AES256_GCM,data:BUei2Df8W9g=,iv:NpOYrAUKS8tRwkGrh9SfyCowHaLSCaB9WrUEleVKp+Q=,tag:W5mebu7kkxC/ZAY+7NDfcA==,type:comment]
|
||||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:Y3bouhWcgp3d9v1KGuXuPZIFiIe/WKnVwEVs799T,iv:uTpVqvRYOhUKM2JNiFsX/YK/sfmajWI899vtmuWuozA=,tag:z8ASJTKzG6lSUBLuvzciwQ==,type:str]
|
R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:pd3cnES/P1/m7ydbes4QLD6lgLY1GVIVYman+oGGv4Y=,iv:UTlG2vehbaaI9Hs72Pgr84963EILdyZNDiV14n7HPtM=,tag:fXKpaVzlZLeiQeQaYS7uzg==,type:str]
|
||||||
|
R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:KAY/y2sWeEudSOoljyDCNVaxqTJf31qtOOlZCPdgg3jJRYhRzXDfVJQ1LhNc0lRu4aGuXW295NNTtfASNCYg0A==,iv:O3DkVlD1r25Nb/jYeg4/lyzDnzux2XyYBPgd6+OmeW0=,tag:E4Onwxo7Vbocd30o8gKwmA==,type:str]
|
||||||
|
R2_ENDPOINT=ENC[AES256_GCM,data:PsiOwvRyxqaGtGoCkWp+xvWk3q/FJB68PanNbDFWD1f/B2/ZNP2P8Opy1jkQ13Eql1oXRyMtYVZ4LG1PdHtpon4=,iv:+5ZjakxyB6JUmMK/ayQW4XJffyTPr+f7kb2BXUq6Ics=,tag:+ohog6SewrSh/SLoM3t35A==,type:str]
|
||||||
#ENC[AES256_GCM,data:E3cNcRc=,iv:GR/I/NNyv/Ha6ZMH8nd0GZstJLI9MNLCutEKefuBDpk=,tag:dHOwKaKKPoWSt2TiVJVXJA==,type:comment]
|
#ENC[AES256_GCM,data:E3cNcRc=,iv:GR/I/NNyv/Ha6ZMH8nd0GZstJLI9MNLCutEKefuBDpk=,tag:dHOwKaKKPoWSt2TiVJVXJA==,type:comment]
|
||||||
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:w1I=,iv:CGm9QV5OeVaDVBbRXJL/qO7RnOeSemG+zh3QCgww688=,tag:lfv4wxdx4hzFRC8vPu0Txg==,type:str]
|
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:w1I=,iv:CGm9QV5OeVaDVBbRXJL/qO7RnOeSemG+zh3QCgww688=,tag:lfv4wxdx4hzFRC8vPu0Txg==,type:str]
|
||||||
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:9fA=,iv:uBe1LugrsipQpOQX3wLFf4Er+v1SIQKNEcdglsmDwKM=,tag:g5lyQgBUCpWNWb2bkCmS3Q==,type:str]
|
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:9fA=,iv:uBe1LugrsipQpOQX3wLFf4Er+v1SIQKNEcdglsmDwKM=,tag:g5lyQgBUCpWNWb2bkCmS3Q==,type:str]
|
||||||
#ENC[AES256_GCM,data:Rd7HVrAHuomB78FCbYDB,iv:kxl7/gArMFCkWuQiv+hXWxCzgNkwDbe2WMs7p9/rlXQ=,tag:+IOGQO/HziVl32CDjiI9Pg==,type:comment]
|
#ENC[AES256_GCM,data:Rd7HVrAHuomB78FCbYDB,iv:kxl7/gArMFCkWuQiv+hXWxCzgNkwDbe2WMs7p9/rlXQ=,tag:+IOGQO/HziVl32CDjiI9Pg==,type:comment]
|
||||||
RESEND_API_KEY=ENC[AES256_GCM,data:srgytZ80mgTWF9DePH8QUR6TqrxI,iv:fCttiplfgdso2lKT2wPaS57SZ3npu0r2GIMnZLcAi7Q=,tag:k7OrEr2J5ikDWeDdZ6raRg==,type:str]
|
RESEND_API_KEY=ENC[AES256_GCM,data:rbnmeF4TqhG6Z8FOgtTu1A8y6aMWQH7cu04eye88utZeLwag,iv:hg5zYYzeygee13QutIY2uXAAp3msVMDf6XoPSqtsMKE=,tag:aR0wYZ636VEpbvCN1lad3w==,type:str]
|
||||||
EMAIL_FROM=ENC[AES256_GCM,data:oI1SUEpq5lbRT1FmHQ7QecDSj222kQ==,iv:ou981i5Ksx89IzDmudYFVuKWnHqXFXfcMI1jLwBAtPQ=,tag:QYmUIsgcqccmgrOJX+1Kvg==,type:str]
|
EMAIL_FROM=ENC[AES256_GCM,data:STVZgvdgAuX1keZZ6KXrFhLz2h0KA0yRQRl+FIPMNT459SsY,iv:J/gG8kgJzqvI80UiGWKV7g0rrW4NI3KQTsyYEnVf0Uk=,tag:8uL7vS3f2PCPDbeH+DBRLQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:BLQ9NzKrxA==,iv:7Lc0e7NxwMWZ3T405KAdaNXWtGnnHHWcp6oI8m2GJio=,tag:/NMk8DWNjxrRoDcYjDjvPQ==,type:comment]
|
#ENC[AES256_GCM,data:BLQ9NzKrxA==,iv:7Lc0e7NxwMWZ3T405KAdaNXWtGnnHHWcp6oI8m2GJio=,tag:/NMk8DWNjxrRoDcYjDjvPQ==,type:comment]
|
||||||
PADDLE_API_KEY=ENC[AES256_GCM,data:fS/C0Iygf+S1xjss49D2w8/LlcfI,iv:wLNuuqpBGnClizMRTIRtMdsu8SytU5p13zpkLbXEnNI=,tag:4//Cj5GQ/EolpKxOyEMkNg==,type:str]
|
PADDLE_API_KEY=ENC[AES256_GCM,data:fS/C0Iygf+S1xjss49D2w8/LlcfI,iv:wLNuuqpBGnClizMRTIRtMdsu8SytU5p13zpkLbXEnNI=,tag:4//Cj5GQ/EolpKxOyEMkNg==,type:str]
|
||||||
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:8Z/ODGntXsms8i+p+enaBVZjJuUa9ZIe,iv:NBr4IlxG60eQf7E43oDCCKKKDYeQSB1zMXL/z4YckP8=,tag:M4bF4y74bdLZgQ5dWkHFnQ==,type:str]
|
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:8Z/ODGntXsms8i+p+enaBVZjJuUa9ZIe,iv:NBr4IlxG60eQf7E43oDCCKKKDYeQSB1zMXL/z4YckP8=,tag:M4bF4y74bdLZgQ5dWkHFnQ==,type:str]
|
||||||
@@ -24,21 +26,20 @@ PADDLE_PRICE_PRO=ENC[AES256_GCM,data:qk74BtToWDvY32eaYKyB1G3q+znH,iv:TLwWA7erfJP
|
|||||||
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:c78c,iv:f7ZIb5n/f4DeMg5WKzVE/lbgfT7RfftnB3amrvuviE8=,tag:nPAI9P9oTV84cHWXOmYacw==,type:str]
|
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:c78c,iv:f7ZIb5n/f4DeMg5WKzVE/lbgfT7RfftnB3amrvuviE8=,tag:nPAI9P9oTV84cHWXOmYacw==,type:str]
|
||||||
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:rTs=,iv:s4ns8X4FPtOdmNtZ35xwgMk5F+kdiAnz0BKdhf6qN3k=,tag:6RSI4kp9ENb5iNj7jXY86Q==,type:str]
|
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:rTs=,iv:s4ns8X4FPtOdmNtZ35xwgMk5F+kdiAnz0BKdhf6qN3k=,tag:6RSI4kp9ENb5iNj7jXY86Q==,type:str]
|
||||||
#ENC[AES256_GCM,data:IiDU8DxK2LgK,iv:n0zJ+UixDFs2u1rLSxJ/VnWXYJZ8Vda/BQdyS+RujEE=,tag:GfVtYNoHmy9GX5+ZW7QjPg==,type:comment]
|
#ENC[AES256_GCM,data:IiDU8DxK2LgK,iv:n0zJ+UixDFs2u1rLSxJ/VnWXYJZ8Vda/BQdyS+RujEE=,tag:GfVtYNoHmy9GX5+ZW7QjPg==,type:comment]
|
||||||
WAITLIST_MODE=ENC[AES256_GCM,data:e0tSBHY=,iv:L83mH2xgqLakaq9wb4RymKeXb7l67MNo38zGmSbhi48=,tag:i0z/OalFlgvj/lP4ipzfYQ==,type:str]
|
WAITLIST_MODE=ENC[AES256_GCM,data:PL6dKA==,iv:1447ZD6aAO33qcVV+LHAlpNbLznJmzm2MLf2pAgHsIA=,tag:J/WmINlDCGlHW6xSSMRDZg==,type:str]
|
||||||
RESEND_AUDIENCE_WAITLIST=ENC[AES256_GCM,data:FcQEW8NGrdY7naM1LZuqaAEllNpMjIV9,iv:v0XxXCsjmk1rigORy8vrf1NNzYfn093x2sNb1JAPXuY=,tag:XjLmhewcV3M+Lk4zUhIWbg==,type:str]
|
|
||||||
#ENC[AES256_GCM,data:LgHFs0MBe0NfkE0DMJNYUkZh,iv:/C+IKpNQgSbOcwW9+1wN2gfwtY/OT5InkFDyJdPNw/M=,tag:jqEcXMfhowRVNSnrSs3ENg==,type:comment]
|
#ENC[AES256_GCM,data:LgHFs0MBe0NfkE0DMJNYUkZh,iv:/C+IKpNQgSbOcwW9+1wN2gfwtY/OT5InkFDyJdPNw/M=,tag:jqEcXMfhowRVNSnrSs3ENg==,type:comment]
|
||||||
UMAMI_SCRIPT_URL=ENC[AES256_GCM,data:85Nyjy8Rho38dyerGD5Mmw==,iv:+MXncm4quelDuV4QTI2Qqgt9G9ZffIkVDYpIdfOVI5Y=,tag:6LVNGEipfo+XWfdA6g7O5w==,type:str]
|
UMAMI_SCRIPT_URL=ENC[AES256_GCM,data:93ElrUmwstsR5gTx2AxFQ7vS14qZDYU3Yb7yXkeg28sA,iv:GzlnuNA8O6aTVYQYIsGhFJPewJQ3eIXm4Tiob/Yg/Ek=,tag:hh/LO6nlZx7xwohZhD/bcg==,type:str]
|
||||||
UMAMI_WEBSITE_ID=ENC[AES256_GCM,data:ArK+fRNSVlXQBnbCOl6+,iv:1nhATMUcBq9m+GLGlkVXaJhFOH9yVfngux7ZPi1bzLM=,tag:SJSSl8G9rztaCbf49e54eQ==,type:str]
|
UMAMI_WEBSITE_ID=ENC[AES256_GCM,data:ZM3JMBncWZ0qYyGwxSbePkDNs9/Tw4+LfAWssB/nazL9t6h4,iv:x7HlnTqRwE4skZJv1t+K3uXHvyY9/ENa10R4QWaKWiQ=,tag:nXfGGZekAAHk1zLzNzvgpg==,type:str]
|
||||||
|
UMAMI_API_URL=ENC[AES256_GCM,data:ErcfZNoB9ESrRqSG/laQMvRJjx2ieeSRlFQ=,iv:WAvEGFsVS2y07dnGZI2KcSpJQ93WjUqW93en1bS03kk=,tag:F1a/yZihEfH/PpUfZHGKCw==,type:str]
|
||||||
|
UMAMI_API_TOKEN=ENC[AES256_GCM,data:N7xqH1we7SvCkPLxI7olyV3/bKaiDhUoVYfj/OgVja39+NyeZKeWwkpKy5Mg8Hs/KXBwDJgN8NHA5T4gJgvoZ4MC+n1FMMaAOcmZkTGH412Uu8SEiTlfj4ZLUC5v1+izzAPC6BgBngDEz5SOsH+sdh2Tb/eImIgGw/CxLjyRBk2LsCrKsG8KtYAJ71peH8FF8s+najkhYaZ7zUH2Kl7xcPtyBnMfF+0PfnUSaoEatucAXX0LEf+pCEPp80wbKb1sWafXmfAEXgRX+Lv+rjxSJxmr1xoJ5eG5loNs3gHUoj9WOwqwmws61pnhRtoIKi8sc0wu4D09m/RWuDm3dVqtWQ8AxuS/dmqx5VICVaBoDM6t9ePIpirVct/Hxge+68XKKKI0VaW6uWXECJn8T1vLyChNc0x5Z1mqi1E6tmcdvm6O/EBL/Qgw/MaHQk4UwgmEvRIBnqxxUcjYllS4CGADdcxmbEfNknMWFrCS/x0EIoI0dLzjst9xT/peUQk=,iv:OdJqyCreI9i8cYi+58cU8ZboCPWOPTCqo/iFO9zkh4M=,tag:8giR+gN/1pjWj+tbZOlilA==,type:str]
|
||||||
#ENC[AES256_GCM,data:zx6ieYt6brZX6IrIgGkfGCqDlf0FOw==,iv:3dBgRYc9eI/Dhx109NUMh2yW2Fmqegg0n3rsjcbzJEw=,tag:4lbfJT/n1T53D0peeI4IhQ==,type:comment]
|
#ENC[AES256_GCM,data:zx6ieYt6brZX6IrIgGkfGCqDlf0FOw==,iv:3dBgRYc9eI/Dhx109NUMh2yW2Fmqegg0n3rsjcbzJEw=,tag:4lbfJT/n1T53D0peeI4IhQ==,type:comment]
|
||||||
LANDING_DIR=ENC[AES256_GCM,data:3YAGFB10q6g6ZLIHdDuvzMaD59+E,iv:S9NVxU/w+cwU1OPWjOEjnG8ocMdWrqR9VG4rFa4h4uA=,tag:0vq5Cn0Di1cUmbLrv1C1Uw==,type:str]
|
ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:ArkJg/hBzLN8P/Q+jmbmWOM2iQVLybBCaoCGMJgaYQM=,iv:zroifzAQ4rGn+QLF/SZUPeWmIOFkLWq8QVtVWUeiYOk=,tag:8oVeHuXStKxCLaP77TMxDA==,type:str]
|
||||||
ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:ARYR45VFPLX37u5UNn9fJeBNXDj8,iv:rWDphUHYX/nLD46fDNfx3ZyFEbYK1hMksHCGqWTI66o=,tag:qE1FR6Sj+k07Yb+SlV3Vgw==,type:str]
|
NTFY_TOKEN=ENC[AES256_GCM,data:63Y734rhyTCHt4hdw4S/LPOZ/eEktk6X7SMFsFidwps=,iv:3OJEXCN5sqGyOJwE6fOniBXXslT/rOMASOotE1s+quk=,tag:NTc3YJp6dtNRpLGxIDLO8Q==,type:str]
|
||||||
#ENC[AES256_GCM,data:ySDq589xP4ZwGD5JTQxh1Lr89h8zoz7RDLYfSl2Up/TSFF1tqA==,iv:oBQMgWLlT+r4TbtdLPSs7q7stg/qnEEbsu65+HjGBqQ=,tag:JiySwKWJIuZbEsY0sWJnQA==,type:comment]
|
|
||||||
GITLAB_READ_TOKEN=ENC[AES256_GCM,data:JRxX3H9mj3DCa0kyi7aGqvop,iv:W/oqCW7sDv791VclZteW0M+jkab3unGVWJoB//w4FJ4=,tag:3FJbkKPxH/obs67Hcd80+A==,type:str]
|
|
||||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrUWNTNnlLMWgzU21RY3p4\nUjRaVnJzK3lBS2hUTVZCcXI0Y0Q1K2QrOERJCi9qeVc2UVlleWZldUpUckF3WWVM\nMVhWanpGdGlGdXhGV2FnQytQYnZCSncKLS0tIER6RlNqMDhXMlFObkhOVmtVOXZw\nSVRHZTVzYkl5aytSWmNEblhTOVJCOGcKjWhIRS+pjFCMNp52Nt5GyLMhG9Xich7O\n8AlIkVaNN96Q7bVa52norLUQNQOprIGwEu5JXdUFU5Y3ULnoCTQimQ==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrUWNTNnlLMWgzU21RY3p4\nUjRaVnJzK3lBS2hUTVZCcXI0Y0Q1K2QrOERJCi9qeVc2UVlleWZldUpUckF3WWVM\nMVhWanpGdGlGdXhGV2FnQytQYnZCSncKLS0tIER6RlNqMDhXMlFObkhOVmtVOXZw\nSVRHZTVzYkl5aytSWmNEblhTOVJCOGcKjWhIRS+pjFCMNp52Nt5GyLMhG9Xich7O\n8AlIkVaNN96Q7bVa52norLUQNQOprIGwEu5JXdUFU5Y3ULnoCTQimQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||||
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQTllrSWJnU2I4WitWYVY0\nL1RRdFZudzdoSU1Ycmorb3ZRS0p0YnNBR2gwCmQ1T0M0YlpuNFo4SzVMUC9iTlM0\ndHhpd0NOZldJcVBsU0d6M3BwVVJaWjQKLS0tICtVVUVTRm1QSFZCNncvb0RqdC8r\nTmpMM3NmMFBKMDN6QjlIdko3NmFubEkK94oIMrcOYcBy69NjWn6NyWqhvKcP/0Az\nbOuqR0tgSs5xb8s9UUFHRpegZ3uJhQz4VMvOBN8fYaQjO5+4X22D9A==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQTllrSWJnU2I4WitWYVY0\nL1RRdFZudzdoSU1Ycmorb3ZRS0p0YnNBR2gwCmQ1T0M0YlpuNFo4SzVMUC9iTlM0\ndHhpd0NOZldJcVBsU0d6M3BwVVJaWjQKLS0tICtVVUVTRm1QSFZCNncvb0RqdC8r\nTmpMM3NmMFBKMDN6QjlIdko3NmFubEkK94oIMrcOYcBy69NjWn6NyWqhvKcP/0Az\nbOuqR0tgSs5xb8s9UUFHRpegZ3uJhQz4VMvOBN8fYaQjO5+4X22D9A==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_1__map_recipient=age1frqzxxwumn0zfkl3mp647t3zgx7uudksevsqrnxvycfhkl84yvmqskdzq5
|
sops_age__list_1__map_recipient=age1frqzxxwumn0zfkl3mp647t3zgx7uudksevsqrnxvycfhkl84yvmqskdzq5
|
||||||
sops_lastmodified=2026-02-26T19:25:04Z
|
sops_lastmodified=2026-02-28T22:44:47Z
|
||||||
sops_mac=ENC[AES256_GCM,data:LnKzPbxRoxtzw54ZqYuuZxq458Q8Mpo5edT7GvuLrw19NsYPmWMBcFmyXZH6WorEdVyy0YYYJLhiBHCm4J1rnYDCa/331xMtg+qG9N++u1OcpOGZI5QSMbEEFArSLWfOPHqdbYYZ4a5KiRd9L05bkW9kXsfLztbBzHtnxgzoQxQ=,iv:q2eMBkAv9M/liBlm5Tj6+g1V+CdgBYxlxfng2DqFH1Y=,tag:D3MTaywCb2rE4h9CH2EhKA==,type:str]
|
sops_mac=ENC[AES256_GCM,data:V2w9VF12xKi8gcOHGSOT7WTaJdLboNhnOubQnKUIf8EhKr6TO/4YgBwNughTsyZlVdd8rxo/+jdisADixUvM7H4TpEq/1OW1Cx0ihGLajmw0JftS63TcWaZdEkI9soF7Z91yE84Msrm3gwgQxY/c5+5jyg9KWAhbrFqki0L+dpc=,iv:GcaNiVdbuEdib3BvK8NjnbQPmx8GoHCysgz5YqIp5Ik=,tag:TQKQlhO+Mm1UbQB7GVN7ZQ==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
64
.gitlab-ci.yml
Normal file
64
.gitlab-ci.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
image: python:3.13
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- tag
|
||||||
|
|
||||||
|
variables:
|
||||||
|
UV_CACHE_DIR: "$CI_PROJECT_DIR/.uv-cache"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
paths:
|
||||||
|
- .uv-cache/
|
||||||
|
|
||||||
|
.uv_setup: &uv_setup
|
||||||
|
- curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
- source $HOME/.local/bin/env
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test:cli:
|
||||||
|
stage: test
|
||||||
|
before_script:
|
||||||
|
- *uv_setup
|
||||||
|
script:
|
||||||
|
- uv sync --all-packages
|
||||||
|
- uv run pytest tests/
|
||||||
|
|
||||||
|
test:sqlmesh:
|
||||||
|
stage: test
|
||||||
|
before_script:
|
||||||
|
- *uv_setup
|
||||||
|
script:
|
||||||
|
- uv sync --all-packages
|
||||||
|
- cd transform/sqlmesh_materia && uv run sqlmesh test
|
||||||
|
|
||||||
|
test:web:
|
||||||
|
stage: test
|
||||||
|
before_script:
|
||||||
|
- *uv_setup
|
||||||
|
script:
|
||||||
|
- uv sync --all-packages
|
||||||
|
- cd web && uv run pytest tests/ -x -q
|
||||||
|
- cd web && uv run ruff check src/ tests/
|
||||||
|
|
||||||
|
# ── Tag (pull-based deploy) ───────────────────────────────────────────────────
|
||||||
|
# Creates v<N> tag after all tests pass. The on-server supervisor polls for new
|
||||||
|
# tags every 60s and deploys automatically. No SSH keys or deploy credentials
|
||||||
|
# needed in CI — only the built-in CI_JOB_TOKEN.
|
||||||
|
|
||||||
|
tag:
|
||||||
|
stage: tag
|
||||||
|
image: alpine:latest
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache git
|
||||||
|
script:
|
||||||
|
- git tag "v${CI_PIPELINE_IID}"
|
||||||
|
- git push "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "v${CI_PIPELINE_IID}"
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
@@ -45,7 +45,7 @@ test:web:
|
|||||||
script:
|
script:
|
||||||
- uv sync --all-packages
|
- uv sync --all-packages
|
||||||
- cd web && uv run pytest tests/ -x -q
|
- cd web && uv run pytest tests/ -x -q
|
||||||
- cd web && uv run ruff check src/ tests/
|
- uv run ruff check .
|
||||||
|
|
||||||
# ── Tag (pull-based deploy) ───────────────────────────────────────────────────
|
# ── Tag (pull-based deploy) ───────────────────────────────────────────────────
|
||||||
# Creates v<N> tag after all tests pass. The on-server supervisor polls for new
|
# Creates v<N> tag after all tests pass. The on-server supervisor polls for new
|
||||||
|
|||||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to BeanFlows are documented here.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Monorepo copier migration**: moved all deployment files from `web/` to repo root so
|
||||||
|
`copier update` can manage them from the template
|
||||||
|
- `Dockerfile` at root: updated for monorepo layout (`web/src/` paths, `--package beanflows`)
|
||||||
|
- `docker-compose.yml`, `docker-compose.prod.yml`, `deploy.sh`, `litestream.yml`, `router/`
|
||||||
|
all moved to root
|
||||||
|
- `deploy.sh`: fixed sops path (`$APP_DIR/.env.prod.sops`, was `$APP_DIR/../.env.prod.sops`)
|
||||||
|
- `.copier-answers.yml` at root: points to local template, `_commit: v0.19.0`
|
||||||
|
- `.env.example` at root: updated paths for root-relative DuckDB locations
|
||||||
|
- `web/src/beanflows/core.py` (`Config`): added `ENABLE_CMS`, `ENABLE_DAAS`, `ENABLE_DIRECTORY`,
|
||||||
|
`ENABLE_LEADS`, `BUSINESS_MODEL` feature flags (mirrors copier.yml questions)
|
||||||
|
- `supervisor.py`: `web_code_changed()` now checks root `Dockerfile`; deploy script is `./deploy.sh`
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **ICE certified stock aging report** — Monthly age-bucket × port breakdown extracted via ICE API, stored as gzip CSV, modelled through raw→foundation→serving, exposed at `GET /api/v1/commodities/<code>/stocks/aging`
|
- **ICE certified stock aging report** — Monthly age-bucket × port breakdown extracted via ICE API, stored as gzip CSV, modelled through raw→foundation→serving, exposed at `GET /api/v1/commodities/<code>/stocks/aging`
|
||||||
- **ICE historical warehouse stocks by port** — End-of-month data from Nov 1996 to present, downloaded from static ICE URL, full SQLMesh pipeline, exposed at `GET /api/v1/commodities/<code>/stocks/by-port`
|
- **ICE historical warehouse stocks by port** — End-of-month data from Nov 1996 to present, downloaded from static ICE URL, full SQLMesh pipeline, exposed at `GET /api/v1/commodities/<code>/stocks/by-port`
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -118,11 +118,11 @@ uv add --package new_source extract-core niquests
|
|||||||
- Each tick: git pull (tag-based) → due extractors → SQLMesh → export_serving → web deploy if changed
|
- Each tick: git pull (tag-based) → due extractors → SQLMesh → export_serving → web deploy if changed
|
||||||
- Crash-safe: systemd `Restart=always` + 10-minute backoff on tick failure
|
- Crash-safe: systemd `Restart=always` + 10-minute backoff on tick failure
|
||||||
|
|
||||||
**CI/CD** (`.gitlab/.gitlab-ci.yml`) — pull-based, no SSH:
|
**CI/CD** (`.gitea/workflows/ci.yaml`) — pull-based, no SSH:
|
||||||
- `test` stage: pytest, sqlmesh test, web pytest
|
- `test-cli`, `test-sqlmesh`, `test-web` jobs: pytest, sqlmesh test, web pytest
|
||||||
- `tag` stage: creates `v${CI_PIPELINE_IID}` tag after tests pass (master branch only)
|
- `tag` job: creates `v${github.run_number}` tag after all tests pass (master branch only)
|
||||||
- Supervisor polls for new tags every 60s, checks out latest, runs `uv sync`
|
- Supervisor polls for new tags every 60s, checks out latest, runs `uv sync`
|
||||||
- No SSH keys or deploy credentials in CI — only `CI_JOB_TOKEN` (built-in)
|
- No SSH keys or deploy credentials in CI — only `github.token` (built-in Gitea Actions)
|
||||||
|
|
||||||
**CLI modules** (`src/materia/`):
|
**CLI modules** (`src/materia/`):
|
||||||
- `cli.py` — Typer app with subcommands: pipeline, secrets, version
|
- `cli.py` — Typer app with subcommands: pipeline, secrets, version
|
||||||
@@ -209,3 +209,6 @@ Read `coding_philosophy.md` for the full guide. Key points:
|
|||||||
| `SERVING_DUCKDB_PATH` | `analytics.duckdb` | Path to the serving DB (read by web app) |
|
| `SERVING_DUCKDB_PATH` | `analytics.duckdb` | Path to the serving DB (read by web app) |
|
||||||
| `ALERT_WEBHOOK_URL` | _(empty)_ | ntfy.sh URL for supervisor failure alerts |
|
| `ALERT_WEBHOOK_URL` | _(empty)_ | ntfy.sh URL for supervisor failure alerts |
|
||||||
| `SUPERVISOR_GIT_PULL` | _(unset)_ | Set to any value to enable tag-based git pull in supervisor |
|
| `SUPERVISOR_GIT_PULL` | _(unset)_ | Set to any value to enable tag-based git pull in supervisor |
|
||||||
|
| `R2_ACCESS_KEY_ID` | _(empty)_ | Cloudflare R2 access key — enables backup timer when all three R2 vars are set |
|
||||||
|
| `R2_SECRET_ACCESS_KEY` | _(empty)_ | Cloudflare R2 secret key |
|
||||||
|
| `R2_ENDPOINT` | _(empty)_ | Cloudflare account ID (used to construct R2 endpoint URL) |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ RUN tailwindcss -i ./web/src/beanflows/static/css/input.css \
|
|||||||
|
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM python:3.12-slim AS build
|
FROM python:3.13-slim AS build
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
@@ -20,7 +20,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --no-dev --frozen --package beanflows
|
uv sync --no-dev --frozen --package beanflows
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM python:3.12-slim AS runtime
|
FROM python:3.13-slim AS runtime
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
RUN useradd -m -u 1000 appuser
|
RUN useradd -m -u 1000 appuser
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -31,4 +31,4 @@ USER appuser
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV DATABASE_PATH=/app/data/app.db
|
ENV DATABASE_PATH=/app/data/app.db
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["hypercorn", "beanflows.app:app", "--bind", "0.0.0.0:5000", "--workers", "1"]
|
CMD ["granian", "--interface", "asgi", "--host", "0.0.0.0", "--port", "5000", "--workers", "1", "beanflows.app:app"]
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -2,7 +2,7 @@ TAILWIND_VERSION := v4.1.18
|
|||||||
TAILWIND := web/bin/tailwindcss
|
TAILWIND := web/bin/tailwindcss
|
||||||
SOPS_DOTENV := sops --input-type dotenv --output-type dotenv
|
SOPS_DOTENV := sops --input-type dotenv --output-type dotenv
|
||||||
|
|
||||||
.PHONY: help dev css-build css-watch \
|
.PHONY: help dev css-build css-watch install-hooks \
|
||||||
secrets-decrypt-dev secrets-decrypt-prod \
|
secrets-decrypt-dev secrets-decrypt-prod \
|
||||||
secrets-edit-dev secrets-edit-prod \
|
secrets-edit-dev secrets-edit-prod \
|
||||||
secrets-encrypt-dev secrets-encrypt-prod \
|
secrets-encrypt-dev secrets-encrypt-prod \
|
||||||
@@ -13,6 +13,7 @@ help:
|
|||||||
@echo " dev Start full dev environment (migrate, seed, app + worker + CSS watcher)"
|
@echo " dev Start full dev environment (migrate, seed, app + worker + CSS watcher)"
|
||||||
@echo " css-build Build + minify Tailwind CSS"
|
@echo " css-build Build + minify Tailwind CSS"
|
||||||
@echo " css-watch Watch + rebuild Tailwind CSS"
|
@echo " css-watch Watch + rebuild Tailwind CSS"
|
||||||
|
@echo " install-hooks Install git pre-commit hook (run once after cloning)"
|
||||||
@echo " secrets-decrypt-dev Decrypt .env.dev.sops → .env"
|
@echo " secrets-decrypt-dev Decrypt .env.dev.sops → .env"
|
||||||
@echo " secrets-decrypt-prod Decrypt .env.prod.sops → .env"
|
@echo " secrets-decrypt-prod Decrypt .env.prod.sops → .env"
|
||||||
@echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR"
|
@echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR"
|
||||||
@@ -23,6 +24,11 @@ help:
|
|||||||
|
|
||||||
# ── Dev environment ───────────────────────────────────────────────────────────
|
# ── Dev environment ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
install-hooks:
|
||||||
|
cp scripts/hooks/pre-commit .git/hooks/pre-commit
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
@echo "✓ pre-commit hook installed"
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@./web/scripts/dev_run.sh
|
@./web/scripts/dev_run.sh
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ services:
|
|||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5000:80"
|
- "5001:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./router/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./router/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -33,10 +33,10 @@ services:
|
|||||||
env_file: ./.env
|
env_file: ./.env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/data/materia/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/data/beanflows/analytics.duckdb
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /data/materia/analytics.duckdb:/data/materia/analytics.duckdb:ro
|
- /data/beanflows:/data/beanflows:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -84,10 +84,10 @@ services:
|
|||||||
env_file: ./.env
|
env_file: ./.env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/data/materia/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/data/beanflows/analytics.duckdb
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /data/materia/analytics.duckdb:/data/materia/analytics.duckdb:ro
|
- /data/beanflows:/data/beanflows:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
from extract_core import content_hash, end_run, landing_path, open_state_db, start_run
|
from extract_core import (
|
||||||
from extract_core import write_bytes_atomic
|
content_hash,
|
||||||
|
end_run,
|
||||||
|
landing_path,
|
||||||
|
open_state_db,
|
||||||
|
start_run,
|
||||||
|
write_bytes_atomic,
|
||||||
|
)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from .normalize import normalize_zipped_csv
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -7,8 +6,16 @@ from io import BytesIO
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import niquests
|
import niquests
|
||||||
from extract_core import end_run, landing_path, normalize_etag, open_state_db, start_run
|
from extract_core import (
|
||||||
from extract_core import write_bytes_atomic
|
end_run,
|
||||||
|
landing_path,
|
||||||
|
normalize_etag,
|
||||||
|
open_state_db,
|
||||||
|
start_run,
|
||||||
|
write_bytes_atomic,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .normalize import normalize_zipped_csv
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import zipfile
|
|
||||||
import gzip
|
import gzip
|
||||||
from io import BytesIO
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
def normalize_zipped_csv(buffer: BytesIO)->BytesIO:
|
def normalize_zipped_csv(buffer: BytesIO)->BytesIO:
|
||||||
|
|||||||
9
infra/backup/beanflows-backup.service
Normal file
9
infra/backup/beanflows-backup.service
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Beanflows Landing Data Backup to R2
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/rclone sync /data/beanflows/landing/ r2:backup/beanflows/landing/ --log-level INFO
|
||||||
|
TimeoutStartSec=1800
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Materia Landing Data Backup Timer
|
Description=Beanflows Landing Data Backup Timer
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=*-*-* 00/6:00:00
|
OnCalendar=*-*-* 00/6:00:00
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Materia Landing Data Backup to R2
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/bin/rclone sync /data/materia/landing/ r2:materia-raw/landing/ --log-level INFO
|
|
||||||
TimeoutStartSec=1800
|
|
||||||
@@ -3,12 +3,14 @@
|
|||||||
#
|
#
|
||||||
# Get credentials from: Cloudflare Dashboard → R2 → Manage R2 API Tokens
|
# Get credentials from: Cloudflare Dashboard → R2 → Manage R2 API Tokens
|
||||||
# Or from Pulumi ESC: esc env open beanflows/prod --format shell
|
# Or from Pulumi ESC: esc env open beanflows/prod --format shell
|
||||||
|
#
|
||||||
|
# Bucket: backup (syncs to backup/materia/landing/)
|
||||||
|
|
||||||
[r2]
|
[r2]
|
||||||
type = s3
|
type = s3
|
||||||
provider = Cloudflare
|
provider = Cloudflare
|
||||||
access_key_id = <R2_ACCESS_KEY_ID>
|
access_key_id = <R2_ACCESS_KEY_ID>
|
||||||
secret_access_key = <R2_SECRET_ACCESS_KEY>
|
secret_access_key = <R2_SECRET_ACCESS_KEY>
|
||||||
endpoint = https://<CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.com
|
endpoint = <R2_ENDPOINT>
|
||||||
acl = private
|
acl = private
|
||||||
no_check_bucket = true
|
no_check_bucket = true
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVICE_USER="beanflows_service"
|
SERVICE_USER="beanflows_service"
|
||||||
REPO_DIR="/opt/materia"
|
REPO_DIR="/opt/beanflows"
|
||||||
GITLAB_PROJECT="deemanone/materia"
|
GITEA_REPO="ssh://git@git.padelnomics.io:2222/deemanone/beanflows.git"
|
||||||
UV="/home/${SERVICE_USER}/.local/bin/uv"
|
UV="/home/${SERVICE_USER}/.local/bin/uv"
|
||||||
|
|
||||||
[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root"; exit 1; }
|
[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root"; exit 1; }
|
||||||
@@ -35,7 +35,7 @@ if [ -d "${REPO_DIR}/.git" ]; then
|
|||||||
sudo -u "${SERVICE_USER}" git -C "${REPO_DIR}" fetch --tags --prune-tags origin
|
sudo -u "${SERVICE_USER}" git -C "${REPO_DIR}" fetch --tags --prune-tags origin
|
||||||
else
|
else
|
||||||
sudo -u "${SERVICE_USER}" git clone \
|
sudo -u "${SERVICE_USER}" git clone \
|
||||||
"git@gitlab.com:${GITLAB_PROJECT}.git" "${REPO_DIR}"
|
"${GITEA_REPO}" "${REPO_DIR}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
LATEST_TAG=$(sudo -u "${SERVICE_USER}" \
|
LATEST_TAG=$(sudo -u "${SERVICE_USER}" \
|
||||||
@@ -47,23 +47,74 @@ fi
|
|||||||
# ── Decrypt secrets ───────────────────────────────────────────────────────────
|
# ── Decrypt secrets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
sudo -u "${SERVICE_USER}" bash -c \
|
sudo -u "${SERVICE_USER}" bash -c \
|
||||||
"sops --input-type dotenv --output-type dotenv -d ${REPO_DIR}/.env.prod.sops > ${REPO_DIR}/.env"
|
"SOPS_AGE_KEY_FILE=/home/${SERVICE_USER}/.config/sops/age/keys.txt \
|
||||||
|
sops --input-type dotenv --output-type dotenv -d ${REPO_DIR}/.env.prod.sops > ${REPO_DIR}/.env"
|
||||||
chmod 600 "${REPO_DIR}/.env"
|
chmod 600 "${REPO_DIR}/.env"
|
||||||
|
|
||||||
# ── Python dependencies ───────────────────────────────────────────────────────
|
# ── Python dependencies ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
sudo -u "${SERVICE_USER}" bash -c "cd ${REPO_DIR} && ${UV} sync --all-packages"
|
sudo -u "${SERVICE_USER}" bash -c "cd ${REPO_DIR} && ${UV} sync --all-packages"
|
||||||
|
|
||||||
# ── Systemd service ───────────────────────────────────────────────────────────
|
# ── Systemd supervisor service ────────────────────────────────────────────────
|
||||||
|
|
||||||
cp "${REPO_DIR}/infra/supervisor/materia-supervisor.service" /etc/systemd/system/
|
cp "${REPO_DIR}/infra/supervisor/beanflows-supervisor.service" /etc/systemd/system/
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now materia-supervisor
|
systemctl enable --now beanflows-supervisor
|
||||||
|
|
||||||
|
# ── R2 backup timer (optional) ────────────────────────────────────────────────
|
||||||
|
# Enabled only when R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_ENDPOINT
|
||||||
|
# are present in .env. Idempotent: rclone.conf is always regenerated from env
|
||||||
|
# (so credential rotations take effect on re-run); systemd units are only copied
|
||||||
|
# when they differ from what's already installed.
|
||||||
|
|
||||||
|
_env_get() { grep -E "^${1}=" "${REPO_DIR}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'" || true; }
|
||||||
|
|
||||||
|
R2_KEY=$(_env_get R2_ACCESS_KEY_ID)
|
||||||
|
R2_SECRET=$(_env_get R2_SECRET_ACCESS_KEY)
|
||||||
|
R2_ENDPOINT=$(_env_get R2_ENDPOINT)
|
||||||
|
|
||||||
|
if [ -n "${R2_KEY}" ] && [ -n "${R2_SECRET}" ] && [ -n "${R2_ENDPOINT}" ]; then
|
||||||
|
echo "$(date '+%H:%M:%S') ==> Configuring R2 backup..."
|
||||||
|
|
||||||
|
RCLONE_CONF_DIR="/home/${SERVICE_USER}/.config/rclone"
|
||||||
|
RCLONE_CONF="${RCLONE_CONF_DIR}/rclone.conf"
|
||||||
|
|
||||||
|
sudo -u "${SERVICE_USER}" mkdir -p "${RCLONE_CONF_DIR}"
|
||||||
|
|
||||||
|
# Always write from env so credential rotations take effect automatically.
|
||||||
|
cat > "${RCLONE_CONF}" <<EOF
|
||||||
|
[r2]
|
||||||
|
type = s3
|
||||||
|
provider = Cloudflare
|
||||||
|
access_key_id = ${R2_KEY}
|
||||||
|
secret_access_key = ${R2_SECRET}
|
||||||
|
endpoint = ${R2_ENDPOINT}
|
||||||
|
acl = private
|
||||||
|
no_check_bucket = true
|
||||||
|
EOF
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${RCLONE_CONF}"
|
||||||
|
chmod 600 "${RCLONE_CONF}"
|
||||||
|
|
||||||
|
UNITS_CHANGED=0
|
||||||
|
for unit in beanflows-backup.service beanflows-backup.timer; do
|
||||||
|
if ! diff -q "${REPO_DIR}/infra/backup/${unit}" "/etc/systemd/system/${unit}" >/dev/null 2>&1; then
|
||||||
|
cp "${REPO_DIR}/infra/backup/${unit}" /etc/systemd/system/
|
||||||
|
UNITS_CHANGED=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "${UNITS_CHANGED}" = "1" ] && systemctl daemon-reload
|
||||||
|
|
||||||
|
systemctl enable --now beanflows-backup.timer
|
||||||
|
echo "$(date '+%H:%M:%S') ==> R2 backup timer enabled."
|
||||||
|
else
|
||||||
|
echo "$(date '+%H:%M:%S') ==> R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_ENDPOINT not set — skipping backup timer."
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Bootstrap complete! ==="
|
echo "=== Bootstrap complete! ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Check status: systemctl status materia-supervisor"
|
echo "Check status: systemctl status beanflows-supervisor"
|
||||||
echo "View logs: journalctl -u materia-supervisor -f"
|
echo "View logs: journalctl -u beanflows-supervisor -f"
|
||||||
echo "Workflow status: sudo -u ${SERVICE_USER} ${UV} run -p ${REPO_DIR} python src/materia/supervisor.py status"
|
echo "Workflow status: sudo -u ${SERVICE_USER} ${UV} run -p ${REPO_DIR} python src/beanflows_pipeline/supervisor.py status"
|
||||||
|
echo "Backup timer: systemctl list-timers beanflows-backup.timer"
|
||||||
echo "Tag: $(sudo -u "${SERVICE_USER}" git -C "${REPO_DIR}" describe --tags --always)"
|
echo "Tag: $(sudo -u "${SERVICE_USER}" git -C "${REPO_DIR}" describe --tags --always)"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Single-server local-first setup for BeanFlows.coffee on Hetzner NVMe.
|
|||||||
```
|
```
|
||||||
Hetzner Server (NVMe)
|
Hetzner Server (NVMe)
|
||||||
├── beanflows_service (system user, nologin)
|
├── beanflows_service (system user, nologin)
|
||||||
│ ├── ~/.ssh/materia_deploy # ed25519 deploy key for GitLab read access
|
│ ├── ~/.ssh/beanflows_deploy # ed25519 deploy key for Gitea read access
|
||||||
│ └── ~/.config/sops/age/keys.txt # age keypair (auto-discovered by SOPS)
|
│ └── ~/.config/sops/age/keys.txt # age keypair (auto-discovered by SOPS)
|
||||||
├── /opt/materia/ # Git repo (owned by beanflows_service, latest release tag)
|
├── /opt/materia/ # Git repo (owned by beanflows_service, latest release tag)
|
||||||
├── /opt/materia/.env # Decrypted from .env.prod.sops at deploy time
|
├── /opt/materia/.env # Decrypted from .env.prod.sops at deploy time
|
||||||
@@ -24,7 +24,7 @@ Hetzner Server (NVMe)
|
|||||||
1. **Extract** — Supervisor runs due extractors per `infra/supervisor/workflows.toml`
|
1. **Extract** — Supervisor runs due extractors per `infra/supervisor/workflows.toml`
|
||||||
2. **Transform** — SQLMesh reads landing → writes `lakehouse.duckdb`
|
2. **Transform** — SQLMesh reads landing → writes `lakehouse.duckdb`
|
||||||
3. **Export** — `export_serving` copies `serving.*` → `analytics.duckdb` (atomic rename)
|
3. **Export** — `export_serving` copies `serving.*` → `analytics.duckdb` (atomic rename)
|
||||||
4. **Backup** — rclone syncs `/data/materia/landing/` → R2 `materia-raw/landing/`
|
4. **Backup** — rclone syncs `/data/materia/landing/` → R2 `backup/materia/landing/`
|
||||||
5. **Web** — Web app reads `analytics.duckdb` read-only (per-thread connections)
|
5. **Web** — Web app reads `analytics.duckdb` read-only (per-thread connections)
|
||||||
|
|
||||||
## Setup (new server)
|
## Setup (new server)
|
||||||
@@ -37,11 +37,11 @@ bash infra/setup_server.sh
|
|||||||
|
|
||||||
This creates the `beanflows_service` user, data directories, installs all tools (git, curl, age, sops, rclone, uv), generates an ed25519 SSH deploy key and an age keypair (both as the service user). It prints both public keys.
|
This creates the `beanflows_service` user, data directories, installs all tools (git, curl, age, sops, rclone, uv), generates an ed25519 SSH deploy key and an age keypair (both as the service user). It prints both public keys.
|
||||||
|
|
||||||
### 2. Add keys to GitLab and SOPS
|
### 2. Add keys to Gitea and SOPS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add the SSH deploy key to GitLab:
|
# Add the SSH deploy key to Gitea:
|
||||||
# → Repository Settings → Deploy Keys → Add key (read-only)
|
# → git.padelnomics.io → beanflows repo → Settings → Deploy Keys → Add key (read-only)
|
||||||
|
|
||||||
# Add the server age public key to .sops.yaml on your workstation,
|
# Add the server age public key to .sops.yaml on your workstation,
|
||||||
# then re-encrypt prod secrets to include the server key:
|
# then re-encrypt prod secrets to include the server key:
|
||||||
@@ -59,20 +59,7 @@ ssh root@<server_ip> 'bash -s' < infra/bootstrap_supervisor.sh
|
|||||||
|
|
||||||
This clones the repo via SSH, decrypts secrets, installs Python dependencies, and starts the supervisor service. No access tokens required — access is via the SSH deploy key. (All tools must already be installed by setup_server.sh.)
|
This clones the repo via SSH, decrypts secrets, installs Python dependencies, and starts the supervisor service. No access tokens required — access is via the SSH deploy key. (All tools must already be installed by setup_server.sh.)
|
||||||
|
|
||||||
### 4. Set up R2 backup
|
If `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_ENDPOINT` are present in `.env.prod.sops`, bootstrap also generates `rclone.conf` and enables `materia-backup.timer` automatically. No manual R2 setup step needed.
|
||||||
|
|
||||||
```bash
|
|
||||||
apt install rclone
|
|
||||||
# Configure rclone as the service user (used by the backup timer):
|
|
||||||
sudo -u beanflows_service mkdir -p /home/beanflows_service/.config/rclone
|
|
||||||
sudo -u beanflows_service cp infra/backup/rclone.conf.example \
|
|
||||||
/home/beanflows_service/.config/rclone/rclone.conf
|
|
||||||
# Fill in R2 credentials from .env.prod.sops (ACCESS_KEY_ID, SECRET_ACCESS_KEY, bucket endpoint)
|
|
||||||
cp infra/backup/materia-backup.service /etc/systemd/system/
|
|
||||||
cp infra/backup/materia-backup.timer /etc/systemd/system/
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now materia-backup.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Secrets management
|
## Secrets management
|
||||||
|
|
||||||
@@ -100,8 +87,8 @@ SOPS auto-discovers the service user's age key at `~/.config/sops/age/keys.txt`
|
|||||||
|
|
||||||
No SSH keys or deploy credentials in CI.
|
No SSH keys or deploy credentials in CI.
|
||||||
|
|
||||||
1. CI runs tests (`test:cli`, `test:sqlmesh`, `test:web`)
|
1. CI runs tests (`test-cli`, `test-sqlmesh`, `test-web`)
|
||||||
2. On master, CI creates tag `v${CI_PIPELINE_IID}` using built-in `CI_JOB_TOKEN`
|
2. On master, CI creates tag `v${github.run_number}` using built-in `github.token`
|
||||||
3. Supervisor polls for new tags every 60s
|
3. Supervisor polls for new tags every 60s
|
||||||
4. When a new tag appears: `git checkout --detach <tag>` + `uv sync --all-packages`
|
4. When a new tag appears: `git checkout --detach <tag>` + `uv sync --all-packages`
|
||||||
5. If `web/` files changed: `./web/deploy.sh` (Docker blue/green + health check)
|
5. If `web/` files changed: `./web/deploy.sh` (Docker blue/green + health check)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Creates beanflows_service user (nologin) + adds to docker group
|
# 1. Creates beanflows_service user (nologin) + adds to docker group
|
||||||
# 2. Creates /opt/materia + /data/materia/landing with correct ownership
|
# 2. Creates /opt/beanflows + /data/beanflows/landing with correct ownership
|
||||||
# 3. Installs git, curl, age, sops, rclone, uv
|
# 3. Installs git, curl, age, sops, rclone, uv
|
||||||
# 4. Generates ed25519 SSH deploy key for GitLab read access
|
# 4. Generates ed25519 SSH deploy key for GitLab read access
|
||||||
# 5. Generates age keypair at ~/.config/sops/age/keys.txt (as service user)
|
# 5. Generates age keypair at ~/.config/sops/age/keys.txt (as service user)
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVICE_USER="beanflows_service"
|
SERVICE_USER="beanflows_service"
|
||||||
APP_DIR="/opt/materia"
|
APP_DIR="/opt/beanflows"
|
||||||
DATA_DIR="/data/materia"
|
DATA_DIR="/data/beanflows"
|
||||||
SSH_DIR="/home/${SERVICE_USER}/.ssh"
|
SSH_DIR="/home/${SERVICE_USER}/.ssh"
|
||||||
DEPLOY_KEY="${SSH_DIR}/materia_deploy"
|
DEPLOY_KEY="${SSH_DIR}/beanflows_deploy"
|
||||||
SOPS_AGE_DIR="/home/${SERVICE_USER}/.config/sops/age"
|
SOPS_AGE_DIR="/home/${SERVICE_USER}/.config/sops/age"
|
||||||
|
|
||||||
ROTATE_KEYS="${ROTATE_KEYS:-}"
|
ROTATE_KEYS="${ROTATE_KEYS:-}"
|
||||||
@@ -41,7 +41,7 @@ usermod -aG docker "${SERVICE_USER}"
|
|||||||
|
|
||||||
log "Creating directories..."
|
log "Creating directories..."
|
||||||
mkdir -p "${APP_DIR}" "${DATA_DIR}/landing"
|
mkdir -p "${APP_DIR}" "${DATA_DIR}/landing"
|
||||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}"
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}"
|
||||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}"
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}"
|
||||||
|
|
||||||
# ── System tools ──────────────────────────────────────────────────────────────
|
# ── System tools ──────────────────────────────────────────────────────────────
|
||||||
@@ -63,12 +63,13 @@ fi
|
|||||||
|
|
||||||
if [ ! -f "${DEPLOY_KEY}" ]; then
|
if [ ! -f "${DEPLOY_KEY}" ]; then
|
||||||
sudo -u "${SERVICE_USER}" ssh-keygen -t ed25519 \
|
sudo -u "${SERVICE_USER}" ssh-keygen -t ed25519 \
|
||||||
-f "${DEPLOY_KEY}" -N "" -C "materia-deploy"
|
-f "${DEPLOY_KEY}" -N "" -C "beanflows-deploy"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "${SSH_DIR}/config" ]; then
|
if [ ! -f "${SSH_DIR}/config" ]; then
|
||||||
cat > "${SSH_DIR}/config" <<EOF
|
cat > "${SSH_DIR}/config" <<EOF
|
||||||
Host gitlab.com
|
Host git.padelnomics.io
|
||||||
|
Port 2222
|
||||||
IdentityFile ${DEPLOY_KEY}
|
IdentityFile ${DEPLOY_KEY}
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
EOF
|
EOF
|
||||||
@@ -76,7 +77,7 @@ EOF
|
|||||||
chmod 600 "${SSH_DIR}/config"
|
chmod 600 "${SSH_DIR}/config"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ssh-keyscan -H gitlab.com >> "${SSH_DIR}/known_hosts" 2>/dev/null
|
ssh-keyscan -H -p 2222 git.padelnomics.io >> "${SSH_DIR}/known_hosts" 2>/dev/null
|
||||||
sort -u "${SSH_DIR}/known_hosts" -o "${SSH_DIR}/known_hosts"
|
sort -u "${SSH_DIR}/known_hosts" -o "${SSH_DIR}/known_hosts"
|
||||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${SSH_DIR}/known_hosts"
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${SSH_DIR}/known_hosts"
|
||||||
chmod 644 "${SSH_DIR}/known_hosts"
|
chmod 644 "${SSH_DIR}/known_hosts"
|
||||||
|
|||||||
29
infra/supervisor/beanflows-supervisor.service
Normal file
29
infra/supervisor/beanflows-supervisor.service
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Beanflows Supervisor - Pipeline Orchestration
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=beanflows_service
|
||||||
|
WorkingDirectory=/opt/beanflows
|
||||||
|
ExecStart=/bin/sh -c 'exec uv run python src/beanflows_pipeline/supervisor.py'
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
EnvironmentFile=/opt/beanflows/.env
|
||||||
|
Environment=PATH=/home/beanflows_service/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
Environment=LANDING_DIR=/data/beanflows/landing
|
||||||
|
Environment=DUCKDB_PATH=/data/beanflows/lakehouse.duckdb
|
||||||
|
Environment=SERVING_DUCKDB_PATH=/data/beanflows/analytics.duckdb
|
||||||
|
# Environment=SUPERVISOR_GIT_PULL=1 # Uncomment once deploy.sh + blue/green is set up
|
||||||
|
|
||||||
|
# Resource limits
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=beanflows-supervisor
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Materia Supervisor - Pipeline Orchestration
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=beanflows_service
|
|
||||||
WorkingDirectory=/opt/materia
|
|
||||||
ExecStart=/bin/sh -c 'exec uv run python src/materia/supervisor.py'
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
EnvironmentFile=/opt/materia/.env
|
|
||||||
Environment=PATH=/home/beanflows_service/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
||||||
Environment=LANDING_DIR=/data/materia/landing
|
|
||||||
Environment=DUCKDB_PATH=/data/materia/lakehouse.duckdb
|
|
||||||
Environment=SERVING_DUCKDB_PATH=/data/materia/analytics.duckdb
|
|
||||||
Environment=SUPERVISOR_GIT_PULL=1
|
|
||||||
|
|
||||||
# Resource limits
|
|
||||||
LimitNOFILE=65536
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=materia-supervisor
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -18,6 +18,11 @@ module = "cftc_cot.execute"
|
|||||||
entry = "extract_cot_dataset"
|
entry = "extract_cot_dataset"
|
||||||
schedule = "weekly"
|
schedule = "weekly"
|
||||||
|
|
||||||
|
[extract_cot_combined]
|
||||||
|
module = "cftc_cot.execute"
|
||||||
|
entry = "extract_cot_combined"
|
||||||
|
schedule = "weekly"
|
||||||
|
|
||||||
[extract_prices]
|
[extract_prices]
|
||||||
module = "coffee_prices.execute"
|
module = "coffee_prices.execute"
|
||||||
entry = "extract_coffee_prices"
|
entry = "extract_coffee_prices"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "materia"
|
name = "beanflows-pipeline"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "readme.md"
|
readme = "readme.md"
|
||||||
@@ -20,7 +20,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
materia = "materia.cli:app"
|
beanflows = "beanflows_pipeline.cli:app"
|
||||||
|
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -43,7 +43,7 @@ dev = [
|
|||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
extract_core = {workspace = true }
|
extract_core = {workspace = true }
|
||||||
psdonline = {workspace = true }
|
psdonline = {workspace = true }
|
||||||
sqlmesh_materia = {workspace = true }
|
sqlmesh_beanflows = {workspace = true }
|
||||||
cftc_cot = {workspace = true }
|
cftc_cot = {workspace = true }
|
||||||
coffee_prices = {workspace = true }
|
coffee_prices = {workspace = true }
|
||||||
ice_stocks = {workspace = true }
|
ice_stocks = {workspace = true }
|
||||||
@@ -90,11 +90,13 @@ exclude = [
|
|||||||
"notebooks",
|
"notebooks",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
line-length = 100
|
||||||
indent-width = 4
|
indent-width = 4
|
||||||
|
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP"]
|
||||||
|
|
||||||
ignore = [
|
ignore = [
|
||||||
"E501", # line too long (handled by formatter)
|
"E501", # line too long (handled by formatter)
|
||||||
|
|||||||
18
scripts/hooks/pre-commit
Normal file
18
scripts/hooks/pre-commit
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pre-commit hook: ruff lint + auto-fix.
|
||||||
|
# Install: make install-hooks
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
RUFF="$REPO_ROOT/.venv/bin/ruff"
|
||||||
|
|
||||||
|
if [[ ! -x "$RUFF" ]]; then
|
||||||
|
echo "pre-commit: ruff not found at $RUFF — run 'uv sync' first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ ruff check"
|
||||||
|
"$RUFF" check --fix "$REPO_ROOT"
|
||||||
|
|
||||||
|
# Re-stage any files ruff fixed so they land in the commit.
|
||||||
|
git diff --name-only | xargs -r git add
|
||||||
2
src/beanflows_pipeline/__init__.py
Normal file
2
src/beanflows_pipeline/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
def main() -> None:
|
||||||
|
print("Hello from beanflows!")
|
||||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
|||||||
import typer
|
import typer
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="materia",
|
name="beanflows",
|
||||||
help="BeanFlows.coffee data platform management CLI",
|
help="BeanFlows.coffee data platform management CLI",
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ def worker_list(
|
|||||||
provider: Annotated[str, typer.Option("--provider", "-p")] = "hetzner",
|
provider: Annotated[str, typer.Option("--provider", "-p")] = "hetzner",
|
||||||
):
|
):
|
||||||
"""List all active worker instances."""
|
"""List all active worker instances."""
|
||||||
from materia.workers import list_workers
|
from beanflows_pipeline.workers import list_workers
|
||||||
|
|
||||||
workers = list_workers(provider)
|
workers = list_workers(provider)
|
||||||
if not workers:
|
if not workers:
|
||||||
@@ -47,7 +47,7 @@ def worker_create(
|
|||||||
location: Annotated[str | None, typer.Option("--location", "-l")] = None,
|
location: Annotated[str | None, typer.Option("--location", "-l")] = None,
|
||||||
):
|
):
|
||||||
"""Create a new worker instance."""
|
"""Create a new worker instance."""
|
||||||
from materia.workers import create_worker
|
from beanflows_pipeline.workers import create_worker
|
||||||
|
|
||||||
typer.echo(f"Creating worker '{name}' ({server_type}) on {provider}...")
|
typer.echo(f"Creating worker '{name}' ({server_type}) on {provider}...")
|
||||||
worker = create_worker(name, server_type, provider, location)
|
worker = create_worker(name, server_type, provider, location)
|
||||||
@@ -61,7 +61,7 @@ def worker_destroy(
|
|||||||
force: Annotated[bool, typer.Option("--force", "-f")] = False,
|
force: Annotated[bool, typer.Option("--force", "-f")] = False,
|
||||||
):
|
):
|
||||||
"""Destroy a worker instance."""
|
"""Destroy a worker instance."""
|
||||||
from materia.workers import destroy_worker
|
from beanflows_pipeline.workers import destroy_worker
|
||||||
|
|
||||||
if not force:
|
if not force:
|
||||||
confirm = typer.confirm(f"Destroy worker '{name}'?")
|
confirm = typer.confirm(f"Destroy worker '{name}'?")
|
||||||
@@ -82,7 +82,7 @@ def pipeline_run(
|
|||||||
name: Annotated[str, typer.Argument(help="Pipeline name (extract, transform)")],
|
name: Annotated[str, typer.Argument(help="Pipeline name (extract, transform)")],
|
||||||
):
|
):
|
||||||
"""Run a pipeline locally."""
|
"""Run a pipeline locally."""
|
||||||
from materia.pipelines import run_pipeline
|
from beanflows_pipeline.pipelines import run_pipeline
|
||||||
|
|
||||||
typer.echo(f"Running pipeline '{name}'...")
|
typer.echo(f"Running pipeline '{name}'...")
|
||||||
result = run_pipeline(name)
|
result = run_pipeline(name)
|
||||||
@@ -98,7 +98,7 @@ def pipeline_run(
|
|||||||
@pipeline_app.command("list")
|
@pipeline_app.command("list")
|
||||||
def pipeline_list():
|
def pipeline_list():
|
||||||
"""List available pipelines."""
|
"""List available pipelines."""
|
||||||
from materia.pipelines import PIPELINES
|
from beanflows_pipeline.pipelines import PIPELINES
|
||||||
|
|
||||||
typer.echo("Available pipelines:")
|
typer.echo("Available pipelines:")
|
||||||
for name, config in PIPELINES.items():
|
for name, config in PIPELINES.items():
|
||||||
@@ -113,7 +113,7 @@ app.add_typer(secrets_app, name="secrets")
|
|||||||
@secrets_app.command("list")
|
@secrets_app.command("list")
|
||||||
def secrets_list():
|
def secrets_list():
|
||||||
"""List available secrets (keys only)."""
|
"""List available secrets (keys only)."""
|
||||||
from materia.secrets import list_secrets
|
from beanflows_pipeline.vault import list_secrets
|
||||||
|
|
||||||
secrets = list_secrets()
|
secrets = list_secrets()
|
||||||
if not secrets:
|
if not secrets:
|
||||||
@@ -130,7 +130,7 @@ def secrets_get(
|
|||||||
key: Annotated[str, typer.Argument(help="Secret key")],
|
key: Annotated[str, typer.Argument(help="Secret key")],
|
||||||
):
|
):
|
||||||
"""Get a secret value."""
|
"""Get a secret value."""
|
||||||
from materia.secrets import get_secret
|
from beanflows_pipeline.vault import get_secret
|
||||||
|
|
||||||
value = get_secret(key)
|
value = get_secret(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -143,7 +143,7 @@ def secrets_get(
|
|||||||
@secrets_app.command("test")
|
@secrets_app.command("test")
|
||||||
def secrets_test():
|
def secrets_test():
|
||||||
"""Test sops decryption (verifies sops is installed and age key is present)."""
|
"""Test sops decryption (verifies sops is installed and age key is present)."""
|
||||||
from materia.secrets import test_connection
|
from beanflows_pipeline.vault import test_connection
|
||||||
|
|
||||||
typer.echo("Testing SOPS decryption...")
|
typer.echo("Testing SOPS decryption...")
|
||||||
if test_connection():
|
if test_connection():
|
||||||
@@ -11,7 +11,7 @@ reopens the connection automatically — no restart or signal required.
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
DUCKDB_PATH=lakehouse.duckdb SERVING_DUCKDB_PATH=serving.duckdb \
|
DUCKDB_PATH=lakehouse.duckdb SERVING_DUCKDB_PATH=serving.duckdb \
|
||||||
uv run materia pipeline run export_serving
|
uv run beanflows pipeline run export_serving
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -57,7 +57,7 @@ PIPELINES = {
|
|||||||
"timeout_seconds": 6600,
|
"timeout_seconds": 6600,
|
||||||
},
|
},
|
||||||
"transform": {
|
"transform": {
|
||||||
"command": ["uv", "run", "--package", "sqlmesh_materia", "sqlmesh", "-p", "transform/sqlmesh_materia", "plan", "prod", "--no-prompts", "--auto-apply"],
|
"command": ["uv", "run", "--package", "sqlmesh_beanflows", "sqlmesh", "-p", "transform/sqlmesh_beanflows", "plan", "prod", "--no-prompts", "--auto-apply"],
|
||||||
"timeout_seconds": 3600,
|
"timeout_seconds": 3600,
|
||||||
},
|
},
|
||||||
# Copies serving.* tables from lakehouse.duckdb → serving.duckdb (atomic swap).
|
# Copies serving.* tables from lakehouse.duckdb → serving.duckdb (atomic swap).
|
||||||
@@ -65,7 +65,7 @@ PIPELINES = {
|
|||||||
"export_serving": {
|
"export_serving": {
|
||||||
"command": ["uv", "run", "python", "-c",
|
"command": ["uv", "run", "python", "-c",
|
||||||
"import logging; logging.basicConfig(level=logging.INFO); "
|
"import logging; logging.basicConfig(level=logging.INFO); "
|
||||||
"from materia.export_serving import export_serving; export_serving()"],
|
"from beanflows_pipeline.export_serving import export_serving; export_serving()"],
|
||||||
"timeout_seconds": 300,
|
"timeout_seconds": 300,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ class Instance:
|
|||||||
|
|
||||||
def get_provider(provider_name: str):
|
def get_provider(provider_name: str):
|
||||||
if provider_name == "hetzner":
|
if provider_name == "hetzner":
|
||||||
from materia.providers import hetzner
|
from beanflows_pipeline.providers import hetzner
|
||||||
|
|
||||||
return hetzner
|
return hetzner
|
||||||
else:
|
else:
|
||||||
@@ -7,8 +7,8 @@ from hcloud import Client
|
|||||||
from hcloud.images import Image
|
from hcloud.images import Image
|
||||||
from hcloud.server_types import ServerType
|
from hcloud.server_types import ServerType
|
||||||
|
|
||||||
from materia.providers import Instance
|
from beanflows_pipeline.providers import Instance
|
||||||
from materia.secrets import get_secret
|
from beanflows_pipeline.vault import get_secret
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> Client:
|
def _get_client() -> Client:
|
||||||
@@ -11,10 +11,10 @@ the supervisor is effectively unkillable.
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# Run the supervisor loop (production)
|
# Run the supervisor loop (production)
|
||||||
LANDING_DIR=data/landing uv run python src/materia/supervisor.py
|
LANDING_DIR=data/landing uv run python src/beanflows/supervisor.py
|
||||||
|
|
||||||
# Show workflow status
|
# Show workflow status
|
||||||
LANDING_DIR=data/landing uv run python src/materia/supervisor.py status
|
LANDING_DIR=data/landing uv run python src/beanflows/supervisor.py status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
@@ -38,7 +38,7 @@ from croniter import croniter
|
|||||||
TICK_INTERVAL_SECONDS = 60
|
TICK_INTERVAL_SECONDS = 60
|
||||||
BACKOFF_SECONDS = 600 # 10 min on tick failure
|
BACKOFF_SECONDS = 600 # 10 min on tick failure
|
||||||
SUBPROCESS_TIMEOUT_SECONDS = 14400 # 4 hours max per subprocess
|
SUBPROCESS_TIMEOUT_SECONDS = 14400 # 4 hours max per subprocess
|
||||||
REPO_DIR = Path(os.getenv("REPO_DIR", "/opt/materia"))
|
REPO_DIR = Path(os.getenv("REPO_DIR", "/opt/beanflows"))
|
||||||
LANDING_DIR = Path(os.getenv("LANDING_DIR", "data/landing"))
|
LANDING_DIR = Path(os.getenv("LANDING_DIR", "data/landing"))
|
||||||
DUCKDB_PATH = os.getenv("DUCKDB_PATH", "data/lakehouse.duckdb")
|
DUCKDB_PATH = os.getenv("DUCKDB_PATH", "data/lakehouse.duckdb")
|
||||||
SERVING_DUCKDB_PATH = os.getenv("SERVING_DUCKDB_PATH", "analytics.duckdb")
|
SERVING_DUCKDB_PATH = os.getenv("SERVING_DUCKDB_PATH", "analytics.duckdb")
|
||||||
@@ -58,7 +58,7 @@ logging.basicConfig(
|
|||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("materia.supervisor")
|
logger = logging.getLogger("beanflows_pipeline.supervisor")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -181,9 +181,9 @@ def run_workflow(conn, workflow: dict) -> None:
|
|||||||
entry_fn = getattr(module, entry_name)
|
entry_fn = getattr(module, entry_name)
|
||||||
entry_fn()
|
entry_fn()
|
||||||
logger.info("Workflow %s completed successfully", workflow["name"])
|
logger.info("Workflow %s completed successfully", workflow["name"])
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Workflow %s failed", workflow["name"])
|
logger.exception("Workflow %s failed", workflow["name"])
|
||||||
send_alert(f"Workflow '{workflow['name']}' failed")
|
send_alert(f"[extract] {type(exc).__name__}: {str(exc)[:100]}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -222,8 +222,8 @@ def run_due_workflows(conn, workflows: list[dict]) -> bool:
|
|||||||
# Transform + Export + Deploy
|
# Transform + Export + Deploy
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> bool:
|
def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tuple[bool, str]:
|
||||||
"""Run a shell command. Returns True on success."""
|
"""Run a shell command. Returns (success, error_snippet)."""
|
||||||
logger.info("Shell: %s", cmd)
|
logger.info("Shell: %s", cmd)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd, shell=True, capture_output=True, text=True, timeout=timeout_seconds
|
cmd, shell=True, capture_output=True, text=True, timeout=timeout_seconds
|
||||||
@@ -233,45 +233,82 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> bo
|
|||||||
"Shell failed (rc=%d): %s\nstdout: %s\nstderr: %s",
|
"Shell failed (rc=%d): %s\nstdout: %s\nstderr: %s",
|
||||||
result.returncode, cmd, result.stdout[-500:], result.stderr[-500:],
|
result.returncode, cmd, result.stdout[-500:], result.stderr[-500:],
|
||||||
)
|
)
|
||||||
return False
|
raw = (result.stderr or result.stdout).strip()
|
||||||
return True
|
snippet = next((ln.strip() for ln in raw.splitlines() if ln.strip()), raw)[:120]
|
||||||
|
return False, snippet
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def run_transform() -> None:
|
def run_transform() -> None:
|
||||||
"""Run SQLMesh — evaluates model staleness internally."""
|
"""Run SQLMesh — evaluates model staleness internally."""
|
||||||
logger.info("Running SQLMesh transform")
|
logger.info("Running SQLMesh transform")
|
||||||
ok = run_shell("uv run sqlmesh -p transform/sqlmesh_materia run")
|
ok, err = run_shell("uv run sqlmesh -p transform/sqlmesh_beanflows plan prod --auto-apply")
|
||||||
if not ok:
|
if not ok:
|
||||||
send_alert("SQLMesh transform failed")
|
send_alert(f"[transform] {err}")
|
||||||
|
|
||||||
|
|
||||||
def run_export() -> None:
|
def run_export() -> None:
|
||||||
"""Export serving tables to analytics.duckdb."""
|
"""Export serving tables to analytics.duckdb."""
|
||||||
logger.info("Exporting serving tables")
|
logger.info("Exporting serving tables")
|
||||||
ok = run_shell(
|
ok, err = run_shell(
|
||||||
f"DUCKDB_PATH={DUCKDB_PATH} SERVING_DUCKDB_PATH={SERVING_DUCKDB_PATH} "
|
f"DUCKDB_PATH={DUCKDB_PATH} SERVING_DUCKDB_PATH={SERVING_DUCKDB_PATH} "
|
||||||
f"uv run materia pipeline run export_serving"
|
f"uv run beanflows pipeline run export_serving"
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
send_alert("Serving export failed")
|
send_alert(f"[export] {err}")
|
||||||
|
|
||||||
|
|
||||||
|
_last_seen_head: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def web_code_changed() -> bool:
|
def web_code_changed() -> bool:
|
||||||
"""Check if web app code changed since last deploy."""
|
"""True on the first tick after a commit that changed web app code.
|
||||||
|
|
||||||
|
Compares the current HEAD to the HEAD from the previous tick. On first call
|
||||||
|
after process start, falls back to HEAD~1 so the just-deployed commit is
|
||||||
|
evaluated exactly once. Records HEAD before returning so the same commit
|
||||||
|
never triggers twice.
|
||||||
|
"""
|
||||||
|
global _last_seen_head
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "diff", "--name-only", "HEAD~1", "HEAD", "--", "web/", "Dockerfile"],
|
["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
current_head = result.stdout.strip()
|
||||||
|
|
||||||
|
if _last_seen_head is None:
|
||||||
|
base_result = subprocess.run(
|
||||||
|
["git", "rev-parse", "HEAD~1"], capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
base = base_result.stdout.strip() if base_result.returncode == 0 else current_head
|
||||||
|
else:
|
||||||
|
base = _last_seen_head
|
||||||
|
|
||||||
|
_last_seen_head = current_head # advance now — won't fire again for this HEAD
|
||||||
|
|
||||||
|
if base == current_head:
|
||||||
|
return False
|
||||||
|
|
||||||
|
diff = subprocess.run(
|
||||||
|
["git", "diff", "--name-only", base, current_head, "--", "web/", "Dockerfile"],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
)
|
)
|
||||||
return bool(result.stdout.strip())
|
return bool(diff.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
def current_deployed_tag() -> str | None:
|
def current_deployed_tag() -> str | None:
|
||||||
"""Return the tag currently checked out, or None if not on a tag."""
|
"""Return the highest-version tag pointing at HEAD, or None.
|
||||||
|
|
||||||
|
Uses --points-at HEAD so multiple tags on the same commit (e.g. a CI
|
||||||
|
integer tag and a date-based tag) are handled correctly.
|
||||||
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "describe", "--tags", "--exact-match", "HEAD"],
|
["git", "tag", "--list", "--sort=-version:refname", "--points-at", "HEAD", "v*"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
)
|
)
|
||||||
return result.stdout.strip() or None
|
tags = result.stdout.strip().splitlines()
|
||||||
|
return tags[0] if tags else None
|
||||||
|
|
||||||
|
|
||||||
def latest_remote_tag() -> str | None:
|
def latest_remote_tag() -> str | None:
|
||||||
@@ -353,11 +390,11 @@ def tick() -> None:
|
|||||||
# Deploy web app if code changed
|
# Deploy web app if code changed
|
||||||
if os.getenv("SUPERVISOR_GIT_PULL") and web_code_changed():
|
if os.getenv("SUPERVISOR_GIT_PULL") and web_code_changed():
|
||||||
logger.info("Web code changed — deploying")
|
logger.info("Web code changed — deploying")
|
||||||
ok = run_shell("./deploy.sh")
|
ok, err = run_shell("./deploy.sh")
|
||||||
if ok:
|
if ok:
|
||||||
send_alert("Deploy succeeded")
|
send_alert("[deploy] ok")
|
||||||
else:
|
else:
|
||||||
send_alert("Deploy FAILED — check journalctl -u materia-supervisor")
|
send_alert(f"[deploy] failed: {err}")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -374,9 +411,9 @@ def supervisor_loop() -> None:
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Supervisor stopped (KeyboardInterrupt)")
|
logger.info("Supervisor stopped (KeyboardInterrupt)")
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Supervisor tick failed — backing off %ds", BACKOFF_SECONDS)
|
logger.exception("Supervisor tick failed — backing off %ds", BACKOFF_SECONDS)
|
||||||
send_alert("Supervisor tick failed")
|
send_alert(f"[supervisor] {type(exc).__name__}: {str(exc)[:100]}")
|
||||||
time.sleep(BACKOFF_SECONDS)
|
time.sleep(BACKOFF_SECONDS)
|
||||||
else:
|
else:
|
||||||
time.sleep(TICK_INTERVAL_SECONDS)
|
time.sleep(TICK_INTERVAL_SECONDS)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Worker instance management."""
|
"""Worker instance management."""
|
||||||
|
|
||||||
from materia.providers import Instance, get_provider
|
from beanflows_pipeline.providers import Instance, get_provider
|
||||||
from materia.secrets import get_secret
|
from beanflows_pipeline.vault import get_secret
|
||||||
|
|
||||||
DEFAULT_PROVIDER = "hetzner"
|
DEFAULT_PROVIDER = "hetzner"
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
def main() -> None:
|
|
||||||
print("Hello from materia!")
|
|
||||||
@@ -27,7 +27,7 @@ def test_secrets_test_command(mock_secrets):
|
|||||||
"""Test secrets test command."""
|
"""Test secrets test command."""
|
||||||
result = runner.invoke(app, ["secrets", "test"])
|
result = runner.invoke(app, ["secrets", "test"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "ESC connection successful" in result.stdout
|
assert "SOPS decryption successful" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_secrets_list_command(mock_secrets):
|
def test_secrets_list_command(mock_secrets):
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
from cftc_cot.normalize import find_csv_inner_filename, normalize_zipped_csv
|
from cftc_cot.normalize import find_csv_inner_filename, normalize_zipped_csv
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# normalize.py
|
# normalize.py
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -121,7 +120,7 @@ def test_extract_cot_year_skips_existing_file(tmp_path, monkeypatch):
|
|||||||
mock_head.headers = {"etag": f'"{etag}"'}
|
mock_head.headers = {"etag": f'"{etag}"'}
|
||||||
mock_session.head.return_value = mock_head
|
mock_session.head.return_value = mock_head
|
||||||
|
|
||||||
result = cot_execute.extract_cot_year(2024, mock_session)
|
result = cot_execute.extract_cot_year(2024, mock_session, cot_execute.COT_URL_FUTURES_ONLY, "cot")
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
mock_session.get.assert_not_called() # No download should occur
|
mock_session.get.assert_not_called() # No download should occur
|
||||||
@@ -141,7 +140,7 @@ def test_extract_cot_year_returns_false_on_404(tmp_path, monkeypatch):
|
|||||||
mock_head.status_code = 404
|
mock_head.status_code = 404
|
||||||
mock_session.head.return_value = mock_head
|
mock_session.head.return_value = mock_head
|
||||||
|
|
||||||
result = cot_execute.extract_cot_year(2006, mock_session)
|
result = cot_execute.extract_cot_year(2006, mock_session, cot_execute.COT_URL_FUTURES_ONLY, "cot")
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
mock_session.get.assert_not_called()
|
mock_session.get.assert_not_called()
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
"""Tests for ICE extraction: format detection, XLS parsing, API client."""
|
"""Tests for ICE extraction: format detection, XLS parsing, API client."""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import gzip
|
|
||||||
import io
|
import io
|
||||||
import struct
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import xlwt # noqa: F401 — needed to create XLS fixtures; skip tests if missing
|
import xlwt # noqa: F401 — needed to create XLS fixtures; skip tests if missing
|
||||||
|
|
||||||
from ice_stocks.ice_api import fetch_report_listings, find_latest_report
|
from ice_stocks.ice_api import fetch_report_listings, find_latest_report
|
||||||
from ice_stocks.xls_parse import OLE2_MAGIC, detect_file_format, xls_to_rows
|
from ice_stocks.xls_parse import OLE2_MAGIC, detect_file_format, xls_to_rows
|
||||||
|
|
||||||
@@ -96,7 +93,7 @@ def test_fetch_report_listings_parses_response():
|
|||||||
_make_api_row("Certified Stock Aging Report", "/dl/aging.xls"),
|
_make_api_row("Certified Stock Aging Report", "/dl/aging.xls"),
|
||||||
])
|
])
|
||||||
|
|
||||||
from ice_stocks.ice_api import ICE_BASE_URL, fetch_report_listings
|
from ice_stocks.ice_api import ICE_BASE_URL
|
||||||
rows = fetch_report_listings(mock_session, product_id=2)
|
rows = fetch_report_listings(mock_session, product_id=2)
|
||||||
|
|
||||||
assert len(rows) == 2
|
assert len(rows) == 2
|
||||||
@@ -113,7 +110,6 @@ def test_fetch_report_listings_prepends_base_url_for_absolute():
|
|||||||
_make_api_row("Test", "https://other.example.com/file.xls"),
|
_make_api_row("Test", "https://other.example.com/file.xls"),
|
||||||
])
|
])
|
||||||
|
|
||||||
from ice_stocks.ice_api import fetch_report_listings
|
|
||||||
rows = fetch_report_listings(mock_session, product_id=2)
|
rows = fetch_report_listings(mock_session, product_id=2)
|
||||||
assert rows[0]["download_url"] == "https://other.example.com/file.xls"
|
assert rows[0]["download_url"] == "https://other.example.com/file.xls"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "sqlmesh_materia"
|
name = "sqlmesh_beanflows"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -16,4 +16,4 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["sqlmesh_materia"]
|
packages = ["sqlmesh_beanflows"]
|
||||||
177
uv.lock
generated
177
uv.lock
generated
@@ -9,14 +9,14 @@ resolution-markers = [
|
|||||||
[manifest]
|
[manifest]
|
||||||
members = [
|
members = [
|
||||||
"beanflows",
|
"beanflows",
|
||||||
|
"beanflows-pipeline",
|
||||||
"cftc-cot",
|
"cftc-cot",
|
||||||
"coffee-prices",
|
"coffee-prices",
|
||||||
"extract-core",
|
"extract-core",
|
||||||
"ice-stocks",
|
"ice-stocks",
|
||||||
"materia",
|
|
||||||
"openmeteo",
|
"openmeteo",
|
||||||
"psdonline",
|
"psdonline",
|
||||||
"sqlmesh-materia",
|
"sqlmesh-beanflows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -204,8 +204,8 @@ source = { editable = "web" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "duckdb" },
|
{ name = "duckdb" },
|
||||||
|
{ name = "granian" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "hypercorn" },
|
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "paddle-python-sdk" },
|
{ name = "paddle-python-sdk" },
|
||||||
@@ -227,8 +227,8 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
||||||
{ name = "duckdb", specifier = ">=1.0.0" },
|
{ name = "duckdb", specifier = ">=1.0.0" },
|
||||||
|
{ name = "granian", specifier = ">=2.7.2" },
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
{ name = "hypercorn", specifier = ">=0.17.0" },
|
|
||||||
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
|
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
|
||||||
@@ -246,6 +246,63 @@ dev = [
|
|||||||
{ name = "ruff", specifier = ">=0.3.0" },
|
{ name = "ruff", specifier = ">=0.3.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beanflows-pipeline"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "croniter" },
|
||||||
|
{ name = "hcloud" },
|
||||||
|
{ name = "msgspec" },
|
||||||
|
{ name = "niquests" },
|
||||||
|
{ name = "prefect" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "typer" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pre-commit" },
|
||||||
|
{ name = "pulumi" },
|
||||||
|
{ name = "pulumi-cloudflare" },
|
||||||
|
{ name = "pulumi-hcloud" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "xlwt" },
|
||||||
|
]
|
||||||
|
exploration = [
|
||||||
|
{ name = "ipykernel" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "croniter", specifier = ">=6.0.0" },
|
||||||
|
{ name = "hcloud", specifier = ">=2.8.0" },
|
||||||
|
{ name = "msgspec", specifier = ">=0.19" },
|
||||||
|
{ name = "niquests", specifier = ">=3.15.2" },
|
||||||
|
{ name = "prefect", specifier = ">=3.6.15" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||||
|
{ name = "typer", specifier = ">=0.15.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pre-commit", specifier = ">=4.1.0" },
|
||||||
|
{ name = "pulumi", specifier = ">=3.202.0" },
|
||||||
|
{ name = "pulumi-cloudflare", specifier = ">=6.10.0" },
|
||||||
|
{ name = "pulumi-hcloud", specifier = ">=1.25.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||||
|
{ name = "ruff", specifier = ">=0.9.9" },
|
||||||
|
{ name = "xlwt", specifier = ">=1.3.0" },
|
||||||
|
]
|
||||||
|
exploration = [{ name = "ipykernel", specifier = ">=6.29.5" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "beartype"
|
name = "beartype"
|
||||||
version = "0.22.9"
|
version = "0.22.9"
|
||||||
@@ -838,6 +895,57 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "granian"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/19/d4ea523715ba8dd2ed295932cc3dda6bb197060f78aada6e886ff08587b2/granian-2.7.2.tar.gz", hash = "sha256:cdae2f3a26fa998d41fefad58f1d1c84a0b035a6cc9377addd81b51ba82f927f", size = 128969, upload-time = "2026-02-24T23:04:23.314Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/bc/cf0bc29f583096a842cf0f26ae2fe40c72ed5286d4548be99ecfcdbb17e2/granian-2.7.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:76b840ff13dde8838fd33cd096f2e7cadf2c21a499a67f695f53de57deab6ff8", size = 6440868, upload-time = "2026-02-24T23:02:53.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/0d/bae1dcd2182ba5d9a5df33eb50b56dc5bbe67e31033d822e079aa8c1ff30/granian-2.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:00ccc8d7284bc7360f310179d0b4d17e5ca3077bbe24427e9e9310df397e3831", size = 6097336, upload-time = "2026-02-24T23:02:55.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/7d/3e0a7f32b0ad5faa1d847c51191391552fa239821c95fc7c022688985df2/granian-2.7.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:675987c1b321dc8af593db8639e00c25277449b32e8c1b2ddd46b35f28d9fac4", size = 7098742, upload-time = "2026-02-24T23:02:57.898Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/41/3b44386d636ac6467f0f13f45474c71fc3b90a4f0ba8b536de91b2845a09/granian-2.7.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:681c6fbe3354aaa6251e6191ec89f5174ac3b9fbc4b4db606fea456d01969fcb", size = 6430667, upload-time = "2026-02-24T23:02:59.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/70/7b24e187aed3fb7ac2b29d2480a045559a509ef9fec54cffb8694a2d94af/granian-2.7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5c9ae65af5e572dca27d8ca0da4c5180b08473ac47e6f5329699e9455a5cc3", size = 6948424, upload-time = "2026-02-24T23:03:01.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/4c/cb74c367f9efb874f2c8433fe9bf3e824f05cf719f2251d40e29e07f08c0/granian-2.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e37fab2be919ceb195db00d7f49ec220444b1ecaa07c03f7c1c874cacff9de83", size = 7000407, upload-time = "2026-02-24T23:03:03.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/98/dfed3966ed7fbd3aae56e123598f90dc206484092b8373d0a71e2d8b82a8/granian-2.7.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:8ec167ab30f5396b5caaff16820a39f4e91986d2fe5bdc02992a03c2b2b2b313", size = 7121626, upload-time = "2026-02-24T23:03:05.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/82/acec732a345cd03b2f6e48ac04b66b7b8b61f5c50eb08d7421fc8c56591a/granian-2.7.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:63f426d793f2116d23be265dd826bec1e623680baf94cc270fe08923113a86ba", size = 7253447, upload-time = "2026-02-24T23:03:06.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/2b/64779e69b08c1ff1bfc09a4ede904ab761ff63f936c275710886057c52f7/granian-2.7.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1617cbb4efe3112f07fb6762cf81d2d9fe4bdb78971d1fd0a310f8b132f6a51e", size = 7053005, upload-time = "2026-02-24T23:03:09.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/c9/83e546d5f6b0447a4b9ee48ce15c29e43bb3f6b5e1040d33ac61fc9e3b6f/granian-2.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:7a4bd347694ace7a48cd784b911f2d519c2a22154e0d1ed59f5b4864914a8cfe", size = 4145886, upload-time = "2026-02-24T23:03:10.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/49/9eb88875d709db7e7844e1c681546448dab5ff5651cd1c1d80ac4b1de4e3/granian-2.7.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:016c5857c8baedeab7eb065f98417f5ea26bb72b0f7e0544fe76071efc5ab255", size = 6401748, upload-time = "2026-02-24T23:03:12.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/80/85726ad9999ed89cb6a32f7f57eb50ce7261459d9c30c3b194ae4c5aa2c5/granian-2.7.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dcbe01fa141adf3f90964e86a959e250754aa7c6dad8fa7a855e6fd382de4c13", size = 6101265, upload-time = "2026-02-24T23:03:14.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/82/0df56a42b9f4c327d0e0b052f43369127e1b565b9e66bf2c9488f1c8d759/granian-2.7.2-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:283ba23817a685784b66f45423d2f25715fdc076c8ffb43c49a807ee56a0ffc0", size = 6249488, upload-time = "2026-02-24T23:03:16.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/cc/d83a351560a3d6377672636129c52f06f8393f5831c5ee0f06f274883ea6/granian-2.7.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3258419c741897273ce155568b5a9cbacb7700a00516e87119a90f7d520d6783", size = 7104734, upload-time = "2026-02-24T23:03:17.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/d1/539907ee96d0ee2bcceabb4a6a9643b75378d6dfea09b7a9e4fd22cdf977/granian-2.7.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a196125c4837491c139c9cc83541b48c408c92b9cfbbf004fd28717f9c02ad21", size = 6785504, upload-time = "2026-02-24T23:03:19.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/4b6f45882f8341e7c6cb824d693deb94c306be6525b483c76fb373d1e749/granian-2.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:746555ac8a2dcd9257bfe7ad58f1d7a60892bc4613df6a7d8f736692b3bb3b88", size = 6902790, upload-time = "2026-02-24T23:03:22.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/b8/832970d2d4b144b87be39f5b9dfd31fdb17f298dc238a0b2100c95002cf8/granian-2.7.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:5ac1843c6084933a54a07d9dcae643365f1d83aaff3fd4f2676ea301185e4e8b", size = 7082682, upload-time = "2026-02-24T23:03:23.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/bc/1521dbf026d1c9d2465cd54e016efd8ff6e1e72eff521071dab20dd61c44/granian-2.7.2-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:3612eb6a3f4351dd2c4df246ed0d21056c0556a6b1ed772dd865310aa55a9ba9", size = 7264742, upload-time = "2026-02-24T23:03:25.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/ae/00884ab77045a2f54db90932f9d1ca522201e2a6b2cf2a9b38840db0fd54/granian-2.7.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:34708b145e31b4538e0556704a07454a76d6776c55c5bc3a1335e80ef6b3bae3", size = 7062571, upload-time = "2026-02-24T23:03:27.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/0e/4321e361bccb9681e1045c75e783476de5be7aa47cf05066907530772eba/granian-2.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:841c48608e55daa2fa434392397cc24175abd48bc5bcefa1e4f74b7243e36c72", size = 4098734, upload-time = "2026-02-24T23:03:28.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/4a/8ce622f4f7d58e035d121b9957dd5a8929028dc99cfc5d2bf7f2aa28912c/granian-2.7.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:592806c28c491f9c1d1501bac706ecf5e72b73969f20f912678d53308786d658", size = 6442041, upload-time = "2026-02-24T23:03:30.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/62/7d36ed38a40a68c2856b6d2a6fedd40833e7f82eb90ba0d03f2d69ffadf5/granian-2.7.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9dcde3968b921654bde999468e97d03031f28668bc1fc145c81d8bedb0fb2a4", size = 6100793, upload-time = "2026-02-24T23:03:32.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/c5/17fea68f4cb280c217cbd65534664722c9c9b0138c2754e20c235d70b5f4/granian-2.7.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d4d78408283ec51f0fb00557856b4593947ad5b48287c04e1c22764a0ac28a5", size = 7119810, upload-time = "2026-02-24T23:03:34.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/76/35e240d107e0f158662652fd61191de4fb0c2c080e3786ca8f16c71547b7/granian-2.7.2-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d28b078e8087f794b83822055f95caf93d83b23f47f4efcd5e2f0f7a5d8a81", size = 6450789, upload-time = "2026-02-24T23:03:36.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/55/a6d08cfecc808149a910e51c57883ab26fad69d922dc2e76fb2d87469e2d/granian-2.7.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ff7a93123ab339ba6cad51cc7141f8880ec47b152ce2491595bb08edda20106", size = 6902672, upload-time = "2026-02-24T23:03:38.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/2e/c86d95f324248fcc5dcaf034c9f688b32f7a488f0b2a4a25e6673776107f/granian-2.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a52effb9889f0944f0353afd6ce5a9d9aa83826d44bbf3c8013e978a3d6ef7b7", size = 6964399, upload-time = "2026-02-24T23:03:40.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/4b/44fde33fe10245a3fba76bf843c387fad2d548244345115b9d87e1c40994/granian-2.7.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:76c987c3ca78bf7666ab053c3ed7e3af405af91b2e5ce2f1cf92634c1581e238", size = 7034929, upload-time = "2026-02-24T23:03:42.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/76/38d205cb527046241a9ee4f51048bf44101c626ad4d2af16dd9d14dc1db6/granian-2.7.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:6590f8092c2bb6614e561ba771f084cbf72ecbc38dbf9849762ac38718085c29", size = 7259609, upload-time = "2026-02-24T23:03:43.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/37/04245c7259e65f1083ce193875c6c44da4c98604d3b00a264a74dd4f042b/granian-2.7.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7c1ce9b0c9446b680e9545e7fc95a75f0c53a25dedcf924b1750c3e5ba5bf908", size = 7073161, upload-time = "2026-02-24T23:03:45.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/e4/28097a852d8f93f8e3be2014a81f03aa914b8a2c12ca761fac5ae1344b8b/granian-2.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:a69cafb6518c630c84a9285674d45ea6f7342a6279dc25c6bd933b6fad5c55ab", size = 4121462, upload-time = "2026-02-24T23:03:47.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/07/0e56fb4f178e14b4c1fa1f6f00586ca81761ccbe2d8803f2c12b6b17a7d6/granian-2.7.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a698d9b662d5648c8ae3dc01ad01688e1a8afc3525e431e7cddb841c53e5e291", size = 6415279, upload-time = "2026-02-24T23:03:48.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/bc/3e69305bf34806cd852f4683deec844a2cb9a4d8888d7f172b507f6080a8/granian-2.7.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:17516095b520b3c039ddbe41a6beb2c59d554b668cc229d36d82c93154a799af", size = 6090528, upload-time = "2026-02-24T23:03:50.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/10/7d58a922b44417a6207c0a3230b0841cd7385a36fc518ac15fed16ebf6f7/granian-2.7.2-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:96b0fd9eac60f939b3cbe44c8f32a42fdb7c1a1a9e07ca89e7795cdc7a606beb", size = 6252291, upload-time = "2026-02-24T23:03:52.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/56/65776c6d759dcef9cce15bc11bdea2c64fe668088faf35d87916bd88f595/granian-2.7.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e50fb13e053384b8bd3823d4967606c6fd89f2b0d20e64de3ae212b85ffdfed2", size = 7106748, upload-time = "2026-02-24T23:03:53.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/ee/d9ed836316607401f158ac264a3f770469d1b1edbf119402777a9eff1833/granian-2.7.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bb1ef13125bc05ab2e18869ed311beaeb085a4c4c195d55d0865f5753a4c0b4", size = 6778883, upload-time = "2026-02-24T23:03:55.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/46/eabab80e07a14527c336dec6d902329399f3ba2b82dc94b6435651021359/granian-2.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b1c77189335070c6ba6b8d158518fde4c50f892753620f0b22a7552ad4347143", size = 6903426, upload-time = "2026-02-24T23:03:57.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/8a/8ce186826066f6d453316229383a5be3b0b8a4130146c21f321ee64fe2cb/granian-2.7.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:1777166c3c853eed4440adb3cbbf34bba2b77d595bfc143a5826904a80b22f34", size = 7083877, upload-time = "2026-02-24T23:03:59.425Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/eb/91ed4646ce1c920ad39db0bcddb6f4755e1823002b14fb026104e3eb8bce/granian-2.7.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0ffac19208ae548f3647c849579b803beaed2b50dfb0f3790ad26daac0033484", size = 7267282, upload-time = "2026-02-24T23:04:01.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/2f/58cba479254530ab09132e150e4ab55362f6e875d9e82b6790477843e0aa/granian-2.7.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:82f34e78c1297bf5a1b6a5097e30428db98b59fce60a7387977b794855c0c3bc", size = 7054941, upload-time = "2026-02-24T23:04:03.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/b3/fd13123ac936a4f79f1ba20ad67328a8d09d586262b8f28cc1cfaa555213/granian-2.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e8b87d7ada696eec7e9023974665c83cec978cb83c205eae8fe377de20622f25", size = 4101983, upload-time = "2026-02-24T23:04:04.792Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "graphviz"
|
name = "graphviz"
|
||||||
version = "0.21"
|
version = "0.21"
|
||||||
@@ -1561,63 +1669,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "materia"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { editable = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "croniter" },
|
|
||||||
{ name = "hcloud" },
|
|
||||||
{ name = "msgspec" },
|
|
||||||
{ name = "niquests" },
|
|
||||||
{ name = "prefect" },
|
|
||||||
{ name = "python-dotenv" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "typer" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "pre-commit" },
|
|
||||||
{ name = "pulumi" },
|
|
||||||
{ name = "pulumi-cloudflare" },
|
|
||||||
{ name = "pulumi-hcloud" },
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "pytest-cov" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "ruff" },
|
|
||||||
{ name = "xlwt" },
|
|
||||||
]
|
|
||||||
exploration = [
|
|
||||||
{ name = "ipykernel" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "croniter", specifier = ">=6.0.0" },
|
|
||||||
{ name = "hcloud", specifier = ">=2.8.0" },
|
|
||||||
{ name = "msgspec", specifier = ">=0.19" },
|
|
||||||
{ name = "niquests", specifier = ">=3.15.2" },
|
|
||||||
{ name = "prefect", specifier = ">=3.6.15" },
|
|
||||||
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
|
||||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
|
||||||
{ name = "typer", specifier = ">=0.15.0" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
|
||||||
dev = [
|
|
||||||
{ name = "pre-commit", specifier = ">=4.1.0" },
|
|
||||||
{ name = "pulumi", specifier = ">=3.202.0" },
|
|
||||||
{ name = "pulumi-cloudflare", specifier = ">=6.10.0" },
|
|
||||||
{ name = "pulumi-hcloud", specifier = ">=1.25.0" },
|
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
|
||||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
|
||||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
|
||||||
{ name = "ruff", specifier = ">=0.9.9" },
|
|
||||||
{ name = "xlwt", specifier = ">=1.3.0" },
|
|
||||||
]
|
|
||||||
exploration = [{ name = "ipykernel", specifier = ">=6.29.5" }]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matplotlib-inline"
|
name = "matplotlib-inline"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -3165,9 +3216,9 @@ lsp = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlmesh-materia"
|
name = "sqlmesh-beanflows"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "transform/sqlmesh_materia" }
|
source = { editable = "transform/sqlmesh_beanflows" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "sqlmesh", extra = ["lsp"] },
|
{ name = "sqlmesh", extra = ["lsp"] },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
|
||||||
_commit: v0.17.0
|
|
||||||
_src_path: git@gitlab.com:deemanone/materia_saas_boilerplate.master.git
|
|
||||||
author_email: hendrik@beanflows.coffee
|
|
||||||
author_name: Hendrik Deeman
|
|
||||||
base_url: https://beanflows.coffee
|
|
||||||
business_model: saas
|
|
||||||
description: Commodity analytics for coffee traders
|
|
||||||
enable_cms: true
|
|
||||||
enable_daas: true
|
|
||||||
enable_directory: false
|
|
||||||
enable_i18n: false
|
|
||||||
enable_leads: false
|
|
||||||
payment_provider: paddle
|
|
||||||
project_name: BeanFlows
|
|
||||||
project_slug: beanflows
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# App
|
|
||||||
APP_NAME=BeanFlows
|
|
||||||
SECRET_KEY=change-me-generate-a-real-secret
|
|
||||||
BASE_URL=http://localhost:5001
|
|
||||||
DEBUG=true
|
|
||||||
ADMIN_EMAILS=admin@beanflows.coffee
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_PATH=data/app.db
|
|
||||||
# DUCKDB_PATH points to the full pipeline DB (lakehouse.duckdb) — used by SQLMesh and export_serving.
|
|
||||||
# SERVING_DUCKDB_PATH points to the serving-only export (analytics.duckdb) — used by the web app.
|
|
||||||
# Run `uv run materia pipeline run export_serving` after each SQLMesh transform to populate it.
|
|
||||||
DUCKDB_PATH=../local.duckdb
|
|
||||||
SERVING_DUCKDB_PATH=../analytics.duckdb
|
|
||||||
|
|
||||||
# Auth
|
|
||||||
MAGIC_LINK_EXPIRY_MINUTES=15
|
|
||||||
SESSION_LIFETIME_DAYS=30
|
|
||||||
|
|
||||||
# Email (Resend)
|
|
||||||
RESEND_API_KEY=
|
|
||||||
EMAIL_FROM=hello@example.com
|
|
||||||
|
|
||||||
|
|
||||||
# Paddle
|
|
||||||
PADDLE_API_KEY=
|
|
||||||
PADDLE_WEBHOOK_SECRET=
|
|
||||||
PADDLE_PRICE_STARTER=
|
|
||||||
PADDLE_PRICE_PRO=
|
|
||||||
|
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
RATE_LIMIT_REQUESTS=100
|
|
||||||
RATE_LIMIT_WINDOW=60
|
|
||||||
|
|
||||||
# Waitlist (set to true to enable waitlist gate on /auth/signup)
|
|
||||||
WAITLIST_MODE=false
|
|
||||||
RESEND_AUDIENCE_WAITLIST=
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# CSS build stage (Tailwind standalone CLI, no Node.js)
|
|
||||||
FROM debian:bookworm-slim AS css-build
|
|
||||||
ADD https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
|
||||||
RUN chmod +x /usr/local/bin/tailwindcss
|
|
||||||
WORKDIR /app
|
|
||||||
COPY src/ ./src/
|
|
||||||
RUN tailwindcss -i ./src/beanflows/static/css/input.css \
|
|
||||||
-o ./src/beanflows/static/css/output.css --minify
|
|
||||||
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
FROM python:3.12-slim AS build
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/
|
|
||||||
WORKDIR /app
|
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
|
||||||
COPY uv.lock pyproject.toml README.md ./
|
|
||||||
COPY src/ ./src/
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
||||||
uv sync --no-dev --frozen
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
FROM python:3.12-slim AS runtime
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
|
||||||
RUN useradd -m -u 1000 appuser
|
|
||||||
WORKDIR /app
|
|
||||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
|
||||||
COPY --from=build --chown=appuser:appuser /app .
|
|
||||||
COPY --from=css-build /app/src/beanflows/static/css/output.css ./src/beanflows/static/css/output.css
|
|
||||||
USER appuser
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV DATABASE_PATH=/app/data/app.db
|
|
||||||
EXPOSE 5000
|
|
||||||
CMD ["hypercorn", "beanflows.app:app", "--bind", "0.0.0.0:5000", "--workers", "1"]
|
|
||||||
125
web/deploy.sh
125
web/deploy.sh
@@ -1,125 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
|
|
||||||
# ── Verify sops is installed (setup_server.sh installs it to /usr/local/bin) ──
|
|
||||||
if ! command -v sops &>/dev/null; then
|
|
||||||
echo "ERROR: sops not found — run infra/setup_server.sh first"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Decrypt secrets (SOPS auto-discovers age key from ~/.config/sops/age/) ────
|
|
||||||
echo "==> Decrypting secrets from .env.prod.sops..."
|
|
||||||
sops --input-type dotenv --output-type dotenv -d "$APP_DIR/../.env.prod.sops" > "$APP_DIR/.env"
|
|
||||||
chmod 600 "$APP_DIR/.env"
|
|
||||||
|
|
||||||
COMPOSE="docker compose -f docker-compose.prod.yml"
|
|
||||||
LIVE_FILE=".live-slot"
|
|
||||||
ROUTER_CONF="router/default.conf"
|
|
||||||
|
|
||||||
# ── Determine slots ─────────────────────────────────────────
|
|
||||||
|
|
||||||
CURRENT=$(cat "$LIVE_FILE" 2>/dev/null || echo "none")
|
|
||||||
|
|
||||||
if [ "$CURRENT" = "blue" ]; then
|
|
||||||
TARGET="green"
|
|
||||||
else
|
|
||||||
TARGET="blue"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Current: $CURRENT → Deploying: $TARGET"
|
|
||||||
|
|
||||||
# ── Build ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo "==> Building $TARGET..."
|
|
||||||
$COMPOSE --profile "$TARGET" build
|
|
||||||
|
|
||||||
# ── Backup DB before migration ────────────────────────────────
|
|
||||||
|
|
||||||
BACKUP_TAG="pre-deploy-$(date +%Y%m%d-%H%M%S)"
|
|
||||||
echo "==> Backing up database (${BACKUP_TAG})..."
|
|
||||||
$COMPOSE run --rm --entrypoint "" "${TARGET}-app" \
|
|
||||||
sh -c "cp /app/data/app.db /app/data/app.db.${BACKUP_TAG} 2>/dev/null || true"
|
|
||||||
|
|
||||||
# ── Migrate ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo "==> Running migrations..."
|
|
||||||
$COMPOSE --profile "$TARGET" run --rm "${TARGET}-app" \
|
|
||||||
python -m beanflows.migrations.migrate
|
|
||||||
|
|
||||||
# ── Ensure router points to current live slot before --wait ──
|
|
||||||
# nginx resolves upstream hostnames — if config points to a stopped slot,
|
|
||||||
# the health check fails. Reset router to current slot while target starts.
|
|
||||||
|
|
||||||
_write_router_conf() {
|
|
||||||
local SLOT="$1"
|
|
||||||
mkdir -p "$(dirname "$ROUTER_CONF")"
|
|
||||||
cat > "$ROUTER_CONF" <<NGINX
|
|
||||||
upstream app {
|
|
||||||
server ${SLOT}-app:5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://app;
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NGINX
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$CURRENT" != "none" ]; then
|
|
||||||
echo "==> Resetting router to current slot ($CURRENT)..."
|
|
||||||
_write_router_conf "$CURRENT"
|
|
||||||
$COMPOSE restart router
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Start & health check ───────────────────────────────────
|
|
||||||
|
|
||||||
echo "==> Starting $TARGET (waiting for health check)..."
|
|
||||||
if ! $COMPOSE --profile "$TARGET" up -d --wait; then
|
|
||||||
echo "!!! Health check failed — dumping logs"
|
|
||||||
echo "--- ${TARGET}-app logs ---"
|
|
||||||
$COMPOSE --profile "$TARGET" logs --tail=60 "${TARGET}-app" 2>&1 || true
|
|
||||||
echo "--- router logs ---"
|
|
||||||
$COMPOSE logs --tail=10 router 2>&1 || true
|
|
||||||
echo "!!! Rolling back"
|
|
||||||
$COMPOSE stop "${TARGET}-app" "${TARGET}-worker" "${TARGET}-scheduler"
|
|
||||||
LATEST=$($COMPOSE run --rm --entrypoint "" "${TARGET}-app" \
|
|
||||||
sh -c "ls -t /app/data/app.db.pre-deploy-* 2>/dev/null | head -1")
|
|
||||||
if [ -n "$LATEST" ]; then
|
|
||||||
echo "==> Restoring database from ${LATEST}..."
|
|
||||||
$COMPOSE run --rm --entrypoint "" "${TARGET}-app" \
|
|
||||||
sh -c "cp '${LATEST}' /app/data/app.db"
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Write router config and reload (new slot is healthy) ────
|
|
||||||
|
|
||||||
echo "==> Switching router to $TARGET..."
|
|
||||||
_write_router_conf "$TARGET"
|
|
||||||
$COMPOSE exec router nginx -s reload
|
|
||||||
|
|
||||||
# ── Cleanup old pre-deploy backups (keep last 3) ─────────────
|
|
||||||
|
|
||||||
$COMPOSE run --rm --entrypoint "" "${TARGET}-app" \
|
|
||||||
sh -c "ls -t /app/data/app.db.pre-deploy-* 2>/dev/null | tail -n +4 | xargs rm -f" || true
|
|
||||||
|
|
||||||
# ── Stop old slot ───────────────────────────────────────────
|
|
||||||
|
|
||||||
if [ "$CURRENT" != "none" ]; then
|
|
||||||
echo "==> Stopping $CURRENT..."
|
|
||||||
$COMPOSE stop "${CURRENT}-app" "${CURRENT}-worker" "${CURRENT}-scheduler"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Record live slot ────────────────────────────────────────
|
|
||||||
|
|
||||||
echo "$TARGET" > "$LIVE_FILE"
|
|
||||||
echo "==> Deployed $TARGET successfully!"
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
services:
|
|
||||||
# ── Always-on infrastructure ──────────────────────────────
|
|
||||||
|
|
||||||
router:
|
|
||||||
image: nginx:alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "5000:80"
|
|
||||||
volumes:
|
|
||||||
- ./router/default.conf:/etc/nginx/conf.d/default.conf:ro
|
|
||||||
networks:
|
|
||||||
- net
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "nginx", "-t"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
|
|
||||||
litestream:
|
|
||||||
image: litestream/litestream:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
command: replicate -config /etc/litestream.yml
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
- ./litestream.yml:/etc/litestream.yml:ro
|
|
||||||
|
|
||||||
# ── Blue slot ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
blue-app:
|
|
||||||
profiles: ["blue"]
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: ./.env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
- SERVING_DUCKDB_PATH=/data/materia/analytics.duckdb
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
- /data/materia/analytics.duckdb:/data/materia/analytics.duckdb:ro
|
|
||||||
networks:
|
|
||||||
- net
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
blue-worker:
|
|
||||||
profiles: ["blue"]
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
restart: unless-stopped
|
|
||||||
command: python -m beanflows.worker
|
|
||||||
env_file: ./.env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
networks:
|
|
||||||
- net
|
|
||||||
|
|
||||||
blue-scheduler:
|
|
||||||
profiles: ["blue"]
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
restart: unless-stopped
|
|
||||||
command: python -m beanflows.worker scheduler
|
|
||||||
env_file: ./.env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
networks:
|
|
||||||
- net
|
|
||||||
|
|
||||||
# ── Green slot ────────────────────────────────────────────
|
|
||||||
|
|
||||||
green-app:
|
|
||||||
profiles: ["green"]
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: ./.env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
- SERVING_DUCKDB_PATH=/data/materia/analytics.duckdb
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
- /data/materia/analytics.duckdb:/data/materia/analytics.duckdb:ro
|
|
||||||
networks:
|
|
||||||
- net
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
green-worker:
|
|
||||||
profiles: ["green"]
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
restart: unless-stopped
|
|
||||||
command: python -m beanflows.worker
|
|
||||||
env_file: ./.env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
networks:
|
|
||||||
- net
|
|
||||||
|
|
||||||
green-scheduler:
|
|
||||||
profiles: ["green"]
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
restart: unless-stopped
|
|
||||||
command: python -m beanflows.worker scheduler
|
|
||||||
env_file: ./.env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
networks:
|
|
||||||
- net
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
app-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
net:
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./duckdb:/app/duckdb:ro
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
- SERVING_DUCKDB_PATH=/app/duckdb/analytics.duckdb
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
worker:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
command: python -m beanflows.worker
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
scheduler:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
command: python -m beanflows.worker scheduler
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/data/app.db
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
# Optional: Litestream for backups
|
|
||||||
litestream:
|
|
||||||
image: litestream/litestream:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
command: replicate -config /etc/litestream.yml
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./litestream.yml:/etc/litestream.yml:ro
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Litestream configuration for SQLite replication
|
|
||||||
# Supports S3, Cloudflare R2, MinIO, etc.
|
|
||||||
|
|
||||||
dbs:
|
|
||||||
- path: /app/data/app.db
|
|
||||||
replicas:
|
|
||||||
# Option 1: AWS S3
|
|
||||||
# - url: s3://your-bucket/beanflows/app.db
|
|
||||||
# access-key-id: ${AWS_ACCESS_KEY_ID}
|
|
||||||
# secret-access-key: ${AWS_SECRET_ACCESS_KEY}
|
|
||||||
# region: us-east-1
|
|
||||||
|
|
||||||
# Option 2: Cloudflare R2
|
|
||||||
# - url: s3://your-bucket/beanflows/app.db
|
|
||||||
# access-key-id: ${R2_ACCESS_KEY_ID}
|
|
||||||
# secret-access-key: ${R2_SECRET_ACCESS_KEY}
|
|
||||||
# endpoint: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
|
||||||
|
|
||||||
# Option 3: Local file backup (for development)
|
|
||||||
- path: /app/data/backups
|
|
||||||
retention: 24h
|
|
||||||
snapshot-interval: 1h
|
|
||||||
@@ -17,7 +17,7 @@ dependencies = [
|
|||||||
"paddle-python-sdk>=1.13.0",
|
"paddle-python-sdk>=1.13.0",
|
||||||
"itsdangerous>=2.1.0",
|
"itsdangerous>=2.1.0",
|
||||||
"jinja2>=3.1.0",
|
"jinja2>=3.1.0",
|
||||||
"hypercorn>=0.17.0",
|
"granian>=2.7.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
@@ -27,8 +27,8 @@ build-backend = "hatchling.build"
|
|||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/beanflows"]
|
packages = ["src/beanflows"]
|
||||||
|
|
||||||
[tool.uv]
|
[dependency-groups]
|
||||||
dev-dependencies = [
|
dev = [
|
||||||
"hypothesis>=6.100.0",
|
"hypothesis>=6.100.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-asyncio>=0.23.0",
|
"pytest-asyncio>=0.23.0",
|
||||||
@@ -36,14 +36,6 @@ dev-dependencies = [
|
|||||||
"ruff>=0.3.0",
|
"ruff>=0.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 100
|
|
||||||
target-version = "py311"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "I", "UP"]
|
|
||||||
ignore = ["E501"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
upstream app {
|
|
||||||
server blue-app:5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://app;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -169,7 +169,7 @@ def fetch_country_data_from_duckdb() -> list[dict]:
|
|||||||
)
|
)
|
||||||
SELECT * FROM ranked LIMIT 30
|
SELECT * FROM ranked LIMIT 30
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
cols = [d[0] for d in conn.execute("""
|
_ = [d[0] for d in conn.execute("""
|
||||||
WITH latest AS (SELECT MAX(market_year) AS max_year FROM serving.commodity_metrics
|
WITH latest AS (SELECT MAX(market_year) AS max_year FROM serving.commodity_metrics
|
||||||
WHERE commodity_code = 711100 AND country_code IS NOT NULL)
|
WHERE commodity_code = 711100 AND country_code IS NOT NULL)
|
||||||
SELECT country_name, country_code, market_year, production * 1000,
|
SELECT country_name, country_code, market_year, production * 1000,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
|
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from ..core import execute, fetch_all, fetch_one, csrf_protect
|
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
"cms",
|
"cms",
|
||||||
@@ -71,7 +71,7 @@ async def list_template_data(template_id: int) -> list[dict]:
|
|||||||
|
|
||||||
async def generate_article_from_data(data_row: dict, tmpl: dict) -> int | None:
|
async def generate_article_from_data(data_row: dict, tmpl: dict) -> int | None:
|
||||||
"""Generate (or regenerate) a single article from a template_data row."""
|
"""Generate (or regenerate) a single article from a template_data row."""
|
||||||
from jinja2 import Environment, BaseLoader
|
from jinja2 import BaseLoader, Environment
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(data_row["data_json"])
|
data = json.loads(data_row["data_json"])
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..core import config, csrf_protect, execute, fetch_all, fetch_one
|
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ ALLOWED_METRICS = frozenset({
|
|||||||
|
|
||||||
_local = threading.local()
|
_local = threading.local()
|
||||||
_db_path: str = ""
|
_db_path: str = ""
|
||||||
|
_conn: duckdb.DuckDBPyConnection | None = None # test override: set to bypass _db_path / _local
|
||||||
|
|
||||||
|
|
||||||
def open_analytics_db() -> None:
|
def open_analytics_db() -> None:
|
||||||
@@ -110,12 +111,15 @@ async def fetch_analytics(sql: str, params: list | None = None) -> list[dict]:
|
|||||||
"""Run a read-only DuckDB query off the event loop. Returns list of dicts.
|
"""Run a read-only DuckDB query off the event loop. Returns list of dicts.
|
||||||
Returns empty list if analytics DB is not configured (SERVING_DUCKDB_PATH unset
|
Returns empty list if analytics DB is not configured (SERVING_DUCKDB_PATH unset
|
||||||
or file missing at startup) — dashboard routes degrade gracefully.
|
or file missing at startup) — dashboard routes degrade gracefully.
|
||||||
|
|
||||||
|
If the module-level _conn is set (test override), it is used directly in place
|
||||||
|
of the per-thread _get_conn() path.
|
||||||
"""
|
"""
|
||||||
if not _db_path:
|
if _conn is None and not _db_path:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _query():
|
def _query():
|
||||||
conn = _get_conn()
|
conn = _conn if _conn is not None else _get_conn()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
result = cursor.execute(sql, params or [])
|
result = cursor.execute(sql, params or [])
|
||||||
columns = [desc[0] for desc in result.description]
|
columns = [desc[0] for desc in result.description]
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ async def list_commodities():
|
|||||||
@api_key_required(scopes=["read"])
|
@api_key_required(scopes=["read"])
|
||||||
async def commodity_metrics(code: int):
|
async def commodity_metrics(code: int):
|
||||||
"""Time series metrics for a commodity. Query params: metrics, start_year, end_year."""
|
"""Time series metrics for a commodity. Query params: metrics, start_year, end_year."""
|
||||||
raw_metrics = request.args.getlist("metrics") or ["Production", "Exports", "Imports", "Ending_Stocks"]
|
raw_metrics = request.args.getlist("metrics") or ["production", "exports", "imports", "ending_stocks"]
|
||||||
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS]
|
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS]
|
||||||
if not metrics:
|
if not metrics:
|
||||||
return jsonify({"error": f"No valid metrics. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400
|
return jsonify({"error": f"No valid metrics. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400
|
||||||
@@ -152,7 +152,7 @@ async def commodity_metrics(code: int):
|
|||||||
@api_key_required(scopes=["read"])
|
@api_key_required(scopes=["read"])
|
||||||
async def commodity_countries(code: int):
|
async def commodity_countries(code: int):
|
||||||
"""Country ranking for a commodity. Query params: metric, limit."""
|
"""Country ranking for a commodity. Query params: metric, limit."""
|
||||||
metric = request.args.get("metric", "Production")
|
metric = request.args.get("metric", "production")
|
||||||
if metric not in analytics.ALLOWED_METRICS:
|
if metric not in analytics.ALLOWED_METRICS:
|
||||||
return jsonify({"error": f"Invalid metric. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400
|
return jsonify({"error": f"Invalid metric. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ async def commodity_metrics_csv(code: int):
|
|||||||
return jsonify({"error": "CSV export requires a Starter or Pro plan"}), 403
|
return jsonify({"error": "CSV export requires a Starter or Pro plan"}), 403
|
||||||
|
|
||||||
raw_metrics = request.args.getlist("metrics") or [
|
raw_metrics = request.args.getlist("metrics") or [
|
||||||
"Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution",
|
"production", "exports", "imports", "ending_stocks", "total_distribution",
|
||||||
]
|
]
|
||||||
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS]
|
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS]
|
||||||
if not metrics:
|
if not metrics:
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ def create_app() -> Quart:
|
|||||||
# Health check
|
# Health check
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
async def health():
|
async def health():
|
||||||
from .analytics import _db_path as serving_db_path
|
from .analytics import _db_path as serving_db_path, fetch_analytics
|
||||||
from .analytics import fetch_analytics
|
|
||||||
from .core import fetch_one
|
from .core import fetch_one
|
||||||
result = {"status": "healthy", "sqlite": "ok", "duckdb": "ok"}
|
result = {"status": "healthy", "sqlite": "ok", "duckdb": "ok"}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,13 +2,20 @@
|
|||||||
Auth domain: magic link authentication, user management, decorators.
|
Auth domain: magic link authentication, user management, decorators.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
from functools import wraps
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, render_template, request, redirect, url_for, session, flash, g
|
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..core import config, fetch_one, fetch_all, execute, csrf_protect, waitlist_gate, capture_waitlist_email
|
from ..core import (
|
||||||
|
capture_waitlist_email,
|
||||||
|
config,
|
||||||
|
csrf_protect,
|
||||||
|
execute,
|
||||||
|
fetch_one,
|
||||||
|
waitlist_gate,
|
||||||
|
)
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
|
|||||||
@@ -4,21 +4,49 @@ Payment provider: paddle
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, render_template, request, redirect, url_for, flash, g, jsonify, session
|
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
from ..core import config, fetch_one, fetch_all, execute
|
|
||||||
|
|
||||||
from ..core import verify_hmac_signature
|
|
||||||
|
|
||||||
from ..auth.routes import login_required
|
from ..auth.routes import login_required
|
||||||
|
from ..core import config, execute, fetch_one
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Billing event hook system
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_billing_hooks: dict[str, list] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def on_billing_event(*event_types: str):
|
||||||
|
"""Decorator: register a handler for one or more billing event types."""
|
||||||
|
def decorator(func):
|
||||||
|
for event_type in event_types:
|
||||||
|
_billing_hooks.setdefault(event_type, []).append(func)
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
async def _fire_hooks(event_type: str, data: dict) -> None:
|
||||||
|
"""Fire all registered hooks for an event type, isolating per-hook failures."""
|
||||||
|
for hook in _billing_hooks.get(event_type, []):
|
||||||
|
try:
|
||||||
|
await hook(event_type, data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Hook %s failed for event %s: %s", hook.__name__, event_type, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _paddle_client():
|
||||||
|
"""Return a configured Paddle SDK client."""
|
||||||
|
from paddle_billing import Client, Environment, Options
|
||||||
|
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
|
||||||
|
return Client(config.PADDLE_API_KEY, options=Options(environment=env))
|
||||||
|
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
@@ -42,44 +70,74 @@ async def get_subscription(user_id: int) -> dict | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_billing_customer(user_id: int, provider_customer_id: str) -> None:
|
||||||
|
"""Create or update billing customer record."""
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO billing_customers (user_id, provider_customer_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET provider_customer_id = excluded.provider_customer_id""",
|
||||||
|
(user_id, provider_customer_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_billing_customer(user_id: int) -> dict | None:
|
||||||
|
"""Get billing customer record for a user."""
|
||||||
|
return await fetch_one(
|
||||||
|
"SELECT * FROM billing_customers WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def upsert_subscription(
|
async def upsert_subscription(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
plan: str,
|
plan: str,
|
||||||
status: str,
|
status: str,
|
||||||
provider_customer_id: str,
|
|
||||||
provider_subscription_id: str,
|
provider_subscription_id: str,
|
||||||
current_period_end: str = None,
|
current_period_end: str = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Create or update subscription."""
|
"""Create or update subscription, keyed by provider_subscription_id."""
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
customer_col = "paddle_customer_id" # legacy column, kept for existing rows
|
existing = await fetch_one(
|
||||||
subscription_col = "provider_subscription_id"
|
"SELECT id FROM subscriptions WHERE provider_subscription_id = ?",
|
||||||
|
(provider_subscription_id,),
|
||||||
|
)
|
||||||
existing = await fetch_one("SELECT id FROM subscriptions WHERE user_id = ?", (user_id,))
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
await execute(
|
await execute(
|
||||||
f"""UPDATE subscriptions
|
"""UPDATE subscriptions
|
||||||
SET plan = ?, status = ?, {customer_col} = ?, {subscription_col} = ?,
|
SET plan = ?, status = ?, current_period_end = ?, updated_at = ?
|
||||||
current_period_end = ?, updated_at = ?
|
WHERE provider_subscription_id = ?""",
|
||||||
WHERE user_id = ?""",
|
(plan, status, current_period_end, now, provider_subscription_id),
|
||||||
(plan, status, provider_customer_id, provider_subscription_id,
|
|
||||||
current_period_end, now, user_id),
|
|
||||||
)
|
)
|
||||||
return existing["id"]
|
return existing["id"]
|
||||||
else:
|
else:
|
||||||
return await execute(
|
return await execute(
|
||||||
f"""INSERT INTO subscriptions
|
"""INSERT INTO subscriptions
|
||||||
(user_id, plan, status, {customer_col}, {subscription_col},
|
(user_id, plan, status, provider_subscription_id, current_period_end, created_at, updated_at)
|
||||||
current_period_end, created_at, updated_at)
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
(user_id, plan, status, provider_subscription_id, current_period_end, now, now),
|
||||||
(user_id, plan, status, provider_customer_id, provider_subscription_id,
|
|
||||||
current_period_end, now, now),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_transaction(
|
||||||
|
user_id: int,
|
||||||
|
provider_transaction_id: str,
|
||||||
|
type: str = "payment",
|
||||||
|
amount_cents: int = None,
|
||||||
|
currency: str = "USD",
|
||||||
|
status: str = "pending",
|
||||||
|
) -> int:
|
||||||
|
"""Record a billing transaction. Idempotent on provider_transaction_id."""
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
return await execute(
|
||||||
|
"""INSERT OR IGNORE INTO transactions
|
||||||
|
(user_id, provider_transaction_id, type, amount_cents, currency, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(user_id, provider_transaction_id, type, amount_cents, currency, status, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
|
async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
|
||||||
return await fetch_one(
|
return await fetch_one(
|
||||||
@@ -165,31 +223,21 @@ async def success():
|
|||||||
@bp.route("/checkout/<plan>", methods=["POST"])
|
@bp.route("/checkout/<plan>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
async def checkout(plan: str):
|
async def checkout(plan: str):
|
||||||
"""Create Paddle checkout via API."""
|
"""Create Paddle checkout via SDK."""
|
||||||
price_id = config.PADDLE_PRICES.get(plan)
|
price_id = config.PADDLE_PRICES.get(plan)
|
||||||
if not price_id:
|
if not price_id:
|
||||||
await flash("Invalid plan selected.", "error")
|
await flash("Invalid plan selected.", "error")
|
||||||
return redirect(url_for("billing.pricing"))
|
return redirect(url_for("billing.pricing"))
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
from paddle_billing.Resources.Transactions.Operations import CreateTransaction
|
||||||
response = await client.post(
|
txn = _paddle_client().transactions.create(
|
||||||
"https://api.paddle.com/transactions",
|
CreateTransaction(
|
||||||
headers={
|
items=[{"price_id": price_id, "quantity": 1}],
|
||||||
"Authorization": f"Bearer {config.PADDLE_API_KEY}",
|
custom_data={"user_id": str(g.user["id"]), "plan": plan},
|
||||||
"Content-Type": "application/json",
|
checkout={"url": f"{config.BASE_URL}/billing/success"},
|
||||||
},
|
|
||||||
json={
|
|
||||||
"items": [{"price_id": price_id, "quantity": 1}],
|
|
||||||
"custom_data": {"user_id": str(g.user["id"]), "plan": plan},
|
|
||||||
"checkout": {
|
|
||||||
"url": f"{config.BASE_URL}/billing/success",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
)
|
||||||
|
return redirect(txn.checkout.url)
|
||||||
checkout_url = response.json()["data"]["checkout"]["url"]
|
|
||||||
return redirect(checkout_url)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/manage", methods=["POST"])
|
@bp.route("/manage", methods=["POST"])
|
||||||
@@ -202,13 +250,8 @@ async def manage():
|
|||||||
return redirect(url_for("dashboard.settings"))
|
return redirect(url_for("dashboard.settings"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
subscription = _paddle_client().subscriptions.get(sub["provider_subscription_id"])
|
||||||
response = await client.get(
|
portal_url = subscription.management_urls.update_payment_method
|
||||||
f"https://api.paddle.com/subscriptions/{sub['provider_subscription_id']}",
|
|
||||||
headers={"Authorization": f"Bearer {config.PADDLE_API_KEY}"},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
portal_url = response.json()["data"]["management_urls"]["update_payment_method"]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await flash("Could not reach the billing portal. Please try again or contact support.", "error")
|
await flash("Could not reach the billing portal. Please try again or contact support.", "error")
|
||||||
return redirect(url_for("dashboard.settings"))
|
return redirect(url_for("dashboard.settings"))
|
||||||
@@ -219,28 +262,27 @@ async def manage():
|
|||||||
@bp.route("/cancel", methods=["POST"])
|
@bp.route("/cancel", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
async def cancel():
|
async def cancel():
|
||||||
"""Cancel subscription via Paddle API."""
|
"""Cancel subscription via Paddle SDK."""
|
||||||
sub = await get_subscription(g.user["id"])
|
sub = await get_subscription(g.user["id"])
|
||||||
if sub and sub.get("provider_subscription_id"):
|
if sub and sub.get("provider_subscription_id"):
|
||||||
async with httpx.AsyncClient() as client:
|
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
|
||||||
await client.post(
|
_paddle_client().subscriptions.cancel(
|
||||||
f"https://api.paddle.com/subscriptions/{sub['provider_subscription_id']}/cancel",
|
sub["provider_subscription_id"],
|
||||||
headers={
|
CancelSubscription(effective_from="next_billing_period"),
|
||||||
"Authorization": f"Bearer {config.PADDLE_API_KEY}",
|
)
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={"effective_from": "next_billing_period"},
|
|
||||||
)
|
|
||||||
return redirect(url_for("dashboard.settings"))
|
return redirect(url_for("dashboard.settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/webhook/paddle", methods=["POST"])
|
@bp.route("/webhook/paddle", methods=["POST"])
|
||||||
async def webhook():
|
async def webhook():
|
||||||
"""Handle Paddle webhooks."""
|
"""Handle Paddle webhooks."""
|
||||||
|
import paddle_billing
|
||||||
payload = await request.get_data()
|
payload = await request.get_data()
|
||||||
sig = request.headers.get("Paddle-Signature", "")
|
sig = request.headers.get("Paddle-Signature", "")
|
||||||
|
|
||||||
if not verify_hmac_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET):
|
try:
|
||||||
|
paddle_billing.Notifications.Verifier().verify(payload, config.PADDLE_WEBHOOK_SECRET, sig)
|
||||||
|
except Exception:
|
||||||
return jsonify({"error": "Invalid signature"}), 400
|
return jsonify({"error": "Invalid signature"}), 400
|
||||||
|
|
||||||
event = json.loads(payload)
|
event = json.loads(payload)
|
||||||
@@ -251,11 +293,14 @@ async def webhook():
|
|||||||
|
|
||||||
if event_type == "subscription.activated":
|
if event_type == "subscription.activated":
|
||||||
plan = custom_data.get("plan", "starter")
|
plan = custom_data.get("plan", "starter")
|
||||||
|
uid = int(user_id) if user_id else 0
|
||||||
|
customer_id = data.get("customer_id")
|
||||||
|
if uid and customer_id:
|
||||||
|
await upsert_billing_customer(uid, str(customer_id))
|
||||||
await upsert_subscription(
|
await upsert_subscription(
|
||||||
user_id=int(user_id) if user_id else 0,
|
user_id=uid,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
status="active",
|
status="active",
|
||||||
provider_customer_id=str(data.get("customer_id", "")),
|
|
||||||
provider_subscription_id=data.get("id", ""),
|
provider_subscription_id=data.get("id", ""),
|
||||||
current_period_end=data.get("current_billing_period", {}).get("ends_at"),
|
current_period_end=data.get("current_billing_period", {}).get("ends_at"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async def _serve_article(url_path: str):
|
|||||||
body_html = ""
|
body_html = ""
|
||||||
if article.get("body_template") and article.get("data_json"):
|
if article.get("body_template") and article.get("data_json"):
|
||||||
try:
|
try:
|
||||||
from jinja2 import Environment, BaseLoader
|
from jinja2 import BaseLoader, Environment
|
||||||
data = json.loads(article["data_json"])
|
data = json.loads(article["data_json"])
|
||||||
env = Environment(loader=BaseLoader())
|
env = Environment(loader=BaseLoader())
|
||||||
body_html = env.from_string(article["body_template"]).render(**data)
|
body_html = env.from_string(article["body_template"]).render(**data)
|
||||||
|
|||||||
@@ -77,22 +77,15 @@ class Config:
|
|||||||
RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "")
|
RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "")
|
||||||
|
|
||||||
PLAN_FEATURES: dict = {
|
PLAN_FEATURES: dict = {
|
||||||
"free": ["dashboard", "coffee_only", "limited_history"],
|
"free": ["basic"],
|
||||||
"starter": ["dashboard", "coffee_only", "full_history", "export", "api"],
|
"starter": ["basic", "export"],
|
||||||
"pro": [
|
"pro": ["basic", "export", "api", "priority_support"],
|
||||||
"dashboard",
|
|
||||||
"all_commodities",
|
|
||||||
"full_history",
|
|
||||||
"export",
|
|
||||||
"api",
|
|
||||||
"priority_support",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PLAN_LIMITS: dict = {
|
PLAN_LIMITS: dict = {
|
||||||
"free": {"commodities": 1, "history_years": 5, "api_calls": 0},
|
"free": {"items": 100, "api_calls": 0},
|
||||||
"starter": {"commodities": 1, "history_years": -1, "api_calls": 10000},
|
"starter": {"items": 1000, "api_calls": 10000},
|
||||||
"pro": {"commodities": -1, "history_years": -1, "api_calls": -1}, # -1 = unlimited
|
"pro": {"items": -1, "api_calls": -1}, # -1 = unlimited
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ async def index():
|
|||||||
user = g.user
|
user = g.user
|
||||||
plan = (g.get("subscription") or {}).get("plan", "free")
|
plan = (g.get("subscription") or {}).get("plan", "free")
|
||||||
|
|
||||||
if analytics._db_path:
|
if analytics._db_path or analytics._conn is not None:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
analytics.get_price_latest(analytics.COFFEE_TICKER),
|
analytics.get_price_latest(analytics.COFFEE_TICKER),
|
||||||
analytics.get_cot_positioning_latest(analytics.COFFEE_CFTC_CODE),
|
analytics.get_cot_positioning_latest(analytics.COFFEE_CFTC_CODE),
|
||||||
@@ -208,7 +208,7 @@ async def supply():
|
|||||||
current_year = datetime.date.today().year
|
current_year = datetime.date.today().year
|
||||||
start_year = current_year - rng["years"]
|
start_year = current_year - rng["years"]
|
||||||
|
|
||||||
if analytics._db_path:
|
if analytics._db_path or analytics._conn is not None:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
analytics.get_global_time_series(
|
analytics.get_global_time_series(
|
||||||
analytics.COFFEE_COMMODITY_CODE,
|
analytics.COFFEE_COMMODITY_CODE,
|
||||||
@@ -267,7 +267,7 @@ async def positioning():
|
|||||||
cot_weeks = rng["weeks"]
|
cot_weeks = rng["weeks"]
|
||||||
|
|
||||||
options_delta = None
|
options_delta = None
|
||||||
if analytics._db_path:
|
if analytics._db_path or analytics._conn is not None:
|
||||||
gather_coros = [
|
gather_coros = [
|
||||||
analytics.get_price_latest(analytics.COFFEE_TICKER),
|
analytics.get_price_latest(analytics.COFFEE_TICKER),
|
||||||
analytics.get_price_time_series(analytics.COFFEE_TICKER, limit=price_limit),
|
analytics.get_price_time_series(analytics.COFFEE_TICKER, limit=price_limit),
|
||||||
@@ -322,7 +322,7 @@ async def warehouse():
|
|||||||
stocks_latest = stocks_trend = aging_latest = byport_latest = byport_trend = None
|
stocks_latest = stocks_trend = aging_latest = byport_latest = byport_trend = None
|
||||||
stocks_trend = aging_latest = byport_trend = []
|
stocks_trend = aging_latest = byport_trend = []
|
||||||
|
|
||||||
if analytics._db_path:
|
if analytics._db_path or analytics._conn is not None:
|
||||||
if view == "stocks":
|
if view == "stocks":
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
analytics.get_ice_stocks_latest(),
|
analytics.get_ice_stocks_latest(),
|
||||||
@@ -416,7 +416,7 @@ async def weather():
|
|||||||
rng = RANGE_MAP[range_key]
|
rng = RANGE_MAP[range_key]
|
||||||
days = rng["days"]
|
days = rng["days"]
|
||||||
|
|
||||||
if analytics._db_path:
|
if analytics._db_path or analytics._conn is not None:
|
||||||
if location_id:
|
if location_id:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
analytics.get_weather_stress_latest(),
|
analytics.get_weather_stress_latest(),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Public domain: landing page, marketing pages, legal pages, feedback, sitemap.
|
Public domain: landing page, marketing pages, legal pages, feedback, sitemap.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, render_template, request, g, make_response
|
from quart import Blueprint, g, make_response, render_template, request
|
||||||
|
|
||||||
from ..core import config, execute, fetch_all, check_rate_limit, csrf_protect
|
from ..core import check_rate_limit, config, csrf_protect, execute, fetch_all
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user