fujin-cli 0.1.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/__init__.py +0 -2
- fujin/__main__.py +71 -0
- fujin/commands/__init__.py +2 -0
- fujin/commands/_base.py +76 -0
- fujin/commands/app.py +138 -0
- fujin/commands/config.py +65 -0
- fujin/commands/deploy.py +116 -0
- fujin/commands/docs.py +16 -0
- fujin/commands/down.py +48 -0
- fujin/commands/init.py +82 -0
- fujin/commands/proxy.py +71 -0
- fujin/commands/prune.py +42 -0
- fujin/commands/redeploy.py +48 -0
- fujin/commands/rollback.py +49 -0
- fujin/commands/secrets.py +11 -0
- fujin/commands/server.py +92 -0
- fujin/commands/up.py +15 -0
- fujin/config.py +290 -0
- fujin/connection.py +75 -0
- fujin/errors.py +5 -0
- fujin/hooks.py +55 -0
- fujin/process_managers/__init__.py +40 -0
- fujin/process_managers/systemd.py +155 -0
- fujin/proxies/__init__.py +35 -0
- fujin/proxies/caddy.py +214 -0
- fujin/proxies/dummy.py +29 -0
- fujin/proxies/nginx.py +132 -0
- fujin/templates/simple.service +14 -0
- fujin/templates/web.service +25 -0
- fujin/templates/web.socket +11 -0
- fujin_cli-0.3.0.dist-info/METADATA +66 -0
- fujin_cli-0.3.0.dist-info/RECORD +35 -0
- fujin_cli-0.3.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.3.0.dist-info}/WHEEL +0 -0
- {fujin_cli-0.1.0.dist-info → fujin_cli-0.3.0.dist-info}/licenses/LICENSE.txt +0 -0
fujin/errors.py
ADDED
fujin/hooks.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
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]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class HookManager:
|
|
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)
|
|
53
|
+
|
|
54
|
+
def post_teardown(self) -> None:
|
|
55
|
+
self._run_hook(Hook.POST_TEARDOWN)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from fujin.connection import Connection
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from fujin.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProcessManager(Protocol):
|
|
13
|
+
service_names: list[str]
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def create(cls, config: Config, conn: Connection) -> ProcessManager: ...
|
|
17
|
+
|
|
18
|
+
def get_service_name(self, process_name: str): ...
|
|
19
|
+
|
|
20
|
+
def install_services(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
def uninstall_services(self) -> None: ...
|
|
23
|
+
|
|
24
|
+
def start_services(self, *names) -> None: ...
|
|
25
|
+
|
|
26
|
+
def restart_services(self, *names) -> None: ...
|
|
27
|
+
|
|
28
|
+
def stop_services(self, *names) -> None: ...
|
|
29
|
+
|
|
30
|
+
def is_enabled(self, *names) -> dict[str, bool]: ...
|
|
31
|
+
|
|
32
|
+
def is_active(self, *names) -> dict[str, bool]: ...
|
|
33
|
+
|
|
34
|
+
def service_logs(self, name: str, follow: bool = False): ...
|
|
35
|
+
|
|
36
|
+
def reload_configuration(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
def get_configuration_files(
|
|
39
|
+
self, ignore_local: bool = False
|
|
40
|
+
) -> list[tuple[str, str]]: ...
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fujin.config import Config
|
|
8
|
+
from fujin.connection import Connection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ProcessManager:
|
|
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
|
+
)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def service_names(self) -> list[str]:
|
|
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"
|
|
45
|
+
|
|
46
|
+
def run_pty(self, *args, **kwargs):
|
|
47
|
+
return self.conn.run(*args, **kwargs, pty=True)
|
|
48
|
+
|
|
49
|
+
def install_services(self) -> None:
|
|
50
|
+
conf_files = self.get_configuration_files()
|
|
51
|
+
for filename, content in conf_files:
|
|
52
|
+
self.run_pty(
|
|
53
|
+
f"echo '{content}' | sudo tee /etc/systemd/system/{filename}",
|
|
54
|
+
hide="out",
|
|
55
|
+
)
|
|
56
|
+
|
|
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)}")
|
|
62
|
+
|
|
63
|
+
def get_configuration_files(
|
|
64
|
+
self, ignore_local: bool = False
|
|
65
|
+
) -> list[tuple[str, str]]:
|
|
66
|
+
templates_folder = (
|
|
67
|
+
Path(importlib.util.find_spec("fujin").origin).parent / "templates"
|
|
68
|
+
)
|
|
69
|
+
web_service_content = (templates_folder / "web.service").read_text()
|
|
70
|
+
web_socket_content = (templates_folder / "web.socket").read_text()
|
|
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
|
+
|
|
77
|
+
context = {
|
|
78
|
+
"app_name": self.app_name,
|
|
79
|
+
"user": self.user,
|
|
80
|
+
"app_dir": self.app_dir,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
files = []
|
|
84
|
+
for name, command in self.processes.items():
|
|
85
|
+
template = web_service_content if name == "web" else simple_service_content
|
|
86
|
+
name = self.get_service_name(name)
|
|
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))
|
|
104
|
+
return files
|
|
105
|
+
|
|
106
|
+
def uninstall_services(self) -> None:
|
|
107
|
+
self.stop_services()
|
|
108
|
+
for name in self.service_names:
|
|
109
|
+
self.run_pty(f"sudo systemctl disable {name}", warn=True)
|
|
110
|
+
self.run_pty(f"sudo rm /etc/systemd/system/{name}", warn=True)
|
|
111
|
+
|
|
112
|
+
def start_services(self, *names) -> None:
|
|
113
|
+
names = names or self.service_names
|
|
114
|
+
for name in names:
|
|
115
|
+
if name in self.service_names:
|
|
116
|
+
self.run_pty(f"sudo systemctl start {name}")
|
|
117
|
+
|
|
118
|
+
def restart_services(self, *names) -> None:
|
|
119
|
+
names = names or self.service_names
|
|
120
|
+
for name in names:
|
|
121
|
+
if name in self.service_names:
|
|
122
|
+
self.run_pty(f"sudo systemctl restart {name}")
|
|
123
|
+
|
|
124
|
+
def stop_services(self, *names) -> None:
|
|
125
|
+
names = names or self.service_names
|
|
126
|
+
for name in names:
|
|
127
|
+
if name in self.service_names:
|
|
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
|
+
}
|
|
149
|
+
|
|
150
|
+
def service_logs(self, name: str, follow: bool = False):
|
|
151
|
+
# TODO: add more options here
|
|
152
|
+
self.run_pty(f"sudo journalctl -u {name} {'-f' if follow else ''}", warn=True)
|
|
153
|
+
|
|
154
|
+
def reload_configuration(self) -> None:
|
|
155
|
+
self.run_pty(f"sudo systemctl daemon-reload")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from fujin.config import Config
|
|
7
|
+
from fujin.config import HostConfig
|
|
8
|
+
from fujin.connection import Connection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WebProxy(Protocol):
|
|
12
|
+
config_file: Path
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def create(cls, config: Config, conn: Connection) -> WebProxy: ...
|
|
16
|
+
|
|
17
|
+
def install(self) -> None: ...
|
|
18
|
+
|
|
19
|
+
def uninstall(self) -> None: ...
|
|
20
|
+
|
|
21
|
+
def setup(self) -> None: ...
|
|
22
|
+
|
|
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: ...
|
fujin/proxies/caddy.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import msgspec
|
|
8
|
+
|
|
9
|
+
from fujin.config import Config
|
|
10
|
+
from fujin.connection import Connection
|
|
11
|
+
|
|
12
|
+
DEFAULT_VERSION = "2.8.4"
|
|
13
|
+
GH_TAR_FILENAME = "caddy_{version}_linux_amd64.tar.gz"
|
|
14
|
+
GH_DOWNL0AD_URL = (
|
|
15
|
+
"https://github.com/caddyserver/caddy/releases/download/v{version}/"
|
|
16
|
+
+ GH_TAR_FILENAME
|
|
17
|
+
)
|
|
18
|
+
GH_RELEASE_LATEST_URL = "https://api.github.com/repos/caddyserver/caddy/releases/latest"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# TODO: let the user write the configuration with a simple syntax and export use caddy adapter, same for exporting,
|
|
22
|
+
# don't export to json
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WebProxy(msgspec.Struct):
|
|
26
|
+
conn: Connection
|
|
27
|
+
domain_name: str
|
|
28
|
+
app_name: str
|
|
29
|
+
upstream: str
|
|
30
|
+
statics: dict[str, str]
|
|
31
|
+
local_config_dir: Path
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def config_file(self) -> Path:
|
|
35
|
+
return self.local_config_dir / "caddy.json"
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def create(cls, config: Config, conn: Connection) -> WebProxy:
|
|
39
|
+
return cls(
|
|
40
|
+
conn=conn,
|
|
41
|
+
domain_name=config.host.domain_name,
|
|
42
|
+
upstream=config.webserver.upstream,
|
|
43
|
+
app_name=config.app_name,
|
|
44
|
+
local_config_dir=config.local_config_dir,
|
|
45
|
+
statics=config.webserver.statics,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def run_pty(self, *args, **kwargs):
|
|
49
|
+
return self.conn.run(*args, **kwargs, pty=True)
|
|
50
|
+
|
|
51
|
+
def install(self):
|
|
52
|
+
version = get_latest_gh_tag()
|
|
53
|
+
download_url = GH_DOWNL0AD_URL.format(version=version)
|
|
54
|
+
filename = GH_TAR_FILENAME.format(version=version)
|
|
55
|
+
with self.conn.cd("/tmp"):
|
|
56
|
+
self.conn.run(f"curl -O -L {download_url}")
|
|
57
|
+
self.conn.run(f"tar -xzvf {filename}")
|
|
58
|
+
self.run_pty("sudo mv caddy /usr/bin/")
|
|
59
|
+
self.conn.run(f"rm {filename}")
|
|
60
|
+
self.conn.run("rm LICENSE && rm README.md")
|
|
61
|
+
self.run_pty("sudo groupadd --force --system caddy")
|
|
62
|
+
self.conn.run(
|
|
63
|
+
"sudo useradd --system --gid caddy --create-home --home-dir /var/lib/caddy --shell /usr/sbin/nologin --comment 'Caddy web server' caddy",
|
|
64
|
+
pty=True,
|
|
65
|
+
warn=True,
|
|
66
|
+
)
|
|
67
|
+
self.conn.run(
|
|
68
|
+
f"echo '{systemd_service}' | sudo tee /etc/systemd/system/caddy-api.service",
|
|
69
|
+
hide="out",
|
|
70
|
+
pty=True,
|
|
71
|
+
)
|
|
72
|
+
self.run_pty("sudo systemctl daemon-reload")
|
|
73
|
+
self.run_pty("sudo systemctl enable --now caddy-api")
|
|
74
|
+
|
|
75
|
+
def uninstall(self):
|
|
76
|
+
self.stop()
|
|
77
|
+
self.run_pty("sudo systemctl disable caddy-api")
|
|
78
|
+
self.run_pty("sudo rm /usr/bin/caddy")
|
|
79
|
+
self.run_pty("sudo rm /etc/systemd/system/caddy-api.service")
|
|
80
|
+
self.run_pty("sudo userdel caddy")
|
|
81
|
+
|
|
82
|
+
def setup(self):
|
|
83
|
+
config = (
|
|
84
|
+
json.loads(self.config_file.read_text())
|
|
85
|
+
if self.config_file.exists()
|
|
86
|
+
else self._get_config()
|
|
87
|
+
)
|
|
88
|
+
self.conn.run(f"echo '{json.dumps(config)}' > caddy.json")
|
|
89
|
+
self.conn.run(
|
|
90
|
+
f"curl localhost:2019/config/apps/http/servers/{self.app_name} -H 'Content-Type: application/json' -d @caddy.json"
|
|
91
|
+
)
|
|
92
|
+
# TODO: stop when received an {"error":"loading config: loading new config: http app module: start: listening on :443: listen tcp :443: bind: permission denied"}, not a 200 ok
|
|
93
|
+
|
|
94
|
+
def teardown(self):
|
|
95
|
+
self.conn.run(f"echo '{json.dumps({})}' > caddy.json")
|
|
96
|
+
self.conn.run(
|
|
97
|
+
f"curl localhost:2019/config/apps/http/servers/{self.app_name} -H 'Content-Type: application/json' -d @caddy.json"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def start(self) -> None:
|
|
101
|
+
self.run_pty("sudo systemctl start caddy-api")
|
|
102
|
+
|
|
103
|
+
def stop(self) -> None:
|
|
104
|
+
self.run_pty("sudo systemctl stop caddy-api")
|
|
105
|
+
|
|
106
|
+
def status(self) -> None:
|
|
107
|
+
self.run_pty("sudo systemctl status caddy-api", warn=True)
|
|
108
|
+
|
|
109
|
+
def restart(self) -> None:
|
|
110
|
+
self.run_pty("sudo systemctl restart caddy-api")
|
|
111
|
+
|
|
112
|
+
def logs(self) -> None:
|
|
113
|
+
self.run_pty(f"sudo journalctl -u caddy-api -f", warn=True)
|
|
114
|
+
|
|
115
|
+
def export_config(self) -> None:
|
|
116
|
+
self.config_file.write_text(json.dumps(self._get_config()))
|
|
117
|
+
|
|
118
|
+
def _get_config(self) -> dict:
|
|
119
|
+
handle = []
|
|
120
|
+
config = {
|
|
121
|
+
"listen": [":443"],
|
|
122
|
+
"routes": [
|
|
123
|
+
{
|
|
124
|
+
"match": [{"host": [self.domain_name]}],
|
|
125
|
+
"handle": handle,
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
reverse_proxy = {
|
|
130
|
+
"handler": "reverse_proxy",
|
|
131
|
+
"upstreams": [{"dial": self.upstream}],
|
|
132
|
+
}
|
|
133
|
+
if not self.statics:
|
|
134
|
+
handle.append(reverse_proxy)
|
|
135
|
+
return config
|
|
136
|
+
routes = []
|
|
137
|
+
handle.append({"handler": "subroute", "routes": routes})
|
|
138
|
+
for path, directory in self.statics.items():
|
|
139
|
+
strip_path_prefix = path.replace("/*", "")
|
|
140
|
+
if strip_path_prefix.endswith("/"):
|
|
141
|
+
strip_path_prefix = strip_path_prefix[:-1]
|
|
142
|
+
routes.append(
|
|
143
|
+
{
|
|
144
|
+
"handle": [
|
|
145
|
+
{
|
|
146
|
+
"handler": "subroute",
|
|
147
|
+
"routes": [
|
|
148
|
+
{
|
|
149
|
+
"handle": [
|
|
150
|
+
{
|
|
151
|
+
"handler": "rewrite",
|
|
152
|
+
"strip_path_prefix": strip_path_prefix,
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"handle": [
|
|
158
|
+
{"handler": "vars", "root": directory},
|
|
159
|
+
{
|
|
160
|
+
"handler": "file_server",
|
|
161
|
+
},
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
"match": [{"path": [path]}],
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
routes.append({"handle": [reverse_proxy]})
|
|
171
|
+
return config
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_latest_gh_tag() -> str:
|
|
175
|
+
with urllib.request.urlopen(GH_RELEASE_LATEST_URL) as response:
|
|
176
|
+
if response.status != 200:
|
|
177
|
+
return DEFAULT_VERSION
|
|
178
|
+
try:
|
|
179
|
+
data = json.loads(response.read().decode())
|
|
180
|
+
return data["tag_name"][1:]
|
|
181
|
+
except (KeyError, json.JSONDecodeError):
|
|
182
|
+
return DEFAULT_VERSION
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
systemd_service = """
|
|
186
|
+
# caddy-api.service
|
|
187
|
+
#
|
|
188
|
+
# For using Caddy with its API.
|
|
189
|
+
#
|
|
190
|
+
# This unit is "durable" in that it will automatically resume
|
|
191
|
+
# the last active configuration if the service is restarted.
|
|
192
|
+
#
|
|
193
|
+
# See https://caddyserver.com/docs/install for instructions.
|
|
194
|
+
|
|
195
|
+
[Unit]
|
|
196
|
+
Description=Caddy
|
|
197
|
+
Documentation=https://caddyserver.com/docs/
|
|
198
|
+
After=network.target network-online.target
|
|
199
|
+
Requires=network-online.target
|
|
200
|
+
|
|
201
|
+
[Service]
|
|
202
|
+
Type=notify
|
|
203
|
+
User=caddy
|
|
204
|
+
Group=www-data
|
|
205
|
+
ExecStart=/usr/bin/caddy run --environ --resume
|
|
206
|
+
TimeoutStopSec=5s
|
|
207
|
+
LimitNOFILE=1048576
|
|
208
|
+
PrivateTmp=true
|
|
209
|
+
ProtectSystem=full
|
|
210
|
+
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
|
211
|
+
|
|
212
|
+
[Install]
|
|
213
|
+
WantedBy=multi-user.target
|
|
214
|
+
"""
|
fujin/proxies/dummy.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fujin.config import Config
|
|
6
|
+
from fujin.connection import Connection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WebProxy:
|
|
10
|
+
config_file: Path
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def create(cls, _: Config, __: Connection) -> WebProxy:
|
|
14
|
+
return cls()
|
|
15
|
+
|
|
16
|
+
def install(self):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
def uninstall(self):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def setup(self):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def teardown(self):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def export_config(self):
|
|
29
|
+
pass
|
fujin/proxies/nginx.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import msgspec
|
|
7
|
+
|
|
8
|
+
from fujin.config import Config
|
|
9
|
+
from fujin.connection import Connection
|
|
10
|
+
|
|
11
|
+
CERTBOT_EMAIL = os.getenv("CERTBOT_EMAIL")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebProxy(msgspec.Struct):
|
|
15
|
+
conn: Connection
|
|
16
|
+
domain_name: str
|
|
17
|
+
app_name: str
|
|
18
|
+
upstream: str
|
|
19
|
+
statics: dict[str, str]
|
|
20
|
+
local_config_dir: Path
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def config_file(self) -> Path:
|
|
24
|
+
return self.local_config_dir / f"{self.app_name}.conf"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def create(cls, config: Config, conn: Connection) -> WebProxy:
|
|
28
|
+
return cls(
|
|
29
|
+
conn=conn,
|
|
30
|
+
domain_name=config.host.domain_name,
|
|
31
|
+
upstream=config.webserver.upstream,
|
|
32
|
+
app_name=config.app_name,
|
|
33
|
+
local_config_dir=config.local_config_dir,
|
|
34
|
+
statics=config.webserver.statics,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def run_pty(self, *args, **kwargs):
|
|
38
|
+
return self.conn.run(*args, **kwargs, pty=True)
|
|
39
|
+
|
|
40
|
+
def install(self):
|
|
41
|
+
self.conn.run(
|
|
42
|
+
"sudo apt install -y nginx libpq-dev python3-dev python3-certbot-nginx"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def uninstall(self):
|
|
46
|
+
self.stop()
|
|
47
|
+
self.conn.run("sudo apt remove -y nginx")
|
|
48
|
+
self.conn.run(f"sudo rm /etc/nginx/sites-available/{self.app_name}.conf")
|
|
49
|
+
self.conn.run(f"sudo rm /etc/nginx/sites-enabled/{self.app_name}.conf")
|
|
50
|
+
self.conn.run("sudo systemctl disable certbot.timer")
|
|
51
|
+
self.conn.run("sudo apt remove -y python3-certbot-nginx")
|
|
52
|
+
|
|
53
|
+
def setup(self):
|
|
54
|
+
conf = (
|
|
55
|
+
self.config_file.read_text()
|
|
56
|
+
if self.config_file.exists()
|
|
57
|
+
else self._get_config()
|
|
58
|
+
)
|
|
59
|
+
self.run_pty(
|
|
60
|
+
f"sudo echo '{conf}' | sudo tee /etc/nginx/sites-available/{self.app_name}.conf",
|
|
61
|
+
hide="out",
|
|
62
|
+
)
|
|
63
|
+
self.run_pty(
|
|
64
|
+
f"sudo ln -sf /etc/nginx/sites-available/{self.app_name}.conf /etc/nginx/sites-enabled/{self.app_name}.conf",
|
|
65
|
+
)
|
|
66
|
+
if CERTBOT_EMAIL:
|
|
67
|
+
cert_path = f"/etc/letsencrypt/live/{self.domain_name}/fullchain.pem"
|
|
68
|
+
cert_exists = self.conn.run(f"sudo test -f {cert_path}", warn=True).ok
|
|
69
|
+
|
|
70
|
+
if not cert_exists:
|
|
71
|
+
self.conn.run(
|
|
72
|
+
f"certbot --nginx -d {self.domain_name} --non-interactive --agree-tos --email {CERTBOT_EMAIL} --redirect"
|
|
73
|
+
)
|
|
74
|
+
self.config_file.parent.mkdir(exist_ok=True)
|
|
75
|
+
self.conn.get(
|
|
76
|
+
f"/etc/nginx/sites-available/{self.app_name}.conf",
|
|
77
|
+
str(self.config_file),
|
|
78
|
+
)
|
|
79
|
+
self.run_pty("sudo systemctl enable certbot.timer")
|
|
80
|
+
self.run_pty("sudo systemctl start certbot.timer")
|
|
81
|
+
self.restart()
|
|
82
|
+
|
|
83
|
+
def teardown(self):
|
|
84
|
+
self.run_pty(f"sudo rm /etc/nginx/sites-available/{self.app_name}.conf")
|
|
85
|
+
self.run_pty(f"sudo rm /etc/nginx/sites-enabled/{self.app_name}.conf")
|
|
86
|
+
self.run_pty(
|
|
87
|
+
"sudo systemctl restart nginx",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def start(self) -> None:
|
|
91
|
+
self.run_pty("sudo systemctl start nginx")
|
|
92
|
+
|
|
93
|
+
def stop(self) -> None:
|
|
94
|
+
self.run_pty("sudo systemctl stop nginx")
|
|
95
|
+
|
|
96
|
+
def status(self) -> None:
|
|
97
|
+
self.run_pty("sudo systemctl status nginx", warn=True)
|
|
98
|
+
|
|
99
|
+
def restart(self) -> None:
|
|
100
|
+
self.run_pty("sudo systemctl restart nginx")
|
|
101
|
+
|
|
102
|
+
def logs(self) -> None:
|
|
103
|
+
self.run_pty(f"sudo journalctl -u nginx -f", warn=True)
|
|
104
|
+
|
|
105
|
+
def export_config(self) -> None:
|
|
106
|
+
self.config_file.write_text(self._get_config())
|
|
107
|
+
|
|
108
|
+
def _get_config(self) -> str:
|
|
109
|
+
static_locations = ""
|
|
110
|
+
for path, directory in self.statics.items():
|
|
111
|
+
static_locations += f"""
|
|
112
|
+
location {path} {{
|
|
113
|
+
alias {directory};
|
|
114
|
+
}}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
return f"""
|
|
118
|
+
server {{
|
|
119
|
+
listen 80;
|
|
120
|
+
server_name {self.domain_name};
|
|
121
|
+
|
|
122
|
+
{static_locations}
|
|
123
|
+
|
|
124
|
+
location / {{
|
|
125
|
+
proxy_pass {self.upstream};
|
|
126
|
+
proxy_set_header Host $host;
|
|
127
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
128
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
129
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
130
|
+
}}
|
|
131
|
+
}}
|
|
132
|
+
"""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# All options are documented here https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html
|
|
2
|
+
[Unit]
|
|
3
|
+
Description={app_name} Worker
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
User={user}
|
|
7
|
+
Group={user}
|
|
8
|
+
WorkingDirectory={app_dir}
|
|
9
|
+
ExecStart={app_dir}/{command}
|
|
10
|
+
EnvironmentFile={app_dir}/.env
|
|
11
|
+
Restart=always
|
|
12
|
+
|
|
13
|
+
[Install]
|
|
14
|
+
WantedBy=multi-user.target
|