benchops 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.
- benchops/__init__.py +0 -0
- benchops/auth.py +24 -0
- benchops/cli.py +177 -0
- benchops/config.py +77 -0
- benchops/runner.py +74 -0
- benchops/sync.py +39 -0
- benchops-0.1.0.dist-info/METADATA +175 -0
- benchops-0.1.0.dist-info/RECORD +10 -0
- benchops-0.1.0.dist-info/WHEEL +4 -0
- benchops-0.1.0.dist-info/entry_points.txt +2 -0
benchops/__init__.py
ADDED
|
File without changes
|
benchops/auth.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Authentication and credential management for benchops."""
|
|
2
|
+
|
|
3
|
+
import keyring
|
|
4
|
+
|
|
5
|
+
SERVICE_NAME = "benchops"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthManager:
|
|
9
|
+
"""Securely stores and retrieves server passwords via the system keyring."""
|
|
10
|
+
|
|
11
|
+
def set_password(self, server_alias: str, password: str) -> None:
|
|
12
|
+
"""Store the password for a server in the system credential store."""
|
|
13
|
+
keyring.set_password(SERVICE_NAME, server_alias, password)
|
|
14
|
+
|
|
15
|
+
def get_password(self, server_alias: str) -> str | None:
|
|
16
|
+
"""Return the stored password for a server, or None if not set."""
|
|
17
|
+
return keyring.get_password(SERVICE_NAME, server_alias)
|
|
18
|
+
|
|
19
|
+
def delete_password(self, server_alias: str) -> None:
|
|
20
|
+
"""Delete the stored password for a server if one exists."""
|
|
21
|
+
try:
|
|
22
|
+
keyring.delete_password(SERVICE_NAME, server_alias)
|
|
23
|
+
except keyring.errors.PasswordDeleteError:
|
|
24
|
+
pass
|
benchops/cli.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""CLI entry point for benchops."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from benchops.auth import AuthManager
|
|
13
|
+
from benchops.config import ConfigManager
|
|
14
|
+
from benchops.runner import LocalRunner, RemoteConnectionError, RemoteRunner
|
|
15
|
+
from benchops.sync import create_tarball, extract_and_cleanup, transfer_tarball
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="benchops",
|
|
19
|
+
help="A CLI tool to synchronize local Frappe development environments with remote servers.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
server_app = typer.Typer(help="Manage configured remote servers.")
|
|
23
|
+
app.add_typer(server_app, name="server")
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_app_dir(app_name: str) -> Path:
|
|
29
|
+
cwd = Path.cwd()
|
|
30
|
+
for candidate in (cwd / "apps" / app_name, Path(app_name)):
|
|
31
|
+
if candidate.is_dir():
|
|
32
|
+
return candidate
|
|
33
|
+
raise FileNotFoundError(
|
|
34
|
+
f"Local app directory '{app_name}' not found (looked in '{cwd / 'apps'}' and '{cwd}')."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def init() -> None:
|
|
40
|
+
"""Initialize the benchops configuration."""
|
|
41
|
+
ConfigManager().init_config()
|
|
42
|
+
console.print("[green]benchops initialized successfully.[/green]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@server_app.command("add")
|
|
46
|
+
def add_server(
|
|
47
|
+
alias: str = typer.Option(..., prompt="Server alias", help="Alias for the server."),
|
|
48
|
+
host: str = typer.Option(..., prompt="Server host", help="Hostname or IP address."),
|
|
49
|
+
port: int = typer.Option(22, prompt="SSH port", help="SSH port (default: 22)."),
|
|
50
|
+
user: str = typer.Option(..., prompt="SSH user", help="SSH username."),
|
|
51
|
+
bench_path: str = typer.Option(..., prompt="Remote bench path", help="Path to the bench directory on the server."),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Add or update a configured server."""
|
|
54
|
+
ConfigManager().add_server(alias, host, port, user, bench_path)
|
|
55
|
+
console.print(f"[green]Server '{alias}' saved to configuration.[/green]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@server_app.command("list")
|
|
59
|
+
def list_servers() -> None:
|
|
60
|
+
"""List all configured servers."""
|
|
61
|
+
servers = ConfigManager().list_servers()
|
|
62
|
+
if not servers:
|
|
63
|
+
console.print("[yellow]No servers configured yet. Run 'benchops server add' to add one.[/yellow]")
|
|
64
|
+
return
|
|
65
|
+
table = Table(title="Configured Servers")
|
|
66
|
+
table.add_column("Alias", style="bold cyan", no_wrap=True)
|
|
67
|
+
table.add_column("Host")
|
|
68
|
+
table.add_column("Port")
|
|
69
|
+
table.add_column("User")
|
|
70
|
+
table.add_column("Bench Path")
|
|
71
|
+
for alias, config in sorted(servers.items()):
|
|
72
|
+
table.add_row(
|
|
73
|
+
alias,
|
|
74
|
+
config["host"],
|
|
75
|
+
str(config["port"]),
|
|
76
|
+
config["user"],
|
|
77
|
+
config["bench_path"],
|
|
78
|
+
)
|
|
79
|
+
console.print(table)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@server_app.command("set-auth")
|
|
83
|
+
def set_auth(
|
|
84
|
+
alias: str = typer.Argument(..., help="Alias of the configured server."),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Set authentication credentials (password or SSH key) for a server."""
|
|
87
|
+
config = ConfigManager()
|
|
88
|
+
if config.get_server(alias) is None:
|
|
89
|
+
console.print(f"[red]Error: No server found with alias '{alias}'.[/red]")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
while True:
|
|
93
|
+
auth_type = typer.prompt("Authentication type [password/key]").strip().lower()
|
|
94
|
+
if auth_type in ("password", "key"):
|
|
95
|
+
break
|
|
96
|
+
console.print("[red]Invalid choice. Enter 'password' or 'key'.[/red]")
|
|
97
|
+
|
|
98
|
+
if auth_type == "password":
|
|
99
|
+
password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
100
|
+
try:
|
|
101
|
+
AuthManager().set_password(alias, password)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
console.print(f"[red]Error: Failed to save password: {exc}[/red]")
|
|
104
|
+
raise typer.Exit(1)
|
|
105
|
+
console.print(f"[green]Password saved for server '{alias}'.[/green]")
|
|
106
|
+
else:
|
|
107
|
+
key_path = typer.prompt("Absolute path to the SSH private key", default="~/.ssh/id_rsa")
|
|
108
|
+
try:
|
|
109
|
+
config.update_server_key(alias, key_path)
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
console.print(f"[green]Private key path saved for server '{alias}'.[/green]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("deploy")
|
|
117
|
+
def deploy(
|
|
118
|
+
app_name: str = typer.Argument(..., help="Name of the local Frappe app directory to sync."),
|
|
119
|
+
server_alias: str = typer.Argument(..., help="Alias of the target server."),
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Deploy a local Frappe app to a remote server."""
|
|
122
|
+
config = ConfigManager().get_server(server_alias)
|
|
123
|
+
if config is None:
|
|
124
|
+
console.print(f"[red]Error: Server '{server_alias}' not found in configuration.[/red]")
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
password = AuthManager().get_password(server_alias)
|
|
129
|
+
except Exception:
|
|
130
|
+
password = None
|
|
131
|
+
key_path = config.get("private_key_path")
|
|
132
|
+
|
|
133
|
+
if not password and not key_path:
|
|
134
|
+
console.print(
|
|
135
|
+
f"[red]Error: No authentication configured for server '{server_alias}'. "
|
|
136
|
+
"Run 'benchops server set-auth' first.[/red]"
|
|
137
|
+
)
|
|
138
|
+
raise typer.Exit(1)
|
|
139
|
+
|
|
140
|
+
remote_runner = RemoteRunner(
|
|
141
|
+
host=config["host"],
|
|
142
|
+
port=int(config["port"]),
|
|
143
|
+
user=config["user"],
|
|
144
|
+
password=password,
|
|
145
|
+
key_path=key_path,
|
|
146
|
+
)
|
|
147
|
+
local_runner = LocalRunner()
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
app_dir = _resolve_app_dir(app_name)
|
|
151
|
+
console.print("[yellow]Starting local builds...[/yellow]")
|
|
152
|
+
local_runner.run(["bench", "build", "--app", app_name], cwd=str(app_dir.parent))
|
|
153
|
+
|
|
154
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
155
|
+
tarball = create_tarball(str(app_dir), os.path.join(tmp_dir, f"{app_name}.tar.gz"))
|
|
156
|
+
console.print(f"[green]Created tarball: {tarball}[/green]")
|
|
157
|
+
|
|
158
|
+
console.print(
|
|
159
|
+
f"[yellow]Connecting to {config['user']}@{config['host']}:{config['port']}...[/yellow]"
|
|
160
|
+
)
|
|
161
|
+
remote_dest_dir = f"{config['bench_path']}/apps"
|
|
162
|
+
remote_tar_path = transfer_tarball(remote_runner, tarball, remote_dest_dir)
|
|
163
|
+
console.print(f"[green]Transferred tarball to {remote_tar_path}[/green]")
|
|
164
|
+
|
|
165
|
+
extract_and_cleanup(remote_runner, remote_tar_path, remote_dest_dir)
|
|
166
|
+
console.print("[green]Extracted on remote server.[/green]")
|
|
167
|
+
|
|
168
|
+
console.print("[yellow]Starting remote operations...[/yellow]")
|
|
169
|
+
remote_runner.run("bench --site all migrate", cwd=config["bench_path"])
|
|
170
|
+
remote_runner.run("bench clear-cache", cwd=config["bench_path"])
|
|
171
|
+
|
|
172
|
+
console.print(f"[green]Successfully deployed '{app_name}' to '{server_alias}'.[/green]")
|
|
173
|
+
except (subprocess.CalledProcessError, RemoteConnectionError, FileNotFoundError) as exc:
|
|
174
|
+
console.print(f"[red]Deployment failed: {exc}[/red]")
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
finally:
|
|
177
|
+
remote_runner.close()
|
benchops/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Configuration management for benchops."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import tomlkit
|
|
6
|
+
from tomlkit.toml_file import TOMLFile
|
|
7
|
+
|
|
8
|
+
CONFIG_PATH = Path.home() / ".benchops" / "config.toml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigManager:
|
|
12
|
+
"""Reads and writes the local benchops TOML configuration file."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config_path: Path = CONFIG_PATH) -> None:
|
|
15
|
+
self.config_path = config_path
|
|
16
|
+
self.config_dir = self.config_path.parent
|
|
17
|
+
|
|
18
|
+
def init_config(self) -> None:
|
|
19
|
+
"""Create the config directory and file with a [servers] table."""
|
|
20
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
if self.config_path.exists():
|
|
22
|
+
return
|
|
23
|
+
doc = tomlkit.document()
|
|
24
|
+
doc["servers"] = tomlkit.table()
|
|
25
|
+
TOMLFile(self.config_path).write(doc)
|
|
26
|
+
|
|
27
|
+
def _read(self) -> tomlkit.TOMLDocument:
|
|
28
|
+
return TOMLFile(self.config_path).read()
|
|
29
|
+
|
|
30
|
+
def add_server(
|
|
31
|
+
self,
|
|
32
|
+
alias: str,
|
|
33
|
+
host: str,
|
|
34
|
+
port: int,
|
|
35
|
+
user: str,
|
|
36
|
+
bench_path: str,
|
|
37
|
+
private_key_path: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Add or update a server, preserving existing comments and formatting."""
|
|
40
|
+
self.init_config()
|
|
41
|
+
doc = self._read()
|
|
42
|
+
if "servers" not in doc:
|
|
43
|
+
doc["servers"] = tomlkit.table()
|
|
44
|
+
entry = tomlkit.inline_table()
|
|
45
|
+
entry["host"] = host
|
|
46
|
+
entry["port"] = port
|
|
47
|
+
entry["user"] = user
|
|
48
|
+
entry["bench_path"] = bench_path
|
|
49
|
+
if private_key_path is not None:
|
|
50
|
+
entry["private_key_path"] = private_key_path
|
|
51
|
+
doc["servers"][alias] = entry
|
|
52
|
+
TOMLFile(self.config_path).write(doc)
|
|
53
|
+
|
|
54
|
+
def update_server_key(self, alias: str, private_key_path: str) -> None:
|
|
55
|
+
"""Add or update the private key path for an existing server."""
|
|
56
|
+
self.init_config()
|
|
57
|
+
doc = self._read()
|
|
58
|
+
servers = doc.get("servers")
|
|
59
|
+
if servers is None or alias not in servers:
|
|
60
|
+
raise ValueError(f"Server '{alias}' not found in configuration.")
|
|
61
|
+
servers[alias]["private_key_path"] = private_key_path
|
|
62
|
+
TOMLFile(self.config_path).write(doc)
|
|
63
|
+
|
|
64
|
+
def get_server(self, alias: str) -> dict | None:
|
|
65
|
+
"""Return the configuration for a specific server alias."""
|
|
66
|
+
return self.list_servers().get(alias)
|
|
67
|
+
|
|
68
|
+
def list_servers(self) -> dict:
|
|
69
|
+
"""Return all configured servers keyed by alias."""
|
|
70
|
+
try:
|
|
71
|
+
doc = self._read()
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return {}
|
|
74
|
+
servers = doc.get("servers")
|
|
75
|
+
if servers is None:
|
|
76
|
+
return {}
|
|
77
|
+
return {str(alias): dict(config) for alias, config in servers.items()}
|
benchops/runner.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Execution engine: local subprocess and remote SSH command runners."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import paramiko
|
|
6
|
+
from fabric import Connection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RemoteConnectionError(Exception):
|
|
10
|
+
"""Raised when an SSH connection to a remote host cannot be established."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LocalRunner:
|
|
14
|
+
"""Runs commands locally, streaming output to the terminal in real-time."""
|
|
15
|
+
|
|
16
|
+
def run(self, command: list[str], cwd: str | None = None) -> None:
|
|
17
|
+
"""Run a command locally, streaming stdout and stderr to the console."""
|
|
18
|
+
proc = subprocess.Popen(
|
|
19
|
+
command,
|
|
20
|
+
stdout=subprocess.PIPE,
|
|
21
|
+
stderr=subprocess.STDOUT,
|
|
22
|
+
text=True,
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
)
|
|
25
|
+
assert proc.stdout is not None
|
|
26
|
+
for line in iter(proc.stdout.readline, ""):
|
|
27
|
+
print(line, end="")
|
|
28
|
+
returncode = proc.wait()
|
|
29
|
+
if returncode != 0:
|
|
30
|
+
raise subprocess.CalledProcessError(returncode, command)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RemoteRunner:
|
|
34
|
+
"""Runs commands on a remote host over SSH, streaming output in real-time."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
host: str,
|
|
39
|
+
port: int,
|
|
40
|
+
user: str,
|
|
41
|
+
password: str | None = None,
|
|
42
|
+
key_path: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.host = host
|
|
45
|
+
self.port = port
|
|
46
|
+
self.user = user
|
|
47
|
+
connect_kwargs: dict = {}
|
|
48
|
+
if password:
|
|
49
|
+
connect_kwargs["password"] = password
|
|
50
|
+
if key_path:
|
|
51
|
+
connect_kwargs["key_filename"] = key_path
|
|
52
|
+
self.connection = Connection(
|
|
53
|
+
host=host,
|
|
54
|
+
port=port,
|
|
55
|
+
user=user,
|
|
56
|
+
connect_kwargs=connect_kwargs,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def run(self, command: str, cwd: str | None = None) -> None:
|
|
60
|
+
"""Run a command on the remote host, streaming output to the terminal."""
|
|
61
|
+
try:
|
|
62
|
+
if cwd:
|
|
63
|
+
with self.connection.cd(cwd):
|
|
64
|
+
self.connection.run(command, hide=False)
|
|
65
|
+
else:
|
|
66
|
+
self.connection.run(command, hide=False)
|
|
67
|
+
except (paramiko.ssh_exception.SSHException, OSError) as exc:
|
|
68
|
+
raise RemoteConnectionError(
|
|
69
|
+
f"Failed to connect to {self.user}@{self.host}:{self.port}: {exc}"
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
"""Close the SSH connection."""
|
|
74
|
+
self.connection.close()
|
benchops/sync.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Sync engine: local compression, SFTP transfer, and remote extraction."""
|
|
2
|
+
|
|
3
|
+
import tarfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from benchops.runner import RemoteRunner
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
|
10
|
+
parts = tarinfo.name.split("/")
|
|
11
|
+
if any(part in (".git", "__pycache__", "node_modules") for part in parts):
|
|
12
|
+
return None
|
|
13
|
+
if tarinfo.name.endswith(".pyc"):
|
|
14
|
+
return None
|
|
15
|
+
return tarinfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_tarball(app_path: str, output_path: str) -> str:
|
|
19
|
+
"""Create a .tar.gz archive of a local directory, excluding common junk."""
|
|
20
|
+
src = Path(app_path)
|
|
21
|
+
if not src.is_dir():
|
|
22
|
+
raise FileNotFoundError(f"Application directory not found: {app_path}")
|
|
23
|
+
out = Path(output_path)
|
|
24
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with tarfile.open(out, mode="w:gz") as tar:
|
|
26
|
+
tar.add(src, arcname=src.name, filter=_tar_filter)
|
|
27
|
+
return str(out.resolve())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def transfer_tarball(runner: RemoteRunner, local_path: str, remote_dest_dir: str) -> str:
|
|
31
|
+
"""Transfer a local tarball to a remote directory over SFTP."""
|
|
32
|
+
runner.connection.put(local_path, remote_dest_dir)
|
|
33
|
+
return f"{remote_dest_dir.rstrip('/')}/{Path(local_path).name}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_and_cleanup(runner: RemoteRunner, remote_tar_path: str, remote_extract_dir: str) -> None:
|
|
37
|
+
"""Extract a remote tarball and remove it afterwards."""
|
|
38
|
+
runner.run(f"tar -xzf {remote_tar_path} -C {remote_extract_dir}")
|
|
39
|
+
runner.run(f"rm {remote_tar_path}")
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: benchops
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool to synchronize local Frappe development environments with remote servers.
|
|
5
|
+
Author-email: Mohammad <your.email@example.com>
|
|
6
|
+
Keywords: cli,deployment,erpnext,frappe,sync
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.14.6
|
|
12
|
+
Requires-Dist: fabric>=3.2.3
|
|
13
|
+
Requires-Dist: keyring>=25.7.0
|
|
14
|
+
Requires-Dist: paramiko>=5.0.0
|
|
15
|
+
Requires-Dist: rich>=15.0.0
|
|
16
|
+
Requires-Dist: tomlkit>=0.15.1
|
|
17
|
+
Requires-Dist: typer>=0.27.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# benchops
|
|
21
|
+
|
|
22
|
+
A cross-platform CLI tool that synchronizes local Frappe development
|
|
23
|
+
environments with remote servers.
|
|
24
|
+
|
|
25
|
+
`benchops` automates the repetitive "ship my app to the server" workflow: it
|
|
26
|
+
builds your app locally, packages it into a compressed archive, uploads it to
|
|
27
|
+
the target server over SSH, extracts it into the remote bench, and runs the
|
|
28
|
+
Frappe housekeeping commands to apply the changes — all with output streamed to
|
|
29
|
+
your terminal in real time.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **Server management** — define remote servers in a local TOML config
|
|
34
|
+
(`~/.benchops/config.toml`) with a friendly interactive CLI.
|
|
35
|
+
- **Secure authentication** — store server passwords in your OS credential
|
|
36
|
+
store (`keyring`) or use an SSH private key.
|
|
37
|
+
- **Real-time streaming** — every local and remote command streams its stdout
|
|
38
|
+
and stderr to your terminal as it runs.
|
|
39
|
+
- **Lean transfers** — source tarballs exclude `.git`, `__pycache__`,
|
|
40
|
+
`node_modules`, and `*.pyc` to keep uploads small.
|
|
41
|
+
- **One-command deploy** — build, archive, upload, extract, migrate, and clear
|
|
42
|
+
cache with a single invocation.
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- Python `>= 3.14.6`
|
|
47
|
+
- [uv](https://docs.astral.sh/uv/) (recommended) or `pip`
|
|
48
|
+
- SSH access to the target server (password or key)
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone <your-repo-url> benchops
|
|
54
|
+
cd benchops
|
|
55
|
+
uv sync
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Install the CLI into your environment so the `benchops` command is available:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run pip install -e .
|
|
62
|
+
# or, if you built a distribution:
|
|
63
|
+
uv build && uv pip install --python .venv/bin/python dist/benchops-0.1.0-py3-none-any.whl
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Verify it works:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
benchops --help
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
### 1. Initialize the configuration
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
benchops init
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This creates `~/.benchops/config.toml` with an empty `[servers]` table.
|
|
81
|
+
|
|
82
|
+
### 2. Register a server
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
benchops server add
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
You will be prompted for:
|
|
89
|
+
|
|
90
|
+
| Field | Description |
|
|
91
|
+
| ----------- | ---------------------------------------- |
|
|
92
|
+
| `alias` | Short name used to reference the server |
|
|
93
|
+
| `host` | Hostname or IP address |
|
|
94
|
+
| `port` | SSH port (default: `22`) |
|
|
95
|
+
| `user` | SSH username |
|
|
96
|
+
| `bench_path`| Remote path to the bench directory |
|
|
97
|
+
|
|
98
|
+
You can also pass everything non-interactively:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
benchops server add --alias dev1 --host 192.168.1.10 --port 22 --user frappe --bench-path /home/frappe/bench
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
List your configured servers:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
benchops server list
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 3. Configure authentication
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
benchops server set-auth dev1
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
You will be asked to choose between:
|
|
117
|
+
|
|
118
|
+
- **`password`** — prompted securely (hidden input, double confirmation) and
|
|
119
|
+
stored in your system keyring.
|
|
120
|
+
- **`key`** — path to your SSH private key (default `~/.ssh/id_rsa`), stored in
|
|
121
|
+
the server's configuration.
|
|
122
|
+
|
|
123
|
+
### 4. Deploy
|
|
124
|
+
|
|
125
|
+
Run `benchops deploy` from the directory that contains your app (either inside
|
|
126
|
+
the local bench's `apps/` folder or directly):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
benchops deploy <app_name> <server_alias>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
For example:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
benchops deploy myapp dev1
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The deploy pipeline:
|
|
139
|
+
|
|
140
|
+
1. **Config & auth** — loads the server settings and its credentials.
|
|
141
|
+
2. **Local build** — runs `bench build --app <app_name>` in the app's parent
|
|
142
|
+
directory.
|
|
143
|
+
3. **Archive** — compresses `<app_name>` into a `.tar.gz` (excluding `.git`,
|
|
144
|
+
`__pycache__`, `node_modules`, and `*.pyc`) in a temporary directory.
|
|
145
|
+
4. **Connect** — opens an SSH connection to the server.
|
|
146
|
+
5. **Transfer & extract** — uploads the tarball and extracts it into
|
|
147
|
+
`{bench_path}/apps`.
|
|
148
|
+
6. **Remote operations** — runs `bench --site all migrate` and
|
|
149
|
+
`bench clear-cache` inside `{bench_path}`.
|
|
150
|
+
7. **Cleanup** — closes the connection and reports success.
|
|
151
|
+
|
|
152
|
+
## Configuration
|
|
153
|
+
|
|
154
|
+
All server definitions live in `~/.benchops/config.toml`:
|
|
155
|
+
|
|
156
|
+
```toml
|
|
157
|
+
[servers]
|
|
158
|
+
dev1 = {host = "192.168.1.10", port = 22, user = "frappe", bench_path = "/home/frappe/bench", private_key_path = "/home/mohammad/.ssh/id_rsa"}
|
|
159
|
+
staging = {host = "staging.example.com", port = 22, user = "deploy", bench_path = "/srv/bench"}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Passwords are **not** stored in this file — they are kept in the operating
|
|
163
|
+
system's credential store via `keyring`.
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
uv sync # install dependencies
|
|
169
|
+
uv build # build wheel + sdist
|
|
170
|
+
uv publish # upload to PyPI (set UV_PUBLISH_TOKEN first)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
benchops/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
benchops/auth.py,sha256=uJ73ws12ZY5QmcyBqEW_JJgNhQ9I22CK_aAsAOYfsCU,896
|
|
3
|
+
benchops/cli.py,sha256=gLmY3E8aXZNmA6GloV3aEmorK_u7dHcEt_kVTzAA5a4,6760
|
|
4
|
+
benchops/config.py,sha256=TCEEZ3KA8b6IqyeXLhudta3wT_8fVp8YcQ3qRlxlgAc,2645
|
|
5
|
+
benchops/runner.py,sha256=IwWeN03S5yDj09NQ0tNNKPw1EO1O6-iM8G-pjDDtnx4,2330
|
|
6
|
+
benchops/sync.py,sha256=id-nFwin70xI5aeJNlC4sEOB_gI5xn_0i9BlzQSMzl0,1487
|
|
7
|
+
benchops-0.1.0.dist-info/METADATA,sha256=BXdq84bJ8-3CobVR_Mci08cDcSuCRISBr7hwjjBcYt8,5078
|
|
8
|
+
benchops-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
benchops-0.1.0.dist-info/entry_points.txt,sha256=G9fj6wj9SOj_kyvaJSd2XX_107-QAakTuk-7b9LdxdM,46
|
|
10
|
+
benchops-0.1.0.dist-info/RECORD,,
|