Coverage for dlabel/main.py: 68%

353 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 23:07 +0000

1import functools 

2import subprocess 

3import sys 

4import time 

5from logging import getLogger 

6from pathlib import Path 

7 

8import click 

9import docker 

10 

11from .compose import compose 

12from .dockerfile import get_dockerfile 

13from .traefik import traefik2apache, traefik2nginx, traefik_dump 

14from .util import get_archives, get_diff, get_volumes 

15from .version import VERSION 

16 

17_log = getLogger(__name__) 

18 

19 

20@click.group(invoke_without_command=True) 

21@click.version_option(VERSION) 

22@click.pass_context 

23def cli(ctx): 

24 if ctx.invoked_subcommand is None: 

25 print(ctx.get_help()) 

26 

27 

28def verbose_option(func): 

29 @click.option( 

30 "--verbose/--quiet", 

31 default=None, 

32 help="INFO(default)/DEBUG(verbose)/WARNING(quiet)", 

33 ) 

34 @functools.wraps(func) 

35 def _(verbose, **kwargs): 

36 from logging import basicConfig 

37 

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

39 if verbose is None: 

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

41 elif verbose is False: 

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

43 else: 

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

45 return func(**kwargs) 

46 

47 return _ 

48 

49 

50def format_option(func): 

51 @click.option( 

52 "--format", 

53 default="yaml", 

54 type=click.Choice(["yaml", "json", "toml"]), 

55 show_default=True, 

56 help="output format", 

57 ) 

58 @functools.wraps(func) 

59 def _(format, **kwargs): 

60 res = func(**kwargs) 

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

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

63 else: 

64 if format == "json": 

65 import json 

66 

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

68 elif format == "yaml": 

69 import yaml 

70 

71 yaml.dump( 

72 res, 

73 stream=sys.stdout, 

74 allow_unicode=True, 

75 encoding="utf-8", 

76 sort_keys=False, 

77 ) 

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

79 import toml 

80 

81 toml.dump(res, sys.stdout) 

82 return res 

83 

84 return _ 

85 

86 

87def docker_option(func): 

88 @click.option( 

89 "-H", 

90 "--host", 

91 envvar="DOCKER_HOST", 

92 help="Daemon socket(s) to connect to", 

93 show_envvar=True, 

94 ) 

95 @functools.wraps(func) 

96 def _(host, **kwargs): 

97 if not host: 

98 cl = docker.from_env() 

99 else: 

100 cl = docker.DockerClient(base_url=host) 

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

102 

103 return _ 

104 

105 

106def container_option(func): 

107 @docker_option 

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

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

110 @functools.wraps(func) 

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

112 if not name and not id: 

113 click.echo("id name image") 

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

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

116 return 

117 if id: 

118 ctn = client.containers.get(id) 

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

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

121 if len(ctnlist) != 1: 

122 raise FileNotFoundError( 

123 f"container named {name} not found({len(ctnlist)})" 

124 ) 

125 ctn = ctnlist[0] 

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

127 

128 return _ 

129 

130 

131def webserver_option(func): 

132 @click.option( 

133 "--baseconf", 

134 type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True), 

135 default=None, 

136 show_default=True, 

137 ) 

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

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

140 @functools.wraps(func) 

141 def _(**kwargs): 

142 return func(**kwargs) 

143 

144 return _ 

145 

146 

147@cli.command() 

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

149@verbose_option 

150@docker_option 

151@format_option 

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

153 """show labels""" 

154 res: list[dict] = [] 

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

156 image_labels = ctn.image.labels 

157 res.append( 

158 { 

159 "name": ctn.name, 

160 "labels": { 

161 k: v for k, v in ctn.labels.items() if image_labels.get(k) != v 

162 }, 

163 "image_labels": image_labels, 

164 } 

165 ) 

166 return res 

167 

168 

169@cli.command() 

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

171@verbose_option 

172@docker_option 

173@format_option 

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

175 """show name and attributes of containers""" 

176 res: list[dict] = [] 

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

178 res.append({"name": ctn.name, "attrs": ctn.attrs}) 

179 return res 

180 

181 

182class ComposeGen: 

183 def __init__(self, **kwargs): 

184 self.gen = compose(**kwargs) 

185 

186 def __iter__(self): 

187 self.value = yield from self.gen 

188 return self.value 

189 

190 

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

192@click.option( 

193 "--output", 

194 type=click.Path(file_okay=False, dir_okay=True, exists=True, writable=True), 

195) 

196@click.option( 

197 "--volume/--no-volume", default=True, show_default=True, help="copy volume content" 

198) 

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

200@verbose_option 

201@docker_option 

202@format_option 

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

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

205 volume = False 

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

207 for path, bin in cgen: 

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

209 out = Path(output) / path 

210 if out.is_relative_to(output): 

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

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

213 out.write_bytes(bin) 

214 else: 

215 _log.debug( 

216 "is not relative: pass %s -> %s (%s bytes)", path, out, len(bin) 

217 ) 

218 return cgen.value 

219 

220 

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

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

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

224@webserver_option 

225@verbose_option 

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

227 return traefik2nginx(*args, **kwargs) 

228 

229 

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

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

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

233@webserver_option 

234@verbose_option 

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

236 return traefik2apache(*args, **kwargs) 

237 

238 

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

240@verbose_option 

241@docker_option 

242@format_option 

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

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

245 

246 

247@cli.command() 

248@docker_option 

249@verbose_option 

250@format_option 

251def list_volume(client: docker.DockerClient): 

252 """list volumes""" 

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

254 

255 

256@cli.command() 

257@docker_option 

258@verbose_option 

259@click.option( 

260 "--image", default="hello-world", show_default=True, help="container image name" 

261) 

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

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

264@click.argument("volume") 

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

266 """get volume content as tar""" 

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

268 vol = client.volumes.get(volume) 

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

270 

271 try: 

272 img = client.images.get(image) 

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

274 except docker.errors.ImageNotFound: 

275 img = client.images.pull(image) 

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

277 

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

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

280 _log.debug( 

281 "Container created with image %s and volume %s mounted at %s", 

282 image, 

283 volume, 

284 mount, 

285 ) 

286 

287 try: 

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

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

290 for b in bin: 

291 output.write(b) 

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

293 finally: 

294 cl.remove(force=True) 

295 _log.debug("Container removed") 

296 

297 

298@cli.command() 

299@verbose_option 

300@format_option 

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

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

303def traefik_load(input, strict): 

304 """load traefik configuration""" 

305 import yaml 

306 

307 from .traefik_conf import TraefikConfig 

308 

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

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

311 

312 

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

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

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

316 _log.info( 

317 "result %s: stdout=%s, stderr=%s", title, cmdresult.stdout, cmdresult.stderr 

318 ) 

319 

320 

321def webserver_run( 

322 client: docker.DockerClient, 

323 conv_fn, 

324 conffile: str, 

325 baseconf: str | None, 

326 server_url: str, 

327 ipaddr: bool, 

328 interval: int, 

329 oneshot: bool, 

330 test_cmd: list[str], 

331 boot_cmd: list[str], 

332 stop_cmd: list[str], 

333 reload_cmd: list[str], 

334): 

335 import atexit 

336 

337 import dictknife 

338 

339 config = traefik_dump(client) 

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

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

342 # test config 

343 srun("test", test_cmd) 

344 # boot 

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

346 

347 if not oneshot: 

348 

349 @atexit.register 

350 def _(): 

351 srun("exit", stop_cmd) 

352 else: 

353 return 

354 

355 while True: 

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

357 time.sleep(interval) 

358 newconfig = traefik_dump(client) 

359 if newconfig != config: 

360 _log.info("change detected") 

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

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

363 _log.info("generate config") 

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

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

366 srun("test", test_cmd) 

367 srun("reload", reload_cmd) 

368 config = newconfig 

369 else: 

370 _log.debug("not changed") 

371 

372 

373@cli.command() 

374@docker_option 

375@webserver_option 

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

377@click.option( 

378 "--nginx", default="nginx", show_default=True, help="nginx binary filepath" 

379) 

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

381@click.option( 

382 "--interval", type=int, default=10, show_default=True, help="check interval" 

383) 

384@verbose_option 

385def traefik_nginx_monitor( 

386 client: docker.DockerClient, 

387 baseconf: str, 

388 conffile: str, 

389 nginx: str, 

390 server_url: str, 

391 ipaddr: bool, 

392 interval: int, 

393 oneshot: bool, 

394): 

395 """boot nginx with configuration from labels""" 

396 webserver_run( 

397 client, 

398 traefik2nginx, 

399 conffile, 

400 baseconf, 

401 server_url, 

402 ipaddr, 

403 interval, 

404 oneshot, 

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

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

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

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

409 ) 

410 

411 

412@cli.command() 

413@docker_option 

414@webserver_option 

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

416@click.option( 

417 "--apache", default="httpd", show_default=True, help="httpd binary filepath" 

418) 

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

420@click.option( 

421 "--interval", type=int, default=10, show_default=True, help="check interval" 

422) 

423@verbose_option 

424def traefik_apache_monitor( 

425 client: docker.DockerClient, 

426 baseconf: str, 

427 conffile: str, 

428 apache: str, 

429 server_url: str, 

430 ipaddr: bool, 

431 interval: int, 

432 oneshot: bool, 

433): 

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

435 webserver_run( 

436 client, 

437 traefik2apache, 

438 conffile, 

439 baseconf, 

440 server_url, 

441 ipaddr, 

442 interval, 

443 oneshot, 

444 [apache, "-t"], 

445 [apache], 

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

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

448 ) 

449 

450 

451@cli.command() 

452@verbose_option 

453@container_option 

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

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

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

457def make_dockerfile( 

458 client: docker.DockerClient, 

459 container: docker.models.containers.Container, 

460 output, 

461 ignore, 

462 labels, 

463): 

464 """make Dockerfile from running container""" 

465 import io 

466 import tarfile 

467 from contextlib import ExitStack 

468 

469 tf: tarfile.TarFile | None = None 

470 with ExitStack() as stack: 

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

472 if output == "-": 

473 _log.debug("stream output") 

474 tf = stack.enter_context( 

475 tarfile.open( 

476 mode="w|", fileobj=sys.stdout.buffer, format=tarfile.GNU_FORMAT 

477 ) 

478 ) 

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

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

481 tf = stack.enter_context( 

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

483 ) 

484 else: 

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

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

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

488 ti = tarfile.TarInfo(name) 

489 ti.mode = 0o644 

490 ti.mtime = time.time() 

491 ti.size = len(bin) 

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

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

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

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

496 sys.stdout.buffer.write(bin) 

497 

498 

499@cli.command() 

500@verbose_option 

501@container_option 

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

503@click.option( 

504 "--collector", default="syft", show_default=True, help="syft binary filepath" 

505) 

506@click.option( 

507 "--checker", default="grype", show_default=True, help="grype binary filepath" 

508) 

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

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

511def diff_sbom( 

512 client: docker.DockerClient, 

513 container: docker.models.containers.Container, 

514 ignore, 

515 collector, 

516 sbom, 

517 checker, 

518 ignore_volume, 

519): 

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

521 import subprocess 

522 import tarfile 

523 import tempfile 

524 

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

526 ignores = set(ignore) 

527 if ignore_volume: 

528 ignores.update(get_volumes(container)) 

529 _deleted, added, modified, _link = get_diff(container, ignores) 

530 with tempfile.TemporaryDirectory() as td: 

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

532 rootdir = Path(td) / "root" 

533 if sbom: 

534 sbomfn = Path(sbom) 

535 else: 

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

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

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

539 tarfn.write_bytes(tfbin) 

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

541 with tarfile.open(tarfn) as tf: 

542 tf.extractall(rootdir, filter="data") 

543 _log.info("generate sbom") 

544 subprocess.check_call( 

545 [collector, "scan", f"dir:{rootdir}", "-o", f"json={sbomfn}"] 

546 ) 

547 _log.info("check vuln") 

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

549 

550 

551@cli.command() 

552@verbose_option 

553@container_option 

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

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

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

557def tar_diff( 

558 client: docker.DockerClient, 

559 container: docker.models.containers.Container, 

560 ignore, 

561 ignore_volume, 

562 gzip, 

563): 

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

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

566 ignores = set(ignore) 

567 if ignore_volume: 

568 ignores.update(get_volumes(container)) 

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

570 _deleted, added, modified, _link = get_diff(container, ignores) 

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

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

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

574 sys.stdout.buffer.write(tfbin) 

575 

576 

577@cli.command() 

578@verbose_option 

579@docker_option 

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

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

582@click.option( 

583 "--schema/--no-schema", default=False, help="output openapi schema and exit" 

584) 

585@format_option 

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

587 """start API server""" 

588 import uvicorn 

589 from fastapi import FastAPI 

590 

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

592 

593 api = FastAPI() 

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

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

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

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

598 if schema: 

599 return api.openapi() 

600 else: 

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

602 

603 

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

605 cli()