fujin-cli 0.2.0__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of fujin-cli might be problematic. Click here for more details.

fujin/commands/down.py CHANGED
@@ -1,30 +1,48 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from typing import Annotated
4
5
 
5
6
  import cappa
6
- from rich.prompt import Prompt
7
+ from rich.prompt import Confirm
7
8
 
8
- from fujin.commands import AppCommand
9
+ from fujin.commands import BaseCommand
9
10
 
10
11
 
11
12
  @cappa.command(
12
13
  help="Tear down the project by stopping services and cleaning up resources"
13
14
  )
14
15
  @dataclass
15
- class Down(AppCommand):
16
+ class Down(BaseCommand):
17
+ full: Annotated[
18
+ bool,
19
+ cappa.Arg(
20
+ short="-f",
21
+ long="--full",
22
+ help="Stop and uninstall proxy as part of teardown",
23
+ ),
24
+ ] = False
16
25
 
17
26
  def __call__(self):
18
- confirm = Prompt.ask(
19
- f"""[red]You are about to delete all project files, stop all services, and remove all configurations on the host {self.host} for the project {self.config.app}. Any assets in your project folder will be lost (sqlite not in there ?). Are you sure you want to proceed? This action is irreversible.[/red]""",
20
- choices=["no", "yes"],
21
- default="no",
22
- )
23
- if confirm == "no":
27
+ try:
28
+ confirm = Confirm.ask(
29
+ f"""[red]You are about to delete all project files, stop all services, and remove all configurations on the host {self.config.host.ip} for the project {self.config.app_name}. Any assets in your project folder will be lost (sqlite not in there ?). Are you sure you want to proceed? This action is irreversible.[/red]""",
30
+ )
31
+ except KeyboardInterrupt as e:
32
+ raise cappa.Exit("Teardown aborted", code=0)
33
+ if not confirm:
24
34
  return
25
- project_dir = self.host.project_dir(self.config.app)
26
- self.host.run(f"rm -rf {project_dir}")
27
- self.web_proxy.teardown()
28
- self.process_manager.uninstall_services()
29
- self.process_manager.reload_configuration()
30
- self.stdout.output("[green]Project teardown completed successfully![/green]")
35
+ with self.connection() as conn:
36
+ hook_manager = self.create_hook_manager(conn)
37
+ hook_manager.pre_teardown()
38
+ process_manager = self.create_process_manager(conn)
39
+ conn.run(f"rm -rf {self.app_dir}")
40
+ self.create_web_proxy(conn).teardown()
41
+ process_manager.uninstall_services()
42
+ process_manager.reload_configuration()
43
+ if self.full:
44
+ self.create_web_proxy(conn).uninstall()
45
+ hook_manager.post_teardown()
46
+ self.stdout.output(
47
+ "[green]Project teardown completed successfully![/green]"
48
+ )
fujin/commands/init.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import cappa
7
+ import tomli_w
8
+
9
+ from fujin.commands import BaseCommand
10
+ from fujin.config import tomllib
11
+
12
+
13
+ @cappa.command(help="Generate a sample configuration file")
14
+ class Init(BaseCommand):
15
+ profile: Annotated[
16
+ str, cappa.Arg(choices=["simple", "falco"], short="-p", long="--profile")
17
+ ] = "simple"
18
+
19
+ def __call__(self):
20
+ fujin_toml = Path("fujin.toml")
21
+ if fujin_toml.exists():
22
+ raise cappa.Exit("fujin.toml file already exists", code=1)
23
+ profile_to_func = {"simple": simple_config, "falco": falco_config}
24
+ app_name = Path().resolve().stem.replace("-", "_").replace(" ", "_").lower()
25
+ config = profile_to_func[self.profile](app_name)
26
+ if not Path(".python-version").exists():
27
+ config["python_version"] = "3.12"
28
+ pyproject_toml = Path("pyproject.toml")
29
+ if pyproject_toml.exists():
30
+ pyproject = tomllib.loads(pyproject_toml.read_text())
31
+ config["app"] = pyproject.get("project", {}).get("name", app_name)
32
+ if pyproject.get("project", {}).get("version"):
33
+ # fujin will read the version itself from the pyproject
34
+ config.pop("version")
35
+ fujin_toml.write_text(tomli_w.dumps(config))
36
+ self.stdout.output(
37
+ "[green]Sample configuration file generated successfully![/green]"
38
+ )
39
+
40
+
41
+ def simple_config(app_name) -> dict:
42
+ return {
43
+ "app": app_name,
44
+ "version": "0.1.0",
45
+ "build_command": "uv build && uv pip compile pyproject.toml -o requirements.txt",
46
+ "distfile": f"dist/{app_name}-{{version}}-py3-none-any.whl",
47
+ "webserver": {
48
+ "upstream": "localhost:8000",
49
+ "type": "fujin.proxies.caddy",
50
+ },
51
+ "release_command": f"{app_name} migrate",
52
+ "processes": {
53
+ "web": f".venv/bin/gunicorn {app_name}.wsgi:app --bind 0.0.0.0:8000"
54
+ },
55
+ "aliases": {"shell": "server exec --appenv -i bash"},
56
+ "host": {
57
+ "ip": "127.0.0.1",
58
+ "user": "root",
59
+ "domain_name": f"{app_name}.com",
60
+ "envfile": ".env.prod",
61
+ },
62
+ }
63
+
64
+
65
+ def falco_config(app_name: str) -> dict:
66
+ config = simple_config(app_name)
67
+ config.update(
68
+ {
69
+ "release_command": f"{config['app']} setup",
70
+ "processes": {
71
+ "web": f".venv/bin/{config['app']} prodserver",
72
+ "worker": f".venv/bin/{config['app']} qcluster",
73
+ },
74
+ "aliases": {
75
+ "console": "app exec -i shell_plus",
76
+ "dbconsole": "app exec -i dbshell",
77
+ "print_settings": "app exec print_settings --format=pprint",
78
+ "shell": "server exec --appenv -i bash",
79
+ },
80
+ }
81
+ )
82
+ return config
@@ -0,0 +1,71 @@
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ import cappa
5
+
6
+ from fujin.commands import BaseCommand
7
+
8
+
9
+ @cappa.command(help="Manage web proxy.")
10
+ @dataclass
11
+ class Proxy(BaseCommand):
12
+ @cappa.command(help="Install the proxy on the remote host")
13
+ def install(self):
14
+ with self.connection() as conn:
15
+ self.create_web_proxy(conn).install()
16
+
17
+ @cappa.command(help="Uninstall the proxy from the remote host")
18
+ def uninstall(self):
19
+ with self.connection() as conn:
20
+ self.create_web_proxy(conn).uninstall()
21
+
22
+ @cappa.command(help="Start the proxy on the remote host")
23
+ def start(self):
24
+ with self.connection() as conn:
25
+ self.create_web_proxy(conn).start()
26
+ self.stdout.output("[green]Proxy started successfully![/green]")
27
+
28
+ @cappa.command(help="Stop the proxy on the remote host")
29
+ def stop(self):
30
+ with self.connection() as conn:
31
+ self.create_web_proxy(conn).stop()
32
+ self.stdout.output("[green]Proxy stopped successfully![/green]")
33
+
34
+ @cappa.command(help="Restart the proxy on the remote host")
35
+ def restart(self):
36
+ with self.connection() as conn:
37
+ self.create_web_proxy(conn).restart()
38
+ self.stdout.output("[green]Proxy restarted successfully![/green]")
39
+
40
+ @cappa.command(help="Check the status of the proxy on the remote host")
41
+ def status(self):
42
+ with self.connection() as conn:
43
+ self.create_web_proxy(conn).status()
44
+
45
+ @cappa.command(help="View the logs of the proxy on the remote host")
46
+ def logs(self):
47
+ with self.connection() as conn:
48
+ self.create_web_proxy(conn).logs()
49
+
50
+ @cappa.command(
51
+ name="export-config",
52
+ help="Export the proxy configuration file locally to the .fujin directory",
53
+ )
54
+ def export_config(
55
+ self,
56
+ overwrite: Annotated[
57
+ bool, cappa.Arg(help="overwrite any existing config file")
58
+ ] = False,
59
+ ):
60
+ with self.connection() as conn:
61
+ proxy = self.create_web_proxy(conn)
62
+ if proxy.config_file.exists() and not overwrite:
63
+ self.stdout.output(
64
+ f"[blue]{proxy.config_file} already exists, use --overwrite to overwrite it content.[/blue]"
65
+ )
66
+ else:
67
+ self.config.local_config_dir.mkdir(exist_ok=True)
68
+ proxy.export_config()
69
+ self.stdout.output(
70
+ f"[green]Config file successfully to {proxy.config_file}[/green]"
71
+ )
@@ -0,0 +1,42 @@
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ import cappa
5
+ from rich.prompt import Confirm
6
+
7
+ from fujin.commands import BaseCommand
8
+
9
+
10
+ @cappa.command(
11
+ help="Prune old version artifacts, keeping only the specified number of recent versions"
12
+ )
13
+ @dataclass
14
+ class Prune(BaseCommand):
15
+ keep: Annotated[
16
+ int,
17
+ cappa.Arg(
18
+ short="-k",
19
+ long="--keep",
20
+ help="Number of version artifacts to retain (minimum 1)",
21
+ ),
22
+ ] = 2
23
+
24
+ def __call__(self):
25
+ if self.keep < 1:
26
+ raise cappa.Exit("The minimum value for the --keep option is 1", code=1)
27
+ with self.connection() as conn, conn.cd(self.app_dir):
28
+ result = conn.run(
29
+ f"sed -n '{self.keep + 1},$p' .versions", hide=True
30
+ ).stdout.strip()
31
+ result_list = result.split("\n")
32
+ if result == "":
33
+ self.stdout.output("[blue]No versions to prune[/blue]")
34
+ return
35
+ if not Confirm.ask(
36
+ f"[red]The following versions will be permanently deleted: {', '.join(result_list)}. This action is irreversible. Are you sure you want to proceed?[/red]"
37
+ ):
38
+ return
39
+ to_prune = [f"{self.app_dir}/v{v}" for v in result_list]
40
+ conn.run(f"rm -r {' '.join(to_prune)}", warn=True)
41
+ conn.run(f"sed -i '{self.keep + 1},$d' .versions", warn=True)
42
+ self.stdout.output("[green]Pruning completed successfully[/green]")
@@ -1,19 +1,48 @@
1
1
  from __future__ import annotations
2
2
 
3
- import cappa
3
+ import hashlib
4
4
 
5
- from fujin.commands import AppCommand
5
+ import cappa
6
6
 
7
+ from fujin.commands import BaseCommand
7
8
  from .deploy import Deploy
8
9
 
9
10
 
10
11
  @cappa.command(help="Redeploy the application to apply code and environment changes")
11
- class Redeploy(AppCommand):
12
-
12
+ class Redeploy(BaseCommand):
13
13
  def __call__(self):
14
- deploy = Deploy(_host=self._host)
15
- deploy.transfer_files()
16
- deploy.install_project()
17
- self.hook_manager.pre_deploy()
18
- self.process_manager.restart_services()
19
- self.stdout.output("[green]Redeployment completed successfully![/green]")
14
+ deploy = Deploy()
15
+ deploy.build_app()
16
+ local_requirements = hashlib.md5(
17
+ self.config.requirements.read_bytes()
18
+ ).hexdigest()
19
+ with self.app_environment() as conn:
20
+ hook_manager = self.create_hook_manager(conn)
21
+ hook_manager.pre_deploy()
22
+ current_host_version = conn.run(
23
+ "head -n 1 .versions", warn=True, hide=True
24
+ ).stdout.strip()
25
+ try:
26
+ host_requirements = (
27
+ conn.run(
28
+ f"md5sum v{current_host_version}/requirements.txt",
29
+ warn=True,
30
+ hide=True,
31
+ )
32
+ .stdout.strip()
33
+ .split()[0]
34
+ )
35
+ skip_requirements = host_requirements == local_requirements
36
+ except IndexError:
37
+ skip_requirements = False
38
+ deploy.transfer_files(conn, skip_requirements=skip_requirements)
39
+ if skip_requirements and current_host_version != self.config.version:
40
+ conn.run(
41
+ f"cp v{current_host_version}/requirements.txt {deploy.versioned_assets_dir}/requirements.txt "
42
+ )
43
+ deploy.install_project(conn, skip_setup=skip_requirements)
44
+ deploy.release(conn)
45
+ self.create_process_manager(conn).restart_services()
46
+ deploy.update_version_history(conn)
47
+ hook_manager.post_deploy()
48
+ self.stdout.output("[green]Redeployment completed successfully![/green]")
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass
2
+
3
+ import cappa
4
+ from rich.prompt import Prompt, Confirm
5
+
6
+ from fujin.commands import BaseCommand
7
+ from fujin.commands.deploy import Deploy
8
+
9
+
10
+ @cappa.command(help="Rollback application to a previous version")
11
+ @dataclass
12
+ class Rollback(BaseCommand):
13
+ def __call__(self):
14
+ with self.app_environment() as conn:
15
+ result = conn.run(
16
+ "sed -n '2,$p' .versions", warn=True, hide=True
17
+ ).stdout.strip()
18
+ if not result:
19
+ self.stdout.output("[blue]No rollback targets available")
20
+ return
21
+ versions: list[str] = result.split("\n")
22
+ try:
23
+ version = Prompt.ask(
24
+ "Enter the version you want to rollback to:",
25
+ choices=versions,
26
+ default=versions[0],
27
+ )
28
+ except KeyboardInterrupt as e:
29
+ raise cappa.Exit("Rollback aborted by user.", code=0) from e
30
+
31
+ current_app_version = conn.run(
32
+ "head -n 1 .versions", warn=True, hide=True
33
+ ).stdout.strip()
34
+ versions_to_clean = [current_app_version] + versions[
35
+ : versions.index(version)
36
+ ]
37
+ confirm = Confirm.ask(
38
+ f"[blue]Rolling back to v{version} will permanently delete versions {', '.join(versions_to_clean)}. This action is irreversible. Are you sure you want to proceed?[/blue]"
39
+ )
40
+ if not confirm:
41
+ return
42
+ deploy = Deploy()
43
+ deploy.install_project(conn, version)
44
+ self.create_process_manager(conn).restart_services()
45
+ conn.run(f"rm -r {' '.join(f'v{v}' for v in versions_to_clean)}", warn=True)
46
+ conn.run(f"sed -i '1,/{version}/{{/{version}/!d}}' .versions", warn=True)
47
+ self.stdout.output(
48
+ f"[green]Rollback to version {version} from {current_app_version} completed successfully![/green]"
49
+ )
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+ import cappa
4
+
5
+ from fujin.commands import BaseCommand
6
+
7
+
8
+ @cappa.command(help="")
9
+ @dataclass
10
+ class Secrets(BaseCommand):
11
+ pass
fujin/commands/server.py CHANGED
@@ -1,30 +1,47 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import secrets
4
+ from functools import partial
3
5
  from typing import Annotated
4
6
 
5
7
  import cappa
6
8
 
7
- from fujin.commands import AppCommand
9
+ from fujin.commands import BaseCommand
8
10
 
9
11
 
10
12
  @cappa.command(help="Manage server operations")
11
- class Server(AppCommand):
12
-
13
+ class Server(BaseCommand):
13
14
  @cappa.command(help="Display information about the host system")
14
15
  def info(self):
15
- self.stdout.output(self.host.sudo("cat /etc/os-release", hide="out"))
16
+ with self.connection() as conn:
17
+ result = conn.run(f"command -v fastfetch", warn=True, hide=True)
18
+ if result.ok:
19
+ conn.run("fastfetch", pty=True)
20
+ else:
21
+ self.stdout.output(conn.run("cat /etc/os-release", hide=True).stdout)
16
22
 
17
23
  @cappa.command(help="Setup uv, web proxy, and install necessary dependencies")
18
24
  def bootstrap(self):
19
- self.host.sudo("apt update")
20
- self.host.sudo("apt upgrade -y")
21
- self.host.sudo("apt install -y sqlite3 curl")
22
- result = self.host.run("source $HOME/.cargo/env && command -v uv", warn=True)
23
- if not result.ok:
24
- self.host.run("curl -LsSf https://astral.sh/uv/install.sh | sh")
25
- self.host.run_uv("tool update-shell")
26
- self.web_proxy.install()
27
- self.stdout.output("[green]Server bootstrap completed successfully![/green]")
25
+ with self.connection() as conn:
26
+ hook_manager = self.create_hook_manager(conn)
27
+ hook_manager.pre_bootstrap()
28
+ conn.run("sudo apt update && sudo apt upgrade -y", pty=True)
29
+ conn.run("sudo apt install -y sqlite3 curl rsync", pty=True)
30
+ result = conn.run("command -v uv", warn=True)
31
+ if not result.ok:
32
+ conn.run("curl -LsSf https://astral.sh/uv/install.sh | sh")
33
+ conn.run("uv tool update-shell")
34
+ conn.run("uv tool install fastfetch-bin-edge")
35
+ self.create_web_proxy(conn).install()
36
+ hook_manager.post_bootstrap()
37
+ self.stdout.output(
38
+ "[green]Server bootstrap completed successfully![/green]"
39
+ )
40
+
41
+ @cappa.command(help="Stop and uninstall the web proxy")
42
+ def uninstall_proxy(self):
43
+ with self.connection() as conn:
44
+ self.create_web_proxy(conn).uninstall()
28
45
 
29
46
  @cappa.command(
30
47
  help="Execute an arbitrary command on the server, optionally in interactive mode"
@@ -33,25 +50,43 @@ class Server(AppCommand):
33
50
  self,
34
51
  command: str,
35
52
  interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
53
+ appenv: Annotated[
54
+ bool,
55
+ cappa.Arg(
56
+ default=False,
57
+ long="--appenv",
58
+ help="Change to app directory and enable app environment",
59
+ ),
60
+ ],
36
61
  ):
37
- if interactive:
38
- self.host.run(command, pty=interactive)
39
- else:
40
- result = self.host.run(command, hide=True)
41
- self.stdout.output(result)
62
+ context = self.app_environment() if appenv else self.connection()
63
+ with context as conn:
64
+ if interactive:
65
+ conn.run(command, pty=interactive, warn=True)
66
+ else:
67
+ self.stdout.output(conn.run(command, hide=True).stdout)
42
68
 
43
69
  @cappa.command(
44
70
  name="create-user", help="Create a new user with sudo and ssh access"
45
71
  )
46
- def create_user(self, name: str):
47
- # TODO not working right now, ssh key not working
48
- self.host.sudo(f"adduser --disabled-password --gecos '' {name}")
49
- self.host.sudo(f"mkdir -p /home/{name}/.ssh")
50
- self.host.sudo(f"cp ~/.ssh/authorized_keys /home/{name}/.ssh/")
51
- self.host.sudo(f"chown -R {name}:{name} /home/{name}/.ssh")
52
- self.host.sudo(f"chmod 700 /home/{name}/.ssh")
53
- self.host.sudo(f"chmod 600 /home/{name}/.ssh/authorized_keys")
54
- self.host.sudo(
55
- f"echo '{name} ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers"
56
- )
57
- self.stdout.output(f"[green]New user {name} created successfully![/green]")
72
+ def create_user(
73
+ self,
74
+ name: str,
75
+ with_password: Annotated[bool, cappa.Arg(long="--with-password")] = False,
76
+ ):
77
+ with self.connection() as conn:
78
+ run_pty = partial(conn.run, pty=True)
79
+ run_pty(
80
+ f"sudo adduser --disabled-password --gecos '' {name}",
81
+ )
82
+ run_pty(f"sudo mkdir -p /home/{name}/.ssh")
83
+ run_pty(f"sudo cp ~/.ssh/authorized_keys /home/{name}/.ssh/")
84
+ run_pty(f"sudo chown -R {name}:{name} /home/{name}/.ssh")
85
+ if with_password:
86
+ password = secrets.token_hex(8)
87
+ run_pty(f"echo '{name}:{password}' | sudo chpasswd")
88
+ self.stdout.output(f"[green]Generated password: [/green]{password}")
89
+ run_pty(f"sudo chmod 700 /home/{name}/.ssh")
90
+ run_pty(f"sudo chmod 600 /home/{name}/.ssh/authorized_keys")
91
+ run_pty(f"echo '{name} ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers")
92
+ self.stdout.output(f"[green]New user {name} created successfully![/green]")
fujin/commands/up.py CHANGED
@@ -1,16 +1,15 @@
1
1
  import cappa
2
+ from fujin.commands import BaseCommand
2
3
 
3
- from fujin.commands import AppCommand
4
4
  from .deploy import Deploy
5
5
  from .server import Server
6
6
 
7
7
 
8
8
  @cappa.command(help="Run everything required to deploy an application to a fresh host.")
9
- class Up(AppCommand):
10
-
9
+ class Up(BaseCommand):
11
10
  def __call__(self):
12
- Server(_host=self._host).bootstrap()
13
- Deploy(_host=self._host)()
11
+ Server().bootstrap()
12
+ Deploy()()
14
13
  self.stdout.output(
15
14
  "[green]Server bootstrapped and application deployed successfully![/green]"
16
15
  )