fujin-cli 0.1.0__py3-none-any.whl → 0.2.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/__init__.py +0 -2
- fujin/__main__.py +53 -0
- fujin/commands/__init__.py +3 -0
- fujin/commands/_base.py +83 -0
- fujin/commands/app.py +70 -0
- fujin/commands/config.py +174 -0
- fujin/commands/deploy.py +54 -0
- fujin/commands/down.py +30 -0
- fujin/commands/redeploy.py +19 -0
- fujin/commands/server.py +57 -0
- fujin/commands/up.py +16 -0
- fujin/config.py +131 -0
- fujin/errors.py +5 -0
- fujin/hooks.py +15 -0
- fujin/host.py +85 -0
- fujin/process_managers/__init__.py +29 -0
- fujin/process_managers/systemd.py +105 -0
- fujin/proxies/__init__.py +18 -0
- fujin/proxies/caddy.py +52 -0
- fujin/proxies/dummy.py +16 -0
- fujin/proxies/nginx.py +59 -0
- fujin/templates/other.service +15 -0
- fujin/templates/web.service +24 -0
- fujin/templates/web.socket +11 -0
- fujin_cli-0.2.0.dist-info/METADATA +52 -0
- fujin_cli-0.2.0.dist-info/RECORD +29 -0
- fujin_cli-0.2.0.dist-info/entry_points.txt +2 -0
- fujin/fabfile.py +0 -202
- fujin/utils.py +0 -60
- fujin_cli-0.1.0.dist-info/METADATA +0 -41
- fujin_cli-0.1.0.dist-info/RECORD +0 -8
- fujin_cli-0.1.0.dist-info/entry_points.txt +0 -2
- {fujin_cli-0.1.0.dist-info → fujin_cli-0.2.0.dist-info}/WHEEL +0 -0
- {fujin_cli-0.1.0.dist-info → fujin_cli-0.2.0.dist-info}/licenses/LICENSE.txt +0 -0
fujin/__init__.py
CHANGED
fujin/__main__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import cappa
|
|
5
|
+
|
|
6
|
+
from fujin.commands.app import App
|
|
7
|
+
from fujin.commands.config import ConfigCMD
|
|
8
|
+
from fujin.commands.deploy import Deploy
|
|
9
|
+
from fujin.commands.down import Down
|
|
10
|
+
from fujin.commands.redeploy import Redeploy
|
|
11
|
+
from fujin.commands.server import Server
|
|
12
|
+
from fujin.commands.up import Up
|
|
13
|
+
|
|
14
|
+
if sys.version_info >= (3, 11):
|
|
15
|
+
import tomllib
|
|
16
|
+
else:
|
|
17
|
+
import tomli as tomllib
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cappa.command(help="Deployment of python web apps in a breeze :)")
|
|
21
|
+
class Fujin:
|
|
22
|
+
subcommands: cappa.Subcommands[
|
|
23
|
+
Up | Deploy | Redeploy | App | Server | ConfigCMD | Down
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
# install(show_locals=True)
|
|
29
|
+
alias_cmd = _parse_aliases()
|
|
30
|
+
if alias_cmd:
|
|
31
|
+
cappa.invoke(Fujin, argv=alias_cmd)
|
|
32
|
+
else:
|
|
33
|
+
cappa.invoke(Fujin)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_aliases() -> list[str] | None:
|
|
37
|
+
fujin_toml = Path("fujin.toml")
|
|
38
|
+
if not fujin_toml.exists():
|
|
39
|
+
return
|
|
40
|
+
data = tomllib.loads(fujin_toml.read_text())
|
|
41
|
+
aliases = data.get("aliases")
|
|
42
|
+
if not aliases:
|
|
43
|
+
return
|
|
44
|
+
if len(sys.argv) == 1:
|
|
45
|
+
return
|
|
46
|
+
if sys.argv[1] not in aliases:
|
|
47
|
+
return
|
|
48
|
+
extra_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
|
49
|
+
return [*aliases.get(sys.argv[1]).split(), *extra_args]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
fujin/commands/_base.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import cappa
|
|
7
|
+
|
|
8
|
+
from fujin.config import Config
|
|
9
|
+
from fujin.errors import ImproperlyConfiguredError
|
|
10
|
+
from fujin.hooks import HookManager
|
|
11
|
+
from fujin.host import Host
|
|
12
|
+
from fujin.process_managers import ProcessManager
|
|
13
|
+
from fujin.proxies import WebProxy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BaseCommand:
|
|
18
|
+
@cached_property
|
|
19
|
+
def config(self) -> Config:
|
|
20
|
+
return Config.read()
|
|
21
|
+
|
|
22
|
+
@cached_property
|
|
23
|
+
def stdout(self) -> cappa.Output:
|
|
24
|
+
return cappa.Output()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AppCommand(BaseCommand):
|
|
29
|
+
"""
|
|
30
|
+
A command that provides access to the current host and allows interaction with it,
|
|
31
|
+
including configuring the web proxy and managing systemd services.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_host: Annotated[str | None, cappa.Arg(long="--host", value_name="HOST")]
|
|
35
|
+
|
|
36
|
+
# TODO: add info / details command that will list all services with their current status, if they are installed or running or stopped
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def host(self) -> Host:
|
|
40
|
+
if not self._host:
|
|
41
|
+
host_config = next(
|
|
42
|
+
(hc for hc in self.config.hosts.values() if hc.default), None
|
|
43
|
+
)
|
|
44
|
+
if not host_config:
|
|
45
|
+
raise ImproperlyConfiguredError(
|
|
46
|
+
"No default host has been configured, either pass --host or set the default in your fujin.toml file"
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
host_config = next(
|
|
50
|
+
(
|
|
51
|
+
hc
|
|
52
|
+
for name, hc in self.config.hosts.items()
|
|
53
|
+
if self._host in [name, hc.ip]
|
|
54
|
+
),
|
|
55
|
+
None,
|
|
56
|
+
)
|
|
57
|
+
if not host_config:
|
|
58
|
+
raise cappa.Exit(f"Host {self._host} does not exist", code=1)
|
|
59
|
+
return Host(config=host_config)
|
|
60
|
+
|
|
61
|
+
@cached_property
|
|
62
|
+
def web_proxy(self) -> WebProxy:
|
|
63
|
+
module = importlib.import_module(self.config.webserver.type)
|
|
64
|
+
try:
|
|
65
|
+
return getattr(module, "WebProxy")(host=self.host, config=self.config)
|
|
66
|
+
except KeyError as e:
|
|
67
|
+
raise ImproperlyConfiguredError(
|
|
68
|
+
f"Missing WebProxy class in {self.config.webserver.type}"
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
@cached_property
|
|
72
|
+
def process_manager(self) -> ProcessManager:
|
|
73
|
+
module = importlib.import_module(self.config.process_manager)
|
|
74
|
+
try:
|
|
75
|
+
return getattr(module, "ProcessManager")(host=self.host, config=self.config)
|
|
76
|
+
except KeyError as e:
|
|
77
|
+
raise ImproperlyConfiguredError(
|
|
78
|
+
f"Missing ProcessManager class in {self.config.process_manager}"
|
|
79
|
+
) from e
|
|
80
|
+
|
|
81
|
+
@cached_property
|
|
82
|
+
def hook_manager(self) -> HookManager:
|
|
83
|
+
return HookManager(host=self.host, config=self.config)
|
fujin/commands/app.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import cappa
|
|
6
|
+
|
|
7
|
+
from fujin.commands import AppCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cappa.command(help="Run application-related tasks")
|
|
11
|
+
class App(AppCommand):
|
|
12
|
+
|
|
13
|
+
@cappa.command(help="Run an arbitrary command via the application binary")
|
|
14
|
+
def exec(
|
|
15
|
+
self,
|
|
16
|
+
command: str,
|
|
17
|
+
interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
|
|
18
|
+
):
|
|
19
|
+
with self.host.cd_project_dir(self.config.app):
|
|
20
|
+
if interactive:
|
|
21
|
+
self.host.run(f"{self.config.app_bin} {command}", pty=interactive)
|
|
22
|
+
else:
|
|
23
|
+
result = self.host.run(f"{self.config.app_bin} {command}", hide=True)
|
|
24
|
+
self.stdout.output(result)
|
|
25
|
+
|
|
26
|
+
@cappa.command(
|
|
27
|
+
help="Start the specified service or all services if no name is provided"
|
|
28
|
+
)
|
|
29
|
+
def start(
|
|
30
|
+
self,
|
|
31
|
+
name: Annotated[
|
|
32
|
+
str | None, cappa.Arg(help="Service name, no value means all")
|
|
33
|
+
] = None,
|
|
34
|
+
):
|
|
35
|
+
self.process_manager.start_services(name)
|
|
36
|
+
msg = f"{name} Service" if name else "All Services"
|
|
37
|
+
self.stdout.output(f"[green]{msg} started successfully![/green]")
|
|
38
|
+
|
|
39
|
+
@cappa.command(
|
|
40
|
+
help="Restart the specified service or all services if no name is provided"
|
|
41
|
+
)
|
|
42
|
+
def restart(
|
|
43
|
+
self,
|
|
44
|
+
name: Annotated[
|
|
45
|
+
str | None, cappa.Arg(help="Service name, no value means all")
|
|
46
|
+
] = None,
|
|
47
|
+
):
|
|
48
|
+
self.process_manager.restart_services(name)
|
|
49
|
+
msg = f"{name} Service" if name else "All Services"
|
|
50
|
+
self.stdout.output(f"[green]{msg} restarted successfully![/green]")
|
|
51
|
+
|
|
52
|
+
@cappa.command(
|
|
53
|
+
help="Stop the specified service or all services if no name is provided"
|
|
54
|
+
)
|
|
55
|
+
def stop(
|
|
56
|
+
self,
|
|
57
|
+
name: Annotated[
|
|
58
|
+
str | None, cappa.Arg(help="Service name, no value means all")
|
|
59
|
+
] = None,
|
|
60
|
+
):
|
|
61
|
+
self.process_manager.stop_services(name)
|
|
62
|
+
msg = f"{name} Service" if name else "All Services"
|
|
63
|
+
self.stdout.output(f"[green]{msg} stopped successfully![/green]")
|
|
64
|
+
|
|
65
|
+
@cappa.command(help="Show logs for the specified service")
|
|
66
|
+
def logs(
|
|
67
|
+
self, name: Annotated[str, cappa.Arg(help="Service name")], follow: bool = False
|
|
68
|
+
):
|
|
69
|
+
# TODO: flash out this more
|
|
70
|
+
self.process_manager.service_logs(name=name, follow=follow)
|
fujin/commands/config.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
from rich.console import Console
|
|
9
|
+
from rich.markdown import Markdown
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from fujin.commands import BaseCommand
|
|
14
|
+
from fujin.config import tomllib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cappa.command(name="config", help="Manage application configuration")
|
|
18
|
+
class ConfigCMD(BaseCommand):
|
|
19
|
+
|
|
20
|
+
@cappa.command(help="Display the parsed configuration")
|
|
21
|
+
def show(self):
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
general_config = {
|
|
25
|
+
"app": self.config.app,
|
|
26
|
+
"app_bin": self.config.app_bin,
|
|
27
|
+
"version": self.config.version,
|
|
28
|
+
"python_version": self.config.python_version,
|
|
29
|
+
"build_command": self.config.build_command,
|
|
30
|
+
"distfile": self.config.distfile,
|
|
31
|
+
"requirements": self.config.requirements,
|
|
32
|
+
"webserver": f"{{ upstream = '{self.config.webserver.upstream}', type = '{self.config.webserver.type}' }}",
|
|
33
|
+
}
|
|
34
|
+
formatted_text = "\n".join(
|
|
35
|
+
f"[bold green]{key}:[/bold green] {value}"
|
|
36
|
+
for key, value in general_config.items()
|
|
37
|
+
)
|
|
38
|
+
console.print(
|
|
39
|
+
Panel(
|
|
40
|
+
formatted_text,
|
|
41
|
+
title="General Configuration",
|
|
42
|
+
border_style="green",
|
|
43
|
+
width=100,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Hosts Table with headers and each dictionary on its own line
|
|
48
|
+
hosts_table = Table(title="Hosts", header_style="bold cyan")
|
|
49
|
+
hosts_table.add_column("Host", style="dim")
|
|
50
|
+
hosts_table.add_column("ip")
|
|
51
|
+
hosts_table.add_column("domain_name")
|
|
52
|
+
hosts_table.add_column("user")
|
|
53
|
+
hosts_table.add_column("password_env")
|
|
54
|
+
hosts_table.add_column("projects_dr")
|
|
55
|
+
hosts_table.add_column("ssh_port")
|
|
56
|
+
hosts_table.add_column("key_filename")
|
|
57
|
+
hosts_table.add_column("envfile")
|
|
58
|
+
hosts_table.add_column("primary", justify="center")
|
|
59
|
+
|
|
60
|
+
for host_name, host in self.config.hosts.items():
|
|
61
|
+
host_dict = host.to_dict()
|
|
62
|
+
hosts_table.add_row(
|
|
63
|
+
host_name,
|
|
64
|
+
host_dict["ip"],
|
|
65
|
+
host_dict["domain_name"],
|
|
66
|
+
host_dict["user"],
|
|
67
|
+
str(host_dict["password_env"] or "N/A"),
|
|
68
|
+
host_dict["projects_dir"],
|
|
69
|
+
str(host_dict["ssh_port"]),
|
|
70
|
+
str(host_dict["_key_filename"] or "N/A"),
|
|
71
|
+
host_dict["_envfile"],
|
|
72
|
+
"[green]Yes[/green]" if host_dict["default"] else "[red]No[/red]",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
console.print(hosts_table)
|
|
76
|
+
|
|
77
|
+
# Processes Table with headers and each dictionary on its own line
|
|
78
|
+
processes_table = Table(title="Processes", header_style="bold cyan")
|
|
79
|
+
processes_table.add_column("Name", style="dim")
|
|
80
|
+
processes_table.add_column("Command")
|
|
81
|
+
for name, command in self.config.processes.items():
|
|
82
|
+
processes_table.add_row(name, command)
|
|
83
|
+
console.print(processes_table)
|
|
84
|
+
|
|
85
|
+
aliases_table = Table(title="Aliases", header_style="bold cyan")
|
|
86
|
+
aliases_table.add_column("Alias", style="dim")
|
|
87
|
+
aliases_table.add_column("Command")
|
|
88
|
+
for alias, command in self.config.aliases.items():
|
|
89
|
+
aliases_table.add_row(alias, command)
|
|
90
|
+
|
|
91
|
+
console.print(aliases_table)
|
|
92
|
+
|
|
93
|
+
@cappa.command(help="Generate a sample configuration file")
|
|
94
|
+
def init(
|
|
95
|
+
self,
|
|
96
|
+
profile: Annotated[
|
|
97
|
+
str, cappa.Arg(choices=["simple", "falco"], short="-p", long="--profile")
|
|
98
|
+
] = "simple",
|
|
99
|
+
):
|
|
100
|
+
fujin_toml = Path("fujin.toml")
|
|
101
|
+
if fujin_toml.exists():
|
|
102
|
+
raise cappa.Exit("fujin.toml file already exists", code=1)
|
|
103
|
+
profile_to_func = {"simple": simple_config, "falco": falco_config}
|
|
104
|
+
config = profile_to_func[profile]()
|
|
105
|
+
fujin_toml.write_text(tomli_w.dumps(config))
|
|
106
|
+
self.stdout.output(
|
|
107
|
+
"[green]Sample configuration file generated successfully![/green]"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@cappa.command(help="Config documentation")
|
|
111
|
+
def docs(self):
|
|
112
|
+
self.stdout.output(Markdown(docs))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def simple_config() -> dict:
|
|
116
|
+
app = Path().resolve().stem.replace("-", "_").replace(" ", "_").lower()
|
|
117
|
+
|
|
118
|
+
config = {
|
|
119
|
+
"app": app,
|
|
120
|
+
"version": "0.1.0",
|
|
121
|
+
"build_command": "uv build",
|
|
122
|
+
"distfile": f"dist/{app}-{{version}}-py3-none-any.whl",
|
|
123
|
+
"webserver": {
|
|
124
|
+
"upstream": "localhost:8000",
|
|
125
|
+
"type": "fujin.proxies.caddy",
|
|
126
|
+
},
|
|
127
|
+
"hooks": {"pre_deploy": f".venv/bin/{app} migrate"},
|
|
128
|
+
"processes": {"web": f".venv/bin/gunicorn {app}.wsgi:app --bind 0.0.0.0:8000"},
|
|
129
|
+
"aliases": {"shell": "server exec -i bash"},
|
|
130
|
+
"hosts": {
|
|
131
|
+
"primary": {
|
|
132
|
+
"ip": "127.0.0.1",
|
|
133
|
+
"user": "root",
|
|
134
|
+
"domain_name": f"{app}.com",
|
|
135
|
+
"envfile": ".env.prod",
|
|
136
|
+
"default": True,
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
if not Path(".python-version").exists():
|
|
141
|
+
config["python_version"] = "3.12"
|
|
142
|
+
pyproject_toml = Path("pyproject.toml")
|
|
143
|
+
if pyproject_toml.exists():
|
|
144
|
+
pyproject = tomllib.loads(pyproject_toml.read_text())
|
|
145
|
+
config["app"] = pyproject.get("project", {}).get("name", app)
|
|
146
|
+
if pyproject.get("project", {}).get("version"):
|
|
147
|
+
# fujin will read the version itself from the pyproject
|
|
148
|
+
config.pop("version")
|
|
149
|
+
return config
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def falco_config() -> dict:
|
|
153
|
+
config = simple_config()
|
|
154
|
+
config.update(
|
|
155
|
+
{
|
|
156
|
+
"hooks": {"pre_deploy": f".venv/bin/{config['app']} setup"},
|
|
157
|
+
"processes": {
|
|
158
|
+
"web": f".venv/bin/{config['app']} prodserver",
|
|
159
|
+
"worker": f".venv/bin/{config['app']} qcluster",
|
|
160
|
+
},
|
|
161
|
+
"aliases": {
|
|
162
|
+
"console": "app exec -i shell_plus",
|
|
163
|
+
"dbconsole": "app exec -i dbshell",
|
|
164
|
+
"print_settings": "app exec print_settings --format=pprint",
|
|
165
|
+
"shell": "server exec -i bash",
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
return config
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
docs = """
|
|
173
|
+
# Fujin Configuration
|
|
174
|
+
"""
|
fujin/commands/deploy.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import cappa
|
|
6
|
+
|
|
7
|
+
from fujin.commands import AppCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cappa.command(
|
|
11
|
+
help="Deploy the project by building, transferring files, installing, and configuring services"
|
|
12
|
+
)
|
|
13
|
+
class Deploy(AppCommand):
|
|
14
|
+
|
|
15
|
+
def __call__(self):
|
|
16
|
+
try:
|
|
17
|
+
subprocess.run(self.config.build_command.split(), check=True)
|
|
18
|
+
except subprocess.CalledProcessError as e:
|
|
19
|
+
raise cappa.Exit(f"build command failed: {e}", code=1) from e
|
|
20
|
+
|
|
21
|
+
self.host.make_project_dir(project_name=self.config.app)
|
|
22
|
+
self.transfer_files()
|
|
23
|
+
self.install_project()
|
|
24
|
+
self.hook_manager.pre_deploy()
|
|
25
|
+
|
|
26
|
+
self.process_manager.install_services()
|
|
27
|
+
self.process_manager.reload_configuration()
|
|
28
|
+
self.process_manager.restart_services()
|
|
29
|
+
|
|
30
|
+
self.web_proxy.setup()
|
|
31
|
+
self.stdout.output("[green]Project deployment completed successfully![/green]")
|
|
32
|
+
self.stdout.output(
|
|
33
|
+
f"[blue]Access the deployed project at: https://{self.host.config.domain_name}[/blue]"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def transfer_files(self):
|
|
37
|
+
if not self.host.config.envfile.exists():
|
|
38
|
+
raise cappa.Exit(f"{self.host.config.envfile} not found", code=1)
|
|
39
|
+
|
|
40
|
+
if not self.config.requirements.exists():
|
|
41
|
+
raise cappa.Exit(f"{self.config.requirements} not found", code=1)
|
|
42
|
+
project_dir = self.host.project_dir(self.config.app)
|
|
43
|
+
self.host.put(str(self.config.requirements), f"{project_dir}/requirements.txt")
|
|
44
|
+
self.host.put(str(self.host.config.envfile), f"{project_dir}/.env")
|
|
45
|
+
self.host.put(
|
|
46
|
+
str(self.config.distfile), f"{project_dir}/{self.config.distfile.name}"
|
|
47
|
+
)
|
|
48
|
+
self.host.run(f"echo {self.config.python_version} > .python-version")
|
|
49
|
+
|
|
50
|
+
def install_project(self):
|
|
51
|
+
with self.host.cd_project_dir(self.config.app):
|
|
52
|
+
self.host.run_uv("venv")
|
|
53
|
+
self.host.run_uv("pip install -r requirements.txt")
|
|
54
|
+
self.host.run_uv(f"pip install {self.config.distfile.name}")
|
fujin/commands/down.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import cappa
|
|
6
|
+
from rich.prompt import Prompt
|
|
7
|
+
|
|
8
|
+
from fujin.commands import AppCommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cappa.command(
|
|
12
|
+
help="Tear down the project by stopping services and cleaning up resources"
|
|
13
|
+
)
|
|
14
|
+
@dataclass
|
|
15
|
+
class Down(AppCommand):
|
|
16
|
+
|
|
17
|
+
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":
|
|
24
|
+
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]")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import cappa
|
|
4
|
+
|
|
5
|
+
from fujin.commands import AppCommand
|
|
6
|
+
|
|
7
|
+
from .deploy import Deploy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cappa.command(help="Redeploy the application to apply code and environment changes")
|
|
11
|
+
class Redeploy(AppCommand):
|
|
12
|
+
|
|
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]")
|
fujin/commands/server.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import cappa
|
|
6
|
+
|
|
7
|
+
from fujin.commands import AppCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cappa.command(help="Manage server operations")
|
|
11
|
+
class Server(AppCommand):
|
|
12
|
+
|
|
13
|
+
@cappa.command(help="Display information about the host system")
|
|
14
|
+
def info(self):
|
|
15
|
+
self.stdout.output(self.host.sudo("cat /etc/os-release", hide="out"))
|
|
16
|
+
|
|
17
|
+
@cappa.command(help="Setup uv, web proxy, and install necessary dependencies")
|
|
18
|
+
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]")
|
|
28
|
+
|
|
29
|
+
@cappa.command(
|
|
30
|
+
help="Execute an arbitrary command on the server, optionally in interactive mode"
|
|
31
|
+
)
|
|
32
|
+
def exec(
|
|
33
|
+
self,
|
|
34
|
+
command: str,
|
|
35
|
+
interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
|
|
36
|
+
):
|
|
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)
|
|
42
|
+
|
|
43
|
+
@cappa.command(
|
|
44
|
+
name="create-user", help="Create a new user with sudo and ssh access"
|
|
45
|
+
)
|
|
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]")
|
fujin/commands/up.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import cappa
|
|
2
|
+
|
|
3
|
+
from fujin.commands import AppCommand
|
|
4
|
+
from .deploy import Deploy
|
|
5
|
+
from .server import Server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@cappa.command(help="Run everything required to deploy an application to a fresh host.")
|
|
9
|
+
class Up(AppCommand):
|
|
10
|
+
|
|
11
|
+
def __call__(self):
|
|
12
|
+
Server(_host=self._host).bootstrap()
|
|
13
|
+
Deploy(_host=self._host)()
|
|
14
|
+
self.stdout.output(
|
|
15
|
+
"[green]Server bootstrapped and application deployed successfully![/green]"
|
|
16
|
+
)
|