Coverage for dlabel/main.py: 68%

353 statements  

« prev     ^ index     » next       coverage.py v7.10.0, created at 2025-07-25 23:32 +0000

1import functools 

2import docker 

3import click 

4from logging import getLogger 

5import sys 

6import time 

7import subprocess 

8from pathlib import Path 

9from .traefik import traefik2nginx, traefik2apache, traefik_dump 

10from .compose import compose 

11from .version import VERSION 

12from .util import get_diff, get_archives, get_volumes 

13from .dockerfile import get_dockerfile 

14 

15_log = getLogger(__name__) 

16 

17 

18@click.group(invoke_without_command=True) 

19@click.version_option(VERSION) 

20@click.pass_context 

21def cli(ctx): 

22 if ctx.invoked_subcommand is None: 

23 print(ctx.get_help()) 

24 

25 

26def verbose_option(func): 

27 @click.option("--verbose/--quiet", default=None, help="INFO(default)/DEBUG(verbose)/WARNING(quiet)") 

28 @functools.wraps(func) 

29 def _(verbose, **kwargs): 

30 from logging import basicConfig 

31 fmt = "%(asctime)s %(levelname)s %(name)s %(message)s" 

32 if verbose is None: 

33 basicConfig(level="INFO", format=fmt) 

34 elif verbose is False: 

35 basicConfig(level="WARNING", format=fmt) 

36 else: 

37 basicConfig(level="DEBUG", format=fmt) 

38 return func(**kwargs) 

39 return _ 

40 

41 

42def format_option(func): 

43 @click.option("--format", default="yaml", type=click.Choice(["yaml", "json", "toml"]), show_default=True, 

44 help="output format") 

45 @functools.wraps(func) 

46 def _(format, **kwargs): 

47 res = func(**kwargs) 

48 if res is None: 48 ↛ 49line 48 didn't jump to line 49 because the condition on line 48 was never true

49 _log.debug("no output(None): format=%s", format) 

50 else: 

51 if format == "json": 

52 import json 

53 json.dump(res, indent=2, fp=sys.stdout, ensure_ascii=False) 

54 elif format == "yaml": 

55 import yaml 

56 yaml.dump(res, stream=sys.stdout, allow_unicode=True, encoding="utf-8", sort_keys=False) 

57 elif format == "toml": 57 ↛ 60line 57 didn't jump to line 60 because the condition on line 57 was always true

58 import toml 

59 toml.dump(res, sys.stdout) 

60 return res 

61 return _ 

62 

63 

64def docker_option(func): 

65 @click.option("-H", "--host", envvar="DOCKER_HOST", help="Daemon socket(s) to connect to", show_envvar=True) 

66 @functools.wraps(func) 

67 def _(host, **kwargs): 

68 if not host: 

69 cl = docker.from_env() 

70 else: 

71 cl = docker.DockerClient(base_url=host) 

72 return func(client=cl, **kwargs) 

73 

74 return _ 

75 

76 

77def container_option(func): 

78 @docker_option 

79 @click.option("--name", help="container name") 

80 @click.option("--id", help="container id") 

81 @functools.wraps(func) 

82 def _(client: docker.DockerClient, name: str, id: str, **kwargs): 

83 if not name and not id: 

84 click.echo("id name image") 

85 for ctn in client.containers.list(): 

86 click.echo(f"{ctn.short_id} {ctn.name} {ctn.image.tags}") 

87 return 

88 if id: 

89 ctn = client.containers.get(id) 

90 elif name: 90 ↛ 95line 90 didn't jump to line 95 because the condition on line 90 was always true

91 ctnlist = client.containers.list(filters={"name": name}) 

92 if len(ctnlist) != 1: 

93 raise FileNotFoundError(f"container named {name} not found({len(ctnlist)})") 

94 ctn = ctnlist[0] 

95 return func(client=client, container=ctn, **kwargs) 

96 

97 return _ 

98 

99 

100def webserver_option(func): 

101 @click.option("--baseconf", type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True), 

102 default=None, show_default=True) 

103 @click.option("--server-url", default="http://localhost", show_default=True) 

104 @click.option("--ipaddr/--hostname", default=False, show_default=True) 

105 @functools.wraps(func) 

106 def _(**kwargs): 

107 return func(**kwargs) 

108 return _ 

109 

110 

111@cli.command() 

112@click.option("--output", type=click.File("w"), default="-", show_default=True) 

113@verbose_option 

114@docker_option 

115@format_option 

116def labels(client: docker.DockerClient, output): 

117 """show labels""" 

118 res: list[dict] = [] 

119 for ctn in client.containers.list(): 

120 image_labels = ctn.image.labels 

121 res.append({ 

122 "name": ctn.name, 

123 "labels": {k: v for k, v in ctn.labels.items() if image_labels.get(k) != v}, 

124 "image_labels": image_labels, 

125 }) 

126 return res 

127 

128 

129@cli.command() 

130@click.option("--output", type=click.File("w"), default="-", show_default=True) 

131@verbose_option 

132@docker_option 

133@format_option 

134def attrs(client: docker.DockerClient, output): 

135 """show name and attributes of containers""" 

136 res: list[dict] = [] 

137 for ctn in client.containers.list(): 

138 res.append({ 

139 "name": ctn.name, 

140 "attrs": ctn.attrs 

141 }) 

142 return res 

143 

144 

145class ComposeGen: 

146 def __init__(self, **kwargs): 

147 self.gen = compose(**kwargs) 

148 

149 def __iter__(self): 

150 self.value = yield from self.gen 

151 return self.value 

152 

153 

154@cli.command(compose.__name__, help=compose.__doc__) 

155@click.option("--output", type=click.Path(file_okay=False, dir_okay=True, exists=True, writable=True)) 

156@click.option("--volume/--no-volume", default=True, show_default=True, help="copy volume content") 

157@click.option("--project", help="project name of compose") 

158@verbose_option 

159@docker_option 

160@format_option 

161def _compose(client, output, volume, project): 

162 if not output: 162 ↛ 164line 162 didn't jump to line 164 because the condition on line 162 was always true

163 volume = False 

164 cgen = ComposeGen(client=client, volume=volume, project=project) 

165 for path, bin in cgen: 

166 if output: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 out = Path(output) / path 

168 if out.is_relative_to(output): 

169 _log.debug("output %s -> %s (%s bytes)", path, out, len(bin)) 

170 out.parent.mkdir(parents=True, exist_ok=True) 

171 out.write_bytes(bin) 

172 else: 

173 _log.debug("is not relative: pass %s -> %s (%s bytes)", path, out, len(bin)) 

174 return cgen.value 

175 

176 

177@cli.command(traefik2nginx.__name__, help=traefik2nginx.__doc__) 

178@click.option("--traefik-file", type=click.File("r"), default="-", show_default=True) 

179@click.option("--output", type=click.File("w"), default="-", show_default=True) 

180@webserver_option 

181@verbose_option 

182def _traefik2nginx(*args, **kwargs): 

183 return traefik2nginx(*args, **kwargs) 

184 

185 

186@cli.command(traefik2apache.__name__, help=traefik2apache.__doc__) 

187@click.option("--traefik-file", type=click.File("r"), default="-", show_default=True) 

188@click.option("--output", type=click.File("w"), default="-", show_default=True) 

189@webserver_option 

190@verbose_option 

191def _traefik2apache(*args, **kwargs): 

192 return traefik2apache(*args, **kwargs) 

193 

194 

195@cli.command(traefik_dump.__name__.replace("_", "-"), help=traefik_dump.__doc__) 

196@verbose_option 

197@docker_option 

198@format_option 

199def _traefik_dump(*args, **kwargs): 

200 return traefik_dump(*args, **kwargs).to_dict() 

201 

202 

203@cli.command() 

204@docker_option 

205@verbose_option 

206@format_option 

207def list_volume(client: docker.DockerClient): 

208 """list volumes""" 

209 return [x.attrs for x in client.volumes.list()] 

210 

211 

212@cli.command() 

213@docker_option 

214@verbose_option 

215@click.option("--image", default='hello-world', show_default=True, help="container image name") 

216@click.option("--output", type=click.File("wb"), default="-", show_default=True) 

217@click.option("-z", is_flag=True, help="compress with gzip") 

218@click.argument("volume") 

219def tar_volume(client: docker.DockerClient, volume, image, output, z): 

220 """get volume content as tar""" 

221 mount = "/" + volume.strip("/") 

222 vol = client.volumes.get(volume) 

223 _log.debug("Volume %s found with ID %s", volume, vol.id) 

224 

225 try: 

226 img = client.images.get(image) 

227 _log.debug("Image %s found locally", image) 

228 except docker.errors.ImageNotFound: 

229 img = client.images.pull(image) 

230 _log.debug("Image %s pulled successfully", image) 

231 

232 mnt = docker.types.Mount(target=mount, source=vol.id, read_only=True) 

233 cl = client.containers.create(img, mounts=[mnt]) 

234 _log.debug("Container created with image %s and volume %s mounted at %s", image, volume, mount) 

235 

236 try: 

237 bin, _ = cl.get_archive(mount, encode_stream=z) 

238 _log.debug("Starting to archive volume %s", volume) 

239 for b in bin: 

240 output.write(b) 

241 _log.debug("Volume %s archived successfully", volume) 

242 finally: 

243 cl.remove(force=True) 

244 _log.debug("Container removed") 

245 

246 

247@cli.command() 

248@verbose_option 

249@format_option 

250@click.argument("input", type=click.File("r")) 

251@click.option("--strict/--no-strict", default=False, show_default=True) 

252def traefik_load(input, strict): 

253 """load traefik configuration""" 

254 import yaml 

255 from .traefik_conf import TraefikConfig 

256 res = TraefikConfig.model_validate(yaml.safe_load(input), strict=strict) 

257 return res.model_dump(exclude_none=True, exclude_defaults=True, exclude_unset=True) 

258 

259 

260def srun(title: str, args: list[str], capture_output=True): 

261 _log.info("run %s: %s", title, args) 

262 cmdresult = subprocess.run(args, capture_output=capture_output, check=True) 

263 _log.info("result %s: stdout=%s, stderr=%s", title, cmdresult.stdout, cmdresult.stderr) 

264 

265 

266def webserver_run(client: docker.DockerClient, conv_fn, conffile: str, baseconf: str | None, 

267 server_url: str, ipaddr: bool, interval: int, oneshot: bool, 

268 test_cmd: list[str], boot_cmd: list[str], stop_cmd: list[str], reload_cmd: list[str]): 

269 import dictknife 

270 import atexit 

271 config = traefik_dump(client) 

272 with open(conffile, "w") as ngc: 

273 conv_fn(config, ngc, baseconf, server_url, ipaddr) 

274 # test config 

275 srun("test", test_cmd) 

276 # boot 

277 srun("boot", boot_cmd, capture_output=False) 

278 

279 if not oneshot: 

280 @atexit.register 

281 def _(): 

282 srun("exit", stop_cmd) 

283 else: 

284 return 

285 

286 while True: 

287 _log.debug("sleep %s", interval) 

288 time.sleep(interval) 

289 newconfig = traefik_dump(client) 

290 if newconfig != config: 

291 _log.info("change detected") 

292 for d in dictknife.diff(config.to_dict(), newconfig.to_dict()): 

293 _log.info("diff: %s", d) 

294 _log.info("generate config") 

295 with open(conffile, "w") as ngc: 

296 conv_fn(newconfig, ngc, baseconf, server_url, ipaddr) 

297 srun("test", test_cmd) 

298 srun("reload", reload_cmd) 

299 config = newconfig 

300 else: 

301 _log.debug("not changed") 

302 

303 

304@cli.command() 

305@docker_option 

306@webserver_option 

307@click.option("--conffile", type=click.Path(), required=True) 

308@click.option("--nginx", default="nginx", show_default=True, help="nginx binary filepath") 

309@click.option("--oneshot/--forever", default=True, show_default=True) 

310@click.option("--interval", type=int, default=10, show_default=True, help="check interval") 

311@verbose_option 

312def traefik_nginx_monitor(client: docker.DockerClient, baseconf: str, conffile: str, nginx: str, 

313 server_url: str, ipaddr: bool, interval: int, oneshot: bool): 

314 """boot nginx with configuration from labels""" 

315 webserver_run(client, traefik2nginx, conffile, baseconf, server_url, 

316 ipaddr, interval, oneshot, 

317 [nginx, "-c", conffile, "-t"], 

318 [nginx, "-c", conffile], 

319 [nginx, "-s", "quit"], 

320 [nginx, "-s", "reload"] 

321 ) 

322 

323 

324@cli.command() 

325@docker_option 

326@webserver_option 

327@click.option("--conffile", type=click.Path(), required=True) 

328@click.option("--apache", default="httpd", show_default=True, help="httpd binary filepath") 

329@click.option("--oneshot/--forever", default=True, show_default=True) 

330@click.option("--interval", type=int, default=10, show_default=True, help="check interval") 

331@verbose_option 

332def traefik_apache_monitor(client: docker.DockerClient, baseconf: str, conffile: str, apache: str, 

333 server_url: str, ipaddr: bool, interval: int, oneshot: bool): 

334 """boot apache httpd with configuration from labels""" 

335 webserver_run(client, traefik2apache, conffile, baseconf, server_url, 

336 ipaddr, interval, oneshot, 

337 [apache, "-t"], 

338 [apache], 

339 [apache, "-k", "graceful-stop"], 

340 [apache, "-k", "graceful"] 

341 ) 

342 

343 

344@cli.command() 

345@verbose_option 

346@container_option 

347@click.option("--output", type=click.Path(dir_okay=True)) 

348@click.option("--ignore", multiple=True) 

349@click.option("--labels/--no-labels", default=False, show_default=True) 

350def make_dockerfile(client: docker.DockerClient, container: docker.models.containers.Container, output, ignore, labels): 

351 """make Dockerfile from running container""" 

352 import tarfile 

353 import io 

354 tf: tarfile.TarFile | None = None 

355 if bool(output): 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 if output == "-": 

357 _log.debug("stream output") 

358 tf = tarfile.open(mode="w|", fileobj=sys.stdout.buffer, format=tarfile.GNU_FORMAT) 

359 elif not Path(output).is_dir(): 

360 _log.debug("file output: %s", output) 

361 tf = tarfile.open(name=output, mode="w", format=tarfile.GNU_FORMAT) 

362 else: 

363 _log.debug("directory output: %s", output) 

364 for name, bin in get_dockerfile(container, ignore, labels, bool(output)): 

365 if tf: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 ti = tarfile.TarInfo(name) 

367 ti.mode = 0o644 

368 ti.mtime = time.time() 

369 ti.size = len(bin) 

370 tf.addfile(ti, io.BytesIO(bin)) 

371 elif bool(output): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 (Path(output) / name).write_bytes(bin) 

373 elif name == "Dockerfile": 373 ↛ 364line 373 didn't jump to line 364 because the condition on line 373 was always true

374 sys.stdout.buffer.write(bin) 

375 if tf: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true

376 tf.close() 

377 

378 

379@cli.command() 

380@verbose_option 

381@container_option 

382@click.option("--sbom", type=click.Path(file_okay=True), help="output filename") 

383@click.option("--collector", default="syft", show_default=True, help="syft binary filepath") 

384@click.option("--checker", default="grype", show_default=True, help="grype binary filepath") 

385@click.option("--ignore-volume/--include-volume", default=True, show_default=True) 

386@click.option("--ignore", multiple=True) 

387def diff_sbom(client: docker.DockerClient, container: docker.models.containers.Container, ignore, 

388 collector, sbom, checker, ignore_volume): 

389 """make SBOM and check Vulnerability of updated files in container""" 

390 import tempfile 

391 import tarfile 

392 import subprocess 

393 _log.info("get metadata: %s", container.name) 

394 ignores = set(ignore) 

395 if ignore_volume: 

396 ignores.update(get_volumes(container)) 

397 deleted, added, modified, link = get_diff(container, ignores) 

398 with tempfile.TemporaryDirectory() as td: 

399 tarfn = Path(td) / "files.tar" 

400 rootdir = Path(td)/"root" 

401 if sbom: 

402 sbomfn = Path(sbom) 

403 else: 

404 sbomfn = Path(td) / "sbom.json" 

405 _log.info("get diffs: %s+%s file/dirs", len(added), len(modified)) 

406 tfbin = get_archives(container, added | modified, ignores, "w") 

407 tarfn.write_bytes(tfbin) 

408 _log.info("extract files: size=%s", tarfn.stat().st_size) 

409 with tarfile.open(tarfn) as tf: 

410 tf.extractall(rootdir, filter='data') 

411 _log.info("generate sbom") 

412 subprocess.check_call([collector, "scan", f"dir:{rootdir}", "-o", f"json={sbomfn}"]) 

413 _log.info("check vuln") 

414 subprocess.check_call([checker, f"sbom:{sbomfn}"]) 

415 

416 

417@cli.command() 

418@verbose_option 

419@container_option 

420@click.option("--ignore-volume/--include-volume", default=True, show_default=True) 

421@click.option("--ignore", multiple=True) 

422@click.option("--gzip/--raw", default=False, show_default=True) 

423def tar_diff(client: docker.DockerClient, container: docker.models.containers.Container, ignore, ignore_volume, gzip): 

424 """make SBOM and check Vulnerability of updated files in container""" 

425 _log.info("get metadata: %s", container.name) 

426 ignores = set(ignore) 

427 if ignore_volume: 

428 ignores.update(get_volumes(container)) 

429 _log.debug("ignore path: %s", ignores) 

430 deleted, added, modified, link = get_diff(container, ignores) 

431 mode = "w:gz" if gzip else "w" 

432 _log.info("get diffs: %s+%s file/dirs", len(added), len(modified)) 

433 tfbin = get_archives(container, added | modified, ignores, mode) 

434 sys.stdout.buffer.write(tfbin) 

435 

436 

437@cli.command() 

438@verbose_option 

439@docker_option 

440@click.option("--listen", default="0.0.0.0", show_default=True) 

441@click.option("--port", type=int, default=8000, show_default=True) 

442@click.option("--schema/--no-schema", default=False, help="output openapi schema and exit") 

443@format_option 

444def server(client: docker.DockerClient, listen, port, schema): 

445 """start API server""" 

446 from fastapi import FastAPI 

447 from .api import ComposeRoute, TraefikRoute, NginxRoute, DockerfileRoute 

448 import uvicorn 

449 api = FastAPI() 

450 api.include_router(ComposeRoute(client).router, prefix="/compose") 

451 api.include_router(TraefikRoute(client).router, prefix="/traefik") 

452 api.include_router(NginxRoute(client).router, prefix="/nginx") 

453 api.include_router(DockerfileRoute(client).router, prefix="/dockerfile") 

454 if schema: 

455 return api.openapi() 

456 else: 

457 uvicorn.run(api, host=listen, port=port, log_config=None) 

458 

459 

460if __name__ == "__main__": 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 cli()