Coverage for dlabel/traefik.py: 88%
289 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:07 +0000
1import io
2import re
3from logging import getLogger
4from pathlib import Path
6import docker
7import toml
8import yaml
10from .traefik_conf import HttpMiddleware, HttpService, ProviderConfig, TraefikConfig
11from .util import download_files
13_log = getLogger(__name__)
16def find_block(conf: list[dict], directive: str):
17 for c in conf: 17 ↛ exitline 17 didn't return from function 'find_block' because the loop on line 17 didn't complete
18 if c.get("directive") == directive:
19 _log.debug("found directive %s: %s", directive, c)
20 yield c
23def find_server_block(conf: dict, server_name: str) -> list | None:
24 for entry in conf.get("config", []): 24 ↛ 30line 24 didn't jump to line 30 because the loop on line 24 didn't complete
25 for http in find_block(entry.get("parsed", []), "http"): 25 ↛ 24line 25 didn't jump to line 24 because the loop on line 25 didn't complete
26 for srv in find_block(http.get("block", []), "server"): 26 ↛ 25line 26 didn't jump to line 25 because the loop on line 26 didn't complete
27 for name in find_block(srv.get("block", []), "server_name"): 27 ↛ 26line 27 didn't jump to line 26 because the loop on line 27 didn't complete
28 if server_name in name.get("args", []): 28 ↛ 27line 28 didn't jump to line 27 because the condition on line 28 was always true
29 return srv.get("block", [])
30 return None
33def middleware_compress(mdl: HttpMiddleware) -> list[dict]:
34 res = []
35 if mdl.compress:
36 res.append(
37 {
38 "directive": "gzip",
39 "args": ["on"],
40 }
41 )
42 if not isinstance(mdl.compress, bool): 42 ↛ 57line 42 didn't jump to line 57 because the condition on line 42 was always true
43 if mdl.compress.includedcontenttypes: 43 ↛ 50line 43 didn't jump to line 50 because the condition on line 43 was always true
44 res.append(
45 {
46 "directive": "gzip_types",
47 "args": mdl.compress.includedcontenttypes,
48 }
49 )
50 if mdl.compress.minresponsebodybytes: 50 ↛ 57line 50 didn't jump to line 57 because the condition on line 50 was always true
51 res.append(
52 {
53 "directive": "gzip_min_length",
54 "args": [str(mdl.compress.minresponsebodybytes)],
55 }
56 )
57 return res
60def middleware_compress_apache(mdl: HttpMiddleware) -> list[str]:
61 if mdl.compress:
62 if not isinstance(mdl.compress, bool) and mdl.compress.includedcontenttypes: 62 ↛ 66line 62 didn't jump to line 66 because the condition on line 62 was always true
63 return [
64 f"AddOutputFilterByType DEFLATE {' '.join(mdl.compress.includedcontenttypes)}"
65 ]
66 return ["SetOutputFilter DEFLATE"]
67 return []
70def middleware_headers(mdl: HttpMiddleware) -> list[dict]:
71 res = []
72 if mdl.headers:
73 if mdl.headers.customrequestheaders: 73 ↛ 81line 73 didn't jump to line 81 because the condition on line 73 was always true
74 for k, v in mdl.headers.customrequestheaders.items():
75 res.append(
76 {
77 "directive": "proxy_set_header",
78 "args": [k, v],
79 }
80 )
81 if mdl.headers.customresponseheaders: 81 ↛ 89line 81 didn't jump to line 89 because the condition on line 81 was always true
82 for k, v in mdl.headers.customresponseheaders.items():
83 res.append(
84 {
85 "directive": "add_header",
86 "args": [k, v],
87 }
88 )
89 return res
92def middleware_headers_apache(mdl: HttpMiddleware) -> list[str]:
93 res = []
94 if mdl.headers:
95 if mdl.headers.customrequestheaders: 95 ↛ 98line 95 didn't jump to line 98 because the condition on line 95 was always true
96 for k, v in mdl.headers.customrequestheaders.items():
97 res.append(f"RequestHeader append {k} {v}")
98 if mdl.headers.customresponseheaders: 98 ↛ 101line 98 didn't jump to line 101 because the condition on line 98 was always true
99 for k, v in mdl.headers.customresponseheaders.items():
100 res.append(f"Header append {k} {v}")
101 return res
104def middleware2nginx(mdlconf: list[HttpMiddleware]) -> list[dict]:
105 _log.debug("apply middleware: %s", mdlconf)
106 res = []
107 del_prefix = []
108 add_prefix = "/"
109 for mdl in mdlconf:
110 res.extend(middleware_compress(mdl))
111 res.extend(middleware_headers(mdl))
112 if mdl.stripprefix and mdl.stripprefix.prefixes:
113 del_prefix.extend([re.escape(x) for x in mdl.stripprefix.prefixes])
114 if mdl.stripprefixregex and mdl.stripprefixregex.regex: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 del_prefix.extend(mdl.stripprefixregex.regex)
116 if mdl.addprefix and mdl.addprefix.prefix: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 add_prefix = mdl.addprefix.prefix
118 if del_prefix or add_prefix != "/":
119 res.append(
120 {
121 "directive": "rewrite",
122 "args": [f"{'|'.join(del_prefix)}(.*)", f"{add_prefix}$1", "break"],
123 }
124 )
125 _log.debug("middleware2nginx result: %s -> %s", mdlconf, res)
126 return res
129def rule2locationkey(rule: str) -> list[str]:
130 m = re.match(r"^PathPrefix\(`(?P<prefix>[^`]+)`\)$", rule)
131 location_key = []
132 if m:
133 location_key = [m.group("prefix")]
134 else:
135 m = re.match(r"^Path\(`(?P<path>[^`]+)`\)$", rule)
136 if m: 136 ↛ 138line 136 didn't jump to line 138 because the condition on line 136 was always true
137 location_key = ["=", m.group("path")]
138 return location_key
141def traefik_label_config(labels: dict[str, str], host: str | None, ipaddr: str | None):
142 res = TraefikConfig()
143 for k, v in labels.items():
144 if k == "traefik.enable":
145 continue
146 if k.startswith("traefik."):
147 _, k1 = k.split(".", 1)
148 m = re.match(r"http\.services\.([^\.]+)\.loadbalancer\.server\.port", k1)
149 if m:
150 res = res.setbyaddr(
151 ["http", "services", m.group(1), "loadbalancer", "server", "host"],
152 host,
153 )
154 res = res.setbyaddr(
155 [
156 "http",
157 "services",
158 m.group(1),
159 "loadbalancer",
160 "server",
161 "ipaddress",
162 ],
163 ipaddr,
164 )
165 res = res.setbyaddr(k1.split("."), int(v))
166 else:
167 res = res.setbyaddr(k1.split("."), v)
168 return res
171def traefik_container_config(ctn: docker.models.containers.Container):
172 from_args = TraefikConfig()
173 from_envs = TraefikConfig()
174 from_conf = TraefikConfig()
175 for arg in ctn.attrs.get("Args", []):
176 if arg.startswith("--") and "=" in arg: 176 ↛ 175line 176 didn't jump to line 175 because the condition on line 176 was always true
177 k, v = arg.split("=", 1)
178 from_args = from_args.setbyaddr(k[2:].split("."), v)
179 for env in ctn.attrs.get("Config", {}).get("Env", []):
180 if env.startswith("TRAEFIK_") and "=" in env:
181 k, v = env[8:].split("=", 1)
182 from_envs = from_envs.setbyaddr(k.split("_"), v)
183 provider = ProviderConfig()
184 provider = provider.merge(from_args.providers)
185 provider = provider.merge(from_envs.providers)
186 _log.debug("provider config: %s (arg=%s, env=%s)", provider, from_args, from_envs)
187 if provider.file:
188 _log.debug("loading file: %s", provider.file)
189 to_load = provider.file.filename or provider.file.directory
190 if to_load: 190 ↛ 202line 190 didn't jump to line 202 because the condition on line 190 was always true
191 for _, tinfo, bin in download_files(ctn, to_load):
192 _log.debug("fn=%s, bin(len)=%s", tinfo.name, len(bin))
193 if tinfo.name.endswith(".yml") or tinfo.name.endswith(".yaml"): 193 ↛ 195line 193 didn't jump to line 195 because the condition on line 193 was always true
194 loaded = yaml.safe_load(bin)
195 elif tinfo.name.endswith(".toml"):
196 loaded = toml.loads(bin)
197 else:
198 _log.info("unknown format: %s", tinfo.name)
199 continue
200 _log.debug("load(dict): %s", loaded)
201 from_conf = from_conf.merge(TraefikConfig.model_validate(loaded))
202 return from_args, from_envs, from_conf
205def traefik_dump(client: docker.DockerClient) -> TraefikConfig:
206 """extract traefik configuration"""
207 from_conf = TraefikConfig()
208 from_args = TraefikConfig()
209 from_envs = TraefikConfig()
210 from_label = TraefikConfig()
211 for ctn in client.containers.list():
212 if ctn.status != "running": 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 _log.debug("skip %s (not running: %s)", ctn.name, ctn.status)
214 continue
215 if "traefik" in ctn.image.tags[0]:
216 _log.debug("traefik container: %s", ctn.name)
217 from_args, from_envs, from_conf = traefik_container_config(ctn)
218 _log.debug("loaded: args=%s, conf=%s", from_args, from_conf)
219 if ctn.labels.get("traefik.enable") in ("true",):
220 _log.debug("traefik enabled container: %s", ctn.name)
221 host = ctn.name
222 addrs = [
223 x["IPAddress"]
224 for x in ctn.attrs["NetworkSettings"]["Networks"].values()
225 ]
226 if len(addrs) != 0:
227 addr = addrs[0]
228 else:
229 addr = ""
230 ctn_label = traefik_label_config(ctn.labels, host, addr)
231 from_label = from_label.merge(ctn_label)
232 _log.debug("conf: %s", from_conf)
233 _log.debug("arg: %s", from_args)
234 _log.debug("label: %s", from_label)
235 res = from_conf.merge(from_envs)
236 res = res.merge(from_args)
237 res = res.merge(from_label)
238 return res
241def get_backend(svc: HttpService, ipaddr: bool = False) -> list[str]:
242 if svc.loadbalancer is None: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 return []
244 backend_urls = []
245 if svc.loadbalancer.servers:
246 backend_urls.extend(
247 [x.url.removeprefix("http://") for x in svc.loadbalancer.servers if x.url]
248 )
249 if svc.loadbalancer.server and svc.loadbalancer.server.port:
250 if ipaddr:
251 backend_urls.append(
252 f"{svc.loadbalancer.server.ipaddress}:{svc.loadbalancer.server.port}"
253 )
254 else:
255 backend_urls.append(
256 f"{svc.loadbalancer.server.host}:{svc.loadbalancer.server.port}"
257 )
258 return backend_urls
261def traefik2nginx(
262 traefik_file: TraefikConfig | str,
263 output: io.IOBase,
264 baseconf: str | None,
265 server_url: str,
266 ipaddr: bool,
267):
268 """generate nginx configuration from traefik configuration"""
269 import urllib.parse
271 import crossplane
273 ps = urllib.parse.urlparse(server_url, scheme="http", allow_fragments=False)
274 if baseconf: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true
275 nginx_confs = crossplane.parse(baseconf)
276 else:
277 import tempfile
279 minconf = f"""
280user nginx;
281worker_processes auto;
282error_log /dev/stderr notice;
283events {{worker_connections 512;}}
284http {{server {{listen {ps.port or 80} default_server; server_name {ps.hostname};}}}}
285"""
286 with tempfile.NamedTemporaryFile("r+") as tf:
287 tf.write(minconf)
288 tf.seek(0)
289 nginx_confs = crossplane.parse(tf.name, combine=True)
290 target = find_server_block(nginx_confs, ps.hostname or "localhost")
291 _log.debug("target: %s", target)
292 assert target is not None
293 if isinstance(traefik_file, TraefikConfig):
294 traefik_config = traefik_file
295 else:
296 traefik_config = TraefikConfig.model_validate(yaml.safe_load(traefik_file))
297 if not traefik_config.http: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true
298 raise Exception(f"http not defined: {traefik_config}")
299 services = traefik_config.http.services or {}
300 routers = traefik_config.http.routers or {}
301 middlewares = traefik_config.http.middlewares or {}
302 _log.debug("all middlewares: %s", middlewares)
303 for location in set(services.keys()) & set(routers.keys()):
304 route, svc = routers[location], services[location]
305 rule = route.rule or ""
306 middleware_names = route.middlewares or []
307 _log.debug("middleware_names: %s", middleware_names)
308 location_keys = [rule2locationkey(x) for x in rule.split("||")]
309 middles: list[HttpMiddleware] = [
310 i
311 for i in [middlewares.get(x.split("@", 1)[0]) for x in middleware_names]
312 if i is not None
313 ]
314 _log.debug("middles: %s", middles)
315 backend_urls = get_backend(svc, ipaddr)
316 target.append(
317 {
318 "directive": "#",
319 "comment": f" {location}: {', '.join([' '.join(x) for x in location_keys])} -> {', '.join(backend_urls)}",
320 "line": 1,
321 }
322 )
323 if len(backend_urls) > 1:
324 _log.info("multiple backend urls: %s", backend_urls)
325 target.append(
326 {
327 "directive": "upstream",
328 "args": [location],
329 "block": [
330 {"directive": "server", "args": [x]} for x in backend_urls
331 ],
332 }
333 )
334 backend = location
335 else:
336 backend = backend_urls[0]
337 blk = [{"directive": "proxy_pass", "args": [f"http://{backend}"]}]
338 blk.extend(middleware2nginx(middles))
339 for lk in location_keys:
340 target.append(
341 {
342 "directive": "location",
343 "args": lk,
344 "block": blk,
345 }
346 )
347 for conf in nginx_confs.get("config", []):
348 output.write(crossplane.build(conf.get("parsed", [])))
349 output.write("\n")
352def apache_insert2vf(base_conf: list[str], location_conf: list[str]) -> list[str]:
353 if "</VirtualHost>" in base_conf: 353 ↛ 357line 353 didn't jump to line 357 because the condition on line 353 was always true
354 insert_to = base_conf.index("</VirtualHost>")
355 indent = len(base_conf[insert_to - 1]) - len(base_conf[insert_to - 1].lstrip())
356 else:
357 insert_to = len(base_conf)
358 indent = 0
359 _log.debug("insert to %s", insert_to)
360 return (
361 base_conf[:insert_to]
362 + [""]
363 + [" " * indent + x for x in location_conf]
364 + [""]
365 + base_conf[insert_to:]
366 )
369def middleware2apache(mdlconf: list[HttpMiddleware]) -> list[str]:
370 _log.debug("apply middleware: %s", mdlconf)
371 res = []
372 del_prefix = []
373 add_prefix = "/"
374 for mdl in mdlconf:
375 res.extend(middleware_compress_apache(mdl))
376 res.extend(middleware_headers_apache(mdl))
377 if mdl.stripprefix and mdl.stripprefix.prefixes:
378 del_prefix.extend([re.escape(x) for x in mdl.stripprefix.prefixes])
379 if mdl.stripprefixregex and mdl.stripprefixregex.regex: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 del_prefix.extend(mdl.stripprefixregex.regex)
381 if mdl.addprefix and mdl.addprefix.prefix: 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 add_prefix = mdl.addprefix.prefix
383 if del_prefix or add_prefix != "/":
384 res.append("RewriteEngine On")
385 res.append(f"RewriteRule {'|'.join(del_prefix)}(.*) {add_prefix}$1")
386 _log.debug("middleware2apache result: %s -> %s", mdlconf, res)
387 return res
390def traefik2apache(
391 traefik_file: TraefikConfig | str,
392 output: io.IOBase,
393 baseconf: str | None,
394 server_url: str,
395 ipaddr: bool,
396):
397 """generate apache virtualhost configuration from traefik configuration"""
398 if baseconf: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 apconf = Path(baseconf).read_text()
400 else:
401 import urllib.parse
403 ps = urllib.parse.urlparse(server_url, scheme="http", allow_fragments=False)
404 apconf = f"""
405<VirtualHost *:{ps.port or 80}>
406 ServerName {ps.hostname}
407 ErrorLog /dev/stderr
408</VirtualHost>
409"""
411 if isinstance(traefik_file, TraefikConfig): 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 traefik_config = traefik_file
413 else:
414 traefik_config = TraefikConfig.model_validate(yaml.safe_load(traefik_file))
415 if not traefik_config.http: 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true
416 raise Exception(f"http not defined: {traefik_config}")
417 services = traefik_config.http.services or {}
418 routers = traefik_config.http.routers or {}
419 middlewares = traefik_config.http.middlewares or {}
420 _log.debug("all middlewares: %s", middlewares)
421 res = []
422 for location in set(services.keys()) & set(routers.keys()):
423 route, svc = routers[location], services[location]
424 rule = route.rule or ""
425 _log.debug("rules: %s", rule)
426 middleware_names = route.middlewares or []
427 _log.debug("middleware_names: %s", middleware_names)
428 location_keys = [rule2locationkey(x) for x in rule.split("||")]
429 _log.debug("location: %s", location_keys)
430 backend_urls = get_backend(svc, ipaddr)
431 if len(backend_urls) == 1:
432 backend_to = f"http://{backend_urls[0]}"
433 else:
434 res.append(f"<Proxy balancer://{location}>")
435 for b in backend_urls:
436 res.append(f" BalancerMember http://{b}")
437 res.append("</Proxy>")
438 backend_to = f"balancer://{location}"
439 middles: list[HttpMiddleware] = [
440 i
441 for i in [middlewares.get(x.split("@", 1)[0]) for x in middleware_names]
442 if i is not None
443 ]
444 _log.debug("middles: %s", middles)
445 mdlconf = middleware2apache(middles)
446 for loc in location_keys:
447 if len(loc) == 1:
448 res.append(f"<Location {loc[0]}>")
449 elif loc[0] == "=": 449 ↛ 451line 449 didn't jump to line 451 because the condition on line 449 was always true
450 res.append(f'<Location ~ "^{re.escape(loc[1])}$">')
451 res.append(f" ProxyPass {backend_to}")
452 res.append(f" ProxyPassReverse {backend_to}")
453 res.extend([f" {i}" for i in mdlconf])
454 res.append("</Location>")
455 print("\n".join(apache_insert2vf(apconf.splitlines(), res)), file=output)