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/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ import cappa
2
+
3
+
4
+ class ImproperlyConfiguredError(cappa.Exit):
5
+ code = 1
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