Coverage for dlabel/traefik.py: 88%

289 statements  

« 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 

5 

6import docker 

7import toml 

8import yaml 

9 

10from .traefik_conf import HttpMiddleware, HttpService, ProviderConfig, TraefikConfig 

11from .util import download_files 

12 

13_log = getLogger(__name__) 

14 

15 

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 

21 

22 

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 

31 

32 

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 

58 

59 

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 [] 

68 

69 

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 

90 

91 

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 

102 

103 

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 

127 

128 

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 

139 

140 

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 

169 

170 

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 

203 

204 

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 

239 

240 

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 

259 

260 

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 

270 

271 import crossplane 

272 

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 

278 

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") 

350 

351 

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 ) 

367 

368 

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 

388 

389 

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 

402 

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""" 

410 

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)