minideploy 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.
minideploy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """minideploy - Simple deployment tool for your VPS."""
2
+
3
+ __version__ = "0.1.0"
minideploy/cli.py ADDED
@@ -0,0 +1,584 @@
1
+ """Main CLI entry point for minideploy."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import click
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from minideploy.config import Config
12
+ from minideploy.core.builder import Builder
13
+ from minideploy.core.deployer import Deployer
14
+ from minideploy.core.server_setup import ServerSetup
15
+ from minideploy.core.uploader import Uploader
16
+ from minideploy.logger import create_logger
17
+ from minideploy.services import create_service_manager
18
+ from minideploy.ssh import SSHClient, SSHConfig
19
+
20
+ # Default config file name
21
+ CONFIG_FILE = "deploy.yml"
22
+
23
+
24
+ def load_config(config_path: Path) -> Config:
25
+ """Load and validate configuration from YAML file."""
26
+ if not config_path.exists():
27
+ raise click.UsageError(f"Configuration file not found: {config_path}")
28
+
29
+ try:
30
+ with open(config_path, "r") as f:
31
+ data = yaml.safe_load(f)
32
+
33
+ return Config(**data)
34
+ except ValidationError as e:
35
+ click.echo("Configuration validation error:", err=True)
36
+ for error in e.errors():
37
+ click.echo(
38
+ f" - {' -> '.join(str(x) for x in error['loc'])}: {error['msg']}",
39
+ err=True,
40
+ )
41
+ sys.exit(1)
42
+ except yaml.YAMLError as e:
43
+ raise click.UsageError(f"Invalid YAML in config file: {e}")
44
+
45
+
46
+ def create_clients(config: Config, verbose: bool = False, dry_run: bool = False):
47
+ """Create SSH client and service manager."""
48
+ logger = create_logger(verbose=verbose, dry_run=dry_run)
49
+
50
+ ssh_config = SSHConfig(
51
+ host=config.server.host,
52
+ user=config.server.user,
53
+ port=config.server.port,
54
+ key=config.server.ssh_key,
55
+ )
56
+
57
+ ssh = SSHClient(ssh_config, logger)
58
+ service_manager = create_service_manager(config, ssh, logger)
59
+
60
+ return logger, ssh, service_manager
61
+
62
+
63
+ @click.group()
64
+ @click.option(
65
+ "--config",
66
+ "-c",
67
+ "config_path",
68
+ type=click.Path(),
69
+ help="Path to configuration file (default: deploy.yml)",
70
+ )
71
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
72
+ @click.option(
73
+ "--dry-run", is_flag=True, help="Show what would be done without executing"
74
+ )
75
+ @click.pass_context
76
+ def cli(ctx, config_path: Optional[str], verbose: bool, dry_run: bool):
77
+ """minideploy - Simple deployment tool for your VPS."""
78
+ # Ensure context object exists
79
+ if ctx.obj is None:
80
+ ctx.obj = {}
81
+
82
+ # Load configuration
83
+ if config_path:
84
+ config_file = Path(config_path)
85
+ else:
86
+ config_file = Path.cwd() / CONFIG_FILE
87
+
88
+ try:
89
+ ctx.obj["config"] = load_config(config_file)
90
+ except click.UsageError:
91
+ if ctx.invoked_subcommand != "init":
92
+ raise
93
+ ctx.obj["config"] = None
94
+
95
+ ctx.obj["verbose"] = verbose
96
+ ctx.obj["dry_run"] = dry_run
97
+ ctx.obj["project_dir"] = config_file.parent
98
+
99
+
100
+ @cli.command()
101
+ @click.option(
102
+ "--regenerate",
103
+ is_flag=True,
104
+ help="Regenerate service configuration (removes existing and recreates)",
105
+ )
106
+ @click.pass_context
107
+ def setup(ctx, regenerate: bool):
108
+ """Setup server for deployment (run once)."""
109
+ config = ctx.obj["config"]
110
+ if not config:
111
+ raise click.UsageError(
112
+ "No configuration file found. Run 'minideploy init' first."
113
+ )
114
+
115
+ logger, ssh, service_manager = create_clients(
116
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
117
+ )
118
+
119
+ try:
120
+ setup = ServerSetup(config, ssh, logger, service_manager)
121
+ setup.setup(regenerate=regenerate)
122
+ finally:
123
+ ssh.close()
124
+
125
+
126
+ @cli.command()
127
+ @click.option("--force", is_flag=True, help="Force destroy without confirmation")
128
+ @click.pass_context
129
+ def destroy(ctx, force: bool):
130
+ """Remove all server setup (services and directories)."""
131
+ config = ctx.obj["config"]
132
+ if not config:
133
+ raise click.UsageError("No configuration file found.")
134
+
135
+ if not force:
136
+ click.echo("This will permanently remove:")
137
+ click.echo(f" - Service: {config.app.name}")
138
+ click.echo(f" - Directory: {config.server.deploy_dir}")
139
+ click.echo("")
140
+ if not click.confirm("Are you sure you want to destroy this deployment?"):
141
+ click.echo("Cancelled.")
142
+ return
143
+
144
+ logger, ssh, service_manager = create_clients(
145
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
146
+ )
147
+
148
+ try:
149
+ setup = ServerSetup(config, ssh, logger, service_manager)
150
+ setup.destroy()
151
+ finally:
152
+ ssh.close()
153
+
154
+
155
+ @cli.command()
156
+ @click.option("--skip-clean", is_flag=True, help="Skip cleaning previous build")
157
+ @click.pass_context
158
+ def build(ctx, skip_clean: bool):
159
+ """Build the application locally."""
160
+ config = ctx.obj["config"]
161
+ if not config:
162
+ raise click.UsageError(
163
+ "No configuration file found. Run 'minideploy init' first."
164
+ )
165
+
166
+ logger = create_logger(ctx.obj["verbose"], ctx.obj["dry_run"])
167
+ builder = Builder(config, logger, ctx.obj["project_dir"])
168
+
169
+ if not skip_clean:
170
+ builder.clean()
171
+
172
+ builder.build()
173
+
174
+
175
+ @cli.command()
176
+ @click.pass_context
177
+ def upload(ctx):
178
+ """Upload build to server."""
179
+ config = ctx.obj["config"]
180
+ if not config:
181
+ raise click.UsageError(
182
+ "No configuration file found. Run 'minideploy init' first."
183
+ )
184
+
185
+ logger, ssh, _ = create_clients(config, ctx.obj["verbose"], ctx.obj["dry_run"])
186
+
187
+ try:
188
+ build_dir = ctx.obj["project_dir"] / config.build.output_dir
189
+ if not build_dir.exists():
190
+ raise click.UsageError(
191
+ f"Build directory not found: {build_dir}\nRun 'minideploy build' first."
192
+ )
193
+
194
+ uploader = Uploader(config, ssh, logger)
195
+ uploader.upload_build(build_dir)
196
+
197
+ # Upload env file if specified
198
+ uploader.upload_env_file(ctx.obj["project_dir"])
199
+ finally:
200
+ ssh.close()
201
+
202
+
203
+ @cli.command()
204
+ @click.option("--skip-build", is_flag=True, help="Skip build step")
205
+ @click.option("--skip-upload", is_flag=True, help="Skip upload step")
206
+ @click.option("--release-name", help="Custom release name")
207
+ @click.pass_context
208
+ def deploy(ctx, skip_build: bool, skip_upload: bool, release_name: Optional[str]):
209
+ """Full deployment (build + upload + deploy)."""
210
+ config = ctx.obj["config"]
211
+ if not config:
212
+ raise click.UsageError(
213
+ "No configuration file found. Run 'minideploy init' first."
214
+ )
215
+
216
+ project_dir = ctx.obj["project_dir"]
217
+ logger, ssh, service_manager = create_clients(
218
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
219
+ )
220
+
221
+ try:
222
+ # Build
223
+ if not skip_build:
224
+ builder = Builder(config, logger, project_dir)
225
+ builder.build()
226
+
227
+ # Upload
228
+ if not skip_upload:
229
+ build_dir = project_dir / config.build.output_dir
230
+ if not build_dir.exists():
231
+ raise click.UsageError(
232
+ f"Build directory not found: {build_dir}\n"
233
+ "Run 'minideploy build' first or use --skip-build."
234
+ )
235
+
236
+ uploader = Uploader(config, ssh, logger)
237
+ uploader.upload_build(build_dir)
238
+ uploader.upload_env_file(project_dir)
239
+
240
+ # Deploy
241
+ deployer = Deployer(config, ssh, logger, service_manager)
242
+
243
+ if not release_name:
244
+ release_name = deployer.generate_release_name()
245
+
246
+ deployer.deploy(release_name)
247
+
248
+ logger.success(f"Deployment complete! Release: {release_name}")
249
+ finally:
250
+ ssh.close()
251
+
252
+
253
+ @cli.command()
254
+ @click.option("--to", "target_release", help="Specific release to rollback to")
255
+ @click.pass_context
256
+ def rollback(ctx, target_release: Optional[str]):
257
+ """Rollback to previous release."""
258
+ config = ctx.obj["config"]
259
+ if not config:
260
+ raise click.UsageError("No configuration file found.")
261
+
262
+ logger, ssh, service_manager = create_clients(
263
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
264
+ )
265
+
266
+ try:
267
+ deployer = Deployer(config, ssh, logger, service_manager)
268
+ deployer.rollback(target_release)
269
+ finally:
270
+ ssh.close()
271
+
272
+
273
+ @cli.command()
274
+ @click.pass_context
275
+ def status(ctx):
276
+ """Show deployment status."""
277
+ config = ctx.obj["config"]
278
+ if not config:
279
+ raise click.UsageError("No configuration file found.")
280
+
281
+ logger, ssh, service_manager = create_clients(
282
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
283
+ )
284
+
285
+ try:
286
+ deployer = Deployer(config, ssh, logger, service_manager)
287
+ deployer.show_status()
288
+ finally:
289
+ ssh.close()
290
+
291
+
292
+ @cli.command()
293
+ @click.pass_context
294
+ def releases(ctx):
295
+ """List all releases."""
296
+ config = ctx.obj["config"]
297
+ if not config:
298
+ raise click.UsageError("No configuration file found.")
299
+
300
+ logger, ssh, service_manager = create_clients(
301
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
302
+ )
303
+
304
+ try:
305
+ deployer = Deployer(config, ssh, logger, service_manager)
306
+ deployer.list_releases_table()
307
+ finally:
308
+ ssh.close()
309
+
310
+
311
+ @cli.command()
312
+ @click.pass_context
313
+ def cleanup(ctx):
314
+ """Clean up old releases."""
315
+ config = ctx.obj["config"]
316
+ if not config:
317
+ raise click.UsageError("No configuration file found.")
318
+
319
+ logger, ssh, service_manager = create_clients(
320
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
321
+ )
322
+
323
+ try:
324
+ deployer = Deployer(config, ssh, logger, service_manager)
325
+ deployer.cleanup_old_releases()
326
+ finally:
327
+ ssh.close()
328
+
329
+
330
+ @cli.command()
331
+ @click.option("--instance", "-i", default="1", help="Instance ID")
332
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output")
333
+ @click.option("--lines", "-n", default=50, help="Number of lines to show")
334
+ @click.pass_context
335
+ def logs(ctx, instance: str, follow: bool, lines: int):
336
+ """View service logs."""
337
+ config = ctx.obj["config"]
338
+ if not config:
339
+ raise click.UsageError("No configuration file found.")
340
+
341
+ logger, ssh, service_manager = create_clients(
342
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
343
+ )
344
+
345
+ try:
346
+ # Find instance config
347
+ instance_config = None
348
+ for inst in config.service.instances:
349
+ if inst.id == instance:
350
+ instance_config = inst
351
+ break
352
+
353
+ if not instance_config:
354
+ raise click.UsageError(f"Instance not found: {instance}")
355
+
356
+ service_manager.logs(instance_config, follow=follow, lines=lines)
357
+ finally:
358
+ ssh.close()
359
+
360
+
361
+ @cli.command()
362
+ @click.option("--instance", "-i", default="1", help="Instance ID")
363
+ @click.pass_context
364
+ def health(ctx, instance: str):
365
+ """Run health check on an instance."""
366
+ config = ctx.obj["config"]
367
+ if not config:
368
+ raise click.UsageError("No configuration file found.")
369
+
370
+ logger, ssh, service_manager = create_clients(
371
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
372
+ )
373
+
374
+ try:
375
+ # Find instance config
376
+ instance_config = None
377
+ for inst in config.service.instances:
378
+ if inst.id == instance:
379
+ instance_config = inst
380
+ break
381
+
382
+ if not instance_config:
383
+ raise click.UsageError(f"Instance not found: {instance}")
384
+
385
+ deployer = Deployer(config, ssh, logger, service_manager)
386
+ if deployer.health_check(instance_config):
387
+ logger.success("Health check passed")
388
+ else:
389
+ logger.error("Health check failed")
390
+ sys.exit(1)
391
+ finally:
392
+ ssh.close()
393
+
394
+
395
+ @cli.command()
396
+ @click.argument("action", type=click.Choice(["start", "stop", "restart", "status"]))
397
+ @click.option("--instance", "-i", help="Instance ID (default: all)")
398
+ @click.pass_context
399
+ def service(ctx, action: str, instance: Optional[str]):
400
+ """Manage services (start, stop, restart, status)."""
401
+ config = ctx.obj["config"]
402
+ if not config:
403
+ raise click.UsageError("No configuration file found.")
404
+
405
+ logger, ssh, service_manager = create_clients(
406
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
407
+ )
408
+
409
+ try:
410
+ # Get instances to manage
411
+ if instance:
412
+ instances = [
413
+ inst for inst in config.service.instances if inst.id == instance
414
+ ]
415
+ if not instances:
416
+ raise click.UsageError(f"Instance not found: {instance}")
417
+ else:
418
+ instances = config.service.instances
419
+
420
+ # Perform action
421
+ for inst in instances:
422
+ if action == "start":
423
+ service_manager.start(inst)
424
+ elif action == "stop":
425
+ service_manager.stop(inst)
426
+ elif action == "restart":
427
+ service_manager.restart(inst)
428
+ elif action == "status":
429
+ status = service_manager.status(inst)
430
+ logger.info(f"{config.app.name}@{inst.id}: {status}")
431
+ finally:
432
+ ssh.close()
433
+
434
+
435
+ @cli.command()
436
+ @click.pass_context
437
+ def init(ctx):
438
+ """Initialize a new deploy.yml configuration file."""
439
+ config_path = Path.cwd() / CONFIG_FILE
440
+
441
+ if config_path.exists():
442
+ if not click.confirm(f"{CONFIG_FILE} already exists. Overwrite?"):
443
+ return
444
+
445
+ sample_config = """# yaml-language-server: $schema=https://raw.githubusercontent.com/yourusername/minideploy/main/schema.json
446
+ # minideploy configuration file
447
+ # Documentation: https://github.com/yourusername/minideploy
448
+
449
+ app:
450
+ name: "myapp"
451
+ type: "web"
452
+
453
+ build:
454
+ type: "custom"
455
+ command: "echo 'Replace with your build command'"
456
+ output_dir: "dist"
457
+
458
+ server:
459
+ host: "your-server.com"
460
+ user: "deploy"
461
+ deploy_dir: "/opt/myapp"
462
+
463
+ service:
464
+ type: "systemd"
465
+ instances:
466
+ - id: "1"
467
+ port: 8080
468
+ health_endpoint: "/health"
469
+ systemd_options:
470
+ user: "deploy"
471
+ restart: "always"
472
+ working_directory: "/opt/myapp/current"
473
+ exec_start: "/opt/myapp/current/myapp"
474
+ # Optional: Set environment variables directly
475
+ # environment:
476
+ # NODE_ENV: "production"
477
+ # PORT: "8080"
478
+ # Optional: Use an environment file
479
+ # environment_file: "/opt/myapp/.env"
480
+
481
+ deploy:
482
+ strategy: "rolling"
483
+ keep_releases: 3
484
+ health_check:
485
+ timeout: 30
486
+ retries: 3
487
+ wait_between_instances: 5
488
+ """
489
+
490
+ config_path.write_text(sample_config)
491
+ click.echo(f"Created {CONFIG_FILE}")
492
+ click.echo("")
493
+ click.echo("Next steps:")
494
+ click.echo(" 1. Edit deploy.yml with your configuration")
495
+ click.echo(" 2. Run: minideploy setup")
496
+ click.echo(" 3. Run: minideploy deploy")
497
+
498
+
499
+ @cli.command()
500
+ @click.pass_context
501
+ def ssh(ctx):
502
+ """Open SSH session to server."""
503
+ config = ctx.obj["config"]
504
+ if not config:
505
+ raise click.UsageError("No configuration file found.")
506
+
507
+ logger, ssh_client, _ = create_clients(
508
+ config, ctx.obj["verbose"], ctx.obj["dry_run"]
509
+ )
510
+
511
+ # For interactive SSH, we still use the system ssh command
512
+ click.echo(f"Connecting to {config.server.host}...")
513
+
514
+ import subprocess
515
+
516
+ cmd = ["ssh"]
517
+ cmd.extend(["-p", str(config.server.port)])
518
+
519
+ if config.server.ssh_key:
520
+ cmd.extend(["-i", str(Path(config.server.ssh_key).expanduser())])
521
+
522
+ cmd.append(f"{config.server.user}@{config.server.host}")
523
+
524
+ subprocess.run(cmd)
525
+
526
+
527
+ @cli.command()
528
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
529
+ @click.option("--install", is_flag=True, help="Install completions to shell config")
530
+ def completion(shell: str, install: bool):
531
+ """Generate shell completion script for bash, zsh, or fish."""
532
+ import os
533
+
534
+ from click.shell_completion import BashComplete, FishComplete, ZshComplete
535
+
536
+ # Map shell to completion class and install info
537
+ shell_configs = {
538
+ "bash": {
539
+ "class": BashComplete,
540
+ "install_path": os.path.expanduser("~/.bashrc"),
541
+ "install_cmd": 'eval "$(_MINIDEPLOY_COMPLETE=bash_source minideploy)"',
542
+ },
543
+ "zsh": {
544
+ "class": ZshComplete,
545
+ "install_path": os.path.expanduser("~/.zshrc"),
546
+ "install_cmd": 'eval "$(_MINIDEPLOY_COMPLETE=zsh_source minideploy)"',
547
+ },
548
+ "fish": {
549
+ "class": FishComplete,
550
+ "install_path": os.path.expanduser(
551
+ "~/.config/fish/completions/minideploy.fish"
552
+ ),
553
+ "install_cmd": None,
554
+ },
555
+ }
556
+
557
+ config = shell_configs[shell]
558
+ comp = config["class"](cli, {}, "minideploy", "")
559
+ install_path = config["install_path"]
560
+ install_cmd = config["install_cmd"]
561
+
562
+ # Generate completion script using Click's API
563
+ completion_script = comp.source()
564
+
565
+ if install:
566
+ if shell == "fish":
567
+ # Create directory if needed
568
+ os.makedirs(os.path.dirname(install_path), exist_ok=True)
569
+ Path(install_path).write_text(completion_script)
570
+ click.echo(f"Installed fish completions to: {install_path}")
571
+ else:
572
+ # Append to shell config
573
+ with open(install_path, "a") as f:
574
+ f.write("\n# minideploy completions\n")
575
+ f.write(f"{install_cmd}\n")
576
+ click.echo(f"Added completions to: {install_path}")
577
+ click.echo(f"Run: source {install_path}")
578
+ else:
579
+ # Print completion script
580
+ click.echo(completion_script)
581
+
582
+
583
+ if __name__ == "__main__":
584
+ cli()