Skip to content

Commit f2545af

Browse files
authored
Merge pull request #144 from MRColorR/fix/141
Fix/141
2 parents cfb2b35 + 9fb47a9 commit f2545af

18 files changed

+1238
-721
lines changed

config/app-config.json

Lines changed: 783 additions & 658 deletions
Large diffs are not rendered by default.

config/m4b-config.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"project": {
3-
"project_version": "4.11.6",
3+
"project_version": "4.11.8",
44
"compose_project_name": "money4band",
55
"ds_project_server_url": "https://discord.com/invite/Fq8eeazBAD"
66
},
@@ -34,6 +34,10 @@
3434
},
3535
"default_docker_platform": "linux/amd64"
3636
},
37+
"watchtower": {
38+
"enable_labels": true,
39+
"scope": "money4band"
40+
},
3741
"menu": [
3842
{
3943
"label": "Show supported apps' links",

main.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from utils.updater import check_update_available
1717

1818
# Handle PyInstaller frozen executable path resolution
19-
if getattr(sys, 'frozen', False):
19+
if getattr(sys, "frozen", False):
2020
# Running as compiled executable (PyInstaller)
2121
BASE_DIR = os.path.dirname(sys.executable)
2222
else:
@@ -170,7 +170,11 @@ def mainmenu(
170170
def main():
171171
# Use the global BASE_DIR for PyInstaller compatibility
172172
script_dir = BASE_DIR
173-
script_name = os.path.basename(__file__) if not getattr(sys, 'frozen', False) else "Money4Band"
173+
script_name = (
174+
os.path.basename(__file__)
175+
if not getattr(sys, "frozen", False)
176+
else "Money4Band"
177+
)
174178

175179
# Parse command-line arguments
176180
parser = argparse.ArgumentParser(description="Run the script.")

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
name = "money4band"
44
description = "Multi-app Docker orchestrator for passive income apps"
5-
version = "4.11.1"
5+
version = "4.11.8"
66
authors = [
77
{ name = "MRColorR" }
88
]
@@ -15,7 +15,7 @@ dependencies = [
1515
"psutil"
1616
]
1717

18-
[project.optional-dependencies]
18+
[dependency-groups]
1919
dev = [
2020
"ruff>=0.12.0",
2121
"pytest>=8.0.0",

template/user-config.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"enabled": true,
110110
"ports": [8081]
111111
},
112+
"watchtower": {
113+
"enabled": true
114+
},
112115
"proxies": {
113116
"enabled": false,
114117
"url": "",
@@ -134,6 +137,10 @@
134137
"PROXY=${STACK_PROXY_URL}",
135138
"EXTRA_COMMANDS=ip rule add iif lo ipproto udp dport 53 lookup main;"
136139
],
140+
"labels": [
141+
"com.centurylinklabs.watchtower.enable=true",
142+
"com.centurylinklabs.watchtower.scope=${M4B_WATCHTOWER_SCOPE}"
143+
],
137144
"cap_add": ["NET_ADMIN"],
138145
"privileged": true,
139146
"network_mode": "bridge",
@@ -151,12 +158,18 @@
151158
"hostname": "${DEVICE_NAME}_watchtower",
152159
"image": "nickfedor/watchtower:latest",
153160
"environment": [
161+
"WATCHTOWER_LABEL_ENABLE=${M4B_WATCHTOWER_LABELS}",
162+
"WATCHTOWER_SCOPE=${M4B_WATCHTOWER_SCOPE}",
154163
"WATCHTOWER_POLL_INTERVAL=14400",
155164
"WATCHTOWER_ROLLING_RESTART=true",
156165
"WATCHTOWER_NO_STARTUP_MESSAGE=true",
157166
"WATCHTOWER_CLEANUP=true",
158167
"WATCHTOWER_NOTIFICATION_URL=${WATCHTOWER_NOTIFICATION_URL}"
159168
],
169+
"labels": [
170+
"com.centurylinklabs.watchtower.enable=true",
171+
"com.centurylinklabs.watchtower.scope=${M4B_WATCHTOWER_SCOPE}"
172+
],
160173
"volumes": ["/var/run/docker.sock:/var/run/docker.sock"],
161174
"restart": "always",
162175
"cpus": "${APP_CPU_LIMIT_MEDIUM}",
@@ -168,12 +181,18 @@
168181
"hostname": "${DEVICE_NAME}_watchtower",
169182
"image": "nickfedor/watchtower:latest",
170183
"environment": [
184+
"WATCHTOWER_LABEL_ENABLE=${M4B_WATCHTOWER_LABELS}",
185+
"WATCHTOWER_SCOPE=${M4B_WATCHTOWER_SCOPE}",
171186
"WATCHTOWER_POLL_INTERVAL=14400",
172187
"WATCHTOWER_ROLLING_RESTART=false",
173188
"WATCHTOWER_NO_STARTUP_MESSAGE=true",
174189
"WATCHTOWER_CLEANUP=true",
175190
"WATCHTOWER_NOTIFICATION_URL=${WATCHTOWER_NOTIFICATION_URL}"
176191
],
192+
"labels": [
193+
"com.centurylinklabs.watchtower.enable=true",
194+
"com.centurylinklabs.watchtower.scope=${M4B_WATCHTOWER_SCOPE}"
195+
],
177196
"volumes": ["/var/run/docker.sock:/var/run/docker.sock"],
178197
"restart": "always",
179198
"cpus": "${APP_CPU_LIMIT_MEDIUM}",
@@ -185,6 +204,10 @@
185204
"container_name": "${DEVICE_NAME}_m4b_dashboard",
186205
"hostname": "${DEVICE_NAME}_m4b_dashboard",
187206
"image": "nginx:alpine-slim",
207+
"labels": [
208+
"com.centurylinklabs.watchtower.enable=true",
209+
"com.centurylinklabs.watchtower.scope=${M4B_WATCHTOWER_SCOPE}"
210+
],
188211
"volumes": [
189212
"./.resources/.www:/usr/share/nginx/html",
190213
"./.resources/.assets:/usr/share/nginx/html/.images:ro",

tests/test_fn_install_docker.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_install_docker_windows(
100100
],
101101
stdout=subprocess.PIPE,
102102
stderr=subprocess.PIPE,
103-
universal_newlines=True,
103+
text=True,
104104
shell=True,
105105
check=True,
106106
)
@@ -112,6 +112,7 @@ def test_install_docker_windows(
112112
os.getenv("ProgramFiles"), "Docker", "Docker", "Docker Desktop.exe"
113113
)
114114
],
115+
check=False,
115116
shell=True,
116117
)
117118

@@ -158,8 +159,10 @@ def test_install_docker_macos(self, mock_run, mock_download_file):
158159
@patch("utils.fn_install_docker.install_docker_linux")
159160
@patch("utils.fn_install_docker.install_docker_windows")
160161
@patch("utils.fn_install_docker.install_docker_macos")
162+
@patch("utils.fn_install_docker.is_docker_installed", return_value=False)
161163
def test_main(
162164
self,
165+
mock_is_docker_installed,
163166
mock_install_macos,
164167
mock_install_windows,
165168
mock_install_linux,

tests/test_generator.py

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import os
2+
import tempfile
13
import unittest
24

3-
from utils.generator import generate_device_name, generate_uuid, validate_uuid
5+
import yaml
6+
7+
from utils.generator import (
8+
assemble_docker_compose,
9+
generate_device_name,
10+
generate_env_file,
11+
generate_uuid,
12+
validate_uuid,
13+
)
414

515

616
class TestGeneratorFunctions(unittest.TestCase):
@@ -22,5 +32,177 @@ def test_generate_device_name(self):
2232
self.assertIn(device_name.split("_")[1], animals)
2333

2434

35+
# ── Minimal configs shared by env-file and compose tests ─────────────────────
36+
37+
_M4B_CFG = {
38+
"project": {"project_version": "test"},
39+
"network": {"subnet": "172.19.0.0", "netmask": "24"},
40+
"system": {"default_docker_platform": "linux/amd64"},
41+
"watchtower": {"enable_labels": True, "scope": "money4band"},
42+
}
43+
44+
_APP_CFG = {"apps": [], "extra-apps": []}
45+
46+
_USER_CFG_BASE = {
47+
"device_info": {"device_name": "testdev"},
48+
"resource_limits": {},
49+
"apps": {},
50+
"proxies": {"enabled": False, "url": "", "url_example": ""},
51+
"notifications": {"enabled": False, "url": ""},
52+
"m4b_dashboard": {"enabled": False},
53+
"watchtower": {"enabled": True},
54+
"compose_config_common": {
55+
"network": {
56+
"driver": "${NETWORK_DRIVER}",
57+
"subnet": "${NETWORK_SUBNET}",
58+
"netmask": "${NETWORK_NETMASK}",
59+
},
60+
"watchtower_service": {
61+
"proxy_disabled": {
62+
"container_name": "${DEVICE_NAME}_watchtower",
63+
"image": "nickfedor/watchtower:latest",
64+
"environment": ["WATCHTOWER_SCOPE=${M4B_WATCHTOWER_SCOPE}"],
65+
"labels": ["com.centurylinklabs.watchtower.enable=true"],
66+
"volumes": ["/var/run/docker.sock:/var/run/docker.sock"],
67+
"restart": "always",
68+
},
69+
"proxy_enabled": {
70+
"container_name": "${DEVICE_NAME}_watchtower",
71+
"image": "nickfedor/watchtower:latest",
72+
"environment": ["WATCHTOWER_SCOPE=${M4B_WATCHTOWER_SCOPE}"],
73+
"labels": ["com.centurylinklabs.watchtower.enable=true"],
74+
"volumes": ["/var/run/docker.sock:/var/run/docker.sock"],
75+
"restart": "always",
76+
},
77+
},
78+
"m4b_dashboard_service": {
79+
"container_name": "${DEVICE_NAME}_m4b_dashboard",
80+
"image": "nginx:alpine-slim",
81+
"restart": "always",
82+
},
83+
"proxy_service": {
84+
"container_name": "${DEVICE_NAME}_proxy",
85+
"image": "xjasonlyu/tun2socks",
86+
"restart": "always",
87+
},
88+
},
89+
}
90+
91+
92+
class TestGenerateEnvFileWatchtower(unittest.TestCase):
93+
"""Verify M4B_WATCHTOWER_LABELS and M4B_WATCHTOWER_SCOPE are always emitted."""
94+
95+
def _run(self, m4b_cfg, user_cfg=None):
96+
if user_cfg is None:
97+
user_cfg = _USER_CFG_BASE.copy()
98+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".env") as f:
99+
env_path = f.name
100+
try:
101+
generate_env_file(m4b_cfg, _APP_CFG, user_cfg, env_path)
102+
return open(env_path).read()
103+
finally:
104+
if os.path.exists(env_path):
105+
os.unlink(env_path)
106+
107+
def test_default_scope_emitted(self):
108+
"""Default scope values from m4b-config appear in .env."""
109+
content = self._run(_M4B_CFG)
110+
self.assertIn("M4B_WATCHTOWER_LABELS=true", content)
111+
self.assertIn("M4B_WATCHTOWER_SCOPE=money4band", content)
112+
113+
def test_custom_scope_emitted(self):
114+
"""Custom scope/labels values from m4b-config appear in .env."""
115+
cfg = dict(
116+
_M4B_CFG, watchtower={"enable_labels": False, "scope": "custom-scope"}
117+
)
118+
content = self._run(cfg)
119+
self.assertIn("M4B_WATCHTOWER_LABELS=false", content)
120+
self.assertIn("M4B_WATCHTOWER_SCOPE=custom-scope", content)
121+
122+
def test_missing_watchtower_key_uses_defaults(self):
123+
"""When watchtower key is absent in m4b-config, hardcoded defaults kick in."""
124+
cfg = {k: v for k, v in _M4B_CFG.items() if k != "watchtower"}
125+
content = self._run(cfg)
126+
self.assertIn("M4B_WATCHTOWER_LABELS=true", content)
127+
self.assertIn("M4B_WATCHTOWER_SCOPE=money4band", content)
128+
129+
130+
class TestAssembleDockerComposeWatchtower(unittest.TestCase):
131+
"""Verify the watchtower.enabled toggle controls Watchtower service inclusion."""
132+
133+
def _compose(self, watchtower_enabled: bool) -> dict:
134+
import copy
135+
136+
user_cfg = copy.deepcopy(_USER_CFG_BASE)
137+
user_cfg["watchtower"]["enabled"] = watchtower_enabled
138+
139+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".yaml") as f:
140+
compose_path = f.name
141+
try:
142+
assemble_docker_compose(
143+
_M4B_CFG, _APP_CFG, user_cfg, compose_path, is_main_instance=True
144+
)
145+
with open(compose_path) as f:
146+
return yaml.safe_load(f) or {}
147+
finally:
148+
if os.path.exists(compose_path):
149+
os.unlink(compose_path)
150+
151+
def test_watchtower_service_included_when_enabled(self):
152+
"""watchtower service is present in compose when watchtower.enabled is True."""
153+
doc = self._compose(watchtower_enabled=True)
154+
self.assertIn("watchtower", doc.get("services", {}))
155+
156+
def test_watchtower_service_omitted_when_disabled(self):
157+
"""watchtower service is absent from compose when watchtower.enabled is False."""
158+
doc = self._compose(watchtower_enabled=False)
159+
self.assertNotIn("watchtower", doc.get("services", {}))
160+
161+
def test_watchtower_scope_enforced_on_old_user_config(self):
162+
"""Scope env vars and label are injected even when old user-config lacks them."""
163+
import copy
164+
165+
# Simulate an older user-config whose watchtower service has no scope entries
166+
user_cfg = copy.deepcopy(_USER_CFG_BASE)
167+
user_cfg["compose_config_common"]["watchtower_service"]["proxy_disabled"] = {
168+
"container_name": "${DEVICE_NAME}_watchtower",
169+
"image": "nickfedor/watchtower:latest",
170+
# intentionally missing WATCHTOWER_SCOPE env and scope label
171+
"environment": ["WATCHTOWER_CLEANUP=true"],
172+
"labels": ["com.centurylinklabs.watchtower.enable=true"],
173+
"volumes": ["/var/run/docker.sock:/var/run/docker.sock"],
174+
"restart": "always",
175+
}
176+
177+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".yaml") as f:
178+
compose_path = f.name
179+
try:
180+
assemble_docker_compose(
181+
_M4B_CFG, _APP_CFG, user_cfg, compose_path, is_main_instance=True
182+
)
183+
with open(compose_path) as f:
184+
doc = yaml.safe_load(f) or {}
185+
finally:
186+
if os.path.exists(compose_path):
187+
os.unlink(compose_path)
188+
189+
wt = doc.get("services", {}).get("watchtower", {})
190+
env = wt.get("environment", [])
191+
labels = wt.get("labels", [])
192+
193+
self.assertTrue(
194+
any("WATCHTOWER_SCOPE" in e for e in env),
195+
"WATCHTOWER_SCOPE must be enforced in watchtower environment",
196+
)
197+
self.assertTrue(
198+
any("WATCHTOWER_LABEL_ENABLE" in e for e in env),
199+
"WATCHTOWER_LABEL_ENABLE must be enforced in watchtower environment",
200+
)
201+
self.assertTrue(
202+
any("watchtower.scope" in lbl for lbl in labels),
203+
"scope label must be enforced on watchtower service",
204+
)
205+
206+
25207
if __name__ == "__main__":
26208
unittest.main()

tests/test_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_load_json_config_file(self, mock_file):
2020

2121
config = load_json_config(config_path)
2222
self.assertEqual(config, expected_config)
23-
mock_file.assert_called_once_with(config_path, "r")
23+
mock_file.assert_called_once_with(config_path)
2424

2525
def test_load_json_config_dict(self):
2626
"""

tests/test_networker.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
Test networker module functionality.
33
"""
44

5-
import socket
65
import unittest
76
from unittest.mock import patch
87

@@ -141,11 +140,11 @@ def test_find_next_available_port_empty_exclude_ports(self):
141140
def test_find_next_available_port_realistic_scenario(self):
142141
"""
143142
Test a realistic scenario of assigning multiple sequential ports.
144-
143+
145144
This test simulates the scenario in assign_app_ports where we need to assign
146145
5 ports starting from base 50000, while avoiding both system-used ports
147146
(50001, 50003, 50005) and already assigned ports in the same loop.
148-
147+
149148
Expected result: [50000, 50002, 50004, 50006, 50007]
150149
- 50000: free, assigned
151150
- 50001: used by system, skip

0 commit comments

Comments
 (0)