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,758 @@
1
+ # pyright: reportImplicitStringConcatenation=false, reportUnusedCallResult=false
2
+
3
+ """Promote command implementation.
4
+
5
+ Promotes a staging site to production:
6
+ - Backup staging DB
7
+ - Copy backup to production
8
+ - Provision infrastructure on production
9
+ - Clone repo, restore DB, sync media
10
+ - Start service and verify
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shlex
16
+ import subprocess
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+
21
+ from sum.exceptions import SetupError
22
+ from sum.setup.git_ops import get_git_provider
23
+ from sum.setup.infrastructure import generate_password, generate_secret_key
24
+ from sum.system_config import ConfigurationError, SystemConfig, get_system_config
25
+ from sum.utils.output import OutputFormatter
26
+
27
+ click_module: ModuleType | None
28
+ try:
29
+ import click as click_module
30
+ except ImportError: # pragma: no cover
31
+ click_module = None
32
+
33
+ click: ModuleType | None = click_module
34
+
35
+
36
+ def _backup_staging_db(site_slug: str, staging_dir: Path, config: SystemConfig) -> Path:
37
+ """Create a backup of the staging database."""
38
+ db_name = config.get_db_name(site_slug)
39
+ backup_dir = staging_dir / "backups"
40
+ backup_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
43
+ backup_filename = f"{site_slug}_{timestamp}_promote.sql.gz"
44
+ backup_path = backup_dir / backup_filename
45
+
46
+ try:
47
+ with open(backup_path, "wb") as f:
48
+ pg_dump = subprocess.Popen(
49
+ ["sudo", "-u", "postgres", "pg_dump", db_name],
50
+ stdout=subprocess.PIPE,
51
+ stderr=subprocess.PIPE,
52
+ )
53
+ gzip = subprocess.Popen(
54
+ ["gzip"],
55
+ stdin=pg_dump.stdout,
56
+ stdout=f,
57
+ stderr=subprocess.PIPE,
58
+ )
59
+ if pg_dump.stdout:
60
+ pg_dump.stdout.close()
61
+
62
+ gzip.communicate()
63
+ pg_dump.wait()
64
+
65
+ if pg_dump.returncode != 0:
66
+ stderr = pg_dump.stderr.read() if pg_dump.stderr else b""
67
+ raise SetupError(f"pg_dump failed: {stderr.decode()}")
68
+
69
+ except OSError as exc:
70
+ raise SetupError(f"Failed to create backup: {exc}") from exc
71
+
72
+ return backup_path
73
+
74
+
75
+ def _copy_backup_to_prod(backup_path: Path, config: SystemConfig) -> str:
76
+ """Copy backup file to production server."""
77
+ ssh_host = config.production.ssh_host
78
+ remote_path = f"/tmp/{backup_path.name}"
79
+
80
+ try:
81
+ subprocess.run(
82
+ ["scp", str(backup_path), f"{ssh_host}:{remote_path}"],
83
+ check=True,
84
+ capture_output=True,
85
+ timeout=300,
86
+ )
87
+ except subprocess.CalledProcessError as exc:
88
+ raise SetupError(f"Failed to copy backup to production: {exc.stderr}") from exc
89
+
90
+ return remote_path
91
+
92
+
93
+ def _provision_prod_infrastructure(
94
+ site_slug: str,
95
+ domain: str,
96
+ config: SystemConfig,
97
+ ) -> dict[str, str]:
98
+ """Provision infrastructure on production server via SSH."""
99
+ ssh_host = config.production.ssh_host
100
+ site_dir = config.get_site_dir(site_slug, target="prod")
101
+ db_name = config.get_db_name(site_slug)
102
+ db_user = config.get_db_user(site_slug)
103
+ db_password = generate_password()
104
+ secret_key = generate_secret_key()
105
+ python_package = site_slug.replace("-", "_")
106
+
107
+ credentials = {
108
+ "db_name": db_name,
109
+ "db_user": db_user,
110
+ "db_password": db_password,
111
+ "secret_key": secret_key,
112
+ }
113
+
114
+ # Create directories
115
+ try:
116
+ subprocess.run(
117
+ [
118
+ "ssh",
119
+ ssh_host,
120
+ f"mkdir -p {shlex.quote(str(site_dir))}/{{app,venv,static,media,backups}}",
121
+ ],
122
+ check=True,
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=60,
126
+ )
127
+ except subprocess.CalledProcessError as exc:
128
+ raise SetupError(f"Failed to create directories: {exc.stderr}") from exc
129
+
130
+ # Create database user and database using psql variable substitution
131
+ # Variables must be passed via stdin for psql to interpret them correctly
132
+ # (psql -v only works with stdin input, not with -c flag)
133
+ sql_commands = [
134
+ ("CREATE USER :\"db_user\" WITH PASSWORD :'db_password';", "create user"),
135
+ ('CREATE DATABASE :"db_name" OWNER :"db_user";', "create database"),
136
+ (
137
+ 'GRANT ALL PRIVILEGES ON DATABASE :"db_name" TO :"db_user";',
138
+ "grant privileges",
139
+ ),
140
+ ]
141
+
142
+ for sql, description in sql_commands:
143
+ try:
144
+ # Pass SQL via stdin for variable substitution to work
145
+ # Build the remote command as a single string for SSH
146
+ psql_cmd = (
147
+ f"sudo -u postgres psql "
148
+ f"-v db_user={shlex.quote(db_user)} "
149
+ f"-v db_name={shlex.quote(db_name)} "
150
+ f"-v db_password={shlex.quote(db_password)}"
151
+ )
152
+ subprocess.run(
153
+ ["ssh", ssh_host, psql_cmd],
154
+ input=sql,
155
+ check=True,
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=60,
159
+ )
160
+ except subprocess.CalledProcessError as exc:
161
+ # Ignore "already exists" errors
162
+ if "already exists" not in (exc.stderr or ""):
163
+ raise SetupError(f"Failed to {description}: {exc.stderr}") from exc
164
+
165
+ # Write .env file
166
+ env_content = f"""# SUM Platform Site Configuration
167
+ # Generated by sum-platform promote
168
+
169
+ DJANGO_SECRET_KEY={secret_key}
170
+ DJANGO_SETTINGS_MODULE={python_package}.settings.production
171
+ DJANGO_DEBUG=False
172
+
173
+ DJANGO_DB_NAME={db_name}
174
+ DJANGO_DB_USER={db_user}
175
+ DJANGO_DB_PASSWORD={db_password}
176
+ DJANGO_DB_HOST=localhost
177
+ DJANGO_DB_PORT=5432
178
+
179
+ DJANGO_STATIC_ROOT={site_dir}/static
180
+ DJANGO_MEDIA_ROOT={site_dir}/media
181
+
182
+ ALLOWED_HOSTS={domain}
183
+ WAGTAILADMIN_BASE_URL=https://{domain}
184
+ """
185
+
186
+ # Write env file via SSH using tee to avoid shell interpretation
187
+ env_path = shlex.quote(f"{site_dir}/.env")
188
+ try:
189
+ subprocess.run(
190
+ ["ssh", ssh_host, "tee", env_path],
191
+ check=True,
192
+ capture_output=True,
193
+ text=True,
194
+ input=env_content,
195
+ timeout=60,
196
+ )
197
+ subprocess.run(
198
+ ["ssh", ssh_host, "chmod", "600", env_path],
199
+ check=True,
200
+ capture_output=True,
201
+ timeout=60,
202
+ )
203
+ except subprocess.CalledProcessError as exc:
204
+ raise SetupError(f"Failed to write .env: {exc.stderr}") from exc
205
+
206
+ return credentials
207
+
208
+
209
+ def _clone_repo_on_prod(site_slug: str, config: SystemConfig) -> None:
210
+ """Clone the site repository on production.
211
+
212
+ Uses HTTPS with token to avoid SSH key requirements on production.
213
+ Falls back to SSH if token is not available.
214
+ """
215
+ ssh_host = config.production.ssh_host
216
+ site_dir = config.get_site_dir(site_slug, target="prod")
217
+ provider = get_git_provider(config.agency)
218
+ org = config.agency.org
219
+
220
+ q_app_dir = shlex.quote(f"{site_dir}/app")
221
+ q_site_env = shlex.quote(f"{site_dir}/.env")
222
+ q_app_env = shlex.quote(f"{site_dir}/app/.env")
223
+
224
+ # Get clone URL from provider (SSH format)
225
+ ssh_clone_url = provider.get_clone_url(org, site_slug)
226
+
227
+ # Try to get token for HTTPS clone (avoids SSH key setup on prod)
228
+ repo_url = ssh_clone_url # Default to SSH
229
+ if config.agency.git_provider == "github":
230
+ # Try GitHub CLI token
231
+ try:
232
+ result = subprocess.run(
233
+ ["gh", "auth", "token"],
234
+ capture_output=True,
235
+ text=True,
236
+ timeout=10,
237
+ )
238
+ if result.returncode == 0:
239
+ gh_token = result.stdout.strip()
240
+ repo_url = f"https://{gh_token}@github.com/{org}/{site_slug}.git"
241
+ except (subprocess.SubprocessError, FileNotFoundError):
242
+ pass
243
+ elif config.agency.git_provider == "gitea":
244
+ # Try Gitea token from environment
245
+ import os
246
+ from urllib.parse import urlparse
247
+
248
+ gitea_token = os.environ.get(config.agency.gitea_token_env)
249
+ if gitea_token and config.agency.gitea_url:
250
+ parsed = urlparse(config.agency.gitea_url)
251
+ repo_url = f"https://{gitea_token}@{parsed.netloc}/{org}/{site_slug}.git"
252
+
253
+ cmd = f"git clone {shlex.quote(repo_url)} {q_app_dir}"
254
+
255
+ try:
256
+ subprocess.run(
257
+ ["ssh", ssh_host, cmd],
258
+ check=True,
259
+ capture_output=True,
260
+ text=True,
261
+ timeout=300,
262
+ )
263
+ except subprocess.CalledProcessError as exc:
264
+ raise SetupError(f"Failed to clone repository: {exc.stderr}") from exc
265
+
266
+ # Copy production .env to app directory (overwrite staging .env from repo)
267
+ try:
268
+ subprocess.run(
269
+ ["ssh", ssh_host, f"cp {q_site_env} {q_app_env}"],
270
+ check=True,
271
+ capture_output=True,
272
+ text=True,
273
+ timeout=30,
274
+ )
275
+ except subprocess.CalledProcessError as exc:
276
+ raise SetupError(f"Failed to copy .env to app: {exc.stderr}") from exc
277
+
278
+
279
+ def _setup_venv_on_prod(site_slug: str, config: SystemConfig) -> None:
280
+ """Create venv and install dependencies on production."""
281
+ ssh_host = config.production.ssh_host
282
+ site_dir = config.get_site_dir(site_slug, target="prod")
283
+
284
+ q_venv = shlex.quote(f"{site_dir}/venv")
285
+ q_pip = shlex.quote(f"{site_dir}/venv/bin/pip")
286
+ q_requirements = shlex.quote(f"{site_dir}/app/requirements.txt")
287
+ commands = [
288
+ f"python3 -m venv {q_venv}",
289
+ f"{q_pip} install -r {q_requirements} -q",
290
+ ]
291
+
292
+ for cmd in commands:
293
+ try:
294
+ subprocess.run(
295
+ ["ssh", ssh_host, cmd],
296
+ check=True,
297
+ capture_output=True,
298
+ text=True,
299
+ timeout=600,
300
+ )
301
+ except subprocess.CalledProcessError as exc:
302
+ raise SetupError(f"Failed to setup venv: {exc.stderr}") from exc
303
+
304
+
305
+ def _restore_db_on_prod(
306
+ site_slug: str,
307
+ remote_backup_path: str,
308
+ config: SystemConfig,
309
+ ) -> None:
310
+ """Restore database from backup on production."""
311
+ ssh_host = config.production.ssh_host
312
+ db_name = config.get_db_name(site_slug)
313
+
314
+ # Quote paths to prevent shell injection
315
+ q_backup_path = shlex.quote(remote_backup_path)
316
+ q_db_name = shlex.quote(db_name)
317
+ cmd = f"gunzip -c {q_backup_path} | sudo -u postgres psql {q_db_name}"
318
+
319
+ try:
320
+ subprocess.run(
321
+ ["ssh", ssh_host, cmd],
322
+ check=True,
323
+ capture_output=True,
324
+ text=True,
325
+ timeout=600,
326
+ )
327
+ except subprocess.CalledProcessError as exc:
328
+ raise SetupError(f"Failed to restore database: {exc.stderr}") from exc
329
+
330
+
331
+ def _sync_media_to_prod(site_slug: str, config: SystemConfig) -> None:
332
+ """Sync media files from staging to production."""
333
+ ssh_host = config.production.ssh_host
334
+ staging_dir = config.get_site_dir(site_slug, target="staging")
335
+ prod_dir = config.get_site_dir(site_slug, target="prod")
336
+
337
+ staging_media = staging_dir / "media"
338
+ if not staging_media.exists():
339
+ OutputFormatter.warning("No media directory on staging, skipping sync")
340
+ return
341
+
342
+ try:
343
+ subprocess.run(
344
+ [
345
+ "rsync",
346
+ "-avz",
347
+ "--delete",
348
+ f"{staging_media}/",
349
+ f"{ssh_host}:{prod_dir}/media/",
350
+ ],
351
+ check=True,
352
+ capture_output=True,
353
+ timeout=600,
354
+ )
355
+ except subprocess.CalledProcessError as exc:
356
+ raise SetupError(f"Failed to sync media: {exc.stderr}") from exc
357
+
358
+
359
+ def _run_django_setup_on_prod(site_slug: str, config: SystemConfig) -> None:
360
+ """Run Django migrations and collectstatic on production."""
361
+ ssh_host = config.production.ssh_host
362
+ site_dir = config.get_site_dir(site_slug, target="prod")
363
+
364
+ q_app_dir = shlex.quote(f"{site_dir}/app")
365
+ q_venv_python = shlex.quote(f"{site_dir}/venv/bin/python")
366
+ commands = [
367
+ f"cd {q_app_dir} && {q_venv_python} manage.py migrate --noinput",
368
+ f"cd {q_app_dir} && {q_venv_python} manage.py collectstatic --noinput",
369
+ ]
370
+
371
+ for cmd in commands:
372
+ try:
373
+ subprocess.run(
374
+ ["ssh", ssh_host, cmd],
375
+ check=True,
376
+ capture_output=True,
377
+ text=True,
378
+ timeout=300,
379
+ )
380
+ except subprocess.CalledProcessError as exc:
381
+ raise SetupError(f"Django setup failed: {exc.stderr}") from exc
382
+
383
+
384
+ def _install_systemd_on_prod(site_slug: str, domain: str, config: SystemConfig) -> None:
385
+ """Install systemd service on production."""
386
+ ssh_host = config.production.ssh_host
387
+ site_dir = config.get_site_dir(site_slug, target="prod")
388
+ service_name = config.get_systemd_service_name(site_slug)
389
+ python_package = site_slug.replace("-", "_")
390
+ deploy_user = config.defaults.deploy_user
391
+
392
+ # Read local template and substitute
393
+ template_path = config.templates.systemd_path
394
+ if not template_path.exists():
395
+ raise SetupError(f"Systemd template not found: {template_path}")
396
+
397
+ template_content = template_path.read_text()
398
+ service_content = template_content.replace("__SITE_SLUG__", site_slug)
399
+ service_content = service_content.replace("__DEPLOY_USER__", deploy_user)
400
+ service_content = service_content.replace("__PROJECT_MODULE__", python_package)
401
+ service_content = service_content.replace("__SITE_DIR__", str(site_dir))
402
+
403
+ # Write service file via SSH using sudo tee to avoid shell interpretation
404
+ service_path = f"/etc/systemd/system/{service_name}.service"
405
+ try:
406
+ subprocess.run(
407
+ ["ssh", ssh_host, "sudo", "tee", service_path],
408
+ check=True,
409
+ capture_output=True,
410
+ text=True,
411
+ input=service_content,
412
+ timeout=60,
413
+ )
414
+ subprocess.run(
415
+ ["ssh", ssh_host, "sudo", "systemctl", "daemon-reload"],
416
+ check=True,
417
+ capture_output=True,
418
+ timeout=60,
419
+ )
420
+ subprocess.run(
421
+ ["ssh", ssh_host, "sudo", "systemctl", "enable", service_name],
422
+ check=True,
423
+ capture_output=True,
424
+ timeout=60,
425
+ )
426
+ except subprocess.CalledProcessError as exc:
427
+ raise SetupError(f"Failed to install systemd service: {exc.stderr}") from exc
428
+
429
+
430
+ def _configure_caddy_on_prod(site_slug: str, domain: str, config: SystemConfig) -> None:
431
+ """Configure Caddy on production."""
432
+ ssh_host = config.production.ssh_host
433
+ site_dir = config.get_site_dir(site_slug, target="prod")
434
+ config_name = config.get_caddy_config_name(site_slug)
435
+
436
+ # Read local template and substitute
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
+ caddy_content = template_content.replace("__SITE_SLUG__", site_slug)
443
+ caddy_content = caddy_content.replace("__DOMAIN__", domain)
444
+ caddy_content = caddy_content.replace("__SITE_DIR__", str(site_dir))
445
+
446
+ # Write Caddy config via SSH using sudo tee to avoid shell interpretation
447
+ caddy_path = f"/etc/caddy/sites-enabled/{config_name}"
448
+ try:
449
+ subprocess.run(
450
+ ["ssh", ssh_host, "sudo", "mkdir", "-p", "/etc/caddy/sites-enabled"],
451
+ check=True,
452
+ capture_output=True,
453
+ timeout=60,
454
+ )
455
+ subprocess.run(
456
+ ["ssh", ssh_host, "sudo", "tee", caddy_path],
457
+ check=True,
458
+ capture_output=True,
459
+ text=True,
460
+ input=caddy_content,
461
+ timeout=60,
462
+ )
463
+ subprocess.run(
464
+ ["ssh", ssh_host, "sudo", "systemctl", "reload", "caddy"],
465
+ check=True,
466
+ capture_output=True,
467
+ timeout=60,
468
+ )
469
+ except subprocess.CalledProcessError as exc:
470
+ raise SetupError(f"Failed to configure Caddy: {exc.stderr}") from exc
471
+
472
+
473
+ def _start_service_on_prod(site_slug: str, config: SystemConfig) -> None:
474
+ """Start the site service on production."""
475
+ ssh_host = config.production.ssh_host
476
+ service_name = config.get_systemd_service_name(site_slug)
477
+
478
+ try:
479
+ subprocess.run(
480
+ ["ssh", ssh_host, "sudo", "systemctl", "start", service_name],
481
+ check=True,
482
+ capture_output=True,
483
+ timeout=60,
484
+ )
485
+ except subprocess.CalledProcessError as exc:
486
+ raise SetupError(f"Failed to start service: {exc.stderr}") from exc
487
+
488
+
489
+ def _classify_health_failure(
490
+ domain: str,
491
+ returncode: int | None,
492
+ stderr: str | None,
493
+ ) -> str:
494
+ """Return a human-readable reason for a failed health check."""
495
+ stderr = (stderr or "").strip().lower()
496
+
497
+ if "could not resolve host" in stderr or "name or service not known" in stderr:
498
+ return f"DNS resolution failed for {domain}"
499
+ if "connection refused" in stderr:
500
+ return "Connection refused (service may not be listening)"
501
+ if "ssl certificate problem" in stderr or "tls" in stderr:
502
+ return "SSL/TLS error"
503
+ if "operation timed out" in stderr or "timed out" in stderr:
504
+ return "Timeout after 10 seconds"
505
+ if "server returned nothing" in stderr:
506
+ return "Server returned no data"
507
+ if stderr:
508
+ return f"curl error: {stderr[:100]}"
509
+ return f"Unknown error (exit code {returncode})"
510
+
511
+
512
+ def _verify_health(domain: str) -> bool:
513
+ """Verify the site is responding on production.
514
+
515
+ Returns True if healthy, False otherwise. Logs failure reason.
516
+ """
517
+ try:
518
+ result = subprocess.run(
519
+ ["curl", "-fsS", f"https://{domain}/health/", "--max-time", "10"],
520
+ capture_output=True,
521
+ text=True,
522
+ )
523
+ if result.returncode == 0:
524
+ return True
525
+
526
+ reason = _classify_health_failure(domain, result.returncode, result.stderr)
527
+ OutputFormatter.warning(f"Health check failed: {reason}")
528
+ return False
529
+ except subprocess.SubprocessError as exc:
530
+ OutputFormatter.warning(f"Health check failed: {exc}")
531
+ return False
532
+
533
+
534
+ def run_promote(
535
+ site_name: str,
536
+ *,
537
+ domain: str,
538
+ ) -> int:
539
+ """Promote a staging site to production.
540
+
541
+ Args:
542
+ site_name: Name/slug of the site to promote.
543
+ domain: Production domain for the site.
544
+
545
+ Returns:
546
+ Exit code (0 for success, non-zero for failure).
547
+ """
548
+ try:
549
+ config = get_system_config()
550
+ except ConfigurationError as exc:
551
+ OutputFormatter.error(str(exc))
552
+ return 1
553
+
554
+ staging_dir = config.get_site_dir(site_name, target="staging")
555
+
556
+ # Validate staging site exists
557
+ if not staging_dir.exists():
558
+ OutputFormatter.error(f"Staging site not found: {staging_dir}")
559
+ return 1
560
+
561
+ OutputFormatter.header(f"Promoting {site_name} to production")
562
+ print(f" Domain: {domain}")
563
+ print()
564
+
565
+ total_steps = 11
566
+ current_step = 0
567
+
568
+ try:
569
+ # Step 1: Backup staging DB
570
+ current_step += 1
571
+ OutputFormatter.progress(
572
+ current_step, total_steps, "Backing up staging database", "⏳"
573
+ )
574
+ backup_path = _backup_staging_db(site_name, staging_dir, config)
575
+ OutputFormatter.progress(
576
+ current_step, total_steps, f"Backup created: {backup_path.name}", "✅"
577
+ )
578
+
579
+ # Step 2: Copy backup to production
580
+ current_step += 1
581
+ OutputFormatter.progress(
582
+ current_step, total_steps, "Copying backup to production", "⏳"
583
+ )
584
+ remote_backup = _copy_backup_to_prod(backup_path, config)
585
+ OutputFormatter.progress(
586
+ current_step, total_steps, "Backup copied to production", "✅"
587
+ )
588
+
589
+ # Step 3: Provision infrastructure on production
590
+ current_step += 1
591
+ OutputFormatter.progress(
592
+ current_step, total_steps, "Provisioning production infrastructure", "⏳"
593
+ )
594
+ _provision_prod_infrastructure(site_name, domain, config)
595
+ OutputFormatter.progress(
596
+ current_step, total_steps, "Infrastructure provisioned", "✅"
597
+ )
598
+
599
+ # Step 4: Clone repository on production
600
+ current_step += 1
601
+ OutputFormatter.progress(
602
+ current_step, total_steps, "Cloning repository on production", "⏳"
603
+ )
604
+ _clone_repo_on_prod(site_name, config)
605
+ OutputFormatter.progress(current_step, total_steps, "Repository cloned", "✅")
606
+
607
+ # Step 5: Setup venv on production
608
+ current_step += 1
609
+ OutputFormatter.progress(
610
+ current_step, total_steps, "Setting up virtualenv on production", "⏳"
611
+ )
612
+ _setup_venv_on_prod(site_name, config)
613
+ OutputFormatter.progress(
614
+ current_step, total_steps, "Virtualenv configured", "✅"
615
+ )
616
+
617
+ # Step 6: Restore database
618
+ current_step += 1
619
+ OutputFormatter.progress(
620
+ current_step, total_steps, "Restoring database on production", "⏳"
621
+ )
622
+ _restore_db_on_prod(site_name, remote_backup, config)
623
+ OutputFormatter.progress(current_step, total_steps, "Database restored", "✅")
624
+
625
+ # Step 7: Sync media
626
+ current_step += 1
627
+ OutputFormatter.progress(current_step, total_steps, "Syncing media files", "⏳")
628
+ _sync_media_to_prod(site_name, config)
629
+ OutputFormatter.progress(current_step, total_steps, "Media synced", "✅")
630
+
631
+ # Step 8: Run Django setup
632
+ current_step += 1
633
+ OutputFormatter.progress(
634
+ current_step,
635
+ total_steps,
636
+ "Running Django migrations and collectstatic",
637
+ "⏳",
638
+ )
639
+ _run_django_setup_on_prod(site_name, config)
640
+ OutputFormatter.progress(
641
+ current_step, total_steps, "Django setup complete", "✅"
642
+ )
643
+
644
+ # Step 9: Install systemd service
645
+ current_step += 1
646
+ OutputFormatter.progress(
647
+ current_step, total_steps, "Installing systemd service", "⏳"
648
+ )
649
+ _install_systemd_on_prod(site_name, domain, config)
650
+ OutputFormatter.progress(
651
+ current_step, total_steps, "Systemd service installed", "✅"
652
+ )
653
+
654
+ # Step 10: Configure Caddy
655
+ current_step += 1
656
+ OutputFormatter.progress(current_step, total_steps, "Configuring Caddy", "⏳")
657
+ _configure_caddy_on_prod(site_name, domain, config)
658
+ OutputFormatter.progress(current_step, total_steps, "Caddy configured", "✅")
659
+
660
+ # Step 11: Start service
661
+ current_step += 1
662
+ OutputFormatter.progress(current_step, total_steps, "Starting service", "⏳")
663
+ _start_service_on_prod(site_name, config)
664
+ OutputFormatter.progress(current_step, total_steps, "Service started", "✅")
665
+
666
+ except SetupError as exc:
667
+ OutputFormatter.error(str(exc))
668
+ return 1
669
+
670
+ print()
671
+
672
+ # Verify health (optional, non-fatal)
673
+ OutputFormatter.info("Verifying site health...")
674
+ if _verify_health(domain):
675
+ OutputFormatter.success(f"Site responding at https://{domain}")
676
+ else:
677
+ OutputFormatter.warning(
678
+ f"Site not yet responding at https://{domain}/health/\n"
679
+ "This may be normal if DNS is not yet configured."
680
+ )
681
+
682
+ print()
683
+ OutputFormatter.success("Site promoted to production!")
684
+ print()
685
+ print(f" Production URL: https://{domain}")
686
+ print(f" Admin: https://{domain}/admin/")
687
+ print()
688
+ staging_domain = config.get_site_domain(site_name)
689
+ print(f" Staging still available at: https://{staging_domain}")
690
+ print()
691
+ print(f" Next: Ensure DNS for {domain} points to {config.production.ssh_host}")
692
+ print()
693
+
694
+ return 0
695
+
696
+
697
+ def _promote_command(
698
+ site_name: str,
699
+ domain: str,
700
+ ) -> None:
701
+ """Promote a staging site to production."""
702
+ result = run_promote(
703
+ site_name,
704
+ domain=domain,
705
+ )
706
+ if result != 0:
707
+ raise SystemExit(result)
708
+
709
+
710
+ def _missing_click(*_args: object, **_kwargs: object) -> None:
711
+ raise RuntimeError("click is required to use the promote command")
712
+
713
+
714
+ if click is None:
715
+ promote = _missing_click
716
+ else:
717
+
718
+ @click.command(name="promote")
719
+ @click.argument("site_name")
720
+ @click.option(
721
+ "--domain",
722
+ required=True,
723
+ help="Production domain for the site (e.g., acme-client.com).",
724
+ )
725
+ def _click_promote(
726
+ site_name: str,
727
+ domain: str,
728
+ ) -> None:
729
+ """Promote a staging site to production.
730
+
731
+ Takes a working staging site and deploys it to production with
732
+ a custom domain. Handles database migration, media sync, and
733
+ all infrastructure configuration.
734
+
735
+ \b
736
+ Steps performed:
737
+ 1. Backup staging database
738
+ 2. Copy backup to production
739
+ 3. Provision production infrastructure (DB, dirs, .env)
740
+ 4. Clone repository on production
741
+ 5. Setup venv and install dependencies
742
+ 6. Restore database
743
+ 7. Sync media files
744
+ 8. Run migrations and collectstatic
745
+ 9. Install systemd service
746
+ 10. Configure Caddy reverse proxy
747
+ 11. Start service
748
+
749
+ \b
750
+ Examples:
751
+ sum-platform promote acme --domain acme-client.com
752
+ """
753
+ _promote_command(
754
+ site_name,
755
+ domain=domain,
756
+ )
757
+
758
+ promote = _click_promote