trobz-deploy 0.9.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.
- trobz_deploy/__init__.py +0 -0
- trobz_deploy/cli.py +34 -0
- trobz_deploy/command/__init__.py +0 -0
- trobz_deploy/command/configure.py +233 -0
- trobz_deploy/command/status.py +79 -0
- trobz_deploy/command/update.py +221 -0
- trobz_deploy/templates/__init__.py +0 -0
- trobz_deploy/templates/odoo.service.j2 +15 -0
- trobz_deploy/templates/python.service.j2 +14 -0
- trobz_deploy/templates/service.service.j2 +13 -0
- trobz_deploy/utils/__init__.py +0 -0
- trobz_deploy/utils/addons.py +8 -0
- trobz_deploy/utils/config.py +112 -0
- trobz_deploy/utils/executor.py +136 -0
- trobz_deploy/utils/render.py +23 -0
- trobz_deploy/utils/venv.py +35 -0
- trobz_deploy-0.9.0.dist-info/METADATA +75 -0
- trobz_deploy-0.9.0.dist-info/RECORD +21 -0
- trobz_deploy-0.9.0.dist-info/WHEEL +4 -0
- trobz_deploy-0.9.0.dist-info/entry_points.txt +3 -0
- trobz_deploy-0.9.0.dist-info/licenses/LICENSE +661 -0
trobz_deploy/__init__.py
ADDED
|
File without changes
|
trobz_deploy/cli.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from trobz_deploy.command.configure import configure
|
|
6
|
+
from trobz_deploy.command.status import status
|
|
7
|
+
from trobz_deploy.command.update import update
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.option(
|
|
12
|
+
"--config",
|
|
13
|
+
default="deploy.yml",
|
|
14
|
+
show_default=True,
|
|
15
|
+
metavar="FILE",
|
|
16
|
+
help="Path to the configuration file.",
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"--verbose",
|
|
20
|
+
is_flag=True,
|
|
21
|
+
default=False,
|
|
22
|
+
help="Print each remote command and its output as it runs.",
|
|
23
|
+
)
|
|
24
|
+
@click.pass_context
|
|
25
|
+
def cli(ctx: click.Context, config: str, verbose: bool) -> None:
|
|
26
|
+
"""Deploy and manage applications on remote servers over SSH."""
|
|
27
|
+
ctx.ensure_object(dict)
|
|
28
|
+
ctx.obj["config"] = config
|
|
29
|
+
ctx.obj["verbose"] = verbose
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
cli.add_command(configure)
|
|
33
|
+
cli.add_command(update)
|
|
34
|
+
cli.add_command(status)
|
|
File without changes
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from trobz_deploy.utils.config import load_config, resolve_options
|
|
8
|
+
from trobz_deploy.utils.executor import Executor, ExecutorError
|
|
9
|
+
from trobz_deploy.utils.render import render_unit
|
|
10
|
+
from trobz_deploy.utils.venv import setup_odoo_venv, setup_package_venv, setup_python_venv
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _is_git_repo(executor: Executor, path: str) -> bool:
|
|
14
|
+
try:
|
|
15
|
+
executor.run(f"test -d {path}/.git")
|
|
16
|
+
except ExecutorError:
|
|
17
|
+
return False
|
|
18
|
+
else:
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command()
|
|
23
|
+
@click.argument("instance_name")
|
|
24
|
+
@click.argument("ssh_host", required=False)
|
|
25
|
+
@click.argument("repo_url", required=False)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--type",
|
|
28
|
+
"deploy_type",
|
|
29
|
+
type=click.Choice(["odoo", "python", "service"]),
|
|
30
|
+
default=None,
|
|
31
|
+
help="Deployment type (auto-detected from instance name prefix if omitted).",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"-p",
|
|
35
|
+
"--port",
|
|
36
|
+
"ssh_port",
|
|
37
|
+
type=int,
|
|
38
|
+
default=None,
|
|
39
|
+
help="SSH port on the remote host.",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--force",
|
|
43
|
+
is_flag=True,
|
|
44
|
+
default=False,
|
|
45
|
+
help="Re-run setup steps even if the instance directory already exists.",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--repo-subdir",
|
|
49
|
+
"repo_subdir",
|
|
50
|
+
default=None,
|
|
51
|
+
help="Subdirectory within the repo to use as the service root (for monorepos).",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--repo-branch",
|
|
55
|
+
"repo_branch",
|
|
56
|
+
default=None,
|
|
57
|
+
help="Git branch to clone and track (defaults to the repository's default branch).",
|
|
58
|
+
)
|
|
59
|
+
@click.pass_context
|
|
60
|
+
def configure( # noqa: C901
|
|
61
|
+
ctx: click.Context,
|
|
62
|
+
instance_name: str,
|
|
63
|
+
ssh_host: str | None,
|
|
64
|
+
repo_url: str | None,
|
|
65
|
+
deploy_type: str | None,
|
|
66
|
+
ssh_port: int | None,
|
|
67
|
+
force: bool,
|
|
68
|
+
repo_subdir: str | None,
|
|
69
|
+
repo_branch: str | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Configure a new deployment instance."""
|
|
72
|
+
cfg = load_config(ctx.obj["config"], instance_name)
|
|
73
|
+
try:
|
|
74
|
+
opts = resolve_options(
|
|
75
|
+
cfg,
|
|
76
|
+
instance_name,
|
|
77
|
+
ssh_host=ssh_host,
|
|
78
|
+
ssh_port=ssh_port,
|
|
79
|
+
repo_url=repo_url,
|
|
80
|
+
repo_branch=repo_branch,
|
|
81
|
+
deploy_type=deploy_type,
|
|
82
|
+
repo_subdir=repo_subdir,
|
|
83
|
+
)
|
|
84
|
+
except ValueError as exc:
|
|
85
|
+
raise click.ClickException(click.style(str(exc), fg="red")) from exc
|
|
86
|
+
|
|
87
|
+
eff_ssh_host: str | None = opts.get("ssh_host")
|
|
88
|
+
eff_ssh_port: int | None = opts.get("ssh_port")
|
|
89
|
+
eff_repo_url: str | None = opts.get("repo_url")
|
|
90
|
+
eff_repo_branch: str | None = opts.get("repo_branch")
|
|
91
|
+
eff_type: str = opts["type"]
|
|
92
|
+
_req = opts.get("requirements")
|
|
93
|
+
eff_requirements: list[str] = ([_req] if isinstance(_req, str) else _req) if _req else []
|
|
94
|
+
|
|
95
|
+
if eff_type == "python" and eff_requirements and eff_repo_url:
|
|
96
|
+
msg = click.style(
|
|
97
|
+
"requirements and repo_url are mutually exclusive for python type.",
|
|
98
|
+
fg="red",
|
|
99
|
+
)
|
|
100
|
+
raise click.ClickException(msg)
|
|
101
|
+
|
|
102
|
+
executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port)
|
|
103
|
+
home_dir = executor.capture("echo $HOME")
|
|
104
|
+
instance_path = f"{home_dir}/{instance_name}"
|
|
105
|
+
eff_repo_subdir: str | None = opts.get("repo_subdir")
|
|
106
|
+
service_path = f"{instance_path}/{eff_repo_subdir}" if eff_repo_subdir else instance_path
|
|
107
|
+
|
|
108
|
+
# Step 2: Set up instance directory
|
|
109
|
+
if eff_type == "python" and eff_requirements:
|
|
110
|
+
# Package mode: create directory directly, no git clone
|
|
111
|
+
try:
|
|
112
|
+
executor.run(f"test -d {instance_path}")
|
|
113
|
+
if not force:
|
|
114
|
+
msg = click.style(
|
|
115
|
+
f"Instance directory already exists: ~/{instance_name}\nUse --force to re-run setup.",
|
|
116
|
+
fg="yellow",
|
|
117
|
+
)
|
|
118
|
+
raise click.ClickException(msg)
|
|
119
|
+
click.secho("\nDirectory exists, skipping mkdir (--force).", fg="yellow")
|
|
120
|
+
except ExecutorError:
|
|
121
|
+
click.secho(f"\nCreating instance directory ~/{instance_name}…", fg="green")
|
|
122
|
+
executor.run(f"mkdir -p {instance_path}")
|
|
123
|
+
else:
|
|
124
|
+
if not eff_repo_url:
|
|
125
|
+
msg = click.style(
|
|
126
|
+
"repo_url is required. Provide it as an argument or set it in deploy.yml.",
|
|
127
|
+
fg="red",
|
|
128
|
+
)
|
|
129
|
+
raise click.ClickException(msg)
|
|
130
|
+
|
|
131
|
+
# Repo mode: clone
|
|
132
|
+
if _is_git_repo(executor, instance_path):
|
|
133
|
+
if not force:
|
|
134
|
+
msg = click.style(
|
|
135
|
+
f"Instance directory already exists: ~/{instance_name}\n"
|
|
136
|
+
"Use --force to skip cloning and re-run setup.",
|
|
137
|
+
fg="yellow",
|
|
138
|
+
)
|
|
139
|
+
raise click.ClickException(msg)
|
|
140
|
+
click.secho("\nDirectory exists, skipping clone (--force).", fg="yellow")
|
|
141
|
+
else:
|
|
142
|
+
click.secho(f"\nCloning {eff_repo_url} into ~/{instance_name}…", fg="green")
|
|
143
|
+
try:
|
|
144
|
+
clone_cmd = f"git clone {eff_repo_url} $HOME/{instance_name}"
|
|
145
|
+
if eff_repo_branch:
|
|
146
|
+
clone_cmd += f" --branch {eff_repo_branch}"
|
|
147
|
+
executor.run(clone_cmd)
|
|
148
|
+
except ExecutorError as exc:
|
|
149
|
+
msg = click.style(f"Git clone failed: {exc}", fg="red")
|
|
150
|
+
raise click.ClickException(msg) from exc
|
|
151
|
+
|
|
152
|
+
if eff_type == "odoo":
|
|
153
|
+
executor.run(
|
|
154
|
+
"if [ -f addons/repos.yaml ]; then cd addons/ && gitaggregate -c repos.yaml; fi",
|
|
155
|
+
cwd=instance_path,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Step 3: Set up environment
|
|
159
|
+
click.secho(f"\nSetting up {eff_type} environment…", fg="green")
|
|
160
|
+
try:
|
|
161
|
+
if eff_type == "odoo":
|
|
162
|
+
setup_odoo_venv(executor, instance_path)
|
|
163
|
+
elif eff_type == "python":
|
|
164
|
+
if eff_requirements:
|
|
165
|
+
setup_package_venv(executor, instance_path, eff_requirements, force=force)
|
|
166
|
+
else:
|
|
167
|
+
setup_python_venv(executor, service_path, force=force)
|
|
168
|
+
executor.run(
|
|
169
|
+
"if [ -f .env.example ] && [ ! -f .env ]; then cp .env.example .env; fi",
|
|
170
|
+
cwd=service_path,
|
|
171
|
+
)
|
|
172
|
+
else: # service
|
|
173
|
+
build_cmd: str | None = opts.get("build")
|
|
174
|
+
if not build_cmd:
|
|
175
|
+
msg = click.style(
|
|
176
|
+
"build command is required for service type. Set it in deploy.yml.",
|
|
177
|
+
fg="red",
|
|
178
|
+
)
|
|
179
|
+
raise click.ClickException(msg)
|
|
180
|
+
executor.run(build_cmd, cwd=service_path)
|
|
181
|
+
except ExecutorError as exc:
|
|
182
|
+
raise click.ClickException(click.style(str(exc), fg="red")) from exc
|
|
183
|
+
|
|
184
|
+
# Step 4: Install systemd unit
|
|
185
|
+
click.secho("\nInstalling systemd unit…", fg="green")
|
|
186
|
+
unit_instance_path = instance_path if eff_requirements else service_path
|
|
187
|
+
venv_path = f"{unit_instance_path}/.venv"
|
|
188
|
+
|
|
189
|
+
template_vars: dict[str, Any] = {
|
|
190
|
+
"instance_name": instance_name,
|
|
191
|
+
"instance_path": unit_instance_path,
|
|
192
|
+
}
|
|
193
|
+
if eff_type == "odoo":
|
|
194
|
+
template_vars["venv_path"] = venv_path
|
|
195
|
+
odoo_addons_path = executor.capture("which odoo-addons-path")
|
|
196
|
+
template_vars["odoo_addons_path"] = odoo_addons_path
|
|
197
|
+
else:
|
|
198
|
+
exec_start: str = opts.get("exec_start", "")
|
|
199
|
+
if not exec_start and not eff_requirements:
|
|
200
|
+
res = executor.capture(
|
|
201
|
+
"if [ -f server.py ]; then echo server.py; fi",
|
|
202
|
+
cwd=service_path,
|
|
203
|
+
)
|
|
204
|
+
if res == "server.py":
|
|
205
|
+
exec_start = "python server.py"
|
|
206
|
+
if not exec_start:
|
|
207
|
+
msg = click.style(
|
|
208
|
+
"exec_start is required for service or python type. Set it in deploy.yml.",
|
|
209
|
+
fg="red",
|
|
210
|
+
)
|
|
211
|
+
raise click.ClickException(msg)
|
|
212
|
+
if eff_type == "python":
|
|
213
|
+
template_vars["venv_path"] = venv_path
|
|
214
|
+
template_vars["exec_start"] = exec_start
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
unit_content = render_unit(eff_type, **template_vars)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
msg = click.style(f"Template rendering failed: {exc}", fg="red")
|
|
220
|
+
raise click.ClickException(msg) from exc
|
|
221
|
+
|
|
222
|
+
unit_dir = "$HOME/.config/systemd/user"
|
|
223
|
+
unit_path = f"{unit_dir}/{instance_name}.service"
|
|
224
|
+
try:
|
|
225
|
+
executor.run(f"mkdir -p {unit_dir}")
|
|
226
|
+
executor.write_file(unit_content, unit_path)
|
|
227
|
+
executor.run("loginctl enable-linger")
|
|
228
|
+
executor.run("systemctl --user daemon-reload")
|
|
229
|
+
executor.run(f"systemctl --user enable --now {instance_name}")
|
|
230
|
+
except ExecutorError as exc:
|
|
231
|
+
raise click.ClickException(click.style(str(exc), fg="red")) from exc
|
|
232
|
+
|
|
233
|
+
click.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from trobz_deploy.utils.config import load_config
|
|
6
|
+
from trobz_deploy.utils.executor import Executor, ExecutorError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command()
|
|
10
|
+
@click.argument("instance_name")
|
|
11
|
+
@click.argument("ssh_host", required=False)
|
|
12
|
+
@click.option(
|
|
13
|
+
"-p",
|
|
14
|
+
"--port",
|
|
15
|
+
"ssh_port",
|
|
16
|
+
type=int,
|
|
17
|
+
default=None,
|
|
18
|
+
help="SSH port on the remote host.",
|
|
19
|
+
)
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def status(
|
|
22
|
+
ctx: click.Context,
|
|
23
|
+
instance_name: str,
|
|
24
|
+
ssh_host: str | None,
|
|
25
|
+
ssh_port: int | None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Show status of a deployment instance."""
|
|
28
|
+
cfg = load_config(ctx.obj["config"], instance_name)
|
|
29
|
+
|
|
30
|
+
# Resolve ssh_host/ssh_port: CLI arg > config value
|
|
31
|
+
eff_ssh_host: str | None = ssh_host if ssh_host is not None else cfg.get("ssh_host")
|
|
32
|
+
eff_ssh_port: int | None = ssh_port if ssh_port is not None else cfg.get("ssh_port")
|
|
33
|
+
|
|
34
|
+
executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port)
|
|
35
|
+
instance_path = f"$HOME/{instance_name}"
|
|
36
|
+
|
|
37
|
+
# Step 2: Verify instance directory exists
|
|
38
|
+
try:
|
|
39
|
+
executor.run(f"test -d {instance_path}")
|
|
40
|
+
except ExecutorError:
|
|
41
|
+
msg = f"Instance directory not found: ~/{instance_name}"
|
|
42
|
+
raise click.ClickException(msg) from None
|
|
43
|
+
|
|
44
|
+
# Step 3: Git info
|
|
45
|
+
try:
|
|
46
|
+
remote_url = executor.capture("git remote get-url origin", cwd=instance_path)
|
|
47
|
+
branch = executor.capture("git rev-parse --abbrev-ref HEAD", cwd=instance_path)
|
|
48
|
+
commit = executor.capture("git rev-parse --short HEAD", cwd=instance_path)
|
|
49
|
+
except ExecutorError as exc:
|
|
50
|
+
msg = f"Failed to get git info: {exc}"
|
|
51
|
+
raise click.ClickException(msg) from exc
|
|
52
|
+
|
|
53
|
+
# Step 4: systemd unit status
|
|
54
|
+
unit_line = "unknown"
|
|
55
|
+
try:
|
|
56
|
+
raw = executor.capture(
|
|
57
|
+
f"systemctl --user show {instance_name} --property=ActiveState,SubState,ActiveEnterTimestamp"
|
|
58
|
+
)
|
|
59
|
+
props: dict[str, str] = {}
|
|
60
|
+
for line in raw.splitlines():
|
|
61
|
+
if "=" in line:
|
|
62
|
+
key, _, val = line.partition("=")
|
|
63
|
+
props[key.strip()] = val.strip()
|
|
64
|
+
|
|
65
|
+
active = props.get("ActiveState", "unknown")
|
|
66
|
+
sub = props.get("SubState", "unknown")
|
|
67
|
+
ts = props.get("ActiveEnterTimestamp", "")
|
|
68
|
+
# Timestamp format: "Mon 2026-03-09 08:12:03 UTC"
|
|
69
|
+
since = " ".join(ts.split()[1:3]) if ts else ""
|
|
70
|
+
unit_line = f"{active} ({sub})"
|
|
71
|
+
if since:
|
|
72
|
+
unit_line += f" since {since}"
|
|
73
|
+
except ExecutorError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
click.echo(f"Instance: {instance_name}")
|
|
77
|
+
click.echo(f"Remote: {remote_url}")
|
|
78
|
+
click.echo(f"Branch: {branch} ({commit})")
|
|
79
|
+
click.echo(f"Unit: {unit_line}")
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from trobz_deploy.utils.addons import get_addons_path
|
|
6
|
+
from trobz_deploy.utils.config import load_config, resolve_options
|
|
7
|
+
from trobz_deploy.utils.executor import Executor, ExecutorError
|
|
8
|
+
from trobz_deploy.utils.venv import setup_python_deps, upgrade_package
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("instance_name")
|
|
13
|
+
@click.argument("ssh_host", required=False)
|
|
14
|
+
@click.option(
|
|
15
|
+
"--type",
|
|
16
|
+
"deploy_type",
|
|
17
|
+
type=click.Choice(["odoo", "python", "service"]),
|
|
18
|
+
default=None,
|
|
19
|
+
help="Deployment type (auto-detected from instance name prefix if omitted).",
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
"--db",
|
|
23
|
+
default=None,
|
|
24
|
+
help="Override the target database name (Odoo only).",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"-p",
|
|
28
|
+
"--port",
|
|
29
|
+
"ssh_port",
|
|
30
|
+
type=int,
|
|
31
|
+
default=None,
|
|
32
|
+
help="SSH port on the remote host.",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--ignore-hooks",
|
|
36
|
+
"ignore_hooks",
|
|
37
|
+
is_flag=True,
|
|
38
|
+
default=False,
|
|
39
|
+
help="Skip all hook execution.",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--repo-subdir",
|
|
43
|
+
"repo_subdir",
|
|
44
|
+
default=None,
|
|
45
|
+
help="Subdirectory within the repo to use as the service root (for monorepos).",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--repo-branch",
|
|
49
|
+
"repo_branch",
|
|
50
|
+
default=None,
|
|
51
|
+
help="Git branch to pull (defaults to the currently checked-out branch).",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--watch",
|
|
55
|
+
"watch",
|
|
56
|
+
is_flag=True,
|
|
57
|
+
default=False,
|
|
58
|
+
help="Stream service logs with journalctl after a successful update.",
|
|
59
|
+
)
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def update( # noqa: C901
|
|
62
|
+
ctx: click.Context,
|
|
63
|
+
instance_name: str,
|
|
64
|
+
ssh_host: str | None,
|
|
65
|
+
deploy_type: str | None,
|
|
66
|
+
db: str | None,
|
|
67
|
+
ssh_port: int | None,
|
|
68
|
+
ignore_hooks: bool,
|
|
69
|
+
repo_subdir: str | None,
|
|
70
|
+
repo_branch: str | None,
|
|
71
|
+
watch: bool,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Update an existing deployment instance."""
|
|
74
|
+
cfg = load_config(ctx.obj["config"], instance_name)
|
|
75
|
+
try:
|
|
76
|
+
opts = resolve_options(
|
|
77
|
+
cfg,
|
|
78
|
+
instance_name,
|
|
79
|
+
ssh_host=ssh_host,
|
|
80
|
+
ssh_port=ssh_port,
|
|
81
|
+
deploy_type=deploy_type,
|
|
82
|
+
db=db,
|
|
83
|
+
repo_subdir=repo_subdir,
|
|
84
|
+
repo_branch=repo_branch,
|
|
85
|
+
)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise click.ClickException(click.style(str(exc), fg="red")) from exc
|
|
88
|
+
|
|
89
|
+
eff_ssh_host: str | None = opts.get("ssh_host")
|
|
90
|
+
eff_ssh_port: int | None = opts.get("ssh_port")
|
|
91
|
+
eff_type: str = opts["type"]
|
|
92
|
+
eff_db: str = opts.get("db", instance_name)
|
|
93
|
+
eff_repo_branch: str | None = opts.get("repo_branch")
|
|
94
|
+
_req = opts.get("requirements")
|
|
95
|
+
eff_requirements: list[str] = ([_req] if isinstance(_req, str) else _req) if _req else []
|
|
96
|
+
hooks: dict = opts.get("hooks", {})
|
|
97
|
+
|
|
98
|
+
executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port)
|
|
99
|
+
home_dir = executor.capture("echo $HOME")
|
|
100
|
+
instance_path = f"{home_dir}/{instance_name}"
|
|
101
|
+
eff_repo_subdir: str | None = opts.get("repo_subdir")
|
|
102
|
+
service_path = f"{instance_path}/{eff_repo_subdir}" if eff_repo_subdir else instance_path
|
|
103
|
+
|
|
104
|
+
def run_hooks(hook_name: str) -> bool:
|
|
105
|
+
"""Execute all commands for *hook_name*. Returns True if all succeeded."""
|
|
106
|
+
if ignore_hooks:
|
|
107
|
+
return True
|
|
108
|
+
for cmd in hooks.get(hook_name, []):
|
|
109
|
+
try:
|
|
110
|
+
executor.run(cmd, cwd=instance_path)
|
|
111
|
+
except ExecutorError as exc:
|
|
112
|
+
click.secho(
|
|
113
|
+
f"Hook {hook_name!r} failed: {exc}",
|
|
114
|
+
fg="red",
|
|
115
|
+
err=True,
|
|
116
|
+
)
|
|
117
|
+
return False
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Step 2: pre-update hooks (non-blocking)
|
|
121
|
+
click.secho("\nRunning pre-update hooks…", fg="green")
|
|
122
|
+
run_hooks("pre-update")
|
|
123
|
+
|
|
124
|
+
# Step 3: pre-update-required hooks (blocking on failure)
|
|
125
|
+
click.secho("\nRunning pre-update-required hooks…", fg="green")
|
|
126
|
+
if not run_hooks("pre-update-required"):
|
|
127
|
+
run_hooks("pre-update-fail")
|
|
128
|
+
msg = click.style(
|
|
129
|
+
"pre-update-required hook failed. Update aborted.",
|
|
130
|
+
fg="red",
|
|
131
|
+
)
|
|
132
|
+
raise click.ClickException(msg)
|
|
133
|
+
|
|
134
|
+
# Step 4: pre-update-success
|
|
135
|
+
run_hooks("pre-update-success")
|
|
136
|
+
|
|
137
|
+
# Step 5+6: Pull/upgrade code and update dependencies
|
|
138
|
+
if eff_type == "python" and eff_requirements:
|
|
139
|
+
# Package mode: upgrade pip package directly, no git pull
|
|
140
|
+
click.secho("\nUpgrading package…", fg="green")
|
|
141
|
+
try:
|
|
142
|
+
upgrade_package(executor, instance_path, eff_requirements)
|
|
143
|
+
except ExecutorError as exc:
|
|
144
|
+
run_hooks("post-update")
|
|
145
|
+
run_hooks("post-update-fail")
|
|
146
|
+
msg = click.style(f"Package upgrade failed: {exc}", fg="red")
|
|
147
|
+
raise click.ClickException(msg) from exc
|
|
148
|
+
else:
|
|
149
|
+
# Repo mode: git pull then update deps
|
|
150
|
+
try:
|
|
151
|
+
executor.run(f"test -d {instance_path}/.git")
|
|
152
|
+
except ExecutorError:
|
|
153
|
+
msg = click.style(
|
|
154
|
+
f"Instance directory not found or not a git repo: ~/{instance_name}",
|
|
155
|
+
fg="red",
|
|
156
|
+
)
|
|
157
|
+
raise click.ClickException(msg) from None
|
|
158
|
+
|
|
159
|
+
click.secho("\nPulling latest code…", fg="green")
|
|
160
|
+
try:
|
|
161
|
+
if eff_repo_branch:
|
|
162
|
+
executor.run(
|
|
163
|
+
f"git fetch origin && git checkout {eff_repo_branch} && git pull",
|
|
164
|
+
cwd=instance_path,
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
executor.run("git pull", cwd=instance_path)
|
|
168
|
+
except ExecutorError as exc:
|
|
169
|
+
run_hooks("post-update")
|
|
170
|
+
run_hooks("post-update-fail")
|
|
171
|
+
msg = click.style(f"git pull failed: {exc}", fg="red")
|
|
172
|
+
raise click.ClickException(msg) from exc
|
|
173
|
+
|
|
174
|
+
click.secho("\nUpdating dependencies…", fg="green")
|
|
175
|
+
try:
|
|
176
|
+
if eff_type == "odoo":
|
|
177
|
+
executor.run(
|
|
178
|
+
"if [ -f addons/repos.yaml ]; then cd addons/ && gitaggregate -c repos.yaml; fi",
|
|
179
|
+
cwd=instance_path,
|
|
180
|
+
)
|
|
181
|
+
executor.run("odoo-venv update .venv --backup --yes", cwd=instance_path)
|
|
182
|
+
elif eff_type == "python":
|
|
183
|
+
setup_python_deps(executor, service_path)
|
|
184
|
+
else: # service
|
|
185
|
+
build_cmd: str | None = opts.get("build")
|
|
186
|
+
if build_cmd:
|
|
187
|
+
executor.run(build_cmd, cwd=service_path)
|
|
188
|
+
except ExecutorError as exc:
|
|
189
|
+
run_hooks("post-update")
|
|
190
|
+
run_hooks("post-update-fail")
|
|
191
|
+
msg = click.style(f"Dependency update failed: {exc}", fg="red")
|
|
192
|
+
raise click.ClickException(msg) from exc
|
|
193
|
+
|
|
194
|
+
# Step 7: Apply changes
|
|
195
|
+
click.secho("\nApplying changes…", fg="green")
|
|
196
|
+
try:
|
|
197
|
+
if eff_type == "odoo":
|
|
198
|
+
addons_path = get_addons_path(executor, instance_path)
|
|
199
|
+
executor.run(
|
|
200
|
+
f".venv/bin/click-odoo-update -d {eff_db} --addons-path={addons_path}",
|
|
201
|
+
cwd=instance_path,
|
|
202
|
+
)
|
|
203
|
+
executor.run(f"systemctl --user restart {instance_name}")
|
|
204
|
+
except ExecutorError as exc:
|
|
205
|
+
run_hooks("post-update")
|
|
206
|
+
run_hooks("post-update-fail")
|
|
207
|
+
msg = click.style(f"Restart/upgrade failed: {exc}", fg="red")
|
|
208
|
+
raise click.ClickException(msg) from exc
|
|
209
|
+
|
|
210
|
+
# Step 8: post-update hooks
|
|
211
|
+
run_hooks("post-update")
|
|
212
|
+
run_hooks("post-update-success")
|
|
213
|
+
|
|
214
|
+
click.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green")
|
|
215
|
+
|
|
216
|
+
if watch:
|
|
217
|
+
click.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan")
|
|
218
|
+
try:
|
|
219
|
+
executor.stream(f"journalctl --user -u {instance_name} -f")
|
|
220
|
+
except KeyboardInterrupt:
|
|
221
|
+
click.echo()
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Odoo instance {{ instance_name }}
|
|
3
|
+
After=network.target postgresql.service
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
WorkingDirectory={{ instance_path }}
|
|
8
|
+
ExecStart=bash -c "{{ venv_path }}/bin/python {{ venv_path }}/bin/odoo \
|
|
9
|
+
--config {{ instance_path }}/config/odoo.conf \
|
|
10
|
+
--addons-path $({{ odoo_addons_path }} {{ instance_path }})"
|
|
11
|
+
Restart=on-failure
|
|
12
|
+
RestartSec=5s
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Python service {{ instance_name }}
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
WorkingDirectory={{ instance_path }}
|
|
8
|
+
EnvironmentFile=-{{ instance_path }}/.env
|
|
9
|
+
ExecStart={{ venv_path }}/bin/{{ exec_start }}
|
|
10
|
+
Restart=on-failure
|
|
11
|
+
RestartSec=5s
|
|
12
|
+
|
|
13
|
+
[Install]
|
|
14
|
+
WantedBy=default.target
|
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from trobz_deploy.utils.executor import Executor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_addons_path(executor: Executor, instance_path: str) -> str:
|
|
7
|
+
"""Run ``odoo-addons-path`` in *instance_path* and return the result."""
|
|
8
|
+
return executor.capture("odoo-addons-path", cwd=instance_path)
|