fujin-cli 0.2.0__py3-none-any.whl → 0.4.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 +195 -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.4.0.dist-info/METADATA +66 -0
- fujin_cli-0.4.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.4.0.dist-info}/WHEEL +0 -0
- {fujin_cli-0.2.0.dist-info → fujin_cli-0.4.0.dist-info}/entry_points.txt +0 -0
- {fujin_cli-0.2.0.dist-info → fujin_cli-0.4.0.dist-info}/licenses/LICENSE.txt +0 -0
fujin/config.py
CHANGED
|
@@ -1,3 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fujin uses a ``fujin.toml`` file at the root of your project for configuration. Below are all available configuration options.
|
|
3
|
+
|
|
4
|
+
app
|
|
5
|
+
---
|
|
6
|
+
The name of your project or application. Must be a valid Python package name.
|
|
7
|
+
|
|
8
|
+
app_bin
|
|
9
|
+
-------
|
|
10
|
+
Path to your application's executable. Used by the **app** subcommand for remote execution.
|
|
11
|
+
Default: ``.venv/bin/{app}``
|
|
12
|
+
|
|
13
|
+
version
|
|
14
|
+
--------
|
|
15
|
+
The version of your project to build and deploy. If not specified, automatically parsed from ``pyproject.toml`` under ``project.version``.
|
|
16
|
+
|
|
17
|
+
python_version
|
|
18
|
+
--------------
|
|
19
|
+
The Python version for your virtualenv. If not specified, automatically parsed from ``.python-version`` file.
|
|
20
|
+
|
|
21
|
+
versions_to_keep
|
|
22
|
+
----------------
|
|
23
|
+
The number of versions to keep on the host. After each deploy, older versions are pruned based on this setting. By default, it keeps the latest 5 versions,
|
|
24
|
+
set this to `None` to never automatically prune.
|
|
25
|
+
|
|
26
|
+
build_command
|
|
27
|
+
-------------
|
|
28
|
+
The command to use to build your project's distribution file.
|
|
29
|
+
|
|
30
|
+
distfile
|
|
31
|
+
--------
|
|
32
|
+
Path to your project's distribution file. This should be the main artifact containing everything needed to run your project on the server.
|
|
33
|
+
Supports version placeholder, e.g., ``dist/app_name-{version}-py3-none-any.whl``
|
|
34
|
+
|
|
35
|
+
release_command
|
|
36
|
+
---------------
|
|
37
|
+
Optional command to run at the end of deployment (e.g., database migrations).
|
|
38
|
+
|
|
39
|
+
requirements
|
|
40
|
+
------------
|
|
41
|
+
Path to your requirements file.
|
|
42
|
+
Default: ``requirements.txt``
|
|
43
|
+
|
|
44
|
+
Webserver
|
|
45
|
+
---------
|
|
46
|
+
|
|
47
|
+
type
|
|
48
|
+
~~~~
|
|
49
|
+
The reverse proxy implementation to use. Available options:
|
|
50
|
+
|
|
51
|
+
- ``fujin.proxies.caddy`` (default)
|
|
52
|
+
- ``fujin.proxies.nginx``
|
|
53
|
+
- ``fujin.proxies.dummy`` (disables proxy functionality)
|
|
54
|
+
|
|
55
|
+
upstream
|
|
56
|
+
~~~~~~~~
|
|
57
|
+
The address where your web application listens for requests. Supports any value compatible with your chosen web proxy:
|
|
58
|
+
|
|
59
|
+
- HTTP address (e.g., ``localhost:8000``)
|
|
60
|
+
- Unix socket (e.g., ``unix//run/project.sock``)
|
|
61
|
+
|
|
62
|
+
statics
|
|
63
|
+
~~~~~~~
|
|
64
|
+
|
|
65
|
+
Defines the mapping of URL paths to local directories for serving static files. The syntax and support for static
|
|
66
|
+
file serving depend on the selected reverse proxy.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
|
|
70
|
+
.. code-block:: toml
|
|
71
|
+
|
|
72
|
+
[webserver]
|
|
73
|
+
upstream = "unix//run/project.sock"
|
|
74
|
+
type = "fujin.proxies.caddy"
|
|
75
|
+
statics = { "/static/*" = "/var/www/example.com/static/" }
|
|
76
|
+
|
|
77
|
+
processes
|
|
78
|
+
---------
|
|
79
|
+
|
|
80
|
+
A mapping of process names to commands that will be managed by the process manager. Define as many processes as needed, but
|
|
81
|
+
when using any proxy other than ``fujin.proxies.dummy``, a ``web`` process must be declared. Refer to the ``apps_dir``
|
|
82
|
+
setting on the host to understand how ``app_dir`` is determined.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
|
|
86
|
+
.. code-block:: toml
|
|
87
|
+
|
|
88
|
+
[processes]
|
|
89
|
+
web = ".venv/bin/gunicorn myproject.wsgi:application"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
.. note::
|
|
93
|
+
|
|
94
|
+
Commands are relative to your ``app_dir``. When generating systemd service files, the full path is automatically constructed.
|
|
95
|
+
|
|
96
|
+
Host Configuration
|
|
97
|
+
-------------------
|
|
98
|
+
|
|
99
|
+
ip
|
|
100
|
+
~~
|
|
101
|
+
The IP address or hostname of the remote host.
|
|
102
|
+
|
|
103
|
+
domain_name
|
|
104
|
+
~~~~~~~~~~~
|
|
105
|
+
The domain name pointing to this host. Used for web proxy configuration.
|
|
106
|
+
|
|
107
|
+
user
|
|
108
|
+
~~~~
|
|
109
|
+
The login user for running remote tasks. Should have passwordless sudo access for optimal operation.
|
|
110
|
+
|
|
111
|
+
.. note::
|
|
112
|
+
|
|
113
|
+
You can create a user with these requirements using the ``fujin server create-user`` command.
|
|
114
|
+
|
|
115
|
+
envfile
|
|
116
|
+
~~~~~~~
|
|
117
|
+
Path to the production environment file that will be copied to the host.
|
|
118
|
+
|
|
119
|
+
apps_dir
|
|
120
|
+
~~~~~~~~
|
|
121
|
+
|
|
122
|
+
Base directory for project storage on the host. Path is relative to user's home directory.
|
|
123
|
+
Default: ``.local/share/fujin``. This value determines your project's ``app_dir``, which is ``{apps_dir}/{app}``.
|
|
124
|
+
|
|
125
|
+
password_env
|
|
126
|
+
~~~~~~~~~~~~
|
|
127
|
+
|
|
128
|
+
Environment variable containing the user's password. Only needed if the user cannot run sudo without a password.
|
|
129
|
+
|
|
130
|
+
ssh_port
|
|
131
|
+
~~~~~~~~
|
|
132
|
+
|
|
133
|
+
SSH port for connecting to the host.
|
|
134
|
+
Default: ``22``
|
|
135
|
+
|
|
136
|
+
key_filename
|
|
137
|
+
~~~~~~~~~~~~
|
|
138
|
+
|
|
139
|
+
Path to the SSH private key file for authentication. Optional if using your system's default key location.
|
|
140
|
+
|
|
141
|
+
aliases
|
|
142
|
+
-------
|
|
143
|
+
|
|
144
|
+
A mapping of shortcut names to Fujin commands. Allows you to create convenient shortcuts for commonly used commands.
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
|
|
148
|
+
.. code-block:: toml
|
|
149
|
+
|
|
150
|
+
[aliases]
|
|
151
|
+
console = "app exec -i shell_plus" # open an interactive django shell
|
|
152
|
+
dbconsole = "app exec -i dbshell" # open an interactive django database shell
|
|
153
|
+
shell = "server exec --appenv -i bash" # SSH into the project directory with environment variables loaded
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
"""
|
|
157
|
+
|
|
1
158
|
from __future__ import annotations
|
|
2
159
|
|
|
3
160
|
import os
|
|
@@ -13,51 +170,45 @@ if sys.version_info >= (3, 11):
|
|
|
13
170
|
else:
|
|
14
171
|
import tomli as tomllib
|
|
15
172
|
|
|
16
|
-
|
|
17
|
-
from enum import StrEnum
|
|
18
|
-
except ImportError:
|
|
19
|
-
from enum import Enum
|
|
20
|
-
|
|
21
|
-
class StrEnum(str, Enum):
|
|
22
|
-
pass
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class Hook(StrEnum):
|
|
26
|
-
PRE_DEPLOY = "pre_deploy"
|
|
173
|
+
from .hooks import HooksDict
|
|
27
174
|
|
|
28
175
|
|
|
29
176
|
class Config(msgspec.Struct, kw_only=True):
|
|
30
|
-
|
|
177
|
+
app_name: str = msgspec.field(name="app")
|
|
31
178
|
app_bin: str = ".venv/bin/{app}"
|
|
32
179
|
version: str = msgspec.field(default_factory=lambda: read_version_from_pyproject())
|
|
180
|
+
versions_to_keep: int | None = 5
|
|
33
181
|
python_version: str = msgspec.field(default_factory=lambda: find_python_version())
|
|
34
182
|
build_command: str
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
183
|
+
release_command: str | None = None
|
|
184
|
+
skip_project_install: bool = False
|
|
185
|
+
distfile: str
|
|
186
|
+
aliases: dict[str, str] = msgspec.field(default_factory=dict)
|
|
187
|
+
host: HostConfig
|
|
188
|
+
processes: dict[str, str] = msgspec.field(default_factory=dict)
|
|
39
189
|
process_manager: str = "fujin.process_managers.systemd"
|
|
40
190
|
webserver: Webserver
|
|
41
191
|
_requirements: str = msgspec.field(name="requirements", default="requirements.txt")
|
|
42
|
-
hooks:
|
|
192
|
+
hooks: HooksDict = msgspec.field(default_factory=dict)
|
|
193
|
+
local_config_dir: Path = Path(".fujin")
|
|
43
194
|
|
|
44
195
|
def __post_init__(self):
|
|
45
|
-
self.app_bin = self.app_bin.format(app=self.
|
|
46
|
-
self._distfile = self._distfile.format(version=self.version)
|
|
196
|
+
self.app_bin = self.app_bin.format(app=self.app_name)
|
|
197
|
+
# self._distfile = self._distfile.format(version=self.version)
|
|
47
198
|
|
|
48
199
|
if "web" not in self.processes and self.webserver.type != "fujin.proxies.dummy":
|
|
49
200
|
raise ValueError(
|
|
50
201
|
"Missing web process or set the proxy to 'fujin.proxies.dummy' to disable the use of a proxy"
|
|
51
202
|
)
|
|
52
203
|
|
|
53
|
-
@property
|
|
54
|
-
def distfile(self) -> Path:
|
|
55
|
-
return Path(self._distfile)
|
|
56
|
-
|
|
57
204
|
@property
|
|
58
205
|
def requirements(self) -> Path:
|
|
59
206
|
return Path(self._requirements)
|
|
60
207
|
|
|
208
|
+
def get_distfile_path(self, version: str | None = None) -> Path:
|
|
209
|
+
version = version or self.version
|
|
210
|
+
return Path(self.distfile.format(version=version))
|
|
211
|
+
|
|
61
212
|
@classmethod
|
|
62
213
|
def read(cls) -> Config:
|
|
63
214
|
fujin_toml = Path("fujin.toml")
|
|
@@ -76,17 +227,21 @@ class HostConfig(msgspec.Struct, kw_only=True):
|
|
|
76
227
|
domain_name: str
|
|
77
228
|
user: str
|
|
78
229
|
_envfile: str = msgspec.field(name="envfile")
|
|
79
|
-
|
|
230
|
+
apps_dir: str = ".local/share/fujin"
|
|
80
231
|
password_env: str | None = None
|
|
81
232
|
ssh_port: int = 22
|
|
82
233
|
_key_filename: str | None = msgspec.field(name="key_filename", default=None)
|
|
83
|
-
default: bool = False
|
|
84
234
|
|
|
85
235
|
def __post_init__(self):
|
|
86
|
-
self.
|
|
236
|
+
self.apps_dir = f"/home/{self.user}/{self.apps_dir}"
|
|
87
237
|
|
|
88
238
|
def to_dict(self):
|
|
89
|
-
|
|
239
|
+
d = {f: getattr(self, f) for f in self.__struct_fields__}
|
|
240
|
+
d.pop("_key_filename")
|
|
241
|
+
d.pop("_envfile")
|
|
242
|
+
d["key_filename"] = self.key_filename
|
|
243
|
+
d["envfile"] = self.envfile
|
|
244
|
+
return d
|
|
90
245
|
|
|
91
246
|
@property
|
|
92
247
|
def envfile(self) -> Path:
|
|
@@ -107,10 +262,14 @@ class HostConfig(msgspec.Struct, kw_only=True):
|
|
|
107
262
|
raise ImproperlyConfiguredError(msg)
|
|
108
263
|
return password
|
|
109
264
|
|
|
265
|
+
def get_app_dir(self, app_name: str) -> str:
|
|
266
|
+
return f"{self.apps_dir}/{app_name}"
|
|
267
|
+
|
|
110
268
|
|
|
111
269
|
class Webserver(msgspec.Struct):
|
|
112
270
|
upstream: str
|
|
113
271
|
type: str = "fujin.proxies.caddy"
|
|
272
|
+
statics: dict[str, str] = msgspec.field(default_factory=dict)
|
|
114
273
|
|
|
115
274
|
|
|
116
275
|
def read_version_from_pyproject():
|
fujin/connection.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import cappa
|
|
8
|
+
from fabric import Connection
|
|
9
|
+
from invoke import Responder
|
|
10
|
+
from invoke.exceptions import UnexpectedExit
|
|
11
|
+
from paramiko.ssh_exception import (
|
|
12
|
+
AuthenticationException,
|
|
13
|
+
NoValidConnectionsError,
|
|
14
|
+
SSHException,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from fujin.config import HostConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_watchers(host: HostConfig) -> list[Responder]:
|
|
22
|
+
if not host.password:
|
|
23
|
+
return []
|
|
24
|
+
return [
|
|
25
|
+
Responder(
|
|
26
|
+
pattern=r"\[sudo\] password:",
|
|
27
|
+
response=f"{host.password}\n",
|
|
28
|
+
),
|
|
29
|
+
Responder(
|
|
30
|
+
pattern=rf"\[sudo\] password for {host.user}:",
|
|
31
|
+
response=f"{host.password}\n",
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def host_connection(host: HostConfig) -> Connection:
|
|
38
|
+
connect_kwargs = None
|
|
39
|
+
if host.key_filename:
|
|
40
|
+
connect_kwargs = {"key_filename": str(host.key_filename)}
|
|
41
|
+
elif host.password:
|
|
42
|
+
connect_kwargs = {"password": host.password}
|
|
43
|
+
conn = Connection(
|
|
44
|
+
host.ip,
|
|
45
|
+
user=host.user,
|
|
46
|
+
port=host.ssh_port,
|
|
47
|
+
connect_kwargs=connect_kwargs,
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
conn.run = partial(
|
|
51
|
+
conn.run,
|
|
52
|
+
env={
|
|
53
|
+
"PATH": f"/home/{host.user}/.cargo/bin:/home/{host.user}/.local/bin:$PATH"
|
|
54
|
+
},
|
|
55
|
+
watchers=_get_watchers(host),
|
|
56
|
+
)
|
|
57
|
+
yield conn
|
|
58
|
+
except AuthenticationException as e:
|
|
59
|
+
msg = f"Authentication failed for {host.user}@{host.ip} -p {host.ssh_port}.\n"
|
|
60
|
+
if host.key_filename:
|
|
61
|
+
msg += f"An SSH key was provided at {host.key_filename.resolve()}. Please verify its validity and correctness."
|
|
62
|
+
elif host.password:
|
|
63
|
+
msg += f"A password was provided through the environment variable {host.password_env}. Please ensure it is correct for the user {host.user}."
|
|
64
|
+
else:
|
|
65
|
+
msg += "No password or SSH key was provided. Ensure your current host has SSH access to the target host."
|
|
66
|
+
raise cappa.Exit(msg, code=1) from e
|
|
67
|
+
except (UnexpectedExit, NoValidConnectionsError) as e:
|
|
68
|
+
raise cappa.Exit(str(e), code=1) from e
|
|
69
|
+
except SSHException as e:
|
|
70
|
+
raise cappa.Exit(
|
|
71
|
+
f"{e}, possible causes: incorrect user, or either you or the server may be offline",
|
|
72
|
+
code=1,
|
|
73
|
+
) from e
|
|
74
|
+
finally:
|
|
75
|
+
conn.close()
|
fujin/hooks.py
CHANGED
|
@@ -1,15 +1,55 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
|
|
3
|
-
from fujin.
|
|
4
|
-
from
|
|
5
|
-
|
|
3
|
+
from fujin.connection import Connection
|
|
4
|
+
from rich import print as rich_print
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
except ImportError:
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
class StrEnum(str, Enum):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Hook(StrEnum):
|
|
16
|
+
PRE_DEPLOY = "pre_deploy"
|
|
17
|
+
POST_DEPLOY = "post_deploy"
|
|
18
|
+
PRE_BOOTSTRAP = "pre_bootstrap"
|
|
19
|
+
POST_BOOTSTRAP = "post_bootstrap"
|
|
20
|
+
PRE_TEARDOWN = "pre_teardown"
|
|
21
|
+
POST_TEARDOWN = "post_teardown"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
HooksDict = dict[Hook, dict]
|
|
6
25
|
|
|
7
26
|
|
|
8
27
|
@dataclass(frozen=True, slots=True)
|
|
9
28
|
class HookManager:
|
|
10
|
-
|
|
11
|
-
|
|
29
|
+
app_name: str
|
|
30
|
+
hooks: HooksDict
|
|
31
|
+
conn: Connection
|
|
32
|
+
|
|
33
|
+
def _run_hook(self, type_: Hook) -> None:
|
|
34
|
+
if hooks := self.hooks.get(type_):
|
|
35
|
+
for name, command in hooks.items():
|
|
36
|
+
rich_print(f"[blue]Running {type_} hook {name} [/blue]")
|
|
37
|
+
self.conn.run(command, pty=True)
|
|
38
|
+
|
|
39
|
+
def pre_deploy(self) -> None:
|
|
40
|
+
self._run_hook(Hook.PRE_DEPLOY)
|
|
41
|
+
|
|
42
|
+
def post_deploy(self) -> None:
|
|
43
|
+
self._run_hook(Hook.POST_DEPLOY)
|
|
44
|
+
|
|
45
|
+
def pre_bootstrap(self) -> None:
|
|
46
|
+
self._run_hook(Hook.PRE_BOOTSTRAP)
|
|
47
|
+
|
|
48
|
+
def post_bootstrap(self) -> None:
|
|
49
|
+
self._run_hook(Hook.POST_BOOTSTRAP)
|
|
50
|
+
|
|
51
|
+
def pre_teardown(self) -> None:
|
|
52
|
+
self._run_hook(Hook.PRE_TEARDOWN)
|
|
12
53
|
|
|
13
|
-
def
|
|
14
|
-
|
|
15
|
-
self.host.run(pre_deploy)
|
|
54
|
+
def post_teardown(self) -> None:
|
|
55
|
+
self._run_hook(Hook.POST_TEARDOWN)
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from fujin.connection import Connection
|
|
4
7
|
|
|
5
8
|
if TYPE_CHECKING:
|
|
6
9
|
from fujin.config import Config
|
|
7
|
-
from fujin.host import Host
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class ProcessManager(Protocol):
|
|
11
|
-
host: Host
|
|
12
|
-
config: Config
|
|
13
13
|
service_names: list[str]
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
@classmethod
|
|
16
|
+
def create(cls, config: Config, conn: Connection) -> ProcessManager: ...
|
|
17
|
+
|
|
18
|
+
def get_service_name(self, process_name: str): ...
|
|
16
19
|
|
|
17
20
|
def install_services(self) -> None: ...
|
|
18
21
|
|
|
@@ -24,6 +27,14 @@ class ProcessManager(Protocol):
|
|
|
24
27
|
|
|
25
28
|
def stop_services(self, *names) -> None: ...
|
|
26
29
|
|
|
30
|
+
def is_enabled(self, *names) -> dict[str, bool]: ...
|
|
31
|
+
|
|
32
|
+
def is_active(self, *names) -> dict[str, bool]: ...
|
|
33
|
+
|
|
27
34
|
def service_logs(self, name: str, follow: bool = False): ...
|
|
28
35
|
|
|
29
36
|
def reload_configuration(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
def get_configuration_files(
|
|
39
|
+
self, ignore_local: bool = False
|
|
40
|
+
) -> list[tuple[str, str]]: ...
|
|
@@ -5,101 +5,151 @@ from dataclasses import dataclass
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from fujin.config import Config
|
|
8
|
-
from fujin.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@dataclass(frozen=True, slots=True)
|
|
12
|
-
class SystemdFile:
|
|
13
|
-
name: str
|
|
14
|
-
body: str
|
|
8
|
+
from fujin.connection import Connection
|
|
15
9
|
|
|
16
10
|
|
|
17
11
|
@dataclass(frozen=True, slots=True)
|
|
18
12
|
class ProcessManager:
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
conn: Connection
|
|
14
|
+
app_name: str
|
|
15
|
+
processes: dict[str, str]
|
|
16
|
+
app_dir: str
|
|
17
|
+
user: str
|
|
18
|
+
is_using_unix_socket: bool
|
|
19
|
+
local_config_dir: Path
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def create(cls, config: Config, conn: Connection):
|
|
23
|
+
return cls(
|
|
24
|
+
processes=config.processes,
|
|
25
|
+
app_name=config.app_name,
|
|
26
|
+
app_dir=config.host.get_app_dir(config.app_name),
|
|
27
|
+
conn=conn,
|
|
28
|
+
user=config.host.user,
|
|
29
|
+
is_using_unix_socket="unix" in config.webserver.upstream
|
|
30
|
+
and config.webserver.type != "fujin.proxies.dummy",
|
|
31
|
+
local_config_dir=config.local_config_dir,
|
|
32
|
+
)
|
|
21
33
|
|
|
22
34
|
@property
|
|
23
35
|
def service_names(self) -> list[str]:
|
|
24
|
-
|
|
36
|
+
services = [self.get_service_name(name) for name in self.processes]
|
|
37
|
+
if self.is_using_unix_socket:
|
|
38
|
+
services.append(f"{self.app_name}.socket")
|
|
39
|
+
return services
|
|
40
|
+
|
|
41
|
+
def get_service_name(self, process_name: str):
|
|
42
|
+
if process_name == "web":
|
|
43
|
+
return f"{self.app_name}.service"
|
|
44
|
+
return f"{self.app_name}-{process_name}.service"
|
|
25
45
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
return self.config.app
|
|
29
|
-
return f"{self.config.app}-{name}.service"
|
|
46
|
+
def run_pty(self, *args, **kwargs):
|
|
47
|
+
return self.conn.run(*args, **kwargs, pty=True)
|
|
30
48
|
|
|
31
49
|
def install_services(self) -> None:
|
|
32
50
|
conf_files = self.get_configuration_files()
|
|
33
|
-
for
|
|
34
|
-
self.
|
|
35
|
-
f"echo '{
|
|
51
|
+
for filename, content in conf_files:
|
|
52
|
+
self.run_pty(
|
|
53
|
+
f"echo '{content}' | sudo tee /etc/systemd/system/{filename}",
|
|
36
54
|
hide="out",
|
|
37
55
|
)
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
self.
|
|
57
|
+
for name in self.processes:
|
|
58
|
+
if name == "web" and self.is_using_unix_socket:
|
|
59
|
+
self.run_pty(f"sudo systemctl enable --now {self.app_name}.socket")
|
|
60
|
+
else:
|
|
61
|
+
self.run_pty(f"sudo systemctl enable {self.get_service_name(name)}")
|
|
44
62
|
|
|
45
|
-
def get_configuration_files(
|
|
63
|
+
def get_configuration_files(
|
|
64
|
+
self, ignore_local: bool = False
|
|
65
|
+
) -> list[tuple[str, str]]:
|
|
46
66
|
templates_folder = (
|
|
47
67
|
Path(importlib.util.find_spec("fujin").origin).parent / "templates"
|
|
48
68
|
)
|
|
49
69
|
web_service_content = (templates_folder / "web.service").read_text()
|
|
50
70
|
web_socket_content = (templates_folder / "web.socket").read_text()
|
|
51
|
-
|
|
71
|
+
simple_service_content = (templates_folder / "simple.service").read_text()
|
|
72
|
+
if not self.is_using_unix_socket:
|
|
73
|
+
web_service_content = web_service_content.replace(
|
|
74
|
+
"Requires={app_name}.socket\n", ""
|
|
75
|
+
)
|
|
76
|
+
|
|
52
77
|
context = {
|
|
53
|
-
"
|
|
54
|
-
"user": self.
|
|
55
|
-
"
|
|
78
|
+
"app_name": self.app_name,
|
|
79
|
+
"user": self.user,
|
|
80
|
+
"app_dir": self.app_dir,
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
files = []
|
|
59
|
-
for name, command in self.
|
|
84
|
+
for name, command in self.processes.items():
|
|
85
|
+
template = web_service_content if name == "web" else simple_service_content
|
|
60
86
|
name = self.get_service_name(name)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
local_config = self.local_config_dir / name
|
|
88
|
+
body = (
|
|
89
|
+
local_config.read_text()
|
|
90
|
+
if local_config.exists() and not ignore_local
|
|
91
|
+
else template.format(**context, command=command)
|
|
92
|
+
)
|
|
93
|
+
files.append((name, body))
|
|
94
|
+
# if using unix then we are sure a web process was defined and the proxy is not dummy
|
|
95
|
+
if self.is_using_unix_socket:
|
|
96
|
+
name = f"{self.app_name}.socket"
|
|
97
|
+
local_config = self.local_config_dir / name
|
|
98
|
+
body = (
|
|
99
|
+
local_config.read_text()
|
|
100
|
+
if local_config.exists() and not ignore_local
|
|
101
|
+
else web_socket_content.format(**context)
|
|
102
|
+
)
|
|
103
|
+
files.append((name, body))
|
|
72
104
|
return files
|
|
73
105
|
|
|
74
106
|
def uninstall_services(self) -> None:
|
|
75
107
|
self.stop_services()
|
|
76
|
-
self.host.sudo(f"systemctl disable {self.config.app}.socket")
|
|
77
108
|
for name in self.service_names:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
self.host.sudo(f"systemctl disable {name}")
|
|
109
|
+
self.run_pty(f"sudo systemctl disable {name}", warn=True)
|
|
110
|
+
self.run_pty(f"sudo rm /etc/systemd/system/{name}", warn=True)
|
|
81
111
|
|
|
82
112
|
def start_services(self, *names) -> None:
|
|
83
113
|
names = names or self.service_names
|
|
84
114
|
for name in names:
|
|
85
115
|
if name in self.service_names:
|
|
86
|
-
self.
|
|
116
|
+
self.run_pty(f"sudo systemctl start {name}")
|
|
87
117
|
|
|
88
118
|
def restart_services(self, *names) -> None:
|
|
89
119
|
names = names or self.service_names
|
|
90
120
|
for name in names:
|
|
91
121
|
if name in self.service_names:
|
|
92
|
-
self.
|
|
122
|
+
self.run_pty(f"sudo systemctl restart {name}")
|
|
93
123
|
|
|
94
124
|
def stop_services(self, *names) -> None:
|
|
95
125
|
names = names or self.service_names
|
|
96
126
|
for name in names:
|
|
97
127
|
if name in self.service_names:
|
|
98
|
-
self.
|
|
128
|
+
self.run_pty(f"sudo systemctl stop {name}")
|
|
129
|
+
|
|
130
|
+
def is_enabled(self, *names) -> dict[str, bool]:
|
|
131
|
+
names = names or self.service_names
|
|
132
|
+
return {
|
|
133
|
+
name: self.run_pty(
|
|
134
|
+
f"sudo systemctl is-enabled {name}", warn=True, hide=True
|
|
135
|
+
).stdout.strip()
|
|
136
|
+
== "enabled"
|
|
137
|
+
for name in names
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def is_active(self, *names) -> dict[str, bool]:
|
|
141
|
+
names = names or self.service_names
|
|
142
|
+
return {
|
|
143
|
+
name: self.run_pty(
|
|
144
|
+
f"sudo systemctl is-active {name}", warn=True, hide=True
|
|
145
|
+
).stdout.strip()
|
|
146
|
+
== "active"
|
|
147
|
+
for name in names
|
|
148
|
+
}
|
|
99
149
|
|
|
100
150
|
def service_logs(self, name: str, follow: bool = False):
|
|
101
151
|
# TODO: add more options here
|
|
102
|
-
self.
|
|
152
|
+
self.run_pty(f"sudo journalctl -u {name} {'-f' if follow else ''}", warn=True)
|
|
103
153
|
|
|
104
154
|
def reload_configuration(self) -> None:
|
|
105
|
-
self.
|
|
155
|
+
self.run_pty(f"sudo systemctl daemon-reload")
|
fujin/proxies/__init__.py
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
from fujin.config import Config
|
|
7
|
+
from fujin.config import HostConfig
|
|
8
|
+
from fujin.connection import Connection
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class WebProxy(Protocol):
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
config_file: Path
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def create(cls, config: Config, conn: Connection) -> WebProxy: ...
|
|
13
16
|
|
|
14
17
|
def install(self) -> None: ...
|
|
15
18
|
|
|
19
|
+
def uninstall(self) -> None: ...
|
|
20
|
+
|
|
16
21
|
def setup(self) -> None: ...
|
|
17
22
|
|
|
18
23
|
def teardown(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
def start(self) -> None: ...
|
|
26
|
+
|
|
27
|
+
def stop(self) -> None: ...
|
|
28
|
+
|
|
29
|
+
def status(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
def restart(self) -> None: ...
|
|
32
|
+
|
|
33
|
+
def logs(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
def export_config(self) -> None: ...
|