Coverage for dlabel/main.py: 68%
353 statements
« prev ^ index » next coverage.py v7.10.0, created at 2025-07-25 23:32 +0000
« 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
15_log = getLogger(__name__)
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())
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 _
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 _
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)
74 return _
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)
97 return _
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 _
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
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
145class ComposeGen:
146 def __init__(self, **kwargs):
147 self.gen = compose(**kwargs)
149 def __iter__(self):
150 self.value = yield from self.gen
151 return self.value
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
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)
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)
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()
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()]
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)
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)
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)
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")
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)
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)
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)
279 if not oneshot:
280 @atexit.register
281 def _():
282 srun("exit", stop_cmd)
283 else:
284 return
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")
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 )
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 )
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()
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}"])
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)
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)
460if __name__ == "__main__": 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 cli()