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 +3 -0
- minideploy/cli.py +584 -0
- minideploy/config.py +135 -0
- minideploy/core/builder.py +78 -0
- minideploy/core/deployer.py +326 -0
- minideploy/core/server_setup.py +105 -0
- minideploy/core/uploader.py +52 -0
- minideploy/logger.py +65 -0
- minideploy/services/__init__.py +25 -0
- minideploy/services/base.py +62 -0
- minideploy/services/docker_compose.py +123 -0
- minideploy/services/pm2.py +138 -0
- minideploy/services/systemd.py +214 -0
- minideploy/ssh.py +391 -0
- minideploy-0.1.0.dist-info/METADATA +371 -0
- minideploy-0.1.0.dist-info/RECORD +18 -0
- minideploy-0.1.0.dist-info/WHEEL +4 -0
- minideploy-0.1.0.dist-info/entry_points.txt +2 -0
minideploy/__init__.py
ADDED
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()
|