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.
- sum/__init__.py +1 -0
- sum/boilerplate/.env.example +124 -0
- sum/boilerplate/.gitea/workflows/ci.yml +33 -0
- sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
- sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
- sum/boilerplate/.github/workflows/ci.yml +36 -0
- sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
- sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
- sum/boilerplate/.gitignore +45 -0
- sum/boilerplate/README.md +259 -0
- sum/boilerplate/manage.py +34 -0
- sum/boilerplate/project_name/__init__.py +5 -0
- sum/boilerplate/project_name/home/__init__.py +5 -0
- sum/boilerplate/project_name/home/apps.py +20 -0
- sum/boilerplate/project_name/home/management/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
- sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
- sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
- sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
- sum/boilerplate/project_name/home/models.py +13 -0
- sum/boilerplate/project_name/settings/__init__.py +5 -0
- sum/boilerplate/project_name/settings/base.py +348 -0
- sum/boilerplate/project_name/settings/local.py +78 -0
- sum/boilerplate/project_name/settings/production.py +106 -0
- sum/boilerplate/project_name/urls.py +33 -0
- sum/boilerplate/project_name/wsgi.py +16 -0
- sum/boilerplate/pytest.ini +5 -0
- sum/boilerplate/requirements.txt +25 -0
- sum/boilerplate/static/client/.gitkeep +3 -0
- sum/boilerplate/templates/overrides/.gitkeep +3 -0
- sum/boilerplate/tests/__init__.py +3 -0
- sum/boilerplate/tests/test_health.py +51 -0
- sum/cli.py +42 -0
- sum/commands/__init__.py +10 -0
- sum/commands/backup.py +308 -0
- sum/commands/check.py +128 -0
- sum/commands/init.py +265 -0
- sum/commands/promote.py +758 -0
- sum/commands/run.py +96 -0
- sum/commands/themes.py +56 -0
- sum/commands/update.py +301 -0
- sum/config.py +61 -0
- sum/docs/USER_GUIDE.md +663 -0
- sum/exceptions.py +45 -0
- sum/setup/__init__.py +17 -0
- sum/setup/auth.py +184 -0
- sum/setup/database.py +58 -0
- sum/setup/deps.py +73 -0
- sum/setup/git_ops.py +463 -0
- sum/setup/infrastructure.py +576 -0
- sum/setup/orchestrator.py +354 -0
- sum/setup/remote_themes.py +371 -0
- sum/setup/scaffold.py +500 -0
- sum/setup/seed.py +110 -0
- sum/setup/site_orchestrator.py +441 -0
- sum/setup/venv.py +89 -0
- sum/system_config.py +330 -0
- sum/themes_registry.py +180 -0
- sum/utils/__init__.py +25 -0
- sum/utils/django.py +97 -0
- sum/utils/environment.py +76 -0
- sum/utils/output.py +78 -0
- sum/utils/project.py +110 -0
- sum/utils/prompts.py +36 -0
- sum/utils/validation.py +313 -0
- sum_cli-3.0.0.dist-info/METADATA +127 -0
- sum_cli-3.0.0.dist-info/RECORD +72 -0
- sum_cli-3.0.0.dist-info/WHEEL +5 -0
- sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
- sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
- sum_cli-3.0.0.dist-info/top_level.txt +1 -0
sum/commands/promote.py
ADDED
|
@@ -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
|