diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-16 08:06:09 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-16 08:06:09 -0500 |
| commit | a7dcb30c7a465d9fce72b7fc3e605470b2b59814 (patch) | |
| tree | 57a6436de34062773483dbd0cb745ac103c6bb48 /Biz | |
| parent | 4caefe45756fdc21df990b8d6e826c40db1b9c78 (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-x | Biz/EmailAgent.py | 7 | ||||
| -rw-r--r-- | Biz/Packages.nix | 5 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Core.py (renamed from Biz/PodcastItLater/Admin.py) | 0 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 2 | ||||
| -rw-r--r-- | Biz/PodcastItLater/INFRASTRUCTURE.md | 46 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 4 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.nix | 4 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 13 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Worker/Core.py (renamed from Biz/PodcastItLater/Worker.py) | 5 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Worker/Jobs.py | 2 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Worker/Processor.py | 18 |
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"] |
