sum-cli 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. sum/__init__.py +1 -0
  2. sum/boilerplate/.env.example +124 -0
  3. sum/boilerplate/.gitea/workflows/ci.yml +33 -0
  4. sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
  5. sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
  6. sum/boilerplate/.github/workflows/ci.yml +36 -0
  7. sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
  8. sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
  9. sum/boilerplate/.gitignore +45 -0
  10. sum/boilerplate/README.md +259 -0
  11. sum/boilerplate/manage.py +34 -0
  12. sum/boilerplate/project_name/__init__.py +5 -0
  13. sum/boilerplate/project_name/home/__init__.py +5 -0
  14. sum/boilerplate/project_name/home/apps.py +20 -0
  15. sum/boilerplate/project_name/home/management/__init__.py +0 -0
  16. sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
  17. sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
  18. sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
  19. sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
  20. sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
  21. sum/boilerplate/project_name/home/models.py +13 -0
  22. sum/boilerplate/project_name/settings/__init__.py +5 -0
  23. sum/boilerplate/project_name/settings/base.py +348 -0
  24. sum/boilerplate/project_name/settings/local.py +78 -0
  25. sum/boilerplate/project_name/settings/production.py +106 -0
  26. sum/boilerplate/project_name/urls.py +33 -0
  27. sum/boilerplate/project_name/wsgi.py +16 -0
  28. sum/boilerplate/pytest.ini +5 -0
  29. sum/boilerplate/requirements.txt +25 -0
  30. sum/boilerplate/static/client/.gitkeep +3 -0
  31. sum/boilerplate/templates/overrides/.gitkeep +3 -0
  32. sum/boilerplate/tests/__init__.py +3 -0
  33. sum/boilerplate/tests/test_health.py +51 -0
  34. sum/cli.py +42 -0
  35. sum/commands/__init__.py +10 -0
  36. sum/commands/backup.py +308 -0
  37. sum/commands/check.py +128 -0
  38. sum/commands/init.py +265 -0
  39. sum/commands/promote.py +758 -0
  40. sum/commands/run.py +96 -0
  41. sum/commands/themes.py +56 -0
  42. sum/commands/update.py +301 -0
  43. sum/config.py +61 -0
  44. sum/docs/USER_GUIDE.md +663 -0
  45. sum/exceptions.py +45 -0
  46. sum/setup/__init__.py +17 -0
  47. sum/setup/auth.py +184 -0
  48. sum/setup/database.py +58 -0
  49. sum/setup/deps.py +73 -0
  50. sum/setup/git_ops.py +463 -0
  51. sum/setup/infrastructure.py +576 -0
  52. sum/setup/orchestrator.py +354 -0
  53. sum/setup/remote_themes.py +371 -0
  54. sum/setup/scaffold.py +500 -0
  55. sum/setup/seed.py +110 -0
  56. sum/setup/site_orchestrator.py +441 -0
  57. sum/setup/venv.py +89 -0
  58. sum/system_config.py +330 -0
  59. sum/themes_registry.py +180 -0
  60. sum/utils/__init__.py +25 -0
  61. sum/utils/django.py +97 -0
  62. sum/utils/environment.py +76 -0
  63. sum/utils/output.py +78 -0
  64. sum/utils/project.py +110 -0
  65. sum/utils/prompts.py +36 -0
  66. sum/utils/validation.py +313 -0
  67. sum_cli-3.0.0.dist-info/METADATA +127 -0
  68. sum_cli-3.0.0.dist-info/RECORD +72 -0
  69. sum_cli-3.0.0.dist-info/WHEEL +5 -0
  70. sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
  71. sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
  72. sum_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,576 @@
1
+ """Infrastructure provisioning for SUM Platform sites.
2
+
3
+ Handles Postgres database creation, systemd service installation,
4
+ Caddy configuration, and directory structure setup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import pwd
11
+ import secrets
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from sum.exceptions import SetupError
18
+ from sum.system_config import SystemConfig, get_system_config
19
+ from sum.utils.output import OutputFormatter
20
+
21
+
22
+ def generate_password(length: int = 32) -> str:
23
+ """Generate a cryptographically secure password.
24
+
25
+ Uses secrets.token_urlsafe() for strong entropy (~6 bits per character).
26
+ """
27
+ # token_urlsafe(n) returns ~4/3 * n characters; request enough to slice
28
+ return secrets.token_urlsafe(length)[:length]
29
+
30
+
31
+ def generate_secret_key(length: int = 50) -> str:
32
+ """Generate a Django SECRET_KEY with strong entropy.
33
+
34
+ Django recommends at least 50 characters. Uses secrets.token_urlsafe()
35
+ for high-entropy, URL-safe output.
36
+ """
37
+ return secrets.token_urlsafe(length)[:length]
38
+
39
+
40
+ @dataclass
41
+ class InfrastructureCheck:
42
+ """Result of infrastructure prerequisites check."""
43
+
44
+ has_sudo: bool
45
+ has_postgres: bool
46
+ has_gh_cli: bool
47
+ has_git: bool
48
+ has_systemctl: bool
49
+ has_deploy_user: bool = True
50
+ errors: list[str] = field(default_factory=list)
51
+
52
+ @property
53
+ def can_proceed(self) -> bool:
54
+ """Whether we have minimum requirements to proceed."""
55
+ return (
56
+ self.has_sudo
57
+ and self.has_postgres
58
+ and self.has_git
59
+ and self.has_deploy_user
60
+ )
61
+
62
+ def require_all(self) -> None:
63
+ """Raise SetupError if any critical prerequisites are missing."""
64
+ if self.errors:
65
+ raise SetupError(
66
+ "Infrastructure prerequisites not met:\n - "
67
+ + "\n - ".join(self.errors)
68
+ )
69
+
70
+
71
+ def check_infrastructure() -> InfrastructureCheck:
72
+ """Check all infrastructure prerequisites."""
73
+ errors: list[str] = []
74
+
75
+ # Check if running as root or with sudo capability
76
+ has_sudo = os.geteuid() == 0
77
+ if not has_sudo:
78
+ errors.append(
79
+ "This command requires root privileges. Run with: sudo sum-platform init ..."
80
+ )
81
+
82
+ # Check postgres
83
+ has_postgres = shutil.which("psql") is not None
84
+ if not has_postgres:
85
+ errors.append("PostgreSQL client (psql) not found in PATH")
86
+
87
+ # Check git
88
+ has_git = shutil.which("git") is not None
89
+ if not has_git:
90
+ errors.append("git not found in PATH")
91
+
92
+ # Check gh CLI (optional but noted)
93
+ has_gh_cli = shutil.which("gh") is not None
94
+
95
+ # Check systemctl
96
+ has_systemctl = shutil.which("systemctl") is not None
97
+ if not has_systemctl:
98
+ errors.append("systemctl not found - systemd service installation will fail")
99
+
100
+ # Check deploy user exists (required for systemd service)
101
+ has_deploy_user = _check_user_exists("deploy")
102
+ if not has_deploy_user:
103
+ errors.append(
104
+ "Deploy user 'deploy' not found. Create with: "
105
+ "sudo useradd -r -s /bin/false -d /srv/sum deploy && "
106
+ "sudo usermod -aG www-data deploy"
107
+ )
108
+
109
+ return InfrastructureCheck(
110
+ has_sudo=has_sudo,
111
+ has_postgres=has_postgres,
112
+ has_gh_cli=has_gh_cli,
113
+ has_git=has_git,
114
+ has_systemctl=has_systemctl,
115
+ has_deploy_user=has_deploy_user,
116
+ errors=errors,
117
+ )
118
+
119
+
120
+ def _check_user_exists(username: str) -> bool:
121
+ """Check if a system user exists."""
122
+ try:
123
+ pwd.getpwnam(username)
124
+ return True
125
+ except KeyError:
126
+ return False
127
+
128
+
129
+ @dataclass
130
+ class SiteCredentials:
131
+ """Credentials generated for a new site."""
132
+
133
+ db_name: str
134
+ db_user: str
135
+ db_password: str
136
+ django_secret_key: str
137
+ superuser_username: str
138
+ superuser_email: str
139
+ superuser_password: str
140
+
141
+
142
+ def generate_site_credentials(
143
+ site_slug: str,
144
+ superuser_username: str = "admin",
145
+ superuser_email: str | None = None,
146
+ ) -> SiteCredentials:
147
+ """Generate all credentials needed for a new site."""
148
+ config = get_system_config()
149
+
150
+ if superuser_email is None:
151
+ domain = config.get_site_domain(site_slug)
152
+ superuser_email = f"admin@{domain}"
153
+
154
+ return SiteCredentials(
155
+ db_name=config.get_db_name(site_slug),
156
+ db_user=config.get_db_user(site_slug),
157
+ db_password=generate_password(),
158
+ django_secret_key=generate_secret_key(),
159
+ superuser_username=superuser_username,
160
+ superuser_email=superuser_email,
161
+ superuser_password=generate_password(16),
162
+ )
163
+
164
+
165
+ def create_site_directories(site_slug: str, config: SystemConfig | None = None) -> Path:
166
+ """Create the directory structure for a new site.
167
+
168
+ Creates:
169
+ /srv/sum/<slug>/
170
+ ├── app/ # Django project (empty, filled by scaffold)
171
+ ├── static/ # collectstatic output
172
+ ├── media/ # uploads
173
+ └── backups/ # DB backups
174
+
175
+ Note: venv/ is created later by VenvManager to avoid empty directory issues.
176
+
177
+ Returns:
178
+ Path to the site root directory.
179
+
180
+ Raises:
181
+ SetupError: If directory already exists or cannot be created.
182
+ """
183
+ if config is None:
184
+ config = get_system_config()
185
+
186
+ site_dir = config.get_site_dir(site_slug)
187
+
188
+ if site_dir.exists():
189
+ raise SetupError(f"Site directory already exists: {site_dir}")
190
+
191
+ try:
192
+ site_dir.mkdir(parents=True, mode=0o755)
193
+ (site_dir / "app").mkdir(mode=0o755)
194
+ # Note: venv/ is created by VenvManager, not here
195
+ (site_dir / "static").mkdir(mode=0o755)
196
+ (site_dir / "media").mkdir(mode=0o755)
197
+ (site_dir / "backups").mkdir(mode=0o755)
198
+ except OSError as exc:
199
+ raise SetupError(f"Failed to create site directories: {exc}") from exc
200
+
201
+ return site_dir
202
+
203
+
204
+ def create_postgres_database(
205
+ credentials: SiteCredentials, config: SystemConfig | None = None
206
+ ) -> None:
207
+ """Create Postgres database and user for the site.
208
+
209
+ Uses peer authentication (running as postgres user via sudo).
210
+ Uses psql variable substitution to avoid SQL injection.
211
+ """
212
+ if config is None:
213
+ config = get_system_config()
214
+
215
+ postgres_port = str(config.defaults.postgres_port)
216
+
217
+ # Use psql variable substitution to safely pass credentials
218
+ # :"var" is substituted as an identifier; :'var' is substituted as a string literal
219
+ create_user_sql = "CREATE USER :\"db_user\" WITH PASSWORD :'db_password';"
220
+ create_db_sql = 'CREATE DATABASE :"db_name" OWNER :"db_user";'
221
+ grant_sql = 'GRANT ALL PRIVILEGES ON DATABASE :"db_name" TO :"db_user";'
222
+
223
+ # Common args for psql with variable bindings and port
224
+ psql_base = ["sudo", "-u", "postgres", "psql", "-p", postgres_port]
225
+ psql_vars = [
226
+ "-v",
227
+ f"db_user={credentials.db_user}",
228
+ "-v",
229
+ f"db_name={credentials.db_name}",
230
+ "-v",
231
+ f"db_password={credentials.db_password}",
232
+ ]
233
+
234
+ try:
235
+ # Run as postgres user, piping SQL via stdin for variable substitution
236
+ # (psql variable substitution with :var only works via stdin, not -c flag)
237
+ for sql in [create_user_sql, create_db_sql, grant_sql]:
238
+ subprocess.run(
239
+ psql_base + psql_vars,
240
+ input=sql,
241
+ check=True,
242
+ capture_output=True,
243
+ text=True,
244
+ )
245
+ except subprocess.CalledProcessError as exc:
246
+ stderr = exc.stderr or ""
247
+ # Check if it's just "already exists" errors
248
+ if "already exists" in stderr:
249
+ OutputFormatter.warning(f"Database or user may already exist: {stderr}")
250
+ else:
251
+ raise SetupError(f"Failed to create Postgres database: {stderr}") from exc
252
+
253
+
254
+ def write_env_file(
255
+ site_dir: Path,
256
+ site_slug: str,
257
+ credentials: SiteCredentials,
258
+ config: SystemConfig | None = None,
259
+ ) -> Path:
260
+ """Generate and write the .env file for the site.
261
+
262
+ Returns:
263
+ Path to the .env file.
264
+ """
265
+ if config is None:
266
+ config = get_system_config()
267
+
268
+ domain = config.get_site_domain(site_slug)
269
+ env_path = site_dir / ".env"
270
+
271
+ # Convert slug to Python package name (replace hyphens with underscores)
272
+ python_package = site_slug.replace("-", "_")
273
+
274
+ postgres_port = config.defaults.postgres_port
275
+
276
+ env_content = f"""# SUM Platform Site Configuration
277
+ # Generated by sum-platform init
278
+
279
+ # Django
280
+ DJANGO_SECRET_KEY={credentials.django_secret_key}
281
+ DJANGO_SETTINGS_MODULE={python_package}.settings.production
282
+ DJANGO_DEBUG=False
283
+
284
+ # Database
285
+ DJANGO_DB_NAME={credentials.db_name}
286
+ DJANGO_DB_USER={credentials.db_user}
287
+ DJANGO_DB_PASSWORD={credentials.db_password}
288
+ DJANGO_DB_HOST=localhost
289
+ DJANGO_DB_PORT={postgres_port}
290
+
291
+ # Paths
292
+ DJANGO_STATIC_ROOT={site_dir}/static
293
+ DJANGO_MEDIA_ROOT={site_dir}/media
294
+
295
+ # Domain
296
+ ALLOWED_HOSTS={domain}
297
+ WAGTAILADMIN_BASE_URL=https://{domain}
298
+ """
299
+
300
+ # Write with restricted permissions atomically to avoid race condition
301
+ fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
302
+ try:
303
+ os.write(fd, env_content.encode())
304
+ finally:
305
+ os.close(fd)
306
+
307
+ return env_path
308
+
309
+
310
+ def write_credentials_file(
311
+ site_dir: Path,
312
+ site_slug: str,
313
+ credentials: SiteCredentials,
314
+ config: SystemConfig | None = None,
315
+ ) -> Path:
316
+ """Write credentials to a file for reference.
317
+
318
+ Returns:
319
+ Path to the .credentials file.
320
+ """
321
+ if config is None:
322
+ config = get_system_config()
323
+
324
+ domain = config.get_site_domain(site_slug)
325
+ creds_path = site_dir / ".credentials"
326
+
327
+ content = f"""# SUM Platform Site Credentials
328
+ # Generated by sum-platform init
329
+ # KEEP THIS FILE SECURE
330
+
331
+ Site: {site_slug}
332
+ URL: https://{domain}
333
+ Admin URL: https://{domain}/admin/
334
+
335
+ Database:
336
+ Name: {credentials.db_name}
337
+ User: {credentials.db_user}
338
+ Password: {credentials.db_password}
339
+
340
+ Django Admin:
341
+ Username: {credentials.superuser_username}
342
+ Email: {credentials.superuser_email}
343
+ Password: {credentials.superuser_password}
344
+ """
345
+
346
+ # Write with restricted permissions atomically to avoid race condition
347
+ fd = os.open(creds_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
348
+ try:
349
+ os.write(fd, content.encode())
350
+ finally:
351
+ os.close(fd)
352
+
353
+ return creds_path
354
+
355
+
356
+ def fix_site_ownership(
357
+ site_slug: str,
358
+ config: SystemConfig | None = None,
359
+ ) -> None:
360
+ """Fix ownership of site directories for the deploy user.
361
+
362
+ The site is created as root, but needs to be owned by deploy:www-data
363
+ for gunicorn to run properly.
364
+ """
365
+ if config is None:
366
+ config = get_system_config()
367
+
368
+ site_dir = config.get_site_dir(site_slug)
369
+ deploy_user = config.defaults.deploy_user
370
+
371
+ try:
372
+ # Change ownership of app, static, media directories
373
+ # Keep venv and backups owned by root for security
374
+ for subdir in ["app", "static", "media"]:
375
+ path = site_dir / subdir
376
+ if path.exists():
377
+ subprocess.run(
378
+ ["chown", "-R", f"{deploy_user}:www-data", str(path)],
379
+ check=True,
380
+ capture_output=True,
381
+ )
382
+ except subprocess.CalledProcessError as exc:
383
+ raise SetupError(f"Failed to fix site ownership: {exc.stderr}") from exc
384
+
385
+
386
+ def install_systemd_service(
387
+ site_slug: str,
388
+ config: SystemConfig | None = None,
389
+ ) -> None:
390
+ """Install systemd service for the site from template."""
391
+ if config is None:
392
+ config = get_system_config()
393
+
394
+ template_path = config.templates.systemd_path
395
+ if not template_path.exists():
396
+ raise SetupError(f"Systemd template not found: {template_path}")
397
+
398
+ template_content = template_path.read_text()
399
+
400
+ site_dir = config.get_site_dir(site_slug)
401
+ python_package = site_slug.replace("-", "_")
402
+
403
+ # Replace placeholders
404
+ service_content = template_content.replace("__SITE_SLUG__", site_slug)
405
+ service_content = service_content.replace(
406
+ "__DEPLOY_USER__", config.defaults.deploy_user
407
+ )
408
+ service_content = service_content.replace("__PROJECT_MODULE__", python_package)
409
+ service_content = service_content.replace("__SITE_DIR__", str(site_dir))
410
+
411
+ # Write service file
412
+ service_name = config.get_systemd_service_name(site_slug)
413
+ service_path = Path(f"/etc/systemd/system/{service_name}.service")
414
+
415
+ try:
416
+ service_path.write_text(service_content)
417
+
418
+ # Reload systemd and enable service
419
+ subprocess.run(["systemctl", "daemon-reload"], check=True, capture_output=True)
420
+ subprocess.run(
421
+ ["systemctl", "enable", service_name],
422
+ check=True,
423
+ capture_output=True,
424
+ )
425
+ except (OSError, subprocess.CalledProcessError) as exc:
426
+ raise SetupError(f"Failed to install systemd service: {exc}") from exc
427
+
428
+
429
+ def configure_caddy(
430
+ site_slug: str,
431
+ config: SystemConfig | None = None,
432
+ ) -> None:
433
+ """Configure Caddy reverse proxy for the site from template."""
434
+ if config is None:
435
+ config = get_system_config()
436
+
437
+ template_path = config.templates.caddy_path
438
+ if not template_path.exists():
439
+ raise SetupError(f"Caddy template not found: {template_path}")
440
+
441
+ template_content = template_path.read_text()
442
+
443
+ site_dir = config.get_site_dir(site_slug)
444
+ domain = config.get_site_domain(site_slug)
445
+
446
+ # Replace placeholders
447
+ caddy_content = template_content.replace("__SITE_SLUG__", site_slug)
448
+ caddy_content = caddy_content.replace("__DOMAIN__", domain)
449
+ caddy_content = caddy_content.replace("__SITE_DIR__", str(site_dir))
450
+
451
+ # Write Caddy config
452
+ config_name = config.get_caddy_config_name(site_slug)
453
+ caddy_dir = Path("/etc/caddy/sites-enabled")
454
+ caddy_dir.mkdir(parents=True, exist_ok=True)
455
+ caddy_path = caddy_dir / config_name
456
+
457
+ try:
458
+ caddy_path.write_text(caddy_content)
459
+
460
+ # Reload Caddy
461
+ subprocess.run(
462
+ ["systemctl", "reload", "caddy"],
463
+ check=True,
464
+ capture_output=True,
465
+ )
466
+ except (OSError, subprocess.CalledProcessError) as exc:
467
+ raise SetupError(f"Failed to configure Caddy: {exc}") from exc
468
+
469
+
470
+ def start_site_service(site_slug: str, config: SystemConfig | None = None) -> None:
471
+ """Start the systemd service for the site."""
472
+ if config is None:
473
+ config = get_system_config()
474
+
475
+ service_name = config.get_systemd_service_name(site_slug)
476
+
477
+ try:
478
+ subprocess.run(
479
+ ["systemctl", "start", service_name],
480
+ check=True,
481
+ capture_output=True,
482
+ )
483
+ except subprocess.CalledProcessError as exc:
484
+ raise SetupError(
485
+ f"Failed to start service {service_name}: {exc.stderr}"
486
+ ) from exc
487
+
488
+
489
+ def cleanup_site(site_slug: str, config: SystemConfig | None = None) -> None:
490
+ """Clean up a partially created site on failure.
491
+
492
+ Best effort cleanup - logs errors but doesn't raise.
493
+ """
494
+ if config is None:
495
+ config = get_system_config()
496
+
497
+ site_dir = config.get_site_dir(site_slug)
498
+ service_name = config.get_systemd_service_name(site_slug)
499
+ caddy_config = config.get_caddy_config_name(site_slug)
500
+ db_name = config.get_db_name(site_slug)
501
+ db_user = config.get_db_user(site_slug)
502
+
503
+ # Stop and disable service
504
+ try:
505
+ subprocess.run(
506
+ ["systemctl", "stop", service_name],
507
+ capture_output=True,
508
+ )
509
+ subprocess.run(
510
+ ["systemctl", "disable", service_name],
511
+ capture_output=True,
512
+ )
513
+ service_path = Path(f"/etc/systemd/system/{service_name}.service")
514
+ if service_path.exists():
515
+ service_path.unlink()
516
+ subprocess.run(["systemctl", "daemon-reload"], capture_output=True)
517
+ except Exception as exc:
518
+ # Best-effort cleanup: log but don't fail if service cleanup fails
519
+ OutputFormatter.warning(f"Cleanup: failed to remove systemd service: {exc}")
520
+
521
+ # Remove Caddy config
522
+ try:
523
+ caddy_path = Path(f"/etc/caddy/sites-enabled/{caddy_config}")
524
+ if caddy_path.exists():
525
+ caddy_path.unlink()
526
+ subprocess.run(["systemctl", "reload", "caddy"], capture_output=True)
527
+ except Exception as exc:
528
+ # Best-effort cleanup: log but don't fail if Caddy cleanup fails
529
+ OutputFormatter.warning(f"Cleanup: failed to remove Caddy config: {exc}")
530
+
531
+ # Drop database and user (use quoted identifiers for names with hyphens)
532
+ postgres_port = str(config.defaults.postgres_port)
533
+ try:
534
+ subprocess.run(
535
+ [
536
+ "sudo",
537
+ "-u",
538
+ "postgres",
539
+ "psql",
540
+ "-p",
541
+ postgres_port,
542
+ "-c",
543
+ f'DROP DATABASE IF EXISTS "{db_name}";',
544
+ ],
545
+ capture_output=True,
546
+ )
547
+ subprocess.run(
548
+ [
549
+ "sudo",
550
+ "-u",
551
+ "postgres",
552
+ "psql",
553
+ "-p",
554
+ postgres_port,
555
+ "-c",
556
+ f'DROP USER IF EXISTS "{db_user}";',
557
+ ],
558
+ capture_output=True,
559
+ )
560
+ except Exception as exc:
561
+ # Best-effort cleanup: log but don't fail if database cleanup fails
562
+ OutputFormatter.warning(f"Cleanup: failed to drop database/user: {exc}")
563
+
564
+ # Remove site directory with safety validation
565
+ try:
566
+ resolved_site_dir = site_dir.resolve()
567
+ # Safety checks to avoid deleting unintended paths
568
+ if site_slug not in resolved_site_dir.parts or len(resolved_site_dir.parts) < 3:
569
+ OutputFormatter.warning(
570
+ f"Cleanup: refusing to remove unsafe path: {resolved_site_dir}"
571
+ )
572
+ elif resolved_site_dir.exists():
573
+ shutil.rmtree(resolved_site_dir)
574
+ except Exception as exc:
575
+ # Best-effort cleanup: log but don't fail if directory removal fails
576
+ OutputFormatter.warning(f"Cleanup: failed to remove site directory: {exc}")