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/__main__.py +22 -4
- fujin/commands/__init__.py +1 -2
- fujin/commands/_base.py +34 -41
- fujin/commands/app.py +78 -10
- fujin/commands/config.py +16 -125
- fujin/commands/deploy.py +93 -31
- fujin/commands/docs.py +16 -0
- fujin/commands/down.py +33 -15
- fujin/commands/init.py +82 -0
- fujin/commands/proxy.py +71 -0
- fujin/commands/prune.py +42 -0
- fujin/commands/redeploy.py +39 -10
- fujin/commands/rollback.py +49 -0
- fujin/commands/secrets.py +11 -0
- fujin/commands/server.py +65 -30
- fujin/commands/up.py +4 -5
- fujin/config.py +186 -27
- fujin/connection.py +75 -0
- fujin/hooks.py +48 -8
- fujin/process_managers/__init__.py +16 -5
- fujin/process_managers/systemd.py +98 -48
- fujin/proxies/__init__.py +23 -6
- fujin/proxies/caddy.py +192 -30
- fujin/proxies/dummy.py +16 -3
- fujin/proxies/nginx.py +109 -36
- fujin/templates/simple.service +14 -0
- fujin/templates/web.service +12 -11
- fujin/templates/web.socket +2 -2
- fujin_cli-0.3.0.dist-info/METADATA +66 -0
- fujin_cli-0.3.0.dist-info/RECORD +35 -0
- fujin/host.py +0 -85
- fujin/templates/other.service +0 -15
- fujin_cli-0.2.0.dist-info/METADATA +0 -52
- fujin_cli-0.2.0.dist-info/RECORD +0 -29
- {fujin_cli-0.2.0.dist-info → fujin_cli-0.3.0.dist-info}/WHEEL +0 -0
- {fujin_cli-0.2.0.dist-info → fujin_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {fujin_cli-0.2.0.dist-info → fujin_cli-0.3.0.dist-info}/licenses/LICENSE.txt +0 -0
fujin/__main__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import shlex
|
|
1
2
|
import sys
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
@@ -6,8 +7,13 @@ import cappa
|
|
|
6
7
|
from fujin.commands.app import App
|
|
7
8
|
from fujin.commands.config import ConfigCMD
|
|
8
9
|
from fujin.commands.deploy import Deploy
|
|
10
|
+
from fujin.commands.docs import Docs
|
|
9
11
|
from fujin.commands.down import Down
|
|
12
|
+
from fujin.commands.init import Init
|
|
13
|
+
from fujin.commands.proxy import Proxy
|
|
14
|
+
from fujin.commands.prune import Prune
|
|
10
15
|
from fujin.commands.redeploy import Redeploy
|
|
16
|
+
from fujin.commands.rollback import Rollback
|
|
11
17
|
from fujin.commands.server import Server
|
|
12
18
|
from fujin.commands.up import Up
|
|
13
19
|
|
|
@@ -20,12 +26,22 @@ else:
|
|
|
20
26
|
@cappa.command(help="Deployment of python web apps in a breeze :)")
|
|
21
27
|
class Fujin:
|
|
22
28
|
subcommands: cappa.Subcommands[
|
|
23
|
-
|
|
29
|
+
Init
|
|
30
|
+
| Up
|
|
31
|
+
| Deploy
|
|
32
|
+
| Redeploy
|
|
33
|
+
| App
|
|
34
|
+
| Server
|
|
35
|
+
| Proxy
|
|
36
|
+
| ConfigCMD
|
|
37
|
+
| Docs
|
|
38
|
+
| Down
|
|
39
|
+
| Rollback
|
|
40
|
+
| Prune
|
|
24
41
|
]
|
|
25
42
|
|
|
26
43
|
|
|
27
44
|
def main():
|
|
28
|
-
# install(show_locals=True)
|
|
29
45
|
alias_cmd = _parse_aliases()
|
|
30
46
|
if alias_cmd:
|
|
31
47
|
cappa.invoke(Fujin, argv=alias_cmd)
|
|
@@ -38,7 +54,7 @@ def _parse_aliases() -> list[str] | None:
|
|
|
38
54
|
if not fujin_toml.exists():
|
|
39
55
|
return
|
|
40
56
|
data = tomllib.loads(fujin_toml.read_text())
|
|
41
|
-
aliases = data.get("aliases")
|
|
57
|
+
aliases: dict[str, str] = data.get("aliases")
|
|
42
58
|
if not aliases:
|
|
43
59
|
return
|
|
44
60
|
if len(sys.argv) == 1:
|
|
@@ -46,7 +62,9 @@ def _parse_aliases() -> list[str] | None:
|
|
|
46
62
|
if sys.argv[1] not in aliases:
|
|
47
63
|
return
|
|
48
64
|
extra_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
|
49
|
-
|
|
65
|
+
aliased_cmd = aliases.get(sys.argv[1])
|
|
66
|
+
subcommand, args = aliased_cmd.split(" ", 1)
|
|
67
|
+
return [subcommand, *extra_args, *shlex.split(args, posix=True)]
|
|
50
68
|
|
|
51
69
|
|
|
52
70
|
if __name__ == "__main__":
|
fujin/commands/__init__.py
CHANGED
fujin/commands/_base.py
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import importlib
|
|
2
|
+
from contextlib import contextmanager
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from functools import cached_property
|
|
4
|
-
from typing import Annotated
|
|
5
5
|
|
|
6
6
|
import cappa
|
|
7
7
|
|
|
8
8
|
from fujin.config import Config
|
|
9
|
+
from fujin.connection import host_connection, Connection
|
|
9
10
|
from fujin.errors import ImproperlyConfiguredError
|
|
10
11
|
from fujin.hooks import HookManager
|
|
11
|
-
from fujin.host import Host
|
|
12
12
|
from fujin.process_managers import ProcessManager
|
|
13
13
|
from fujin.proxies import WebProxy
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
@dataclass
|
|
17
17
|
class BaseCommand:
|
|
18
|
+
"""
|
|
19
|
+
A command that provides access to the host config and provide a connection to interact with it,
|
|
20
|
+
including configuring the web proxy and managing systemd services.
|
|
21
|
+
"""
|
|
22
|
+
|
|
18
23
|
@cached_property
|
|
19
24
|
def config(self) -> Config:
|
|
20
25
|
return Config.read()
|
|
@@ -23,61 +28,49 @@ class BaseCommand:
|
|
|
23
28
|
def stdout(self) -> cappa.Output:
|
|
24
29
|
return cappa.Output()
|
|
25
30
|
|
|
31
|
+
@cached_property
|
|
32
|
+
def app_dir(self) -> str:
|
|
33
|
+
return self.config.host.get_app_dir(app_name=self.config.app_name)
|
|
26
34
|
|
|
27
|
-
@
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
+
@contextmanager
|
|
36
|
+
def connection(self):
|
|
37
|
+
with host_connection(host=self.config.host) as conn:
|
|
38
|
+
yield conn
|
|
37
39
|
|
|
38
|
-
@
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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)
|
|
40
|
+
@contextmanager
|
|
41
|
+
def app_environment(self) -> Connection:
|
|
42
|
+
with self.connection() as conn:
|
|
43
|
+
with conn.cd(self.app_dir):
|
|
44
|
+
with conn.prefix("source .appenv"):
|
|
45
|
+
yield conn
|
|
60
46
|
|
|
61
47
|
@cached_property
|
|
62
|
-
def
|
|
48
|
+
def web_proxy_class(self) -> type[WebProxy]:
|
|
63
49
|
module = importlib.import_module(self.config.webserver.type)
|
|
64
50
|
try:
|
|
65
|
-
return getattr(module, "WebProxy")
|
|
51
|
+
return getattr(module, "WebProxy")
|
|
66
52
|
except KeyError as e:
|
|
67
53
|
raise ImproperlyConfiguredError(
|
|
68
54
|
f"Missing WebProxy class in {self.config.webserver.type}"
|
|
69
55
|
) from e
|
|
70
56
|
|
|
57
|
+
def create_web_proxy(self, conn: Connection) -> WebProxy:
|
|
58
|
+
return self.web_proxy_class.create(conn=conn, config=self.config)
|
|
59
|
+
|
|
71
60
|
@cached_property
|
|
72
|
-
def
|
|
61
|
+
def process_manager_class(self) -> type[ProcessManager]:
|
|
73
62
|
module = importlib.import_module(self.config.process_manager)
|
|
74
63
|
try:
|
|
75
|
-
return getattr(module, "ProcessManager")
|
|
64
|
+
return getattr(module, "ProcessManager")
|
|
76
65
|
except KeyError as e:
|
|
77
66
|
raise ImproperlyConfiguredError(
|
|
78
67
|
f"Missing ProcessManager class in {self.config.process_manager}"
|
|
79
68
|
) from e
|
|
80
69
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
70
|
+
def create_process_manager(self, conn: Connection) -> ProcessManager:
|
|
71
|
+
return self.process_manager_class.create(conn=conn, config=self.config)
|
|
72
|
+
|
|
73
|
+
def create_hook_manager(self, conn: Connection) -> HookManager:
|
|
74
|
+
return HookManager(
|
|
75
|
+
conn=conn, hooks=self.config.hooks, app_name=self.config.app_name
|
|
76
|
+
)
|
fujin/commands/app.py
CHANGED
|
@@ -4,11 +4,51 @@ from typing import Annotated
|
|
|
4
4
|
|
|
5
5
|
import cappa
|
|
6
6
|
|
|
7
|
-
from fujin.commands import
|
|
7
|
+
from fujin.commands import BaseCommand
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@cappa.command(help="Run application-related tasks")
|
|
11
|
-
class App(
|
|
11
|
+
class App(BaseCommand):
|
|
12
|
+
@cappa.command(help="Display information about the application")
|
|
13
|
+
def info(self):
|
|
14
|
+
with self.app_environment() as conn:
|
|
15
|
+
remote_version = (
|
|
16
|
+
conn.run("head -n 1 .versions", warn=True, hide=True).stdout.strip()
|
|
17
|
+
or "N/A"
|
|
18
|
+
)
|
|
19
|
+
rollback_targets = conn.run(
|
|
20
|
+
"sed -n '2,$p' .versions", warn=True, hide=True
|
|
21
|
+
).stdout.strip()
|
|
22
|
+
infos = {
|
|
23
|
+
"app_name": self.config.app_name,
|
|
24
|
+
"app_dir": self.app_dir,
|
|
25
|
+
"app_bin": self.config.app_bin,
|
|
26
|
+
"local_version": self.config.version,
|
|
27
|
+
"remote_version": remote_version,
|
|
28
|
+
"python_version": self.config.python_version,
|
|
29
|
+
"rollback_targets": ", ".join(rollback_targets.split("\n"))
|
|
30
|
+
if rollback_targets
|
|
31
|
+
else "N/A",
|
|
32
|
+
}
|
|
33
|
+
pm = self.create_process_manager(conn)
|
|
34
|
+
services: dict[str, bool] = pm.is_active()
|
|
35
|
+
|
|
36
|
+
infos_text = "\n".join(f"{key}: {value}" for key, value in infos.items())
|
|
37
|
+
from rich.table import Table
|
|
38
|
+
|
|
39
|
+
table = Table(title="", header_style="bold cyan")
|
|
40
|
+
table.add_column("Service", style="")
|
|
41
|
+
table.add_column("Running?")
|
|
42
|
+
for service, is_active in services.items():
|
|
43
|
+
table.add_row(
|
|
44
|
+
service,
|
|
45
|
+
"[bold green]Yes[/bold green]"
|
|
46
|
+
if is_active
|
|
47
|
+
else "[bold red]No[/bold red]",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self.stdout.output(infos_text)
|
|
51
|
+
self.stdout.output(table)
|
|
12
52
|
|
|
13
53
|
@cappa.command(help="Run an arbitrary command via the application binary")
|
|
14
54
|
def exec(
|
|
@@ -16,12 +56,13 @@ class App(AppCommand):
|
|
|
16
56
|
command: str,
|
|
17
57
|
interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
|
|
18
58
|
):
|
|
19
|
-
with self.
|
|
59
|
+
with self.app_environment() as conn:
|
|
20
60
|
if interactive:
|
|
21
|
-
|
|
61
|
+
conn.run(f"{self.config.app_bin} {command}", pty=interactive, warn=True)
|
|
22
62
|
else:
|
|
23
|
-
|
|
24
|
-
|
|
63
|
+
self.stdout.output(
|
|
64
|
+
conn.run(f"{self.config.app_bin} {command}", hide=True).stdout
|
|
65
|
+
)
|
|
25
66
|
|
|
26
67
|
@cappa.command(
|
|
27
68
|
help="Start the specified service or all services if no name is provided"
|
|
@@ -32,7 +73,8 @@ class App(AppCommand):
|
|
|
32
73
|
str | None, cappa.Arg(help="Service name, no value means all")
|
|
33
74
|
] = None,
|
|
34
75
|
):
|
|
35
|
-
self.
|
|
76
|
+
with self.app_environment() as conn:
|
|
77
|
+
self.create_process_manager(conn).start_services(name)
|
|
36
78
|
msg = f"{name} Service" if name else "All Services"
|
|
37
79
|
self.stdout.output(f"[green]{msg} started successfully![/green]")
|
|
38
80
|
|
|
@@ -45,7 +87,8 @@ class App(AppCommand):
|
|
|
45
87
|
str | None, cappa.Arg(help="Service name, no value means all")
|
|
46
88
|
] = None,
|
|
47
89
|
):
|
|
48
|
-
self.
|
|
90
|
+
with self.app_environment() as conn:
|
|
91
|
+
self.create_process_manager(conn).restart_services(name)
|
|
49
92
|
msg = f"{name} Service" if name else "All Services"
|
|
50
93
|
self.stdout.output(f"[green]{msg} restarted successfully![/green]")
|
|
51
94
|
|
|
@@ -58,7 +101,8 @@ class App(AppCommand):
|
|
|
58
101
|
str | None, cappa.Arg(help="Service name, no value means all")
|
|
59
102
|
] = None,
|
|
60
103
|
):
|
|
61
|
-
self.
|
|
104
|
+
with self.app_environment() as conn:
|
|
105
|
+
self.create_process_manager(conn).stop_services(name)
|
|
62
106
|
msg = f"{name} Service" if name else "All Services"
|
|
63
107
|
self.stdout.output(f"[green]{msg} stopped successfully![/green]")
|
|
64
108
|
|
|
@@ -67,4 +111,28 @@ class App(AppCommand):
|
|
|
67
111
|
self, name: Annotated[str, cappa.Arg(help="Service name")], follow: bool = False
|
|
68
112
|
):
|
|
69
113
|
# TODO: flash out this more
|
|
70
|
-
self.
|
|
114
|
+
with self.app_environment() as conn:
|
|
115
|
+
self.create_process_manager(conn).service_logs(name=name, follow=follow)
|
|
116
|
+
|
|
117
|
+
@cappa.command(
|
|
118
|
+
name="export-config",
|
|
119
|
+
help="Export the service configuration files locally to the .fujin directory",
|
|
120
|
+
)
|
|
121
|
+
def export_config(
|
|
122
|
+
self,
|
|
123
|
+
overwrite: Annotated[
|
|
124
|
+
bool, cappa.Arg(help="overwrite any existing config file")
|
|
125
|
+
] = False,
|
|
126
|
+
):
|
|
127
|
+
with self.connection() as conn:
|
|
128
|
+
for filename, content in self.create_process_manager(
|
|
129
|
+
conn
|
|
130
|
+
).get_configuration_files(ignore_local=True):
|
|
131
|
+
local_config = self.config.local_config_dir / filename
|
|
132
|
+
if local_config.exists() and not overwrite:
|
|
133
|
+
self.stdout.output(
|
|
134
|
+
f"[blue]Skipping {filename}, file already exists. Use --overwrite to replace it.[/blue]"
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
local_config.write_text(content)
|
|
138
|
+
self.stdout.output(f"[green]{filename} exported successfully![/green]")
|
fujin/commands/config.py
CHANGED
|
@@ -1,78 +1,52 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Annotated
|
|
5
|
-
|
|
6
3
|
import cappa
|
|
7
|
-
import tomli_w
|
|
8
4
|
from rich.console import Console
|
|
9
|
-
from rich.markdown import Markdown
|
|
10
5
|
from rich.panel import Panel
|
|
11
6
|
from rich.table import Table
|
|
12
7
|
|
|
13
8
|
from fujin.commands import BaseCommand
|
|
14
|
-
from fujin.config import tomllib
|
|
15
9
|
|
|
16
10
|
|
|
17
|
-
@cappa.command(name="config", help="
|
|
11
|
+
@cappa.command(name="config", help="Display your current configuration")
|
|
18
12
|
class ConfigCMD(BaseCommand):
|
|
19
|
-
|
|
20
|
-
@cappa.command(help="Display the parsed configuration")
|
|
21
|
-
def show(self):
|
|
13
|
+
def __call__(self):
|
|
22
14
|
console = Console()
|
|
23
|
-
|
|
24
15
|
general_config = {
|
|
25
|
-
"app": self.config.
|
|
16
|
+
"app": self.config.app_name,
|
|
26
17
|
"app_bin": self.config.app_bin,
|
|
27
18
|
"version": self.config.version,
|
|
28
19
|
"python_version": self.config.python_version,
|
|
29
20
|
"build_command": self.config.build_command,
|
|
21
|
+
"release_command": self.config.release_command,
|
|
30
22
|
"distfile": self.config.distfile,
|
|
31
23
|
"requirements": self.config.requirements,
|
|
32
24
|
"webserver": f"{{ upstream = '{self.config.webserver.upstream}', type = '{self.config.webserver.type}' }}",
|
|
33
25
|
}
|
|
34
|
-
|
|
26
|
+
general_config_text = "\n".join(
|
|
35
27
|
f"[bold green]{key}:[/bold green] {value}"
|
|
36
28
|
for key, value in general_config.items()
|
|
37
29
|
)
|
|
38
30
|
console.print(
|
|
39
31
|
Panel(
|
|
40
|
-
|
|
32
|
+
general_config_text,
|
|
41
33
|
title="General Configuration",
|
|
42
34
|
border_style="green",
|
|
43
35
|
width=100,
|
|
44
36
|
)
|
|
45
37
|
)
|
|
46
38
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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]",
|
|
39
|
+
host_config_text = "\n".join(
|
|
40
|
+
f"[dim]{key}:[/dim] {value}"
|
|
41
|
+
for key, value in self.config.host.to_dict().items()
|
|
42
|
+
)
|
|
43
|
+
console.print(
|
|
44
|
+
Panel(
|
|
45
|
+
host_config_text,
|
|
46
|
+
title="Host Configuration",
|
|
47
|
+
width=100,
|
|
73
48
|
)
|
|
74
|
-
|
|
75
|
-
console.print(hosts_table)
|
|
49
|
+
)
|
|
76
50
|
|
|
77
51
|
# Processes Table with headers and each dictionary on its own line
|
|
78
52
|
processes_table = Table(title="Processes", header_style="bold cyan")
|
|
@@ -89,86 +63,3 @@ class ConfigCMD(BaseCommand):
|
|
|
89
63
|
aliases_table.add_row(alias, command)
|
|
90
64
|
|
|
91
65
|
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
CHANGED
|
@@ -4,51 +4,113 @@ import subprocess
|
|
|
4
4
|
|
|
5
5
|
import cappa
|
|
6
6
|
|
|
7
|
-
from fujin.commands import
|
|
7
|
+
from fujin.commands import BaseCommand
|
|
8
|
+
from fujin.connection import Connection
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
@cappa.command(
|
|
11
12
|
help="Deploy the project by building, transferring files, installing, and configuring services"
|
|
12
13
|
)
|
|
13
|
-
class Deploy(
|
|
14
|
-
|
|
14
|
+
class Deploy(BaseCommand):
|
|
15
15
|
def __call__(self):
|
|
16
|
-
|
|
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
|
|
16
|
+
self.build_app()
|
|
20
17
|
|
|
21
|
-
self.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
with self.connection() as conn:
|
|
19
|
+
conn.run(f"mkdir -p {self.app_dir}")
|
|
20
|
+
with conn.cd(self.app_dir):
|
|
21
|
+
self.create_hook_manager(conn).pre_deploy()
|
|
22
|
+
self.transfer_files(conn)
|
|
25
23
|
|
|
26
|
-
self.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
with self.app_environment() as conn:
|
|
25
|
+
process_manager = self.create_process_manager(conn)
|
|
26
|
+
self.install_project(conn)
|
|
27
|
+
self.release(conn)
|
|
28
|
+
process_manager.install_services()
|
|
29
|
+
process_manager.reload_configuration()
|
|
30
|
+
process_manager.restart_services()
|
|
31
|
+
self.create_web_proxy(conn).setup()
|
|
32
|
+
self.update_version_history(conn)
|
|
33
|
+
self.prune_assets(conn)
|
|
34
|
+
self.create_hook_manager(conn).post_deploy()
|
|
31
35
|
self.stdout.output("[green]Project deployment completed successfully![/green]")
|
|
32
36
|
self.stdout.output(
|
|
33
|
-
f"[blue]Access the deployed project at: https://{self.host.
|
|
37
|
+
f"[blue]Access the deployed project at: https://{self.config.host.domain_name}[/blue]"
|
|
34
38
|
)
|
|
35
39
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
def build_app(self) -> None:
|
|
41
|
+
try:
|
|
42
|
+
subprocess.run(self.config.build_command, check=True, shell=True)
|
|
43
|
+
except subprocess.CalledProcessError as e:
|
|
44
|
+
raise cappa.Exit(f"build command failed: {e}", code=1) from e
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def versioned_assets_dir(self) -> str:
|
|
48
|
+
return f"{self.app_dir}/v{self.config.version}"
|
|
49
|
+
|
|
50
|
+
def transfer_files(self, conn: Connection, skip_requirements: bool = False):
|
|
51
|
+
if not self.config.host.envfile.exists():
|
|
52
|
+
raise cappa.Exit(f"{self.config.host.envfile} not found", code=1)
|
|
39
53
|
|
|
40
54
|
if not self.config.requirements.exists():
|
|
41
55
|
raise cappa.Exit(f"{self.config.requirements} not found", code=1)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
conn.put(str(self.config.host.envfile), f"{self.app_dir}/.env")
|
|
57
|
+
conn.run(f"mkdir -p {self.versioned_assets_dir}")
|
|
58
|
+
if not skip_requirements:
|
|
59
|
+
conn.put(
|
|
60
|
+
str(self.config.requirements),
|
|
61
|
+
f"{self.versioned_assets_dir}/requirements.txt",
|
|
62
|
+
)
|
|
63
|
+
distfile_path = self.config.get_distfile_path()
|
|
64
|
+
conn.put(
|
|
65
|
+
str(distfile_path),
|
|
66
|
+
f"{self.versioned_assets_dir}/{distfile_path.name}",
|
|
47
67
|
)
|
|
48
|
-
|
|
68
|
+
appenv = f"""
|
|
69
|
+
set -a # Automatically export all variables
|
|
70
|
+
source .env
|
|
71
|
+
set +a # Stop automatic export
|
|
72
|
+
export UV_COMPILE_BYTECODE=1
|
|
73
|
+
export UV_PYTHON=python{self.config.python_version}
|
|
74
|
+
export PATH=".venv/bin:$PATH"
|
|
75
|
+
"""
|
|
76
|
+
conn.run(f"echo '{appenv.strip()}' > .appenv")
|
|
77
|
+
|
|
78
|
+
def install_project(
|
|
79
|
+
self, conn: Connection, version: str | None = None, *, skip_setup: bool = False
|
|
80
|
+
):
|
|
81
|
+
if self.config.skip_project_install:
|
|
82
|
+
return
|
|
83
|
+
version = version or self.config.version
|
|
84
|
+
versioned_assets_dir = f"{self.app_dir}/v{version}"
|
|
85
|
+
if not skip_setup:
|
|
86
|
+
conn.run("uv venv")
|
|
87
|
+
conn.run(f"uv pip install -r {versioned_assets_dir}/requirements.txt")
|
|
88
|
+
conn.run(
|
|
89
|
+
f"uv pip install {versioned_assets_dir}/{self.config.get_distfile_path(version).name}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def release(self, conn: Connection):
|
|
93
|
+
if self.config.release_command:
|
|
94
|
+
conn.run(f"source .env && {self.config.release_command}")
|
|
95
|
+
|
|
96
|
+
def update_version_history(self, conn: Connection):
|
|
97
|
+
result = conn.run("head -n 1 .versions", warn=True, hide=True).stdout.strip()
|
|
98
|
+
if result == self.config.version:
|
|
99
|
+
return
|
|
100
|
+
if result == "":
|
|
101
|
+
conn.run(f"echo '{self.config.version}' > .versions")
|
|
102
|
+
else:
|
|
103
|
+
conn.run(f"sed -i '1i {self.config.version}' .versions")
|
|
49
104
|
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
105
|
+
def prune_assets(self, conn: Connection):
|
|
106
|
+
if not self.config.versions_to_keep:
|
|
107
|
+
return
|
|
108
|
+
result = conn.run(
|
|
109
|
+
f"sed -n '{self.config.versions_to_keep + 1},$p' .versions", hide=True
|
|
110
|
+
).stdout.strip()
|
|
111
|
+
result_list = result.split("\n")
|
|
112
|
+
if result == "":
|
|
113
|
+
return
|
|
114
|
+
to_prune = [f"{self.app_dir}/v{v}" for v in result_list]
|
|
115
|
+
conn.run(f"rm -r {' '.join(to_prune)}", warn=True)
|
|
116
|
+
conn.run(f"sed -i '{self.config.versions_to_keep + 1},$d' .versions", warn=True)
|
fujin/commands/docs.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import cappa
|
|
4
|
+
|
|
5
|
+
import fujin.config
|
|
6
|
+
from fujin.commands import BaseCommand
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@cappa.command(help="Configuration documentation")
|
|
10
|
+
class Docs(BaseCommand):
|
|
11
|
+
def __call__(self):
|
|
12
|
+
docs = f"""
|
|
13
|
+
# Fujin Configuration
|
|
14
|
+
{fujin.config.__doc__}
|
|
15
|
+
"""
|
|
16
|
+
self.stdout.output(docs)
|