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
|
@@ -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}")
|