Coverage for dlabel/main.py: 68%
353 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 functools
2import subprocess
3import sys
4import time
5from logging import getLogger
6from pathlib import Path
8import click
9import docker
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
17_log = getLogger(__name__)
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())
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
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)
47 return _
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
67 json.dump(res, indent=2, fp=sys.stdout, ensure_ascii=False)
68 elif format == "yaml":
69 import yaml
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
81 toml.dump(res, sys.stdout)
82 return res
84 return _
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)
103 return _
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)
128 return _
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)
144 return _
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
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
182class ComposeGen:
183 def __init__(self, **kwargs):
184 self.gen = compose(**kwargs)
186 def __iter__(self):
187 self.value = yield from self.gen
188 return self.value
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
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)
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)
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()
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()]
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)
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)
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 )
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")
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
307 from .traefik_conf import TraefikConfig
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)
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 )
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
337 import dictknife
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)
347 if not oneshot:
349 @atexit.register
350 def _():
351 srun("exit", stop_cmd)
352 else:
353 return
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")
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 )
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 )
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
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)
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
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}"])
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)
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
591 from .api import ComposeRoute, DockerfileRoute, NginxRoute, TraefikRoute
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)
604if __name__ == "__main__": 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true
605 cli()