uhdeploy 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.
- uhdeploy/__init__.py +15 -0
- uhdeploy/__main__.py +8 -0
- uhdeploy/cli.py +1300 -0
- uhdeploy-0.1.0.dist-info/METADATA +298 -0
- uhdeploy-0.1.0.dist-info/RECORD +9 -0
- uhdeploy-0.1.0.dist-info/WHEEL +5 -0
- uhdeploy-0.1.0.dist-info/entry_points.txt +2 -0
- uhdeploy-0.1.0.dist-info/licenses/LICENSE +22 -0
- uhdeploy-0.1.0.dist-info/top_level.txt +1 -0
uhdeploy/cli.py
ADDED
|
@@ -0,0 +1,1300 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
=============================================================================
|
|
4
|
+
đ uhdeploy - Universal Deployer CLI
|
|
5
|
+
=============================================================================
|
|
6
|
+
A modern, declarative deployment tool for web applications, Docker containers,
|
|
7
|
+
and static websites to VPS instances. Supports Dockerfile-style step pipelines,
|
|
8
|
+
SSH/SFTP and FTP protocols, interactive configuration discovery, and full-color
|
|
9
|
+
terminal feedback.
|
|
10
|
+
=============================================================================
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import argparse
|
|
19
|
+
import ftplib
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# Ensure UTF-8 output encoding for consoles (especially Windows)
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
import io
|
|
25
|
+
if hasattr(sys.stdout, "buffer"):
|
|
26
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
27
|
+
if hasattr(sys.stderr, "buffer"):
|
|
28
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
29
|
+
|
|
30
|
+
# Colorama for cross-platform ANSI colors
|
|
31
|
+
try:
|
|
32
|
+
import colorama
|
|
33
|
+
from colorama import Fore, Back, Style
|
|
34
|
+
colorama.init(autoreset=True)
|
|
35
|
+
except ImportError:
|
|
36
|
+
# Graceful fallback if colorama is missing
|
|
37
|
+
class DummyColor:
|
|
38
|
+
def __getattr__(self, name):
|
|
39
|
+
return ""
|
|
40
|
+
Fore = Back = Style = DummyColor()
|
|
41
|
+
|
|
42
|
+
# PyYAML
|
|
43
|
+
try:
|
|
44
|
+
import yaml
|
|
45
|
+
except ImportError:
|
|
46
|
+
print(f"{Fore.RED}â Error: The 'pyyaml' library is required to run uhdeploy.{Style.RESET_ALL}")
|
|
47
|
+
print("Please install it using: pip install pyyaml")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
# Paramiko for SSH / SFTP
|
|
51
|
+
try:
|
|
52
|
+
import paramiko
|
|
53
|
+
except ImportError:
|
|
54
|
+
paramiko = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# đ¨ COLORFUL LOGGING & UI HELPERS
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
def banner():
|
|
62
|
+
lines = [
|
|
63
|
+
f"{Fore.CYAN}âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ",
|
|
64
|
+
f"â {Fore.CYAN}{Style.BRIGHT}đ U H D E P L O Y - U N I V E R S A L D E P L O Y E R {Style.NORMAL}{Fore.CYAN}â",
|
|
65
|
+
f"â {Fore.YELLOW}Local-to-VPS Automated Deployment (SSH / SFTP / FTP / Docker) {Fore.CYAN}â",
|
|
66
|
+
f"âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ{Style.RESET_ALL}"
|
|
67
|
+
]
|
|
68
|
+
print("\n" + "\n".join(lines) + "\n")
|
|
69
|
+
|
|
70
|
+
def log_info(msg):
|
|
71
|
+
print(f"{Fore.CYAN}âšī¸ {msg}{Style.RESET_ALL}")
|
|
72
|
+
|
|
73
|
+
def log_step(service, step_type, msg):
|
|
74
|
+
print(f"\n{Fore.MAGENTA}ââ [{Fore.WHITE}{Style.BRIGHT}{service}{Style.NORMAL}{Fore.MAGENTA}] [{Fore.YELLOW}{step_type}{Fore.MAGENTA}]{Style.RESET_ALL}")
|
|
75
|
+
print(f"{Fore.MAGENTA}âââļ {Fore.WHITE}{msg}{Style.RESET_ALL}")
|
|
76
|
+
|
|
77
|
+
def log_success(msg):
|
|
78
|
+
print(f"{Fore.GREEN}{Style.BRIGHT}â
{msg}{Style.RESET_ALL}")
|
|
79
|
+
|
|
80
|
+
def log_warning(msg):
|
|
81
|
+
print(f"{Fore.YELLOW}â ī¸ {msg}{Style.RESET_ALL}")
|
|
82
|
+
|
|
83
|
+
def log_error(msg):
|
|
84
|
+
print(f"{Fore.RED}{Style.BRIGHT}â {msg}{Style.RESET_ALL}", file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
def log_cmd(cmd, kind="LOCAL"):
|
|
87
|
+
color = Fore.CYAN if kind == "LOCAL" else Fore.YELLOW
|
|
88
|
+
print(f"{color}[{kind}] > {Fore.WHITE}{cmd}{Style.RESET_ALL}")
|
|
89
|
+
|
|
90
|
+
def print_separator(char="â", length=75, color=Fore.CYAN):
|
|
91
|
+
print(f"{color}{char * length}{Style.RESET_ALL}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# =============================================================================
|
|
95
|
+
# đ CONFIGURATION FILE DISCOVERY
|
|
96
|
+
# =============================================================================
|
|
97
|
+
|
|
98
|
+
CONFIG_CANDIDATES = [
|
|
99
|
+
"deploy.yml",
|
|
100
|
+
"deploy.yaml",
|
|
101
|
+
"Deployfile.yml",
|
|
102
|
+
"Deployfile.yaml",
|
|
103
|
+
"Deployfile",
|
|
104
|
+
".deploy.yml",
|
|
105
|
+
".deploy.yaml",
|
|
106
|
+
".env.deploy"
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def search_candidate_in_dir(directory):
|
|
110
|
+
if not os.path.isdir(directory):
|
|
111
|
+
return None
|
|
112
|
+
for name in CONFIG_CANDIDATES:
|
|
113
|
+
candidate = os.path.join(directory, name)
|
|
114
|
+
if os.path.isfile(candidate):
|
|
115
|
+
return candidate
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def find_config_interactive(cli_file=None, cli_project=None):
|
|
119
|
+
"""
|
|
120
|
+
Finds the deployment configuration file following the required flow:
|
|
121
|
+
1. Check CLI specified --file or --project
|
|
122
|
+
2. Check current folder
|
|
123
|
+
3. If not found, show message and ask for project path
|
|
124
|
+
4. If not found in project path, ask for direct file path
|
|
125
|
+
"""
|
|
126
|
+
# 1. Direct CLI file provided
|
|
127
|
+
if cli_file:
|
|
128
|
+
resolved = os.path.abspath(cli_file)
|
|
129
|
+
if os.path.isfile(resolved):
|
|
130
|
+
return resolved, os.path.dirname(resolved)
|
|
131
|
+
log_error(f"Config file specified with --file was not found: '{cli_file}'")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
# 2. CLI project directory provided
|
|
135
|
+
if cli_project:
|
|
136
|
+
resolved_proj = os.path.abspath(cli_project)
|
|
137
|
+
candidate = search_candidate_in_dir(resolved_proj)
|
|
138
|
+
if candidate:
|
|
139
|
+
return candidate, resolved_proj
|
|
140
|
+
log_error(f"No deploy config file found in project directory: '{cli_project}'")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
# 3. Check current folder
|
|
144
|
+
cwd = os.getcwd()
|
|
145
|
+
candidate = search_candidate_in_dir(cwd)
|
|
146
|
+
if candidate:
|
|
147
|
+
return candidate, cwd
|
|
148
|
+
|
|
149
|
+
# 4. Not found in current directory - Interactive prompt
|
|
150
|
+
log_warning(f"No deploy configuration file ({', '.join(CONFIG_CANDIDATES[:2])}) found in current directory:")
|
|
151
|
+
print(f" {Fore.LIGHTBLACK_EX}{cwd}{Style.RESET_ALL}")
|
|
152
|
+
|
|
153
|
+
while True:
|
|
154
|
+
try:
|
|
155
|
+
prompt = f"\n{Fore.CYAN}đ Enter project path {Fore.LIGHTBLACK_EX}(or press Enter to specify file directly): {Style.RESET_ALL}"
|
|
156
|
+
proj_input = input(prompt).strip().strip('"').strip("'")
|
|
157
|
+
except (KeyboardInterrupt, EOFError):
|
|
158
|
+
print(f"\n{Fore.YELLOW}Deployment cancelled by user.{Style.RESET_ALL}")
|
|
159
|
+
sys.exit(0)
|
|
160
|
+
|
|
161
|
+
if not proj_input:
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
proj_dir = os.path.abspath(os.path.expanduser(proj_input))
|
|
165
|
+
if not os.path.isdir(proj_dir):
|
|
166
|
+
log_error(f"Project folder not found: '{proj_input}'. Please enter a valid directory.")
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
candidate = search_candidate_in_dir(proj_dir)
|
|
170
|
+
if candidate:
|
|
171
|
+
log_success(f"Found deployment configuration in '{proj_dir}': {os.path.basename(candidate)}")
|
|
172
|
+
return candidate, proj_dir
|
|
173
|
+
else:
|
|
174
|
+
log_warning(f"No deploy configuration file found inside '{proj_input}'.")
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
# 5. Ask for direct file path
|
|
178
|
+
while True:
|
|
179
|
+
try:
|
|
180
|
+
prompt = f"\n{Fore.CYAN}đ Enter deploy configuration file path {Fore.LIGHTBLACK_EX}(e.g. ./deploy.yml): {Style.RESET_ALL}"
|
|
181
|
+
file_input = input(prompt).strip().strip('"').strip("'")
|
|
182
|
+
except (KeyboardInterrupt, EOFError):
|
|
183
|
+
print(f"\n{Fore.YELLOW}Deployment cancelled by user.{Style.RESET_ALL}")
|
|
184
|
+
sys.exit(0)
|
|
185
|
+
|
|
186
|
+
if not file_input:
|
|
187
|
+
log_error("No file path entered. Cannot proceed with deployment.")
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
file_path = os.path.abspath(os.path.expanduser(file_input))
|
|
191
|
+
if os.path.isfile(file_path):
|
|
192
|
+
log_success(f"Using configuration file: {file_path}")
|
|
193
|
+
return file_path, os.path.dirname(file_path)
|
|
194
|
+
else:
|
|
195
|
+
log_error(f"File not found: '{file_input}'. Please verify the path.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# =============================================================================
|
|
199
|
+
# đ ENVIRONMENT VARIABLES & CONFIG PARSING
|
|
200
|
+
# =============================================================================
|
|
201
|
+
|
|
202
|
+
def load_env_file(filepath):
|
|
203
|
+
"""Parses standard KEY=VALUE environment files into a dictionary."""
|
|
204
|
+
if not os.path.isfile(filepath):
|
|
205
|
+
return {}
|
|
206
|
+
env_vars = {}
|
|
207
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
208
|
+
for line in f:
|
|
209
|
+
line = line.strip()
|
|
210
|
+
if not line or line.startswith("#"):
|
|
211
|
+
continue
|
|
212
|
+
if "=" in line:
|
|
213
|
+
key, val = line.split("=", 1)
|
|
214
|
+
val = val.strip().strip('"').strip("'")
|
|
215
|
+
env_vars[key.strip()] = val
|
|
216
|
+
return env_vars
|
|
217
|
+
|
|
218
|
+
def substitute_env_vars(raw_text, env_dict):
|
|
219
|
+
"""
|
|
220
|
+
Substitutes ${VAR} and ${VAR:-default} and $VAR with environment values.
|
|
221
|
+
"""
|
|
222
|
+
def replace_bracket(match):
|
|
223
|
+
expr = match.group(1)
|
|
224
|
+
if ":-" in expr:
|
|
225
|
+
var_name, default_val = expr.split(":-", 1)
|
|
226
|
+
else:
|
|
227
|
+
var_name, default_val = expr, ""
|
|
228
|
+
return env_dict.get(var_name, os.environ.get(var_name, default_val))
|
|
229
|
+
|
|
230
|
+
def replace_simple(match):
|
|
231
|
+
var_name = match.group(1)
|
|
232
|
+
return env_dict.get(var_name, os.environ.get(var_name, f"${var_name}"))
|
|
233
|
+
|
|
234
|
+
# Replace ${VAR:-default} and ${VAR}
|
|
235
|
+
result = re.sub(r"\$\{([A-Za-z0-9_]+(?::-.*?|)?)\}", replace_bracket, raw_text)
|
|
236
|
+
# Replace $VAR (only if word boundary)
|
|
237
|
+
result = re.sub(r"\$([A-Za-z0-9_]+)", replace_simple, result)
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
def parse_deploy_config(config_path, project_root):
|
|
241
|
+
"""
|
|
242
|
+
Reads deploy.yml, substitutes env vars, and normalizes service definitions.
|
|
243
|
+
"""
|
|
244
|
+
# Load .env files from project_root if available
|
|
245
|
+
env_dict = {}
|
|
246
|
+
default_env = os.path.join(project_root, ".env")
|
|
247
|
+
if os.path.isfile(default_env):
|
|
248
|
+
env_dict.update(load_env_file(default_env))
|
|
249
|
+
|
|
250
|
+
deploy_env = os.path.join(project_root, ".env.deploy")
|
|
251
|
+
if os.path.isfile(deploy_env):
|
|
252
|
+
env_dict.update(load_env_file(deploy_env))
|
|
253
|
+
|
|
254
|
+
with open(config_path, "r", encoding="utf-8", errors="replace") as f:
|
|
255
|
+
raw_content = f.read()
|
|
256
|
+
|
|
257
|
+
# If the file specifies custom env_file:
|
|
258
|
+
custom_env_match = re.search(r"^\s*env_file:\s*(.+)$", raw_content, re.MULTILINE)
|
|
259
|
+
if custom_env_match:
|
|
260
|
+
custom_env_name = custom_env_match.group(1).strip().strip('"').strip("'")
|
|
261
|
+
custom_env_path = os.path.join(project_root, custom_env_name)
|
|
262
|
+
if os.path.isfile(custom_env_path):
|
|
263
|
+
env_dict.update(load_env_file(custom_env_path))
|
|
264
|
+
|
|
265
|
+
# Add runtime helper variables
|
|
266
|
+
env_dict["PROJECT_DIR"] = project_root
|
|
267
|
+
|
|
268
|
+
# Substitute env vars
|
|
269
|
+
resolved_content = substitute_env_vars(raw_content, env_dict)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
data = yaml.safe_load(resolved_content) or {}
|
|
273
|
+
except yaml.YAMLError as e:
|
|
274
|
+
log_error(f"YAML Syntax Error in '{config_path}':\n{e}")
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
# Global / Default server settings
|
|
278
|
+
global_server = data.get("server") or data.get("vps") or data.get("defaults") or {}
|
|
279
|
+
|
|
280
|
+
# Extract services
|
|
281
|
+
# Can be defined under "services:" or directly as top-level keys
|
|
282
|
+
services = {}
|
|
283
|
+
if "services" in data and isinstance(data["services"], dict):
|
|
284
|
+
services = data["services"]
|
|
285
|
+
else:
|
|
286
|
+
reserved_keys = {"server", "vps", "defaults", "env_file", "env", "variables", "vars"}
|
|
287
|
+
for k, v in data.items():
|
|
288
|
+
if k not in reserved_keys and isinstance(v, dict):
|
|
289
|
+
services[k] = v
|
|
290
|
+
|
|
291
|
+
if not services:
|
|
292
|
+
log_error(f"No services found in configuration file '{config_path}'.")
|
|
293
|
+
log_info("Please define at least one service (e.g., frontend, backend). Run uhdeploy --example to see syntax.")
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
|
|
296
|
+
# Normalize services by merging global server info
|
|
297
|
+
normalized = {}
|
|
298
|
+
for name, s_cfg in services.items():
|
|
299
|
+
if not isinstance(s_cfg, dict):
|
|
300
|
+
continue
|
|
301
|
+
cfg = dict(s_cfg)
|
|
302
|
+
|
|
303
|
+
# Merge server details
|
|
304
|
+
srv = dict(global_server)
|
|
305
|
+
srv.update(cfg.get("server") or cfg.get("vps") or cfg.get("target") or {})
|
|
306
|
+
cfg["server"] = srv
|
|
307
|
+
|
|
308
|
+
# Detect docker flag
|
|
309
|
+
# Accept 'docker: false', 'dont_use_docker: true', 'use_docker: false', etc.
|
|
310
|
+
docker_raw = cfg.get("docker")
|
|
311
|
+
if docker_raw is None:
|
|
312
|
+
if cfg.get("dont_use_docker") is True or cfg.get("use_docker") is False:
|
|
313
|
+
cfg["docker"] = False
|
|
314
|
+
else:
|
|
315
|
+
cfg["docker"] = False
|
|
316
|
+
normalized[name] = cfg
|
|
317
|
+
|
|
318
|
+
return normalized, env_dict
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# =============================================================================
|
|
322
|
+
# đ§Š DOCKERFILE-STYLE STEP PARSER
|
|
323
|
+
# =============================================================================
|
|
324
|
+
|
|
325
|
+
def parse_step(step_entry):
|
|
326
|
+
"""
|
|
327
|
+
Parses a step into a standardized dict:
|
|
328
|
+
- String syntax:
|
|
329
|
+
"RUN npm run build" -> {'action': 'RUN', 'command': 'npm run build'}
|
|
330
|
+
"COPY src dest" -> {'action': 'COPY', 'src': 'src', 'dest': 'dest'}
|
|
331
|
+
"CMD python app.py" -> {'action': 'CMD', 'command': 'python app.py'}
|
|
332
|
+
"npm run build" -> {'action': 'RUN', 'command': 'npm run build'}
|
|
333
|
+
- Dict syntax:
|
|
334
|
+
{'run': '...'} or {'copy': '...'} or {'copy': {'src': '...', 'dest': '...'}}
|
|
335
|
+
"""
|
|
336
|
+
if isinstance(step_entry, str):
|
|
337
|
+
trimmed = step_entry.strip()
|
|
338
|
+
parts = trimmed.split(None, 1)
|
|
339
|
+
if not parts:
|
|
340
|
+
return None
|
|
341
|
+
verb = parts[0].upper()
|
|
342
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
343
|
+
|
|
344
|
+
if verb in ("RUN", "CMD"):
|
|
345
|
+
return {"action": "RUN", "command": arg}
|
|
346
|
+
elif verb == "COPY":
|
|
347
|
+
# Handles "COPY src dest" or "COPY src -> dest"
|
|
348
|
+
clean_arg = arg.replace("->", " ")
|
|
349
|
+
cparts = clean_arg.split()
|
|
350
|
+
if len(cparts) >= 2:
|
|
351
|
+
return {"action": "COPY", "src": cparts[0], "dest": cparts[1]}
|
|
352
|
+
return {"action": "COPY", "src": arg, "dest": ""}
|
|
353
|
+
elif verb == "WORKDIR":
|
|
354
|
+
return {"action": "WORKDIR", "dir": arg}
|
|
355
|
+
elif verb == "ENV":
|
|
356
|
+
if "=" in arg:
|
|
357
|
+
k, v = arg.split("=", 1)
|
|
358
|
+
return {"action": "ENV", "key": k.strip(), "val": v.strip()}
|
|
359
|
+
return {"action": "ENV", "raw": arg}
|
|
360
|
+
else:
|
|
361
|
+
# Default fallback for arbitrary shell command without "RUN" prefix
|
|
362
|
+
return {"action": "RUN", "command": trimmed}
|
|
363
|
+
|
|
364
|
+
elif isinstance(step_entry, dict):
|
|
365
|
+
if "run" in step_entry:
|
|
366
|
+
return {"action": "RUN", "command": str(step_entry["run"])}
|
|
367
|
+
if "cmd" in step_entry:
|
|
368
|
+
return {"action": "RUN", "command": str(step_entry["cmd"])}
|
|
369
|
+
if "copy" in step_entry:
|
|
370
|
+
cval = step_entry["copy"]
|
|
371
|
+
if isinstance(cval, dict):
|
|
372
|
+
return {"action": "COPY", "src": str(cval.get("src", "")), "dest": str(cval.get("dest", ""))}
|
|
373
|
+
elif isinstance(cval, str):
|
|
374
|
+
cparts = cval.replace("->", " ").split()
|
|
375
|
+
if len(cparts) >= 2:
|
|
376
|
+
return {"action": "COPY", "src": cparts[0], "dest": cparts[1]}
|
|
377
|
+
return {"action": "COPY", "src": cval, "dest": ""}
|
|
378
|
+
if "workdir" in step_entry:
|
|
379
|
+
return {"action": "WORKDIR", "dir": str(step_entry["workdir"])}
|
|
380
|
+
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
def normalize_steps(step_list):
|
|
384
|
+
"""Converts a step list or single step into parsed normalized items."""
|
|
385
|
+
if not step_list:
|
|
386
|
+
return []
|
|
387
|
+
if isinstance(step_list, str):
|
|
388
|
+
step_list = [step_list]
|
|
389
|
+
parsed = []
|
|
390
|
+
for item in step_list:
|
|
391
|
+
res = parse_step(item)
|
|
392
|
+
if res:
|
|
393
|
+
parsed.append(res)
|
|
394
|
+
return parsed
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# =============================================================================
|
|
398
|
+
# đ SSH & SFTP CLIENT HANDLER
|
|
399
|
+
# =============================================================================
|
|
400
|
+
|
|
401
|
+
class SSHManager:
|
|
402
|
+
def __init__(self, host, user, port=22, key_path=None, passphrase=None, password=None):
|
|
403
|
+
self.host = host
|
|
404
|
+
self.user = user or "root"
|
|
405
|
+
self.port = int(port or 22)
|
|
406
|
+
self.key_path = os.path.expanduser(key_path) if key_path else None
|
|
407
|
+
self.passphrase = passphrase
|
|
408
|
+
self.password = password
|
|
409
|
+
self.client = None
|
|
410
|
+
self._created_dirs = set()
|
|
411
|
+
|
|
412
|
+
def connect(self):
|
|
413
|
+
if not paramiko:
|
|
414
|
+
log_error("Paramiko is required for SSH connections. Please run: pip install paramiko")
|
|
415
|
+
sys.exit(1)
|
|
416
|
+
|
|
417
|
+
# Close existing client cleanly if any
|
|
418
|
+
if self.client:
|
|
419
|
+
try:
|
|
420
|
+
self.client.close()
|
|
421
|
+
except Exception:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
log_info(f"Connecting to VPS ({self.user}@{self.host}:{self.port}) via SSH...")
|
|
425
|
+
client = paramiko.SSHClient()
|
|
426
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
427
|
+
|
|
428
|
+
connect_kwargs = {
|
|
429
|
+
"hostname": self.host,
|
|
430
|
+
"port": self.port,
|
|
431
|
+
"username": self.user,
|
|
432
|
+
"timeout": 30,
|
|
433
|
+
"banner_timeout": 60,
|
|
434
|
+
"auth_timeout": 60
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if self.key_path:
|
|
438
|
+
if not os.path.exists(self.key_path):
|
|
439
|
+
log_error(f"SSH private key file not found: {self.key_path}")
|
|
440
|
+
sys.exit(1)
|
|
441
|
+
connect_kwargs["key_filename"] = self.key_path
|
|
442
|
+
if self.passphrase:
|
|
443
|
+
connect_kwargs["passphrase"] = self.passphrase
|
|
444
|
+
elif self.password:
|
|
445
|
+
connect_kwargs["password"] = self.password
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
client.connect(**connect_kwargs)
|
|
449
|
+
transport = client.get_transport()
|
|
450
|
+
if transport:
|
|
451
|
+
transport.set_keepalive(15)
|
|
452
|
+
self.client = client
|
|
453
|
+
log_success(f"SSH Connection established to {self.host}")
|
|
454
|
+
return self
|
|
455
|
+
except Exception as e:
|
|
456
|
+
log_error(f"Failed to connect to VPS: {e}")
|
|
457
|
+
sys.exit(1)
|
|
458
|
+
|
|
459
|
+
def close(self):
|
|
460
|
+
if self.client:
|
|
461
|
+
try:
|
|
462
|
+
self.client.close()
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
self.client = None
|
|
466
|
+
log_info(f"SSH connection to {self.host} closed.")
|
|
467
|
+
|
|
468
|
+
def run_cmd(self, cmd, cwd=None, retries=3):
|
|
469
|
+
full_cmd = f"cd '{cwd}' && {cmd}" if cwd else cmd
|
|
470
|
+
log_cmd(full_cmd, kind="VPS SSH")
|
|
471
|
+
|
|
472
|
+
for attempt in range(1, retries + 1):
|
|
473
|
+
try:
|
|
474
|
+
transport = self.client.get_transport() if self.client else None
|
|
475
|
+
if not transport or not transport.is_active():
|
|
476
|
+
log_warning(f"SSH connection inactive. Reconnecting (Attempt {attempt}/{retries})...")
|
|
477
|
+
self.connect()
|
|
478
|
+
|
|
479
|
+
stdin, stdout, stderr = self.client.exec_command(full_cmd)
|
|
480
|
+
exit_status = stdout.channel.recv_exit_status()
|
|
481
|
+
|
|
482
|
+
out = stdout.read().decode("utf-8", errors="replace")
|
|
483
|
+
err = stderr.read().decode("utf-8", errors="replace")
|
|
484
|
+
|
|
485
|
+
if out.strip():
|
|
486
|
+
for line in out.strip().splitlines():
|
|
487
|
+
print(f" {Fore.LIGHTBLACK_EX}â{Style.RESET_ALL} {line}")
|
|
488
|
+
if err.strip():
|
|
489
|
+
for line in err.strip().splitlines():
|
|
490
|
+
print(f" {Fore.RED}â{Style.RESET_ALL} {line}", file=sys.stderr)
|
|
491
|
+
|
|
492
|
+
if exit_status != 0:
|
|
493
|
+
log_error(f"Remote command failed with exit status {exit_status}")
|
|
494
|
+
sys.exit(1)
|
|
495
|
+
return True
|
|
496
|
+
except Exception as e:
|
|
497
|
+
log_warning(f"SSH execution attempt {attempt} failed: {e}")
|
|
498
|
+
if attempt == retries:
|
|
499
|
+
log_error(f"Remote command failed after {retries} attempts.")
|
|
500
|
+
sys.exit(1)
|
|
501
|
+
import time
|
|
502
|
+
time.sleep(2)
|
|
503
|
+
|
|
504
|
+
def mkdir_p(self, remote_dir):
|
|
505
|
+
"""Creates directory hierarchy on remote machine via SSH safely."""
|
|
506
|
+
remote_dir_norm = remote_dir.replace("\\", "/")
|
|
507
|
+
if remote_dir_norm in self._created_dirs:
|
|
508
|
+
return
|
|
509
|
+
try:
|
|
510
|
+
transport = self.client.get_transport() if self.client else None
|
|
511
|
+
if not transport or not transport.is_active():
|
|
512
|
+
self.connect()
|
|
513
|
+
|
|
514
|
+
stdin, stdout, stderr = self.client.exec_command(f"mkdir -p '{remote_dir_norm}'")
|
|
515
|
+
stdout.channel.recv_exit_status()
|
|
516
|
+
stdout.read()
|
|
517
|
+
stderr.read()
|
|
518
|
+
self._created_dirs.add(remote_dir_norm)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
def upload_file(self, local_path, remote_path, retries=5):
|
|
523
|
+
import time
|
|
524
|
+
remote_path = remote_path.replace("\\", "/")
|
|
525
|
+
parent_dir = os.path.dirname(remote_path)
|
|
526
|
+
if parent_dir:
|
|
527
|
+
self.mkdir_p(parent_dir)
|
|
528
|
+
|
|
529
|
+
base_name = os.path.basename(local_path)
|
|
530
|
+
|
|
531
|
+
for attempt in range(1, retries + 1):
|
|
532
|
+
sftp = None
|
|
533
|
+
try:
|
|
534
|
+
transport = self.client.get_transport() if self.client else None
|
|
535
|
+
if not transport or not transport.is_active():
|
|
536
|
+
log_warning(f"SSH connection inactive. Reconnecting (Attempt {attempt}/{retries})...")
|
|
537
|
+
self.connect()
|
|
538
|
+
|
|
539
|
+
sftp = self.client.open_sftp()
|
|
540
|
+
|
|
541
|
+
def progress(transferred, total):
|
|
542
|
+
if total > 0:
|
|
543
|
+
percent = (transferred / total) * 100
|
|
544
|
+
mb_trans = transferred / (1024 * 1024)
|
|
545
|
+
mb_tot = total / (1024 * 1024)
|
|
546
|
+
sys.stdout.write(f"\r {Fore.CYAN}đ¤ [{base_name}] {mb_trans:.1f} MB / {mb_tot:.1f} MB ({percent:.1f}%){Style.RESET_ALL} ")
|
|
547
|
+
sys.stdout.flush()
|
|
548
|
+
|
|
549
|
+
if attempt == 1:
|
|
550
|
+
print(f" {Fore.LIGHTBLUE_EX}â Uploading file:{Style.RESET_ALL} {local_path} -> {remote_path}")
|
|
551
|
+
|
|
552
|
+
sftp.put(local_path, remote_path, callback=progress, confirm=False)
|
|
553
|
+
sys.stdout.write("\n")
|
|
554
|
+
return True
|
|
555
|
+
|
|
556
|
+
except Exception as e:
|
|
557
|
+
sys.stdout.write("\n")
|
|
558
|
+
log_warning(f"SFTP upload failed on attempt {attempt}/{retries}: {e}")
|
|
559
|
+
if attempt == retries:
|
|
560
|
+
log_error(f"SFTP upload failed permanently after {retries} attempts.")
|
|
561
|
+
raise e
|
|
562
|
+
try:
|
|
563
|
+
if sftp:
|
|
564
|
+
sftp.close()
|
|
565
|
+
except Exception:
|
|
566
|
+
pass
|
|
567
|
+
try:
|
|
568
|
+
if self.client:
|
|
569
|
+
self.client.close()
|
|
570
|
+
except Exception:
|
|
571
|
+
pass
|
|
572
|
+
time.sleep(2)
|
|
573
|
+
self.connect()
|
|
574
|
+
|
|
575
|
+
finally:
|
|
576
|
+
if sftp:
|
|
577
|
+
try:
|
|
578
|
+
sftp.close()
|
|
579
|
+
except Exception:
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
def upload_directory(self, local_dir, remote_dir):
|
|
583
|
+
remote_dir = remote_dir.replace("\\", "/")
|
|
584
|
+
self.mkdir_p(remote_dir)
|
|
585
|
+
|
|
586
|
+
print(f" {Fore.LIGHTBLUE_EX}â Syncing directory:{Style.RESET_ALL} {local_dir} -> {remote_dir}")
|
|
587
|
+
for root, dirs, files in os.walk(local_dir):
|
|
588
|
+
rel_path = os.path.relpath(root, local_dir)
|
|
589
|
+
target_sub = remote_dir if rel_path == "." else f"{remote_dir}/{rel_path}".replace("\\", "/")
|
|
590
|
+
self.mkdir_p(target_sub)
|
|
591
|
+
|
|
592
|
+
for f in files:
|
|
593
|
+
l_file = os.path.join(root, f)
|
|
594
|
+
r_file = f"{target_sub}/{f}".replace("\\", "/")
|
|
595
|
+
self.upload_file(l_file, r_file)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# =============================================================================
|
|
599
|
+
# đĄ FTP CLIENT HANDLER (Standard Library ftplib)
|
|
600
|
+
# =============================================================================
|
|
601
|
+
|
|
602
|
+
class FTPManager:
|
|
603
|
+
def __init__(self, host, user, port=21, password=None):
|
|
604
|
+
self.host = host
|
|
605
|
+
self.user = user or "anonymous"
|
|
606
|
+
self.port = int(port or 21)
|
|
607
|
+
self.password = password or ""
|
|
608
|
+
self.ftp = None
|
|
609
|
+
|
|
610
|
+
def connect(self):
|
|
611
|
+
log_info(f"Connecting to VPS ({self.user}@{self.host}:{self.port}) via FTP...")
|
|
612
|
+
try:
|
|
613
|
+
ftp = ftplib.FTP()
|
|
614
|
+
ftp.connect(self.host, self.port, timeout=30)
|
|
615
|
+
ftp.login(self.user, self.password)
|
|
616
|
+
self.ftp = ftp
|
|
617
|
+
log_success(f"FTP Connection established to {self.host}")
|
|
618
|
+
return self
|
|
619
|
+
except Exception as e:
|
|
620
|
+
log_error(f"Failed to connect to FTP server: {e}")
|
|
621
|
+
sys.exit(1)
|
|
622
|
+
|
|
623
|
+
def close(self):
|
|
624
|
+
if self.ftp:
|
|
625
|
+
try:
|
|
626
|
+
self.ftp.quit()
|
|
627
|
+
except Exception:
|
|
628
|
+
try:
|
|
629
|
+
self.ftp.close()
|
|
630
|
+
except Exception:
|
|
631
|
+
pass
|
|
632
|
+
log_info(f"FTP connection to {self.host} closed.")
|
|
633
|
+
|
|
634
|
+
def run_cmd(self, cmd, cwd=None):
|
|
635
|
+
log_warning(f"[FTP Limitation] Cannot run remote command: '{cmd}'. FTP protocol does not support shell execution!")
|
|
636
|
+
log_info("Only file transfers are supported over FTP. Use SSH for remote commands/Docker.")
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
def mkdir_p(self, remote_dir):
|
|
640
|
+
"""Recursively ensures remote directories exist on FTP."""
|
|
641
|
+
remote_dir = remote_dir.replace("\\", "/").strip("/")
|
|
642
|
+
parts = remote_dir.split("/")
|
|
643
|
+
current = ""
|
|
644
|
+
for p in parts:
|
|
645
|
+
current += "/" + p
|
|
646
|
+
try:
|
|
647
|
+
self.ftp.cwd(current)
|
|
648
|
+
except ftplib.error_perm:
|
|
649
|
+
try:
|
|
650
|
+
self.ftp.mkd(current)
|
|
651
|
+
except Exception:
|
|
652
|
+
pass
|
|
653
|
+
|
|
654
|
+
def upload_file(self, local_path, remote_path):
|
|
655
|
+
remote_path = remote_path.replace("\\", "/")
|
|
656
|
+
parent_dir = os.path.dirname(remote_path)
|
|
657
|
+
if parent_dir:
|
|
658
|
+
self.mkdir_p(parent_dir)
|
|
659
|
+
|
|
660
|
+
print(f" {Fore.LIGHTBLUE_EX}â [FTP] Uploading:{Style.RESET_ALL} {local_path} -> {remote_path}")
|
|
661
|
+
with open(local_path, "rb") as f:
|
|
662
|
+
self.ftp.storbinary(f"STOR {remote_path}", f)
|
|
663
|
+
|
|
664
|
+
def upload_directory(self, local_dir, remote_dir):
|
|
665
|
+
remote_dir = remote_dir.replace("\\", "/")
|
|
666
|
+
self.mkdir_p(remote_dir)
|
|
667
|
+
|
|
668
|
+
print(f" {Fore.LIGHTBLUE_EX}â [FTP] Syncing directory:{Style.RESET_ALL} {local_dir} -> {remote_dir}")
|
|
669
|
+
for root, dirs, files in os.walk(local_dir):
|
|
670
|
+
rel_path = os.path.relpath(root, local_dir)
|
|
671
|
+
target_sub = remote_dir if rel_path == "." else f"{remote_dir}/{rel_path}".replace("\\", "/")
|
|
672
|
+
self.mkdir_p(target_sub)
|
|
673
|
+
|
|
674
|
+
for f in files:
|
|
675
|
+
l_file = os.path.join(root, f)
|
|
676
|
+
r_file = f"{target_sub}/{f}".replace("\\", "/")
|
|
677
|
+
self.upload_file(l_file, r_file)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# =============================================================================
|
|
681
|
+
# âī¸ LOCAL COMMAND & BUILD RUNNER
|
|
682
|
+
# =============================================================================
|
|
683
|
+
|
|
684
|
+
def run_local_cmd(cmd, cwd=None):
|
|
685
|
+
log_cmd(cmd, kind="LOCAL")
|
|
686
|
+
res = subprocess.run(cmd, shell=True, cwd=cwd)
|
|
687
|
+
if res.returncode != 0:
|
|
688
|
+
log_error(f"Local command failed with exit code {res.returncode}: '{cmd}'")
|
|
689
|
+
sys.exit(1)
|
|
690
|
+
|
|
691
|
+
def run_local_copy(src, dest, cwd=None):
|
|
692
|
+
src_path = os.path.join(cwd, src) if cwd and not os.path.isabs(src) else src
|
|
693
|
+
dest_path = os.path.join(cwd, dest) if cwd and not os.path.isabs(dest) else dest
|
|
694
|
+
|
|
695
|
+
if not os.path.exists(src_path):
|
|
696
|
+
log_error(f"Source file/folder for COPY not found: '{src_path}'")
|
|
697
|
+
sys.exit(1)
|
|
698
|
+
|
|
699
|
+
print(f" {Fore.LIGHTBLUE_EX}đ [LOCAL COPY]{Style.RESET_ALL} {src} -> {dest}")
|
|
700
|
+
dest_dir = os.path.dirname(dest_path) if not os.path.isdir(dest_path) else dest_path
|
|
701
|
+
if dest_dir:
|
|
702
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
703
|
+
|
|
704
|
+
if os.path.isdir(src_path):
|
|
705
|
+
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
|
|
706
|
+
else:
|
|
707
|
+
shutil.copy2(src_path, dest_path)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# =============================================================================
|
|
711
|
+
# đ SERVICE DEPLOYMENT ORCHESTRATOR
|
|
712
|
+
# =============================================================================
|
|
713
|
+
|
|
714
|
+
def deploy_service(name, cfg, project_root, dry_run=False):
|
|
715
|
+
print_separator("â", 75, Fore.CYAN)
|
|
716
|
+
log_step(name, "STARTING", cfg.get("description", f"Deploying service '{name}'"))
|
|
717
|
+
|
|
718
|
+
# 1. Resolve work directory
|
|
719
|
+
rel_work_dir = cfg.get("work_dir") or cfg.get("working_dir") or "."
|
|
720
|
+
work_dir = os.path.abspath(os.path.join(project_root, rel_work_dir))
|
|
721
|
+
log_info(f"Local Working Directory: {Fore.WHITE}{work_dir}")
|
|
722
|
+
|
|
723
|
+
server_cfg = cfg.get("server", {})
|
|
724
|
+
proto = str(server_cfg.get("type", "ssh")).lower().strip()
|
|
725
|
+
host = server_cfg.get("host") or server_cfg.get("ip")
|
|
726
|
+
user = server_cfg.get("user") or server_cfg.get("username", "root")
|
|
727
|
+
port = server_cfg.get("port") or (21 if proto == "ftp" else 22)
|
|
728
|
+
remote_base = (server_cfg.get("remote_dir") or server_cfg.get("path") or f"/opt/{name}").replace("\\", "/")
|
|
729
|
+
|
|
730
|
+
# Check connection requirements
|
|
731
|
+
if not dry_run and not host:
|
|
732
|
+
log_error(f"No VPS host specified for service '{name}'! Set server.host in deploy.yml or VPS_IP in .env.")
|
|
733
|
+
sys.exit(1)
|
|
734
|
+
|
|
735
|
+
log_info(f"Target Server: {Fore.WHITE}{user}@{host}:{port} ({proto.upper()}) | Remote Dir: {remote_base}")
|
|
736
|
+
|
|
737
|
+
# 2. Local Build Phase
|
|
738
|
+
build_steps = normalize_steps(cfg.get("build") or cfg.get("for_build") or cfg.get("build_steps"))
|
|
739
|
+
if build_steps:
|
|
740
|
+
print_separator("â", 50, Fore.MAGENTA)
|
|
741
|
+
log_step(name, "LOCAL BUILD", f"Executing {len(build_steps)} build step(s)...")
|
|
742
|
+
for s in build_steps:
|
|
743
|
+
if dry_run:
|
|
744
|
+
print(f" {Fore.YELLOW}[DRY-RUN BUILD]{Style.RESET_ALL} {s}")
|
|
745
|
+
continue
|
|
746
|
+
|
|
747
|
+
action = s["action"]
|
|
748
|
+
if action in ("RUN", "CMD"):
|
|
749
|
+
run_local_cmd(s["command"], cwd=work_dir)
|
|
750
|
+
elif action == "COPY":
|
|
751
|
+
run_local_copy(s["src"], s["dest"], cwd=work_dir)
|
|
752
|
+
elif action == "WORKDIR":
|
|
753
|
+
work_dir = os.path.abspath(os.path.join(work_dir, s["dir"]))
|
|
754
|
+
log_info(f"Changed local working dir to: {work_dir}")
|
|
755
|
+
|
|
756
|
+
if dry_run:
|
|
757
|
+
log_info(f"[DRY-RUN] Finished simulating build for '{name}'.")
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
# 3. Establish Connection to VPS
|
|
761
|
+
conn = None
|
|
762
|
+
if proto == "ftp":
|
|
763
|
+
conn = FTPManager(
|
|
764
|
+
host=host,
|
|
765
|
+
user=user,
|
|
766
|
+
port=port,
|
|
767
|
+
password=server_cfg.get("password")
|
|
768
|
+
).connect()
|
|
769
|
+
else: # Default: SSH
|
|
770
|
+
conn = SSHManager(
|
|
771
|
+
host=host,
|
|
772
|
+
user=user,
|
|
773
|
+
port=port,
|
|
774
|
+
key_path=server_cfg.get("key_path"),
|
|
775
|
+
passphrase=server_cfg.get("passphrase"),
|
|
776
|
+
password=server_cfg.get("password")
|
|
777
|
+
).connect()
|
|
778
|
+
|
|
779
|
+
try:
|
|
780
|
+
# Ensure remote base directory exists
|
|
781
|
+
conn.mkdir_p(remote_base)
|
|
782
|
+
|
|
783
|
+
# Pre-deploy commands / remote preparations
|
|
784
|
+
before_steps = normalize_steps(cfg.get("before_deploy") or cfg.get("pre_deploy") or cfg.get("before_run"))
|
|
785
|
+
if before_steps:
|
|
786
|
+
print_separator("â", 50, Fore.MAGENTA)
|
|
787
|
+
log_step(name, "PRE-DEPLOY", f"Running {len(before_steps)} pre-deploy step(s) on VPS...")
|
|
788
|
+
for s in before_steps:
|
|
789
|
+
action = s["action"]
|
|
790
|
+
if action in ("RUN", "CMD"):
|
|
791
|
+
conn.run_cmd(s["command"], cwd=remote_base)
|
|
792
|
+
elif action == "COPY":
|
|
793
|
+
local_src = os.path.join(project_root, s["src"]) if not os.path.isabs(s["src"]) else s["src"]
|
|
794
|
+
remote_dst = s["dest"].replace("${remote_dir}", remote_base)
|
|
795
|
+
if not remote_dst.startswith("/"):
|
|
796
|
+
remote_dst = f"{remote_base}/{remote_dst}".replace("\\", "/")
|
|
797
|
+
if os.path.isdir(local_src):
|
|
798
|
+
conn.upload_directory(local_src, remote_dst)
|
|
799
|
+
elif os.path.isfile(local_src):
|
|
800
|
+
conn.upload_file(local_src, remote_dst)
|
|
801
|
+
|
|
802
|
+
# 4. Handle Docker OR Non-Docker Deployments
|
|
803
|
+
docker_cfg = cfg.get("docker")
|
|
804
|
+
is_docker = False
|
|
805
|
+
if isinstance(docker_cfg, dict):
|
|
806
|
+
is_docker = docker_cfg.get("enabled", True)
|
|
807
|
+
elif isinstance(docker_cfg, bool):
|
|
808
|
+
is_docker = docker_cfg
|
|
809
|
+
|
|
810
|
+
if is_docker:
|
|
811
|
+
if proto == "ftp":
|
|
812
|
+
log_warning("â ī¸ Docker deployments require SSH for execution. Over FTP, container commands cannot run.")
|
|
813
|
+
|
|
814
|
+
print_separator("â", 50, Fore.MAGENTA)
|
|
815
|
+
log_step(name, "DOCKER DEPLOY", "Deploying with Docker...")
|
|
816
|
+
|
|
817
|
+
d_dict = docker_cfg if isinstance(docker_cfg, dict) else {}
|
|
818
|
+
compose_rel = d_dict.get("compose_file") or "docker-compose.yml"
|
|
819
|
+
compose_path = os.path.join(project_root, compose_rel)
|
|
820
|
+
service_name = d_dict.get("service_name") or d_dict.get("service") or name
|
|
821
|
+
image_name = d_dict.get("image") or f"{name}:latest"
|
|
822
|
+
context_rel = d_dict.get("context") or rel_work_dir
|
|
823
|
+
context_dir = os.path.join(project_root, context_rel)
|
|
824
|
+
build_mode = d_dict.get("build_mode", "local").lower() # 'local' (tarball) or 'remote'
|
|
825
|
+
|
|
826
|
+
# 4.1 Sync docker-compose.yml to VPS if present
|
|
827
|
+
if os.path.isfile(compose_path):
|
|
828
|
+
log_info(f"Syncing docker-compose file: {compose_rel} -> {remote_base}/docker-compose.yml")
|
|
829
|
+
conn.upload_file(compose_path, f"{remote_base}/docker-compose.yml")
|
|
830
|
+
|
|
831
|
+
# Auto-detect and sync referenced env_file(s) from docker-compose.yml or config
|
|
832
|
+
compose_dir = os.path.dirname(compose_path) or project_root
|
|
833
|
+
env_files_to_sync = set()
|
|
834
|
+
|
|
835
|
+
# Explicit env files from deploy.yml
|
|
836
|
+
explicit_envs = d_dict.get("env_file") or d_dict.get("env_files") or []
|
|
837
|
+
if isinstance(explicit_envs, str):
|
|
838
|
+
explicit_envs = [explicit_envs]
|
|
839
|
+
for ee in explicit_envs:
|
|
840
|
+
env_files_to_sync.add(ee)
|
|
841
|
+
|
|
842
|
+
# Auto-parse from docker-compose.yml
|
|
843
|
+
try:
|
|
844
|
+
with open(compose_path, "r", encoding="utf-8", errors="replace") as cf:
|
|
845
|
+
compose_data = yaml.safe_load(cf) or {}
|
|
846
|
+
if isinstance(compose_data, dict):
|
|
847
|
+
services_dict = compose_data.get("services", {})
|
|
848
|
+
if isinstance(services_dict, dict):
|
|
849
|
+
for _, s_def in services_dict.items():
|
|
850
|
+
if isinstance(s_def, dict) and "env_file" in s_def:
|
|
851
|
+
ef = s_def["env_file"]
|
|
852
|
+
if isinstance(ef, str):
|
|
853
|
+
env_files_to_sync.add(ef)
|
|
854
|
+
elif isinstance(ef, list):
|
|
855
|
+
for item in ef:
|
|
856
|
+
if isinstance(item, str):
|
|
857
|
+
env_files_to_sync.add(item)
|
|
858
|
+
elif isinstance(item, dict) and "path" in item:
|
|
859
|
+
env_files_to_sync.add(item["path"])
|
|
860
|
+
except Exception as e:
|
|
861
|
+
log_warning(f"Could not parse docker-compose.yml for env_files: {e}")
|
|
862
|
+
|
|
863
|
+
# Sync compose dir root .env if it exists and not yet added
|
|
864
|
+
root_env = os.path.join(compose_dir, ".env")
|
|
865
|
+
if os.path.isfile(root_env) and not explicit_envs:
|
|
866
|
+
env_files_to_sync.add(".env")
|
|
867
|
+
|
|
868
|
+
# Upload all discovered env files
|
|
869
|
+
for ef in sorted(env_files_to_sync):
|
|
870
|
+
clean_ef = ef.replace("\\", "/").strip("/")
|
|
871
|
+
# Handle paths that start with ./
|
|
872
|
+
search_rel = clean_ef[2:] if clean_ef.startswith("./") else clean_ef
|
|
873
|
+
|
|
874
|
+
candidate_paths = [
|
|
875
|
+
os.path.join(compose_dir, search_rel),
|
|
876
|
+
os.path.join(project_root, search_rel),
|
|
877
|
+
os.path.join(work_dir, search_rel),
|
|
878
|
+
os.path.join(compose_dir, os.path.basename(search_rel)),
|
|
879
|
+
os.path.join(project_root, os.path.basename(search_rel))
|
|
880
|
+
]
|
|
881
|
+
found_local = None
|
|
882
|
+
for cp in candidate_paths:
|
|
883
|
+
if os.path.isfile(cp):
|
|
884
|
+
found_local = cp
|
|
885
|
+
break
|
|
886
|
+
|
|
887
|
+
if found_local:
|
|
888
|
+
remote_env_dest = f"{remote_base}/{search_rel}".replace("\\", "/")
|
|
889
|
+
log_info(f"Syncing compose env file: {search_rel} -> {remote_env_dest}")
|
|
890
|
+
conn.upload_file(found_local, remote_env_dest)
|
|
891
|
+
else:
|
|
892
|
+
log_warning(f"Environment file '{ef}' referenced by docker-compose was not found locally.")
|
|
893
|
+
|
|
894
|
+
if build_mode == "local":
|
|
895
|
+
# Build locally, package as tarball, transfer, load into remote Docker
|
|
896
|
+
tar_name = f"{service_name}.tar"
|
|
897
|
+
local_tar_path = os.path.join(project_root, tar_name)
|
|
898
|
+
remote_tar_path = f"{remote_base}/{tar_name}"
|
|
899
|
+
|
|
900
|
+
log_step(name, "DOCKER BUILD", f"Building image locally: {image_name}...")
|
|
901
|
+
dockerfile_arg = f"-f {d_dict['dockerfile']}" if d_dict.get("dockerfile") else ""
|
|
902
|
+
run_local_cmd(f"docker build {dockerfile_arg} -t {image_name} {context_dir}")
|
|
903
|
+
|
|
904
|
+
log_step(name, "DOCKER SAVE", f"Saving image to archive: {tar_name}...")
|
|
905
|
+
run_local_cmd(f"docker save -o {local_tar_path} {image_name}")
|
|
906
|
+
|
|
907
|
+
log_step(name, "SFTP UPLOAD", f"Uploading image archive to VPS...")
|
|
908
|
+
conn.upload_file(local_tar_path, remote_tar_path)
|
|
909
|
+
|
|
910
|
+
if proto == "ssh":
|
|
911
|
+
log_step(name, "DOCKER LOAD", f"Loading image into Docker daemon on VPS...")
|
|
912
|
+
conn.run_cmd(f"docker load -i {remote_tar_path}")
|
|
913
|
+
conn.run_cmd(f"rm -f {remote_tar_path}")
|
|
914
|
+
|
|
915
|
+
# Clean up local archive
|
|
916
|
+
if os.path.exists(local_tar_path):
|
|
917
|
+
os.remove(local_tar_path)
|
|
918
|
+
log_info(f"Cleaned up local archive: {tar_name}")
|
|
919
|
+
|
|
920
|
+
else: # 'remote' build mode
|
|
921
|
+
if proto == "ssh":
|
|
922
|
+
log_step(name, "DOCKER BUILD REMOTE", f"Building service {service_name} directly on VPS...")
|
|
923
|
+
conn.run_cmd(f"docker compose build {service_name}", cwd=remote_base)
|
|
924
|
+
|
|
925
|
+
# Recreate container on VPS
|
|
926
|
+
if proto == "ssh":
|
|
927
|
+
log_step(name, "DOCKER RESTART", f"Starting container(s) on VPS...")
|
|
928
|
+
conn.run_cmd(f"docker compose up -d {service_name}", cwd=remote_base)
|
|
929
|
+
|
|
930
|
+
else:
|
|
931
|
+
# Non-Docker Deployment (Static file / code upload)
|
|
932
|
+
print_separator("â", 50, Fore.MAGENTA)
|
|
933
|
+
log_step(name, "UPLOAD", "Uploading build artifacts to remote server...")
|
|
934
|
+
upload_list = cfg.get("upload") or []
|
|
935
|
+
if isinstance(upload_list, str):
|
|
936
|
+
upload_list = [upload_list]
|
|
937
|
+
|
|
938
|
+
if not upload_list:
|
|
939
|
+
# Default: upload entire working dir or dist folder if present
|
|
940
|
+
candidate_dist = os.path.join(work_dir, "dist")
|
|
941
|
+
if os.path.isdir(candidate_dist):
|
|
942
|
+
upload_list.append(f"dist/ -> {remote_base}")
|
|
943
|
+
else:
|
|
944
|
+
upload_list.append(f". -> {remote_base}")
|
|
945
|
+
|
|
946
|
+
for up in upload_list:
|
|
947
|
+
src, dst = "", ""
|
|
948
|
+
if isinstance(up, dict):
|
|
949
|
+
src, dst = up.get("src", ""), up.get("dest", "")
|
|
950
|
+
elif isinstance(up, str):
|
|
951
|
+
parts = up.replace("->", " ").split()
|
|
952
|
+
if len(parts) >= 2:
|
|
953
|
+
src, dst = parts[0], parts[1]
|
|
954
|
+
else:
|
|
955
|
+
src, dst = parts[0], remote_base
|
|
956
|
+
|
|
957
|
+
dst = dst.replace("${remote_dir}", remote_base)
|
|
958
|
+
if not dst.startswith("/"):
|
|
959
|
+
dst = f"{remote_base}/{dst}".replace("\\", "/")
|
|
960
|
+
|
|
961
|
+
local_full = os.path.join(work_dir, src) if not os.path.isabs(src) else src
|
|
962
|
+
if os.path.isdir(local_full):
|
|
963
|
+
conn.upload_directory(local_full, dst)
|
|
964
|
+
elif os.path.isfile(local_full):
|
|
965
|
+
conn.upload_file(local_full, dst)
|
|
966
|
+
else:
|
|
967
|
+
log_warning(f"File or directory to upload not found: {local_full}")
|
|
968
|
+
|
|
969
|
+
# 5. Remote Post-Deploy / Run in VPS Phase
|
|
970
|
+
remote_steps = normalize_steps(cfg.get("run_in_vps") or cfg.get("after_deploy") or cfg.get("post_deploy"))
|
|
971
|
+
if remote_steps:
|
|
972
|
+
print_separator("â", 50, Fore.MAGENTA)
|
|
973
|
+
log_step(name, "RUN IN VPS", f"Executing {len(remote_steps)} remote command(s)...")
|
|
974
|
+
for s in remote_steps:
|
|
975
|
+
action = s["action"]
|
|
976
|
+
if action in ("RUN", "CMD"):
|
|
977
|
+
conn.run_cmd(s["command"], cwd=remote_base)
|
|
978
|
+
elif action == "COPY":
|
|
979
|
+
local_src = os.path.join(project_root, s["src"]) if not os.path.isabs(s["src"]) else s["src"]
|
|
980
|
+
remote_dst = s["dest"].replace("${remote_dir}", remote_base)
|
|
981
|
+
if not remote_dst.startswith("/"):
|
|
982
|
+
remote_dst = f"{remote_base}/{remote_dst}".replace("\\", "/")
|
|
983
|
+
if os.path.isdir(local_src):
|
|
984
|
+
conn.upload_directory(local_src, remote_dst)
|
|
985
|
+
elif os.path.isfile(local_src):
|
|
986
|
+
conn.upload_file(local_src, remote_dst)
|
|
987
|
+
|
|
988
|
+
log_success(f"Service '{name}' deployed successfully!")
|
|
989
|
+
|
|
990
|
+
finally:
|
|
991
|
+
if conn:
|
|
992
|
+
conn.close()
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
# =============================================================================
|
|
996
|
+
# đ CLI HELP & EXAMPLES
|
|
997
|
+
# =============================================================================
|
|
998
|
+
|
|
999
|
+
EXAMPLE_DEPLOY_YAML = """# =============================================================================
|
|
1000
|
+
# đ Universal Deployer Configuration (deploy.yml)
|
|
1001
|
+
# =============================================================================
|
|
1002
|
+
# Environment variables like ${VPS_IP} or ${SSH_KEY_PATH} are loaded from .env!
|
|
1003
|
+
|
|
1004
|
+
server:
|
|
1005
|
+
type: ssh # "ssh" (SFTP + Shell) or "ftp" (File transfer only)
|
|
1006
|
+
host: ${VPS_IP} # VPS Hostname or IP
|
|
1007
|
+
user: ${VPS_USER:-root} # User (defaults to root)
|
|
1008
|
+
port: 22 # Port (22 for SSH, 21 for FTP)
|
|
1009
|
+
key_path: ${SSH_KEY_PATH} # Path to SSH private key
|
|
1010
|
+
passphrase: ${SSH_PASSPHRASE}# (Optional) Passphrase for private key
|
|
1011
|
+
password: ${VPS_PASSWORD} # (Optional) Password for SSH or FTP
|
|
1012
|
+
remote_dir: /opt/my-app # Remote base directory on VPS
|
|
1013
|
+
|
|
1014
|
+
services:
|
|
1015
|
+
# ---------------------------------------------------------------------------
|
|
1016
|
+
# đ Frontend (React / Next.js / Vue / HTML) - WITHOUT DOCKER
|
|
1017
|
+
# ---------------------------------------------------------------------------
|
|
1018
|
+
frontend:
|
|
1019
|
+
description: "Build frontend locally and sync static files to VPS"
|
|
1020
|
+
docker: false # No docker for frontend
|
|
1021
|
+
work_dir: ./frontend # Working directory
|
|
1022
|
+
|
|
1023
|
+
# Dockerfile-like build instructions:
|
|
1024
|
+
build:
|
|
1025
|
+
- RUN npm install
|
|
1026
|
+
- RUN npm run build
|
|
1027
|
+
- COPY package.json dist/package.json
|
|
1028
|
+
|
|
1029
|
+
# Upload dist output to remote directory
|
|
1030
|
+
upload:
|
|
1031
|
+
- dist/ -> ${remote_dir}/frontend/dist
|
|
1032
|
+
|
|
1033
|
+
# Commands executed on remote VPS (SSH only):
|
|
1034
|
+
run_in_vps:
|
|
1035
|
+
- RUN systemctl reload nginx
|
|
1036
|
+
- RUN echo "Frontend updated!"
|
|
1037
|
+
|
|
1038
|
+
# ---------------------------------------------------------------------------
|
|
1039
|
+
# đŗ Backend (FastAPI / Node / Python) - WITH DOCKER
|
|
1040
|
+
# ---------------------------------------------------------------------------
|
|
1041
|
+
backend:
|
|
1042
|
+
description: "Build local Docker image, transfer tar, and restart container"
|
|
1043
|
+
work_dir: ./backend
|
|
1044
|
+
|
|
1045
|
+
docker:
|
|
1046
|
+
enabled: true
|
|
1047
|
+
compose_file: docker-compose.yml
|
|
1048
|
+
# env_file: .env.production # (Optional: auto-detected from docker-compose.yml!)
|
|
1049
|
+
service_name: backend
|
|
1050
|
+
build_mode: local # "local" (tarball upload) or "remote" (build on VPS)
|
|
1051
|
+
image: my-backend:latest
|
|
1052
|
+
context: ./backend
|
|
1053
|
+
|
|
1054
|
+
# Run on VPS before docker starts:
|
|
1055
|
+
before_deploy:
|
|
1056
|
+
- RUN mkdir -p ${remote_dir}/backend/data
|
|
1057
|
+
|
|
1058
|
+
# Run on VPS after deployment:
|
|
1059
|
+
# (Note: 'docker compose up -d <service>' is already executed automatically!)
|
|
1060
|
+
run_in_vps:
|
|
1061
|
+
- RUN docker compose ps
|
|
1062
|
+
- RUN docker compose logs --tail=25 backend
|
|
1063
|
+
"""
|
|
1064
|
+
|
|
1065
|
+
def print_help_screen():
|
|
1066
|
+
banner()
|
|
1067
|
+
help_text = f"""{Fore.WHITE}{Style.BRIGHT}USAGE:{Style.RESET_ALL}
|
|
1068
|
+
uhdeploy [OPTIONS] [SERVICE_NAME]
|
|
1069
|
+
{Fore.LIGHTBLACK_EX}(or python -m uhdeploy [OPTIONS] [SERVICE_NAME]){Style.RESET_ALL}
|
|
1070
|
+
|
|
1071
|
+
{Fore.WHITE}{Style.BRIGHT}DESCRIPTION:{Style.RESET_ALL}
|
|
1072
|
+
uhdeploy reads your {Fore.CYAN}deploy.yml{Style.RESET_ALL} (or {Fore.CYAN}deploy.yaml{Style.RESET_ALL}) file and deploys
|
|
1073
|
+
your services to a VPS using SSH/SFTP or FTP. It supports Docker packaging,
|
|
1074
|
+
local build pipelines, and Dockerfile-style step syntax ({Fore.YELLOW}RUN{Style.RESET_ALL}, {Fore.YELLOW}COPY{Style.RESET_ALL}, {Fore.YELLOW}CMD{Style.RESET_ALL}).
|
|
1075
|
+
|
|
1076
|
+
{Fore.WHITE}{Style.BRIGHT}OPTIONS:{Style.RESET_ALL}
|
|
1077
|
+
{Fore.GREEN}-h, --help{Style.RESET_ALL} Show this colorful help message and exit.
|
|
1078
|
+
{Fore.GREEN}-e, --example{Style.RESET_ALL} Display a syntax-highlighted example deploy.yml.
|
|
1079
|
+
{Fore.GREEN}--init{Style.RESET_ALL} Generate a starter {Fore.CYAN}deploy.yml{Style.RESET_ALL} in the current directory.
|
|
1080
|
+
{Fore.GREEN}-f, --file PATH{Style.RESET_ALL} Specify path to deploy configuration file.
|
|
1081
|
+
{Fore.GREEN}-p, --project DIR{Style.RESET_ALL} Specify project directory containing deploy configuration.
|
|
1082
|
+
{Fore.GREEN}-s, --service NAME{Style.RESET_ALL} Name of service to deploy (or {Fore.CYAN}'all'{Style.RESET_ALL} for all services).
|
|
1083
|
+
{Fore.GREEN}-l, --list{Style.RESET_ALL} List all services available in the configuration file.
|
|
1084
|
+
{Fore.GREEN}--dry-run{Style.RESET_ALL} Validate configuration and simulate steps without connecting.
|
|
1085
|
+
|
|
1086
|
+
{Fore.WHITE}{Style.BRIGHT}INTERACTIVE DISCOVERY:{Style.RESET_ALL}
|
|
1087
|
+
If no config file is found in the current folder, uhdeploy prompts you
|
|
1088
|
+
to enter your project path or config file path automatically.
|
|
1089
|
+
|
|
1090
|
+
{Fore.WHITE}{Style.BRIGHT}DOCKERFILE-STYLE STEP COMMANDS:{Style.RESET_ALL}
|
|
1091
|
+
In {Fore.CYAN}build{Style.RESET_ALL}, {Fore.CYAN}before_deploy{Style.RESET_ALL}, or {Fore.CYAN}run_in_vps{Style.RESET_ALL}, you can write steps like:
|
|
1092
|
+
âĸ {Fore.YELLOW}RUN <shell command>{Style.RESET_ALL} Executes command (locally or on VPS)
|
|
1093
|
+
âĸ {Fore.YELLOW}COPY <src> <dest>{Style.RESET_ALL} Copies local file/folder or uploads to VPS
|
|
1094
|
+
âĸ {Fore.YELLOW}WORKDIR <dir>{Style.RESET_ALL} Changes working directory
|
|
1095
|
+
âĸ {Fore.YELLOW}CMD <command>{Style.RESET_ALL} Alias for RUN
|
|
1096
|
+
|
|
1097
|
+
{Fore.WHITE}{Style.BRIGHT}SSH VS. FTP CAPABILITIES:{Style.RESET_ALL}
|
|
1098
|
+
âĸ {Fore.CYAN}SSH{Style.RESET_ALL}: Full support for file transfers (SFTP), remote terminal commands,
|
|
1099
|
+
Docker container management, and system services.
|
|
1100
|
+
âĸ {Fore.CYAN}FTP{Style.RESET_ALL}: File transfer protocol only. Useful for static / PHP hosting.
|
|
1101
|
+
Remote shell commands ({Fore.YELLOW}run_in_vps{Style.RESET_ALL}) are not supported over FTP.
|
|
1102
|
+
|
|
1103
|
+
{Fore.WHITE}{Style.BRIGHT}EXAMPLES OF RUNNING:{Style.RESET_ALL}
|
|
1104
|
+
{Fore.LIGHTBLACK_EX}# Interactive service selection menu:{Style.RESET_ALL}
|
|
1105
|
+
uhdeploy
|
|
1106
|
+
|
|
1107
|
+
{Fore.LIGHTBLACK_EX}# Deploy frontend only:{Style.RESET_ALL}
|
|
1108
|
+
uhdeploy frontend
|
|
1109
|
+
|
|
1110
|
+
{Fore.LIGHTBLACK_EX}# Deploy all services:{Style.RESET_ALL}
|
|
1111
|
+
uhdeploy all
|
|
1112
|
+
|
|
1113
|
+
{Fore.LIGHTBLACK_EX}# Dry-run backend to verify steps:{Style.RESET_ALL}
|
|
1114
|
+
uhdeploy backend --dry-run
|
|
1115
|
+
"""
|
|
1116
|
+
print(help_text)
|
|
1117
|
+
|
|
1118
|
+
def print_example_file():
|
|
1119
|
+
banner()
|
|
1120
|
+
print(f"{Fore.CYAN}đ EXAMPLE DEPLOYMENT FILE (deploy.yml):{Style.RESET_ALL}\n")
|
|
1121
|
+
for line in EXAMPLE_DEPLOY_YAML.splitlines():
|
|
1122
|
+
if line.strip().startswith("#"):
|
|
1123
|
+
print(f"{Fore.LIGHTBLACK_EX}{line}{Style.RESET_ALL}")
|
|
1124
|
+
elif line.strip().endswith(":") and not line.strip().startswith("-"):
|
|
1125
|
+
print(f"{Fore.CYAN}{Style.BRIGHT}{line}{Style.RESET_ALL}")
|
|
1126
|
+
elif line.strip().startswith("- RUN") or line.strip().startswith("- CMD"):
|
|
1127
|
+
print(f"{Fore.YELLOW}{line}{Style.RESET_ALL}")
|
|
1128
|
+
elif line.strip().startswith("- COPY"):
|
|
1129
|
+
print(f"{Fore.LIGHTBLUE_EX}{line}{Style.RESET_ALL}")
|
|
1130
|
+
elif "${" in line:
|
|
1131
|
+
print(f"{Fore.GREEN}{line}{Style.RESET_ALL}")
|
|
1132
|
+
else:
|
|
1133
|
+
print(f"{Fore.WHITE}{line}{Style.RESET_ALL}")
|
|
1134
|
+
print()
|
|
1135
|
+
|
|
1136
|
+
def init_deploy_file():
|
|
1137
|
+
target = os.path.join(os.getcwd(), "deploy.yml")
|
|
1138
|
+
if os.path.exists(target):
|
|
1139
|
+
log_warning(f"'deploy.yml' already exists in {os.getcwd()}.")
|
|
1140
|
+
confirm = input(f"{Fore.CYAN}Overwrite? (y/N): {Style.RESET_ALL}").strip().lower()
|
|
1141
|
+
if confirm != "y":
|
|
1142
|
+
print("Aborted.")
|
|
1143
|
+
return
|
|
1144
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
1145
|
+
f.write(EXAMPLE_DEPLOY_YAML)
|
|
1146
|
+
log_success(f"Generated starter 'deploy.yml' in: {target}")
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
# =============================================================================
|
|
1150
|
+
# đ INTERACTIVE SERVICE SELECTION MENU
|
|
1151
|
+
# =============================================================================
|
|
1152
|
+
|
|
1153
|
+
def select_service_interactive(services):
|
|
1154
|
+
banner()
|
|
1155
|
+
svc_keys = list(services.keys())
|
|
1156
|
+
if not svc_keys:
|
|
1157
|
+
log_error("No services available to deploy.")
|
|
1158
|
+
sys.exit(1)
|
|
1159
|
+
|
|
1160
|
+
print(f"{Fore.WHITE}{Style.BRIGHT}Select component to deploy:{Style.RESET_ALL}\n")
|
|
1161
|
+
for idx, name in enumerate(svc_keys, 1):
|
|
1162
|
+
s_cfg = services[name]
|
|
1163
|
+
is_docker = False
|
|
1164
|
+
docker_val = s_cfg.get("docker")
|
|
1165
|
+
if isinstance(docker_val, dict):
|
|
1166
|
+
is_docker = docker_val.get("enabled", True)
|
|
1167
|
+
elif isinstance(docker_val, bool):
|
|
1168
|
+
is_docker = docker_val
|
|
1169
|
+
|
|
1170
|
+
docker_badge = f"{Fore.CYAN}[Docker]{Style.RESET_ALL}" if is_docker else f"{Fore.LIGHTBLUE_EX}[No Docker]{Style.RESET_ALL}"
|
|
1171
|
+
proto = (s_cfg.get("server", {}).get("type") or "ssh").upper()
|
|
1172
|
+
proto_badge = f"{Fore.YELLOW}[{proto}]{Style.RESET_ALL}"
|
|
1173
|
+
desc = s_cfg.get("description", "")
|
|
1174
|
+
desc_str = f" - {Fore.LIGHTBLACK_EX}{desc}{Style.RESET_ALL}" if desc else ""
|
|
1175
|
+
|
|
1176
|
+
print(f" {Fore.GREEN}{Style.BRIGHT}{idx}){Style.RESET_ALL} {Fore.WHITE}{Style.BRIGHT}{name:<15}{Style.RESET_ALL} {docker_badge} {proto_badge}{desc_str}")
|
|
1177
|
+
|
|
1178
|
+
all_idx = len(svc_keys) + 1
|
|
1179
|
+
print(f" {Fore.GREEN}{Style.BRIGHT}{all_idx}){Style.RESET_ALL} {Fore.WHITE}{Style.BRIGHT}All Components {Style.RESET_ALL} {Fore.LIGHTBLACK_EX}(Deploy all services sequentially){Style.RESET_ALL}")
|
|
1180
|
+
print(f" {Fore.RED}q){Style.RESET_ALL} Quit\n")
|
|
1181
|
+
|
|
1182
|
+
while True:
|
|
1183
|
+
try:
|
|
1184
|
+
choice = input(f"{Fore.CYAN}Enter choice (1-{all_idx} or q): {Style.RESET_ALL}").strip().lower()
|
|
1185
|
+
except (KeyboardInterrupt, EOFError):
|
|
1186
|
+
print("\nOperation cancelled.")
|
|
1187
|
+
sys.exit(0)
|
|
1188
|
+
|
|
1189
|
+
if choice == "q":
|
|
1190
|
+
print(f"{Fore.YELLOW}Goodbye!{Style.RESET_ALL}")
|
|
1191
|
+
sys.exit(0)
|
|
1192
|
+
|
|
1193
|
+
if choice.isdigit():
|
|
1194
|
+
c_int = int(choice)
|
|
1195
|
+
if 1 <= c_int <= len(svc_keys):
|
|
1196
|
+
return [svc_keys[c_int - 1]]
|
|
1197
|
+
elif c_int == all_idx:
|
|
1198
|
+
return svc_keys
|
|
1199
|
+
|
|
1200
|
+
log_error("Invalid selection. Please choose an option from the menu.")
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
# =============================================================================
|
|
1204
|
+
# đ MAIN ENTRY POINT
|
|
1205
|
+
# =============================================================================
|
|
1206
|
+
|
|
1207
|
+
def main():
|
|
1208
|
+
parser = argparse.ArgumentParser(
|
|
1209
|
+
prog="uhdeploy",
|
|
1210
|
+
description="uhdeploy - Universal Local-to-VPS Deployment CLI",
|
|
1211
|
+
add_help=False
|
|
1212
|
+
)
|
|
1213
|
+
parser.add_argument("service", nargs="?", default=None, help="Service name to deploy (or 'all')")
|
|
1214
|
+
parser.add_argument("-h", "--help", action="store_true", help="Show help screen")
|
|
1215
|
+
parser.add_argument("-e", "--example", action="store_true", help="Show example deploy.yml configuration")
|
|
1216
|
+
parser.add_argument("--init", action="store_true", help="Generate starter deploy.yml in current directory")
|
|
1217
|
+
parser.add_argument("-f", "--file", dest="config_file", default=None, help="Path to deploy configuration file")
|
|
1218
|
+
parser.add_argument("-p", "--project", dest="project_dir", default=None, help="Project directory path")
|
|
1219
|
+
parser.add_argument("-s", "--service", dest="flag_service", default=None, help="Service name to deploy")
|
|
1220
|
+
parser.add_argument("-l", "--list", action="store_true", help="List all configured services")
|
|
1221
|
+
parser.add_argument("--dry-run", action="store_true", help="Dry run deployment without making remote changes")
|
|
1222
|
+
|
|
1223
|
+
args = parser.parse_args()
|
|
1224
|
+
|
|
1225
|
+
# Help flag
|
|
1226
|
+
if args.help:
|
|
1227
|
+
print_help_screen()
|
|
1228
|
+
sys.exit(0)
|
|
1229
|
+
|
|
1230
|
+
# Example flag
|
|
1231
|
+
if args.example:
|
|
1232
|
+
print_example_file()
|
|
1233
|
+
sys.exit(0)
|
|
1234
|
+
|
|
1235
|
+
# Init flag
|
|
1236
|
+
if args.init:
|
|
1237
|
+
banner()
|
|
1238
|
+
init_deploy_file()
|
|
1239
|
+
sys.exit(0)
|
|
1240
|
+
|
|
1241
|
+
# Find configuration file (Interactive flow if not found)
|
|
1242
|
+
config_path, project_root = find_config_interactive(
|
|
1243
|
+
cli_file=args.config_file,
|
|
1244
|
+
cli_project=args.project_dir
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
log_info(f"Loaded configuration: {Fore.WHITE}{config_path}")
|
|
1248
|
+
log_info(f"Project Root: {Fore.WHITE}{project_root}")
|
|
1249
|
+
|
|
1250
|
+
# Parse config and substitute environment variables
|
|
1251
|
+
services, env_vars = parse_deploy_config(config_path, project_root)
|
|
1252
|
+
|
|
1253
|
+
# List services flag
|
|
1254
|
+
if args.list:
|
|
1255
|
+
banner()
|
|
1256
|
+
print(f"{Fore.WHITE}{Style.BRIGHT}Configured Services in {os.path.basename(config_path)}:{Style.RESET_ALL}\n")
|
|
1257
|
+
for s_name, s_data in services.items():
|
|
1258
|
+
s_type = "Docker" if s_data.get("docker") else "No Docker"
|
|
1259
|
+
proto = (s_data.get("server", {}).get("type") or "ssh").upper()
|
|
1260
|
+
print(f" âĸ {Fore.GREEN}{s_name:<16}{Style.RESET_ALL} [{s_type}] [{proto}] - {s_data.get('description', '')}")
|
|
1261
|
+
print()
|
|
1262
|
+
sys.exit(0)
|
|
1263
|
+
|
|
1264
|
+
# Determine service to deploy
|
|
1265
|
+
target_service = args.flag_service or args.service
|
|
1266
|
+
|
|
1267
|
+
if target_service:
|
|
1268
|
+
if target_service.lower() == "all":
|
|
1269
|
+
selected_services = list(services.keys())
|
|
1270
|
+
else:
|
|
1271
|
+
if target_service not in services:
|
|
1272
|
+
log_error(f"Service '{target_service}' is not defined in {os.path.basename(config_path)}.")
|
|
1273
|
+
log_info(f"Available services: {', '.join(services.keys())}")
|
|
1274
|
+
sys.exit(1)
|
|
1275
|
+
selected_services = [target_service]
|
|
1276
|
+
else:
|
|
1277
|
+
# If only 1 service defined, use it directly or show interactive menu
|
|
1278
|
+
selected_services = select_service_interactive(services)
|
|
1279
|
+
|
|
1280
|
+
# Execute deployment for chosen services
|
|
1281
|
+
banner()
|
|
1282
|
+
log_info(f"Deploying service(s): {Fore.WHITE}{', '.join(selected_services)}")
|
|
1283
|
+
if args.dry_run:
|
|
1284
|
+
log_warning("DRY-RUN MODE ENABLED: No remote changes or uploads will be performed.")
|
|
1285
|
+
|
|
1286
|
+
for s_name in selected_services:
|
|
1287
|
+
deploy_service(
|
|
1288
|
+
name=s_name,
|
|
1289
|
+
cfg=services[s_name],
|
|
1290
|
+
project_root=project_root,
|
|
1291
|
+
dry_run=args.dry_run
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
print_separator("â", 75, Fore.GREEN)
|
|
1295
|
+
log_success("All selected deployment tasks completed successfully!")
|
|
1296
|
+
print_separator("â", 75, Fore.GREEN)
|
|
1297
|
+
|
|
1298
|
+
if __name__ == "__main__":
|
|
1299
|
+
main()
|
|
1300
|
+
|