1+ import os
2+ import tempfile
13import 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
616class 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+
25207if __name__ == "__main__" :
26208 unittest .main ()
0 commit comments