django-deploy-toolkit 0.1.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.
- django_deploy_toolkit/__init__.py +4 -0
- django_deploy_toolkit/cli.py +409 -0
- django_deploy_toolkit/detector.py +575 -0
- django_deploy_toolkit/generators/__init__.py +1 -0
- django_deploy_toolkit/generators/nginx.py +89 -0
- django_deploy_toolkit/generators/service.py +72 -0
- django_deploy_toolkit/generators/socket.py +54 -0
- django_deploy_toolkit/installer.py +344 -0
- django_deploy_toolkit/reporter.py +215 -0
- django_deploy_toolkit/rollback.py +267 -0
- django_deploy_toolkit/utils.py +180 -0
- django_deploy_toolkit/validators.py +349 -0
- django_deploy_toolkit-0.1.0.dist-info/METADATA +306 -0
- django_deploy_toolkit-0.1.0.dist-info/RECORD +18 -0
- django_deploy_toolkit-0.1.0.dist-info/WHEEL +5 -0
- django_deploy_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- django_deploy_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_deploy_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Click-based CLI for django-deploy-toolkit."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .detector import ProjectDetector
|
|
12
|
+
from .generators.nginx import NginxGenerator
|
|
13
|
+
from .generators.service import ServiceGenerator
|
|
14
|
+
from .generators.socket import SocketGenerator
|
|
15
|
+
from .installer import Installer
|
|
16
|
+
from .reporter import Reporter
|
|
17
|
+
from .rollback import RollbackManager
|
|
18
|
+
from .utils import (
|
|
19
|
+
check_gunicorn_installed,
|
|
20
|
+
check_nginx_installed,
|
|
21
|
+
check_platform,
|
|
22
|
+
check_systemd_available,
|
|
23
|
+
)
|
|
24
|
+
from .validators import ConfigValidator
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _configure_logging(verbose):
|
|
30
|
+
"""Set up logging level based on the verbose flag."""
|
|
31
|
+
log_level = logging.DEBUG if verbose else logging.WARNING
|
|
32
|
+
logging.basicConfig(
|
|
33
|
+
level=log_level,
|
|
34
|
+
format="%(levelname)s [%(name)s] %(message)s",
|
|
35
|
+
force=True,
|
|
36
|
+
)
|
|
37
|
+
logging.getLogger("django_deploy_toolkit").setLevel(log_level)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.group(invoke_without_command=True)
|
|
41
|
+
@click.version_option(version=__version__, prog_name="django-deploy-toolkit")
|
|
42
|
+
@click.option(
|
|
43
|
+
"--project-path",
|
|
44
|
+
type=click.Path(exists=True, file_okay=False),
|
|
45
|
+
default=None,
|
|
46
|
+
help="Override the detected project path.",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--project-name",
|
|
50
|
+
type=str,
|
|
51
|
+
default=None,
|
|
52
|
+
help="Override the detected project name.",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--no-confirm",
|
|
56
|
+
is_flag=True,
|
|
57
|
+
default=False,
|
|
58
|
+
help="Skip the confirmation prompt.",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--verbose/--no-verbose",
|
|
62
|
+
default=False,
|
|
63
|
+
help="Show or hide detailed output.",
|
|
64
|
+
)
|
|
65
|
+
@click.pass_context
|
|
66
|
+
def main(ctx, project_path, project_name, no_confirm, verbose):
|
|
67
|
+
"""django-deploy-toolkit: Auto-generate Gunicorn & Nginx configs for Django projects."""
|
|
68
|
+
_configure_logging(verbose)
|
|
69
|
+
check_platform()
|
|
70
|
+
|
|
71
|
+
ctx.ensure_object(dict)
|
|
72
|
+
ctx.obj["project_path"] = project_path
|
|
73
|
+
ctx.obj["project_name"] = project_name
|
|
74
|
+
ctx.obj["no_confirm"] = no_confirm
|
|
75
|
+
ctx.obj["verbose"] = verbose
|
|
76
|
+
|
|
77
|
+
# If no subcommand, show help
|
|
78
|
+
if ctx.invoked_subcommand is None:
|
|
79
|
+
click.echo(ctx.get_help())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@main.command()
|
|
83
|
+
@click.option(
|
|
84
|
+
"--dry-run",
|
|
85
|
+
is_flag=True,
|
|
86
|
+
default=False,
|
|
87
|
+
help="Show what would be done without actually doing it.",
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--verbose/--no-verbose",
|
|
91
|
+
default=False,
|
|
92
|
+
help="Show or hide detailed output.",
|
|
93
|
+
)
|
|
94
|
+
@click.pass_context
|
|
95
|
+
def setup(ctx, dry_run, verbose):
|
|
96
|
+
"""Full interactive setup: detect, validate, generate, and install."""
|
|
97
|
+
verbose = verbose or ctx.obj.get("verbose", False)
|
|
98
|
+
_configure_logging(verbose)
|
|
99
|
+
project_path = ctx.obj["project_path"]
|
|
100
|
+
project_name = ctx.obj["project_name"]
|
|
101
|
+
no_confirm = ctx.obj["no_confirm"]
|
|
102
|
+
|
|
103
|
+
console.print(
|
|
104
|
+
"\n[bold cyan]🚀 django-deploy-toolkit — Setup[/bold cyan]\n"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# --- Pre-flight checks ---
|
|
108
|
+
warnings = []
|
|
109
|
+
|
|
110
|
+
if not check_systemd_available():
|
|
111
|
+
warnings.append(
|
|
112
|
+
"Systemd not available. Are you running inside Docker? "
|
|
113
|
+
"The setup may not work correctly."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not check_nginx_installed():
|
|
117
|
+
warnings.append(
|
|
118
|
+
"Nginx is not installed. Install it with: sudo apt install nginx"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for warning in warnings:
|
|
122
|
+
console.print(f"[yellow]⚠ {warning}[/yellow]")
|
|
123
|
+
|
|
124
|
+
if warnings and not dry_run:
|
|
125
|
+
if not no_confirm and not click.confirm(
|
|
126
|
+
"\nWarnings detected. Continue anyway?", default=True
|
|
127
|
+
):
|
|
128
|
+
raise SystemExit("Setup cancelled.")
|
|
129
|
+
|
|
130
|
+
# --- Detection ---
|
|
131
|
+
console.print("[bold]Detecting project configuration...[/bold]\n")
|
|
132
|
+
detector = ProjectDetector(project_path=project_path)
|
|
133
|
+
config = detector.detect_all()
|
|
134
|
+
|
|
135
|
+
# Apply overrides
|
|
136
|
+
if project_name:
|
|
137
|
+
config["project_name"] = project_name
|
|
138
|
+
if project_path:
|
|
139
|
+
config["project_path"] = os.path.abspath(project_path)
|
|
140
|
+
|
|
141
|
+
# --- Warnings about detected values ---
|
|
142
|
+
if config.get("python_path"):
|
|
143
|
+
if not detector.is_virtualenv():
|
|
144
|
+
console.print(
|
|
145
|
+
"[yellow]⚠ Python path points to system Python, not a virtualenv. "
|
|
146
|
+
"Consider activating your project's virtual environment.[/yellow]\n"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not check_gunicorn_installed(config["python_path"]):
|
|
150
|
+
console.print(
|
|
151
|
+
"[yellow]⚠ Gunicorn not found in the detected Python environment. "
|
|
152
|
+
"Install it with: pip install gunicorn[/yellow]\n"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# --- Validation ---
|
|
156
|
+
validator = ConfigValidator(config, no_confirm=no_confirm)
|
|
157
|
+
config = validator.validate_and_prompt()
|
|
158
|
+
sources = validator.get_sources()
|
|
159
|
+
|
|
160
|
+
# --- Report ---
|
|
161
|
+
reporter = Reporter(config, sources)
|
|
162
|
+
|
|
163
|
+
if dry_run:
|
|
164
|
+
reporter.print_dry_run_header()
|
|
165
|
+
|
|
166
|
+
reporter.print_settings_table()
|
|
167
|
+
|
|
168
|
+
# --- Installation ---
|
|
169
|
+
installer = Installer(config, dry_run=dry_run, overwrite=False)
|
|
170
|
+
results = installer.install()
|
|
171
|
+
|
|
172
|
+
reporter.print_results_table(results)
|
|
173
|
+
|
|
174
|
+
# Check if any step failed
|
|
175
|
+
failed = [r for r in results if r[1] == "failed"]
|
|
176
|
+
if failed:
|
|
177
|
+
failed_step = failed[0][0]
|
|
178
|
+
error = failed[0][2]
|
|
179
|
+
reporter.print_failure(error, failed_step)
|
|
180
|
+
raise SystemExit(1)
|
|
181
|
+
else:
|
|
182
|
+
if not dry_run:
|
|
183
|
+
reporter.print_success()
|
|
184
|
+
else:
|
|
185
|
+
console.print(
|
|
186
|
+
"[bold yellow]Dry run complete. "
|
|
187
|
+
"No changes were made.[/bold yellow]\n"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Always remind the user to update server_name
|
|
191
|
+
server_ip = config.get("server_ip", "_")
|
|
192
|
+
nginx_path = f"/etc/nginx/sites-available/{config['project_name']}"
|
|
193
|
+
console.print(
|
|
194
|
+
f"[bold cyan]📌 Important:[/bold cyan] Your Nginx [bold]server_name[/bold] "
|
|
195
|
+
f"is currently set to [yellow]{server_ip}[/yellow].\n"
|
|
196
|
+
f" If you have a domain name, update it in the Nginx config:\n\n"
|
|
197
|
+
f" [dim]sudo nano {nginx_path}[/dim]\n\n"
|
|
198
|
+
f" Change [yellow]server_name {server_ip};[/yellow] → "
|
|
199
|
+
f"[green]server_name yourdomain.com;[/green]\n"
|
|
200
|
+
f" Then reload Nginx: [dim]sudo systemctl reload nginx[/dim]\n"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@main.command()
|
|
205
|
+
@click.option(
|
|
206
|
+
"--verbose/--no-verbose",
|
|
207
|
+
default=False,
|
|
208
|
+
help="Show or hide detailed output.",
|
|
209
|
+
)
|
|
210
|
+
@click.pass_context
|
|
211
|
+
def detect(ctx, verbose):
|
|
212
|
+
"""Run detection only and print results without installing."""
|
|
213
|
+
verbose = verbose or ctx.obj.get("verbose", False)
|
|
214
|
+
_configure_logging(verbose)
|
|
215
|
+
project_path = ctx.obj["project_path"]
|
|
216
|
+
|
|
217
|
+
console.print(
|
|
218
|
+
"\n[bold cyan]🔍 django-deploy-toolkit — Detection[/bold cyan]\n"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
detector = ProjectDetector(project_path=project_path)
|
|
222
|
+
config = detector.detect_all()
|
|
223
|
+
|
|
224
|
+
# Build sources
|
|
225
|
+
sources = {}
|
|
226
|
+
for key, value in config.items():
|
|
227
|
+
sources[key] = "auto" if value is not None else "missing"
|
|
228
|
+
|
|
229
|
+
reporter = Reporter(config, sources)
|
|
230
|
+
reporter.print_detection_results()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@main.command()
|
|
234
|
+
@click.option(
|
|
235
|
+
"--output-dir",
|
|
236
|
+
type=click.Path(file_okay=False),
|
|
237
|
+
default=".",
|
|
238
|
+
help="Directory to write generated files to.",
|
|
239
|
+
)
|
|
240
|
+
@click.option(
|
|
241
|
+
"--verbose/--no-verbose",
|
|
242
|
+
default=False,
|
|
243
|
+
help="Show or hide detailed output.",
|
|
244
|
+
)
|
|
245
|
+
@click.pass_context
|
|
246
|
+
def generate(ctx, output_dir, verbose):
|
|
247
|
+
"""Generate config files to the current directory without installing."""
|
|
248
|
+
verbose = verbose or ctx.obj.get("verbose", False)
|
|
249
|
+
_configure_logging(verbose)
|
|
250
|
+
project_path = ctx.obj["project_path"]
|
|
251
|
+
project_name = ctx.obj["project_name"]
|
|
252
|
+
no_confirm = ctx.obj["no_confirm"]
|
|
253
|
+
|
|
254
|
+
console.print(
|
|
255
|
+
"\n[bold cyan]📝 django-deploy-toolkit — Generate[/bold cyan]\n"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# --- Detection ---
|
|
259
|
+
detector = ProjectDetector(project_path=project_path)
|
|
260
|
+
config = detector.detect_all()
|
|
261
|
+
|
|
262
|
+
if project_name:
|
|
263
|
+
config["project_name"] = project_name
|
|
264
|
+
if project_path:
|
|
265
|
+
config["project_path"] = os.path.abspath(project_path)
|
|
266
|
+
|
|
267
|
+
# --- Validation ---
|
|
268
|
+
validator = ConfigValidator(config, no_confirm=no_confirm)
|
|
269
|
+
config = validator.validate_and_prompt()
|
|
270
|
+
|
|
271
|
+
# --- Generate files ---
|
|
272
|
+
output_dir = os.path.abspath(output_dir)
|
|
273
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
274
|
+
|
|
275
|
+
pname = config["project_name"]
|
|
276
|
+
|
|
277
|
+
socket_path = os.path.join(output_dir, f"{pname}.socket")
|
|
278
|
+
service_path = os.path.join(output_dir, f"{pname}.service")
|
|
279
|
+
nginx_path = os.path.join(output_dir, f"{pname}.nginx.conf")
|
|
280
|
+
|
|
281
|
+
SocketGenerator(config).write(socket_path)
|
|
282
|
+
console.print(f"[green]✓ Written:[/green] {socket_path}")
|
|
283
|
+
|
|
284
|
+
ServiceGenerator(config).write(service_path)
|
|
285
|
+
console.print(f"[green]✓ Written:[/green] {service_path}")
|
|
286
|
+
|
|
287
|
+
NginxGenerator(config).write(nginx_path)
|
|
288
|
+
console.print(f"[green]✓ Written:[/green] {nginx_path}")
|
|
289
|
+
|
|
290
|
+
console.print(
|
|
291
|
+
f"\n[bold green]Files generated in {output_dir}[/bold green]\n"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@main.command()
|
|
296
|
+
@click.option(
|
|
297
|
+
"--name",
|
|
298
|
+
type=str,
|
|
299
|
+
required=True,
|
|
300
|
+
help="Project name to rollback.",
|
|
301
|
+
)
|
|
302
|
+
@click.pass_context
|
|
303
|
+
def rollback(ctx, name):
|
|
304
|
+
"""Rollback the last install for a given project.
|
|
305
|
+
|
|
306
|
+
Removes the generated config files and disables the services.
|
|
307
|
+
"""
|
|
308
|
+
no_confirm = ctx.obj["no_confirm"]
|
|
309
|
+
|
|
310
|
+
console.print(
|
|
311
|
+
f"\n[bold red]🔄 django-deploy-toolkit — Rollback for '{name}'[/bold red]\n"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
socket_path = f"/etc/systemd/system/{name}.socket"
|
|
315
|
+
service_path = f"/etc/systemd/system/{name}.service"
|
|
316
|
+
nginx_available = f"/etc/nginx/sites-available/{name}"
|
|
317
|
+
nginx_enabled = f"/etc/nginx/sites-enabled/{name}"
|
|
318
|
+
|
|
319
|
+
files_to_check = [
|
|
320
|
+
("Socket file", socket_path),
|
|
321
|
+
("Service file", service_path),
|
|
322
|
+
("Nginx config", nginx_available),
|
|
323
|
+
("Nginx symlink", nginx_enabled),
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
found = []
|
|
327
|
+
for label, path in files_to_check:
|
|
328
|
+
if os.path.exists(path) or os.path.islink(path):
|
|
329
|
+
found.append((label, path))
|
|
330
|
+
console.print(f" Found: {label} at {path}")
|
|
331
|
+
|
|
332
|
+
if not found:
|
|
333
|
+
console.print(
|
|
334
|
+
f"[yellow]No deployment files found for project '{name}'.[/yellow]\n"
|
|
335
|
+
)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
if not no_confirm:
|
|
339
|
+
if not click.confirm(
|
|
340
|
+
"\nRemove these files and disable services?", default=False
|
|
341
|
+
):
|
|
342
|
+
console.print("[dim]Rollback cancelled.[/dim]")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
from .utils import is_root, run_system_command
|
|
346
|
+
|
|
347
|
+
use_sudo = not is_root()
|
|
348
|
+
|
|
349
|
+
# Stop and disable services
|
|
350
|
+
for unit in [f"{name}.service", f"{name}.socket"]:
|
|
351
|
+
try:
|
|
352
|
+
run_system_command(
|
|
353
|
+
["systemctl", "stop", unit],
|
|
354
|
+
use_sudo=use_sudo,
|
|
355
|
+
)
|
|
356
|
+
console.print(f" [green]Stopped:[/green] {unit}")
|
|
357
|
+
except RuntimeError:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
run_system_command(
|
|
362
|
+
["systemctl", "disable", unit],
|
|
363
|
+
use_sudo=use_sudo,
|
|
364
|
+
)
|
|
365
|
+
console.print(f" [green]Disabled:[/green] {unit}")
|
|
366
|
+
except RuntimeError:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
# Remove files
|
|
370
|
+
for label, path in found:
|
|
371
|
+
try:
|
|
372
|
+
if use_sudo:
|
|
373
|
+
run_system_command(["rm", "-f", path], use_sudo=True)
|
|
374
|
+
else:
|
|
375
|
+
if os.path.islink(path):
|
|
376
|
+
os.unlink(path)
|
|
377
|
+
else:
|
|
378
|
+
os.remove(path)
|
|
379
|
+
console.print(f" [green]Removed:[/green] {path}")
|
|
380
|
+
except (OSError, RuntimeError) as e:
|
|
381
|
+
console.print(f" [red]Failed to remove {path}: {e}[/red]")
|
|
382
|
+
|
|
383
|
+
# Reload
|
|
384
|
+
try:
|
|
385
|
+
run_system_command(
|
|
386
|
+
["systemctl", "daemon-reload"],
|
|
387
|
+
use_sudo=use_sudo,
|
|
388
|
+
)
|
|
389
|
+
console.print(" [green]Reloaded systemd daemon[/green]")
|
|
390
|
+
except RuntimeError:
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
run_system_command(["nginx", "-t"], use_sudo=use_sudo)
|
|
395
|
+
run_system_command(
|
|
396
|
+
["systemctl", "reload", "nginx"],
|
|
397
|
+
use_sudo=use_sudo,
|
|
398
|
+
)
|
|
399
|
+
console.print(" [green]Reloaded Nginx[/green]")
|
|
400
|
+
except RuntimeError:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
console.print(
|
|
404
|
+
f"\n[bold green]Rollback for '{name}' complete.[/bold green]\n"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
main()
|