summaryrefslogtreecommitdiff
path: root/Biz
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-16 08:06:09 -0500
committerBen Sima <ben@bensima.com>2025-12-16 08:06:09 -0500
commita7dcb30c7a465d9fce72b7fc3e605470b2b59814 (patch)
tree57a6436de34062773483dbd0cb745ac103c6bb48 /Biz
parent4caefe45756fdc21df990b8d6e826c40db1b9c78 (diff)
feat(deploy): Complete mini-PaaS deployment system (t-266)
- Add Omni/Deploy/ with Manifest, Deployer, Systemd, Caddy modules - Manifest CLI: show, update, add-service, list, rollback commands - Deployer: polls S3 manifest, pulls closures, manages systemd units - Caddy integration for dynamic reverse proxy routes - bild: auto-cache to S3, outputs STORE_PATH for push.sh - push.sh: supports both NixOS and service deploys - Biz.nix: simplified to base OS + deployer only - Services (podcastitlater-web/worker) now deployer-managed - Documentation: README.md with operations guide
Diffstat (limited to 'Biz')
-rwxr-xr-xBiz/EmailAgent.py7
-rw-r--r--Biz/Packages.nix5
-rw-r--r--Biz/PodcastItLater/Admin/Core.py (renamed from Biz/PodcastItLater/Admin.py)0
-rw-r--r--Biz/PodcastItLater/Core.py2
-rw-r--r--Biz/PodcastItLater/INFRASTRUCTURE.md46
-rw-r--r--Biz/PodcastItLater/UI.py4
-rw-r--r--Biz/PodcastItLater/Web.nix4
-rw-r--r--Biz/PodcastItLater/Web.py13
-rw-r--r--Biz/PodcastItLater/Worker/Core.py (renamed from Biz/PodcastItLater/Worker.py)5
-rw-r--r--Biz/PodcastItLater/Worker/Jobs.py2
-rw-r--r--Biz/PodcastItLater/Worker/Processor.py18
11 files changed, 50 insertions, 56 deletions
diff --git a/Biz/EmailAgent.py b/Biz/EmailAgent.py
index 6ac4c95..ca42de3 100755
--- a/Biz/EmailAgent.py
+++ b/Biz/EmailAgent.py
@@ -31,7 +31,7 @@ def send_email(
Send an email using the provided parameters.
Args:
- to_addr: Recipient email addresses
+ to_addrs: Recipient email addresses
from_addr: Sender email address
smtp_server: SMTP server hostname
password: Password for authentication
@@ -56,8 +56,9 @@ def send_email(
with body_html.open(encoding="utf-*") as html:
msg.add_alternative(html.read(), subtype="html")
with smtplib.SMTP(smtp_server, port) as server:
- server.starttls()
- server.login(from_addr, password)
+ if password:
+ server.starttls()
+ server.login(from_addr, password)
return server.send_message(
msg,
from_addr=from_addr,
diff --git a/Biz/Packages.nix b/Biz/Packages.nix
index 6b17fe5..492671f 100644
--- a/Biz/Packages.nix
+++ b/Biz/Packages.nix
@@ -10,6 +10,9 @@
{bild ? import ../Omni/Bild.nix {}}: {
storybook = bild.run ../Biz/Storybook.py;
podcastitlater-web = bild.run ../Biz/PodcastItLater/Web.py;
- podcastitlater-worker = bild.run ../Biz/PodcastItLater/Worker.py;
+ podcastitlater-worker = bild.run ../Biz/PodcastItLater/Worker/Core.py;
dragons-analysis = bild.run ../Biz/Dragons/Analysis.hs;
+ # Mini-PaaS deployer
+ biz-deployer = bild.run ../Omni/Deploy/Deployer.hs;
+ deploy-manifest = bild.run ../Omni/Deploy/Manifest.hs;
}
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin/Core.py
index 10ea7f6..10ea7f6 100644
--- a/Biz/PodcastItLater/Admin.py
+++ b/Biz/PodcastItLater/Admin/Core.py
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py
index d0ed2f0..05ed153 100644
--- a/Biz/PodcastItLater/Core.py
+++ b/Biz/PodcastItLater/Core.py
@@ -1342,7 +1342,7 @@ class Database: # noqa: PLR0904
with Database.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as count FROM feedback")
- return cursor.fetchone()["count"]
+ return int(cursor.fetchone()["count"])
@staticmethod
def get_metrics_summary() -> dict[str, Any]:
diff --git a/Biz/PodcastItLater/INFRASTRUCTURE.md b/Biz/PodcastItLater/INFRASTRUCTURE.md
index 1c61618..0d6392b 100644
--- a/Biz/PodcastItLater/INFRASTRUCTURE.md
+++ b/Biz/PodcastItLater/INFRASTRUCTURE.md
@@ -1,38 +1,24 @@
# Infrastructure Setup for PodcastItLater
-## Mailgun Setup
+## Email Delivery via Mailgun
-Since PodcastItLater requires sending transactional emails (magic links), we use Mailgun.
+PodcastItLater sends transactional emails (magic links for login) via Mailgun for reliable deliverability.
-### 1. Sign up for Mailgun
-Sign up at [mailgun.com](https://www.mailgun.com/).
+### Setup Steps
-### 2. Add Domain
-Add `podcastitlater.com` (or `mg.podcastitlater.com`) to Mailgun.
-We recommend using the root domain `podcastitlater.com` if you want emails to come from `@podcastitlater.com`.
+1. **Add domain to Mailgun**: Add `bensima.com` at [mailgun.com](https://app.mailgun.com/mg/sending/new)
-### 3. Configure DNS
-Mailgun will provide DNS records to verify the domain and authorize email sending. You must add these to your DNS provider (e.g., Cloudflare, Namecheap).
+2. **Configure DNS**: Add the records Mailgun provides:
+ - **TXT** (SPF): Update existing to include `include:mailgun.org`
+ - **TXT** (DKIM): Add the DKIM record Mailgun provides
+ - **CNAME** (tracking, optional): For open/click tracking
-Required records usually include:
-- **TXT** (SPF): `v=spf1 include:mailgun.org ~all`
-- **TXT** (DKIM): `k=rsa; p=...` (Provided by Mailgun)
-- **MX** (if receiving email, optional for just sending): `10 mxa.mailgun.org`, `10 mxb.mailgun.org`
-- **CNAME** (for tracking, optional): `email.podcastitlater.com` -> `mailgun.org`
+3. **Get SMTP credentials**: Go to Sending → Domain Settings → SMTP Credentials
-### 4. Verify Domain
-Click "Verify DNS Settings" in Mailgun dashboard. This may take up to 24 hours but is usually instant.
-
-### 5. Generate API Key / SMTP Credentials
-Go to "Sending" -> "Domain Settings" -> "SMTP Credentials".
-Create a new SMTP user (e.g., `postmaster@podcastitlater.com`).
-**Save the password immediately.**
-
-### 6. Update Secrets
-Update the production secrets file on the server (`/run/podcastitlater/env`):
-
-```bash
-SMTP_SERVER=smtp.mailgun.org
-SMTP_PASSWORD=your-new-smtp-password
-EMAIL_FROM=noreply@podcastitlater.com
-```
+4. **Update production secrets** in `/run/podcastitlater/env`:
+ ```bash
+ EMAIL_FROM=noreply@bensima.com
+ SMTP_SERVER=smtp.mailgun.org
+ SMTP_PORT=587
+ SMTP_PASSWORD=your-mailgun-smtp-password
+ ```
diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py
index b243ae7..5c65ca4 100644
--- a/Biz/PodcastItLater/UI.py
+++ b/Biz/PodcastItLater/UI.py
@@ -751,7 +751,7 @@ class FeedbackForm(Component[AnyChildren, FeedbackFormAttrs]):
html.textarea(
name="use_case",
id="use_case",
- rows="3", # type: ignore[call-arg]
+ rows=3,
placeholder=(
"e.g., catching up on articles during commute, "
"listening to research papers while exercising..."
@@ -770,7 +770,7 @@ class FeedbackForm(Component[AnyChildren, FeedbackFormAttrs]):
html.textarea(
name="feedback_text",
id="feedback_text",
- rows="3", # type: ignore[call-arg]
+ rows=3,
placeholder="Suggestions, issues, feature requests...",
classes=["form-control"],
),
diff --git a/Biz/PodcastItLater/Web.nix b/Biz/PodcastItLater/Web.nix
index 7533ca4..0980f5b 100644
--- a/Biz/PodcastItLater/Web.nix
+++ b/Biz/PodcastItLater/Web.nix
@@ -5,7 +5,7 @@
...
}: let
cfg = config.services.podcastitlater-web;
- rootDomain = "podcastitlater.com";
+ rootDomain = "podcastitlater.bensima.com";
ports = import ../../Omni/Cloud/Ports.nix;
in {
options.services.podcastitlater-web = {
@@ -39,7 +39,7 @@ in {
# Manual step: create this file with secrets
# SECRET_KEY=your-secret-key-for-sessions
# SESSION_SECRET=your-session-secret
- # EMAIL_FROM=noreply@podcastitlater.com
+ # EMAIL_FROM=noreply@bensima.com
# SMTP_SERVER=smtp.mailgun.org
# SMTP_PASSWORD=your-smtp-password
# STRIPE_SECRET_KEY=sk_live_your_stripe_secret_key
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index 076eb95..257938f 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -18,7 +18,7 @@ Provides ludic + htmx interface and RSS feed generation.
# : dep stripe
# : dep sqids
import Biz.EmailAgent
-import Biz.PodcastItLater.Admin as Admin
+import Biz.PodcastItLater.Admin.Core as Admin
import Biz.PodcastItLater.Billing as Billing
import Biz.PodcastItLater.Core as Core
import Biz.PodcastItLater.Episode as Episode
@@ -28,7 +28,6 @@ import httpx
import logging
import ludic.html as html
import Omni.App as App
-import Omni.Log as Log
import Omni.Test as Test
import os
import pathlib
@@ -57,7 +56,9 @@ from typing import override
from unittest.mock import patch
logger = logging.getLogger(__name__)
-Log.setup(logger)
+logging.basicConfig(
+ level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s"
+)
# Configuration
@@ -86,9 +87,10 @@ def decode_episode_id(sqid: str) -> int | None:
# Authentication configuration
MAGIC_LINK_MAX_AGE = 3600 # 1 hour
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
-EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@podcastitlater.com")
+EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@bensima.com")
SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.mailgun.org")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
+SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
# Initialize serializer for magic links
magic_link_serializer = URLSafeTimedSerializer(
@@ -192,6 +194,7 @@ PodcastItLater
password=SMTP_PASSWORD,
subject=subject,
body_text=body_text_path,
+ port=SMTP_PORT,
)
finally:
# Clean up temporary file
@@ -1067,7 +1070,7 @@ async def submit_feedback(request: Request) -> UI.FeedbackPage:
feedback_text = form_data.get("feedback_text")
use_case = form_data.get("use_case")
- rating = int(rating_str) if rating_str else None
+ rating = int(str(rating_str)) if rating_str else None
feedback_id = secrets.token_urlsafe(16)
diff --git a/Biz/PodcastItLater/Worker.py b/Biz/PodcastItLater/Worker/Core.py
index ecef2c0..e536785 100644
--- a/Biz/PodcastItLater/Worker.py
+++ b/Biz/PodcastItLater/Worker/Core.py
@@ -18,7 +18,6 @@ import Biz.PodcastItLater.Worker.TextProcessing as TextProcessing
import json
import logging
import Omni.App as App
-import Omni.Log as Log
import Omni.Test as Test
import os
import pytest
@@ -32,7 +31,9 @@ from datetime import timezone
from typing import Any
logger = logging.getLogger(__name__)
-Log.setup(logger)
+logging.basicConfig(
+ level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s"
+)
# Configuration from environment variables
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
diff --git a/Biz/PodcastItLater/Worker/Jobs.py b/Biz/PodcastItLater/Worker/Jobs.py
index 630aaf0..3511b63 100644
--- a/Biz/PodcastItLater/Worker/Jobs.py
+++ b/Biz/PodcastItLater/Worker/Jobs.py
@@ -179,7 +179,7 @@ class TestJobProcessing(Test.TestCase):
def setUp(self) -> None:
"""Set up test environment."""
# Import here to avoid circular dependencies
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
Core.Database.init_db()
diff --git a/Biz/PodcastItLater/Worker/Processor.py b/Biz/PodcastItLater/Worker/Processor.py
index bdda3e5..9d3b61f 100644
--- a/Biz/PodcastItLater/Worker/Processor.py
+++ b/Biz/PodcastItLater/Worker/Processor.py
@@ -865,7 +865,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_generation(self) -> None:
"""Generate audio from text."""
# Import ShutdownHandler dynamically to avoid circular import
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
# Mock the export to write test audio data
def mock_export(buffer: io.BytesIO, **_kwargs: typing.Any) -> None:
@@ -901,7 +901,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_chunking(self) -> None:
"""Handle long articles with chunking."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
long_text = "Long content " * 1000
chunks = ["Chunk 1", "Chunk 2", "Chunk 3"]
@@ -945,7 +945,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_empty_text(self) -> None:
"""Handle empty input."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
with unittest.mock.patch(
"Biz.PodcastItLater.Worker.TextProcessing.prepare_text_for_tts",
@@ -960,7 +960,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_special_characters(self) -> None:
"""Handle unicode and special chars."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
special_text = 'Unicode: 你好世界 Émojis: 🎙️📰 Special: <>&"'
@@ -1029,7 +1029,7 @@ class TestTextToSpeech(Test.TestCase):
def test_chunk_concatenation(self) -> None:
"""Verify audio joining."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
# Mock multiple audio segments
chunks = ["Chunk 1", "Chunk 2"]
@@ -1069,7 +1069,7 @@ class TestTextToSpeech(Test.TestCase):
def test_parallel_tts_generation(self) -> None:
"""Test parallel TTS processing."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["Chunk 1", "Chunk 2", "Chunk 3", "Chunk 4"]
@@ -1128,7 +1128,7 @@ class TestTextToSpeech(Test.TestCase):
def test_parallel_tts_high_memory_fallback(self) -> None:
"""Test fallback to serial processing when memory is high."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["Chunk 1", "Chunk 2"]
@@ -1171,7 +1171,7 @@ class TestTextToSpeech(Test.TestCase):
@staticmethod
def test_parallel_tts_error_handling() -> None:
"""Test error handling in parallel TTS processing."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["Chunk 1", "Chunk 2"]
@@ -1208,7 +1208,7 @@ class TestTextToSpeech(Test.TestCase):
def test_parallel_tts_order_preservation(self) -> None:
"""Test that chunks are combined in the correct order."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["First", "Second", "Third", "Fourth", "Fifth"]