diff --git a/examples/addc.json b/examples/addc.json
new file mode 100644
index 0000000..45c9ab0
--- /dev/null
+++ b/examples/addc.json
@@ -0,0 +1,127 @@
+{
+ "samba-container-config": "v0",
+ "configs": {
+ "demo": {
+ "instance_features": ["addc"],
+ "domain_settings": "sink",
+ "instance_name": "dc1"
+ }
+ },
+ "domain_settings": {
+ "sink": {
+ "realm": "DOMAIN1.SINK.TEST",
+ "short_domain": "DOMAIN1",
+ "admin_password": "Passw0rd"
+ }
+ },
+ "domain_groups": {
+ "sink": [
+ {"name": "supervisors"},
+ {"name": "employees"},
+ {"name": "characters"},
+ {"name": "bulk"}
+ ]
+ },
+ "domain_users": {
+ "sink": [
+ {
+ "name": "bwayne",
+ "password": "1115Rose.",
+ "given_name": "Bruce",
+ "surname": "Wayne",
+ "member_of": ["supervisors", "characters", "employees"]
+ },
+ {
+ "name": "ckent",
+ "password": "1115Rose.",
+ "given_name": "Clark",
+ "surname": "Kent",
+ "member_of": ["characters", "employees"]
+ },
+ {
+ "name": "bbanner",
+ "password": "1115Rose.",
+ "given_name": "Bruce",
+ "surname": "Banner",
+ "member_of": ["characters", "employees"]
+ },
+ {
+ "name": "pparker",
+ "password": "1115Rose.",
+ "given_name": "Peter",
+ "surname": "Parker",
+ "member_of": ["characters", "employees"]
+ },
+ {
+ "name": "user0",
+ "password": "1115Rose.",
+ "given_name": "George0",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user1",
+ "password": "1115Rose.",
+ "given_name": "George1",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user2",
+ "password": "1115Rose.",
+ "given_name": "George2",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user3",
+ "password": "1115Rose.",
+ "given_name": "George3",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user4",
+ "password": "1115Rose.",
+ "given_name": "George4",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user5",
+ "password": "1115Rose.",
+ "given_name": "George5",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user6",
+ "password": "1115Rose.",
+ "given_name": "George6",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user7",
+ "password": "1115Rose.",
+ "given_name": "George7",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user8",
+ "password": "1115Rose.",
+ "given_name": "George8",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ },
+ {
+ "name": "user9",
+ "password": "1115Rose.",
+ "given_name": "George9",
+ "surname": "Hue-Sir",
+ "member_of": ["bulk"]
+ }
+ ]
+ }
+}
diff --git a/sambacc/addc.py b/sambacc/addc.py
new file mode 100644
index 0000000..f60a416
--- /dev/null
+++ b/sambacc/addc.py
@@ -0,0 +1,178 @@
+#
+# sambacc: a samba container configuration tool
+# Copyright (C) 2021 John Mulligan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see
+#
+
+import logging
+import subprocess
+import typing
+
+from sambacc import samba_cmds
+
+_logger = logging.getLogger(__name__)
+
+
+def provision(
+ realm: str,
+ dcname: str,
+ admin_password: str,
+ dns_backend: typing.Optional[str] = None,
+ domain: typing.Optional[str] = None,
+) -> None:
+ # this function is a direct translation of a previous shell script
+ # as samba-tool is based on python libs, this function could possibly
+ # be converted to import samba's libs and use that.
+ _logger.info(f"Provisioning AD domain: realm={realm}")
+ subprocess.check_call(
+ _provision_cmd(
+ realm,
+ dcname,
+ admin_password=admin_password,
+ dns_backend=dns_backend,
+ domain=domain,
+ )
+ )
+ return
+
+
+def join(
+ realm: str,
+ dcname: str,
+ admin_password: str,
+ dns_backend: typing.Optional[str] = None,
+ domain: typing.Optional[str] = None,
+) -> None:
+ _logger.info(f"Joining AD domain: realm={realm}")
+ subprocess.check_call(
+ _join_cmd(
+ realm,
+ dcname,
+ admin_password=admin_password,
+ dns_backend=dns_backend,
+ )
+ )
+
+
+def create_user(
+ name: str,
+ password: str,
+ surname: typing.Optional[str],
+ given_name: typing.Optional[str],
+) -> None:
+ cmd = _user_create_cmd(name, password, surname, given_name)
+ _logger.info("Creating user: %r", name)
+ subprocess.check_call(cmd)
+
+
+def create_group(name: str) -> None:
+ cmd = _group_add_cmd(name)
+ _logger.info("Creating group: %r", name)
+ subprocess.check_call(cmd)
+
+
+def add_group_members(group_name: str, members: typing.List[str]) -> None:
+ cmd = _group_add_members_cmd(group_name, members)
+ _logger.info("Adding group members: %r", cmd)
+ subprocess.check_call(cmd)
+
+
+def _provision_cmd(
+ realm: str,
+ dcname: str,
+ admin_password: str,
+ dns_backend: typing.Optional[str] = None,
+ domain: typing.Optional[str] = None,
+) -> typing.List[str]:
+ if not dns_backend:
+ dns_backend = "SAMBA_INTERNAL"
+ if not domain:
+ domain = realm.split(".")[0].upper()
+ cmd = samba_cmds.sambatool[
+ "domain",
+ "provision",
+ f"--option=netbios name={dcname}",
+ "--use-rfc2307",
+ f"--dns-backend={dns_backend}",
+ "--server-role=dc",
+ f"--realm={realm}",
+ f"--domain={domain}",
+ f"--adminpass={admin_password}",
+ ].argv()
+ return cmd
+
+
+def _join_cmd(
+ realm: str,
+ dcname: str,
+ admin_password: str,
+ dns_backend: typing.Optional[str] = None,
+ domain: typing.Optional[str] = None,
+) -> typing.List[str]:
+ if not dns_backend:
+ dns_backend = "SAMBA_INTERNAL"
+ if not domain:
+ domain = realm.split(".")[0].upper()
+ cmd = samba_cmds.sambatool[
+ "domain",
+ "join",
+ realm,
+ "DC",
+ f"-U{domain}\\Administrator",
+ f"--option=netbios name={dcname}",
+ f"--dns-backend={dns_backend}",
+ f"--password={admin_password}",
+ ].argv()
+ return cmd
+
+
+def _user_create_cmd(
+ name: str,
+ password: str,
+ surname: typing.Optional[str],
+ given_name: typing.Optional[str],
+) -> typing.List[str]:
+ cmd = samba_cmds.sambatool[
+ "user",
+ "create",
+ name,
+ password,
+ ].argv()
+ if surname:
+ cmd.append(f"--surname={surname}")
+ if given_name:
+ cmd.append(f"--given-name={given_name}")
+ return cmd
+
+
+def _group_add_cmd(name: str) -> typing.List[str]:
+ cmd = samba_cmds.sambatool[
+ "group",
+ "add",
+ name,
+ ].argv()
+ return cmd
+
+
+def _group_add_members_cmd(
+ group_name: str, members: typing.List[str]
+) -> typing.List[str]:
+ cmd = samba_cmds.sambatool[
+ "group",
+ "addmembers",
+ group_name,
+ ",".join(members),
+ ].argv()
+ return cmd
diff --git a/sambacc/commands/addc.py b/sambacc/commands/addc.py
new file mode 100644
index 0000000..876a23c
--- /dev/null
+++ b/sambacc/commands/addc.py
@@ -0,0 +1,168 @@
+#
+# sambacc: a samba container configuration tool
+# Copyright (C) 2021 John Mulligan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see
+#
+
+import logging
+import os
+import shutil
+
+from sambacc import addc
+from sambacc import samba_cmds
+
+from .cli import best_waiter, CommandBuilder, Context, Fail
+
+try:
+ import dns
+ import dns.resolver
+ import dns.exception
+
+ _DNS = True
+except ImportError:
+ _DNS = False
+
+
+_logger = logging.getLogger(__name__)
+
+_populated: str = "/var/lib/samba/POPULATED"
+_provisioned: str = "/etc/samba/smb.conf"
+
+dccommands = CommandBuilder()
+
+
+@dccommands.command(name="summary")
+def summary(ctx: Context) -> None:
+ print("Hello", ctx)
+
+
+_setup_choices = ["init-all", "provision", "populate", "wait-domain", "join"]
+
+
+def _dosetup(ctx: Context, step_name: str) -> bool:
+ setup = ctx.cli.setup or []
+ return ("init-all" in setup) or (step_name in setup)
+
+
+def _run_container_args(parser):
+ parser.add_argument(
+ "--setup",
+ action="append",
+ choices=_setup_choices,
+ help=(
+ "Specify one or more setup step names to preconfigure the"
+ " container environment before the server process is started."
+ " The special 'init-all' name will perform all known setup steps."
+ ),
+ )
+ parser.add_argument(
+ "--name",
+ help="Specify a custom name for the dc, overriding the config file.",
+ )
+
+
+def _prep_provision(ctx: Context) -> None:
+ if os.path.exists(_provisioned):
+ _logger.info("Domain already provisioned")
+ return
+ domconfig = ctx.instance_config.domain()
+ _logger.info(f"Provisioning domain: {domconfig.realm}")
+
+ dcname = ctx.cli.name or domconfig.dcname
+ addc.provision(
+ realm=domconfig.realm,
+ domain=domconfig.short_domain,
+ dcname=dcname,
+ admin_password=domconfig.admin_password,
+ )
+
+
+def _prep_join(ctx: Context) -> None:
+ if os.path.exists(_provisioned):
+ _logger.info("Already configured. Not joining")
+ return
+ domconfig = ctx.instance_config.domain()
+ _logger.info(f"Provisioning domain: {domconfig.realm}")
+
+ dcname = ctx.cli.name or domconfig.dcname
+ addc.join(
+ realm=domconfig.realm,
+ domain=domconfig.short_domain,
+ dcname=dcname,
+ admin_password=domconfig.admin_password,
+ )
+
+
+def _prep_wait_on_domain(ctx: Context) -> None:
+ if not _DNS:
+ _logger.info("Can not query domain. Exiting.")
+ raise Fail("no dns support available (missing dnsypthon)")
+
+ realm = ctx.instance_config.domain().realm
+ waiter = best_waiter(max_timeout=30)
+ while True:
+ _logger.info(f"checking for AD domain in dns: {realm}")
+ try:
+ dns.resolver.query(f"_ldap._tcp.{realm}.", "SRV")
+ return
+ except dns.exception.DNSException:
+ _logger.info(f"dns record for {realm} not found")
+ waiter.wait()
+
+
+def _prep_populate(ctx: Context) -> None:
+ if os.path.exists(_populated):
+ _logger.info("populated marker exists")
+ return
+ _logger.info("Populating domain with default entries")
+
+ for dgroup in ctx.instance_config.domain_groups():
+ addc.create_group(dgroup.groupname)
+
+ for duser in ctx.instance_config.domain_users():
+ addc.create_user(
+ name=duser.username,
+ password=duser.plaintext_passwd,
+ surname=duser.surname,
+ given_name=duser.given_name,
+ )
+ # TODO: probably should improve this to avoid extra calls / loops
+ for gname in duser.member_of:
+ addc.add_group_members(group_name=gname, members=[duser.username])
+
+ # "touch" the populated marker
+ with open(_populated, "w"):
+ pass
+
+
+def _prep_krb5_conf(ctx: Context) -> None:
+ shutil.copy("/var/lib/samba/private/krb5.conf", "/etc/krb5.conf")
+
+
+@dccommands.command(name="run", arg_func=_run_container_args)
+def run(ctx: Context) -> None:
+ _logger.info("Running AD DC container")
+ if _dosetup(ctx, "wait-domain"):
+ _prep_wait_on_domain(ctx)
+ if _dosetup(ctx, "join"):
+ _prep_join(ctx)
+ if _dosetup(ctx, "provision"):
+ _prep_provision(ctx)
+ if _dosetup(ctx, "populate"):
+ _prep_populate(ctx)
+
+ _prep_krb5_conf(ctx)
+ _logger.info("Starting samba server")
+ samba_cmds.execute(samba_cmds.samba_dc_foreground())
diff --git a/sambacc/commands/dcmain.py b/sambacc/commands/dcmain.py
new file mode 100644
index 0000000..28ef218
--- /dev/null
+++ b/sambacc/commands/dcmain.py
@@ -0,0 +1,52 @@
+#
+# sambacc: a samba container configuration tool
+# Copyright (C) 2021 John Mulligan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see
+#
+
+from . import addc
+from .cli import Fail
+from .main import (
+ CommandContext,
+ action_filter,
+ enable_logging,
+ env_to_cli,
+ global_args,
+ pre_action,
+)
+
+
+default_cfunc = addc.summary
+
+
+def main(args=None) -> None:
+ cli = addc.dccommands.assemble(arg_func=global_args).parse_args(args)
+ env_to_cli(cli)
+ enable_logging(cli)
+ if not cli.identity:
+ raise Fail("missing container identity")
+
+ pre_action(cli)
+ skip = action_filter(cli)
+ if skip:
+ print(f"Action skipped: {skip}")
+ return
+ cfunc = getattr(cli, "cfunc", default_cfunc)
+ cfunc(CommandContext(cli))
+ return
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sambacc/config.py b/sambacc/config.py
index b6263a5..9e30c07 100644
--- a/sambacc/config.py
+++ b/sambacc/config.py
@@ -35,8 +35,9 @@
# the standard location for samba's smb.conf
SMB_CONF = "/etc/samba/smb.conf"
-CTDB = "ctdb"
-FEATURES = "instance_features"
+CTDB: typing.Final[str] = "ctdb"
+ADDC: typing.Final[str] = "addc"
+FEATURES: typing.Final[str] = "instance_features"
def read_config_files(fnames) -> GlobalConfig:
@@ -149,9 +150,13 @@ def groups(self) -> typing.Iterable[GroupEntry]:
yield uentry.vgroup()
@property
- def with_ctdb(self):
+ def with_ctdb(self) -> bool:
return CTDB in self.iconfig.get(FEATURES, [])
+ @property
+ def with_addc(self) -> bool:
+ return ADDC in self.iconfig.get(FEATURES, [])
+
def ctdb_smb_config(self) -> CTDBSambaConfig:
if not self.with_ctdb:
raise ValueError("ctdb not supported in configuration")
@@ -170,6 +175,33 @@ def ctdb_config(self) -> typing.Dict[str, str]:
ctdb.setdefault("realtime_scheduling", "false")
return ctdb
+ def domain(self) -> DomainConfig:
+ """Return the general domain settings for this DC instance."""
+ if not self.with_addc:
+ raise ValueError("ad dc not supported by configuration")
+ domains = self.gconfig.data.get("domain_settings", {})
+ instance_name: str = self.iconfig.get("instance_name", "")
+ return DomainConfig(
+ drec=domains[self.iconfig["domain_settings"]],
+ instance_name=instance_name,
+ )
+
+ def domain_users(self) -> typing.Iterable[DomainUserEntry]:
+ if not self.with_addc:
+ raise ValueError("ad dc not supported by configuration")
+ ds_name: str = self.iconfig["domain_settings"]
+ dusers = self.gconfig.data.get("domain_users", {}).get(ds_name, [])
+ for n, entry in enumerate(dusers):
+ yield DomainUserEntry(self, entry, n)
+
+ def domain_groups(self) -> typing.Iterable[DomainGroupEntry]:
+ if not self.with_addc:
+ raise ValueError("ad dc not supported by configuration")
+ ds_name: str = self.iconfig["domain_settings"]
+ dgroups = self.gconfig.data.get("domain_groups", {}).get(ds_name, [])
+ for n, entry in enumerate(dgroups):
+ yield DomainGroupEntry(self, entry, n)
+
class CTDBSambaConfig:
def global_options(self) -> typing.Iterable[typing.Tuple[str, str]]:
@@ -279,3 +311,25 @@ def gid(self) -> int:
def group_fields(self) -> GroupEntryTuple:
# fields: name, passwd, gid, members(comma separated)
return (self.groupname, "x", str(self.gid), "")
+
+
+class DomainConfig:
+ def __init__(self, drec: dict, instance_name: str):
+ self.realm = drec["realm"]
+ self.short_domain = drec.get("short_domain", "")
+ self.admin_password = drec.get("admin_password", "")
+ self.dcname = instance_name
+
+
+class DomainUserEntry(UserEntry):
+ def __init__(self, iconf: InstanceConfig, urec: dict, num: int):
+ super().__init__(iconf, urec, num)
+ self.surname = urec.get("surname")
+ self.given_name = urec.get("given_name")
+ self.member_of = urec.get("member_of", [])
+ if not isinstance(self.member_of, list):
+ raise ValueError("member_of should contain a list of group names")
+
+
+class DomainGroupEntry(GroupEntry):
+ pass
diff --git a/sambacc/samba_cmds.py b/sambacc/samba_cmds.py
index fc5fbca..1a7415f 100644
--- a/sambacc/samba_cmds.py
+++ b/sambacc/samba_cmds.py
@@ -140,6 +140,8 @@ def __repr__(self) -> str:
winbindd = SambaCommand("/usr/sbin/winbindd")
+samba_dc = SambaCommand("/usr/sbin/samba")
+
def smbd_foreground():
return smbd[
@@ -153,6 +155,10 @@ def winbindd_foreground():
]
+def samba_dc_foreground():
+ return samba_dc["--foreground"]
+
+
ctdbd = SambaCommand("/usr/sbin/ctdbd")
ctdbd_foreground = ctdbd["--interactive"]
@@ -161,6 +167,8 @@ def winbindd_foreground():
ctdb = SambaCommand("ctdb")
+sambatool = SambaCommand("samba-tool")
+
def encode(value: typing.Union[str, bytes, None]) -> bytes:
if value is None:
diff --git a/setup.cfg b/setup.cfg
index d8c513b..6eb8e47 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -19,9 +19,11 @@ include_package_data = True
[options.entry_points]
console_scripts =
samba-container = sambacc.commands.main:main
+ samba-dc-container = sambacc.commands.dcmain:main
[options.data_files]
share/sambacc/examples =
examples/ctdb.json
examples/example1.json
examples/minimal.json
+ examples/addc.json
diff --git a/tests/test_config.py b/tests/test_config.py
index c65f014..a77e0f5 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -204,6 +204,50 @@
}
"""
+addc_config1 = """
+{
+ "samba-container-config": "v0",
+ "configs": {
+ "demo": {
+ "instance_features": ["addc"],
+ "domain_settings": "sink",
+ "instance_name": "dc1"
+ }
+ },
+ "domain_settings": {
+ "sink": {
+ "realm": "DOMAIN1.SINK.TEST",
+ "short_domain": "DOMAIN1",
+ "admin_password": "Passw0rd"
+ }
+ },
+ "domain_groups": {
+ "sink": [
+ {"name": "friends"},
+ {"name": "gothamites"}
+ ]
+ },
+ "domain_users": {
+ "sink": [
+ {
+ "name": "bwayne",
+ "password": "1115Rose.",
+ "given_name": "Bruce",
+ "surname": "Wayne",
+ "member_of": ["friends", "gothamites"]
+ },
+ {
+ "name": "ckent",
+ "password": "1115Rose.",
+ "given_name": "Clark",
+ "surname": "Kent",
+ "member_of": ["friends"]
+ }
+ ]
+ }
+}
+"""
+
class TestConfig(unittest.TestCase):
def test_non_json(self):
@@ -430,3 +474,85 @@ def test_instance_ctdb_config():
assert "nodes_json" in cfg
assert "nodes_path" in cfg
assert "log_level" in cfg
+
+
+def test_ad_dc_config_demo():
+ c1 = sambacc.config.GlobalConfig(io.StringIO(addc_config1))
+ i1 = c1.get("demo")
+ assert i1.with_addc
+
+ domcfg = i1.domain()
+ assert domcfg.realm == "DOMAIN1.SINK.TEST"
+ assert domcfg.short_domain == "DOMAIN1"
+ assert domcfg.dcname == "dc1"
+
+ dgroups = sorted(i1.domain_groups(), key=lambda v: v.groupname)
+ assert len(dgroups) == 2
+ assert dgroups[0].groupname == "friends"
+
+ dusers = sorted(i1.domain_users(), key=lambda v: v.username)
+ assert len(dusers) == 2
+ assert dusers[0].username == "bwayne"
+
+
+def test_ad_dc_invalid():
+ c1 = sambacc.config.GlobalConfig(io.StringIO(config1))
+ i1 = c1.get("foobar")
+ assert not i1.with_addc
+
+ with pytest.raises(ValueError):
+ i1.domain()
+
+ with pytest.raises(ValueError):
+ list(i1.domain_users())
+
+ with pytest.raises(ValueError):
+ list(i1.domain_groups())
+
+
+def test_ad_dc_bad_memeber_of():
+ jdata = """
+{
+ "samba-container-config": "v0",
+ "configs": {
+ "demo": {
+ "instance_features": ["addc"],
+ "domain_settings": "sink",
+ "instance_name": "dc1"
+ }
+ },
+ "domain_settings": {
+ "sink": {
+ "realm": "DOMAIN1.SINK.TEST",
+ "short_domain": "DOMAIN1",
+ "admin_password": "Passw0rd"
+ }
+ },
+ "domain_groups": {
+ "sink": [
+ {"name": "friends"}
+ ]
+ },
+ "domain_users": {
+ "sink": [
+ {
+ "name": "ckent",
+ "password": "1115Rose.",
+ "given_name": "Clark",
+ "surname": "Kent",
+ "member_of": "friends"
+ }
+ ]
+ }
+}
+ """
+ c1 = sambacc.config.GlobalConfig(io.StringIO(jdata))
+ i1 = c1.get("demo")
+ assert i1.with_addc
+
+ dgroups = sorted(i1.domain_groups(), key=lambda v: v.groupname)
+ assert len(dgroups) == 1
+ assert dgroups[0].groupname == "friends"
+
+ with pytest.raises(ValueError):
+ list(i1.domain_users())
diff --git a/tox.ini b/tox.ini
index 87b68eb..9174445 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,6 +15,7 @@ deps =
mypy
inotify_simple
black>=21.8b0
+ dnspython
commands =
flake8 sambacc tests
black --check -v .