refactor base structure

This commit is contained in:
Robert Kaussow 2020-03-05 23:51:21 +01:00
parent 12b6e10737
commit 936e4b2188
18 changed files with 676 additions and 784 deletions

View File

@ -1,6 +1,6 @@
[flake8] [flake8]
ignore = D103 ignore = D103, W503
max-line-length = 110 max-line-length = 99
inline-quotes = double inline-quotes = double
exclude = exclude =
.git .git

View File

@ -21,6 +21,8 @@ pytest-mock = "*"
pytest-cov = "*" pytest-cov = "*"
bandit = "*" bandit = "*"
docker-tidy = {editable = true,path = "."} docker-tidy = {editable = true,path = "."}
autopep8 = "*"
yapf = "*"
[packages] [packages]
importlib-metadata = {version = "*",markers = "python_version<'3.8'"} importlib-metadata = {version = "*",markers = "python_version<'3.8'"}

41
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "5435cb449b46e93e063eb55b6d7bd5d990e1c552d7648a35b4a5eef846914075" "sha256": "afa3bac7184b4b165d029d7c1db785812064a0ee572d9617b4974f2a922db927"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -80,11 +80,11 @@
}, },
"environs": { "environs": {
"hashes": [ "hashes": [
"sha256:2291ce502c9e61b8e208c8c9be4ac474e0f523c4dc23e0beb23118086e43b324", "sha256:54099cfbdd9cb320f438bf29992969ccfd5e232ba068bd650b04d76d96001631",
"sha256:44700c562fb6f783640f90c2225d9a80d85d24833b4dd02d20b8ff1c83901e47" "sha256:9578ce00ead984124a5336e5ea073707df303dc19d3b1e7ba34cdce1bb4fe02f"
], ],
"index": "pypi", "index": "pypi",
"version": "==7.2.0" "version": "==7.3.0"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
@ -242,10 +242,10 @@
}, },
"zipp": { "zipp": {
"hashes": [ "hashes": [
"sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2", "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
"sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a" "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
], ],
"version": "==3.0.0" "version": "==3.1.0"
} }
}, },
"develop": { "develop": {
@ -271,6 +271,13 @@
], ],
"version": "==19.3.0" "version": "==19.3.0"
}, },
"autopep8": {
"hashes": [
"sha256:0f592a0447acea0c2b0a9602be1e4e3d86db52badd2e3c84f0193bfd89fd3a43"
],
"index": "pypi",
"version": "==1.5"
},
"bandit": { "bandit": {
"hashes": [ "hashes": [
"sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952", "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952",
@ -402,11 +409,11 @@
}, },
"environs": { "environs": {
"hashes": [ "hashes": [
"sha256:2291ce502c9e61b8e208c8c9be4ac474e0f523c4dc23e0beb23118086e43b324", "sha256:54099cfbdd9cb320f438bf29992969ccfd5e232ba068bd650b04d76d96001631",
"sha256:44700c562fb6f783640f90c2225d9a80d85d24833b4dd02d20b8ff1c83901e47" "sha256:9578ce00ead984124a5336e5ea073707df303dc19d3b1e7ba34cdce1bb4fe02f"
], ],
"index": "pypi", "index": "pypi",
"version": "==7.2.0" "version": "==7.3.0"
}, },
"first": { "first": {
"hashes": [ "hashes": [
@ -956,12 +963,20 @@
], ],
"version": "==0.34.2" "version": "==0.34.2"
}, },
"yapf": {
"hashes": [
"sha256:712e23c468506bf12cadd10169f852572ecc61b266258422d45aaf4ad7ef43de",
"sha256:cad8a272c6001b3401de3278238fdc54997b6c2e56baa751788915f879a52fca"
],
"index": "pypi",
"version": "==0.29.0"
},
"zipp": { "zipp": {
"hashes": [ "hashes": [
"sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2", "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
"sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a" "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
], ],
"version": "==3.0.0" "version": "==3.1.0"
} }
} }
} }

69
dockertidy/Autostop.py Normal file
View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Stop long running docker iamges."""
import dateutil.parser
import docker
import docker.errors
import requests.exceptions
from dockertidy.Config import SingleConfig
from dockertidy.Logger import SingleLog
from dockertidy.Parser import timedelta
class AutoStop:
def __init__(self):
self.config = SingleConfig()
self.log = SingleLog()
self.logger = SingleLog().logger
self.docker = self._get_docker_client()
def stop_containers(self):
client = self.docker
config = self.config.config
max_run_time = timedelta(config["stop"]["max_run_time"])
prefix = config["stop"]["prefix"]
dry_run = config["dry_run"]
matcher = self._build_container_matcher(prefix)
for container_summary in client.containers():
container = client.inspect_container(container_summary["Id"])
name = container["Name"].lstrip("/")
if (matcher(name) and self._has_been_running_since(container, max_run_time)):
self.logger.info(
"Stopping container %s %s: running since %s" %
(container["Id"][:16], name, container["State"]["StartedAt"])
)
if not dry_run:
self._stop_container(client, container["Id"])
def _stop_container(self, client, cid):
try:
client.stop(cid)
except requests.exceptions.Timeout as e:
self.logger.warn("Failed to stop container %s: %s" % (cid, e))
except docker.errors.APIError as ae:
self.logger.warn("Error stopping %s: %s" % (cid, ae))
def _build_container_matcher(self, prefixes):
def matcher(name):
return any(name.startswith(prefix) for prefix in prefixes)
return matcher
def _has_been_running_since(self, container, min_time):
started_at = container.get("State", {}).get("StartedAt")
if not started_at:
return False
return dateutil.parser.parse(started_at) <= min_time
def _get_docker_client(self):
config = self.config.config
return docker.APIClient(version="auto", timeout=config["timeout"])

View File

@ -2,18 +2,12 @@
"""Entrypoint and CLI handler.""" """Entrypoint and CLI handler."""
import argparse import argparse
import logging
import os
import sys
from importlib_metadata import PackageNotFoundError
from importlib_metadata import version
import dockertidy.Exception import dockertidy.Exception
from dockertidy import __version__ from dockertidy import __version__
from dockertidy.Config import SingleConfig from dockertidy.Config import SingleConfig
from dockertidy.Utils import SingleLog from dockertidy.Logger import SingleLog
from dockertidy.Utils import timedelta_type from dockertidy.Parser import timedelta_validator
class DockerTidy: class DockerTidy:
@ -31,53 +25,97 @@ class DockerTidy:
:return: args objec :return: args objec
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Generate documentation from annotated Ansible roles using templates") description="Generate documentation from annotated Ansible roles using templates"
parser.add_argument("-v", dest="logging.level", action="append_const", const=-1, )
help="increase log level") parser.add_argument(
parser.add_argument("-q", dest="logging.level", action="append_const", "--dry-run",
const=1, help="decrease log level") action="store_true",
parser.add_argument("--version", action="version", dest="dry_run",
version="%(prog)s {}".format(__version__)) help="Only log actions, don't stop anything."
)
parser.add_argument(
"-t",
"--timeout",
type=int,
dest="http_timeout",
metavar="HTTP_TIMEOUT",
help="HTTP timeout in seconds for making docker API calls."
)
parser.add_argument(
"-v", dest="logging.level", action="append_const", const=-1, help="increase log level"
)
parser.add_argument(
"-q", dest="logging.level", action="append_const", const=1, help="decrease log level"
)
parser.add_argument(
"--version", action="version", version="%(prog)s {}".format(__version__)
)
subparsers = parser.add_subparsers(help="sub-command help") subparsers = parser.add_subparsers(help="sub-command help")
parser_gc = subparsers.add_parser( parser_gc = subparsers.add_parser("gc", help="Run docker garbage collector.")
"gc", help="Run docker garbage collector.")
parser_gc.add_argument( parser_gc.add_argument(
"--max-container-age", "--max-container-age",
type=timedelta_type, type=timedelta_validator,
dest="gc.max_container_age",
metavar="MAX_CONTAINER_AGE",
help="Maximum age for a container. Containers older than this age " help="Maximum age for a container. Containers older than this age "
"will be removed. Age can be specified in any pytimeparse " "will be removed. Age can be specified in any pytimeparse "
"supported format.") "supported format."
)
parser_gc.add_argument( parser_gc.add_argument(
"--max-image-age", "--max-image-age",
type=timedelta_type, type=timedelta_validator,
dest="gc.max_image_age",
metavar="MAX_IMAGE_AGE",
help="Maxium age for an image. Images older than this age will be " help="Maxium age for an image. Images older than this age will be "
"removed. Age can be specified in any pytimeparse supported " "removed. Age can be specified in any pytimeparse supported "
"format.") "format."
)
parser_gc.add_argument( parser_gc.add_argument(
"--dangling-volumes", "--dangling-volumes",
action="store_true", action="store_true",
help="Dangling volumes will be removed.") dest="gc.dangling_volumes",
parser_gc.add_argument( help="Dangling volumes will be removed."
"--dry-run", action="store_true", )
help="Only log actions, don't remove anything.")
parser_gc.add_argument(
"-t", "--timeout", type=int, default=60,
help="HTTP timeout in seconds for making docker API calls.")
parser_gc.add_argument( parser_gc.add_argument(
"--exclude-image", "--exclude-image",
action="append", action="append",
help="Never remove images with this tag.") type=str,
parser_gc.add_argument( dest="gc.exclude_image",
"--exclude-image-file", metavar="EXCLUDE_IMAGE",
type=argparse.FileType("r"), help="Never remove images with this tag."
help="Path to a file which contains a list of images to exclude, one " )
"image tag per line.")
parser_gc.add_argument( parser_gc.add_argument(
"--exclude-container-label", "--exclude-container-label",
action="append", type=str, default=[], action="append",
help="Never remove containers with this label key or label key=value") type=str,
dest="gc.exclude_container_label",
metavar="EXCLUDE_CONTAINER_LABEL",
help="Never remove containers with this label key "
"or label key=value"
)
parser_stop = subparsers.add_parser(
"stop", help="Stop containers that have been running for too long."
)
parser_stop.add_argument(
"--max-run-time",
type=timedelta_validator,
dest="stop.max_run_time",
metavar="MAX_RUN_TIME",
help="Maximum time a container is allows to run. Time may "
"be specified in any pytimeparse supported format."
)
parser_stop.add_argument(
"--prefix",
action="append",
type=str,
dest="stop.prefix",
metavar="PREFIX",
help="Only stop containers which match one of the "
"prefix."
)
return parser.parse_args().__dict__ return parser.parse_args().__dict__
@ -87,11 +125,12 @@ class DockerTidy:
except dockertidy.Exception.ConfigError as e: except dockertidy.Exception.ConfigError as e:
self.log.sysexit_with_message(e) self.log.sysexit_with_message(e)
print(config.config)
try: try:
self.log.set_level(config.config["logging"]["level"]) self.log.set_level(config.config["logging"]["level"])
except ValueError as e: except ValueError as e:
self.log.sysexit_with_message( self.log.sysexit_with_message("Can not set log level.\n{}".format(str(e)))
"Can not set log level.\n{}".format(str(e)))
self.logger.info("Using config file {}".format(config.config_file)) self.logger.info("Using config file {}".format(config.config_file))

View File

@ -1,9 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Global settings definition.""" """Global settings definition."""
import logging
import os import os
import sys
import anyconfig import anyconfig
import environs import environs
@ -11,9 +9,10 @@ import jsonschema.exceptions
import ruamel.yaml import ruamel.yaml
from appdirs import AppDirs from appdirs import AppDirs
from jsonschema._utils import format_as_index from jsonschema._utils import format_as_index
from pkg_resources import resource_filename
import dockertidy.Exception import dockertidy.Exception
import dockertidy.Parser
from dockertidy.Parser import env
from dockertidy.Utils import Singleton from dockertidy.Utils import Singleton
config_dir = AppDirs("docker-tidy").user_config_dir config_dir = AppDirs("docker-tidy").user_config_dir
@ -26,7 +25,8 @@ class Config():
Settings are loade from multiple locations in defined order (last wins): Settings are loade from multiple locations in defined order (last wins):
- default settings defined by `self._get_defaults()` - default settings defined by `self._get_defaults()`
- yaml config file, defaults to OS specific user config dir (https://pypi.org/project/appdirs/) - yaml config file, defaults to OS specific user config dir
see (https://pypi.org/project/appdirs/)
- provides cli parameters - provides cli parameters
""" """
@ -36,22 +36,18 @@ class Config():
"env": "CONFIG_FILE", "env": "CONFIG_FILE",
"type": environs.Env().str "type": environs.Env().str
}, },
"role_dir": {
"default": "",
"env": "ROLE_DIR",
"type": environs.Env().str
},
"role_name": {
"default": "",
"env": "ROLE_NAME",
"type": environs.Env().str
},
"dry_run": { "dry_run": {
"default": False, "default": False,
"env": "DRY_RUN", "env": "DRY_TUN",
"file": True, "file": True,
"type": environs.Env().bool "type": environs.Env().bool
}, },
"http_timeout": {
"default": 60,
"env": "HTTP_TIMEOUT",
"file": True,
"type": environs.Env().int
},
"logging.level": { "logging.level": {
"default": "WARNING", "default": "WARNING",
"env": "LOG_LEVEL", "env": "LOG_LEVEL",
@ -64,73 +60,47 @@ class Config():
"file": True, "file": True,
"type": environs.Env().bool "type": environs.Env().bool
}, },
"output_dir": { "gc.max_container_age": {
"default": os.getcwd(), "default": "1day",
"env": "OUTPUT_DIR", "env": "GC_MAX_CONTAINER_AGE",
"file": True, "file": True,
"type": environs.Env().str "type": env.timedelta_validator
}, },
"template_dir": { "gc.max_image_age": {
"default": os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates"), "default": "1day",
"env": "TEMPLATE_DIR", "env": "GC_MAX_IMAGE_AGE",
"file": True, "file": True,
"type": environs.Env().str "type": env.timedelta_validator
}, },
"template": { "gc.dangling_volumes": {
"default": "readme",
"env": "TEMPLATE",
"file": True,
"type": environs.Env().str
},
"force_overwrite": {
"default": False, "default": False,
"env": "FORCE_OVERWRITE", "env": "GC_EXCLUDE_IMAGE",
"file": True, "file": True,
"type": environs.Env().bool "type": environs.Env().bool
}, },
"custom_header": { "gc.exclude_image": {
"default": "",
"env": "CUSTOM_HEADER",
"file": True,
"type": environs.Env().str
},
"exclude_files": {
"default": [], "default": [],
"env": "EXCLUDE_FILES", "env": "GC_DANGLING_VOLUMES",
"file": True, "file": True,
"type": environs.Env().list "type": environs.Env().list
}, },
} "gc.exclude_container_label": {
"default": [],
ANNOTATIONS = { "env": "GC_EXCLUDE_CONTAINER_LABEL",
"meta": { "file": True,
"name": "meta", "type": environs.Env().list
"automatic": True,
"subtypes": []
}, },
"todo": { "stop.max_run_time": {
"name": "todo", "default": "3days",
"automatic": True, "env": "STOP_MAX_RUN_TIME",
"subtypes": [] "file": True,
"type": env.timedelta_validator
}, },
"var": { "stop.prefix": {
"name": "var", "default": [],
"automatic": True, "env": "STOP_PREFIX",
"subtypes": [ "file": True,
"value", "type": environs.Env().list
"example",
"description"
]
},
"example": {
"name": "example",
"automatic": True,
"subtypes": []
},
"tag": {
"name": "tag",
"automatic": True,
"subtypes": []
}, },
} }
@ -146,10 +116,8 @@ class Config():
self._args = args self._args = args
self._schema = None self._schema = None
self.config_file = default_config_file self.config_file = default_config_file
self.role_dir = os.getcwd()
self.config = None self.config = None
self._set_config() self._set_config()
self.is_role = self._set_is_role() or False
def _get_args(self, args): def _get_args(self, args):
cleaned = dict(filter(lambda item: item[1] is not None, args.items())) cleaned = dict(filter(lambda item: item[1] is not None, args.items()))
@ -173,9 +141,6 @@ class Config():
for key, item in self.SETTINGS.items(): for key, item in self.SETTINGS.items():
normalized = self._add_dict_branch(normalized, key.split("."), item["default"]) normalized = self._add_dict_branch(normalized, key.split("."), item["default"])
# compute role_name default
normalized["role_name"] = os.path.basename(self.role_dir)
self.schema = anyconfig.gen_schema(normalized) self.schema = anyconfig.gen_schema(normalized)
return normalized return normalized
@ -183,7 +148,7 @@ class Config():
normalized = {} normalized = {}
for key, item in self.SETTINGS.items(): for key, item in self.SETTINGS.items():
if item.get("env"): if item.get("env"):
prefix = "ANSIBLE_DOCTOR_" prefix = "TIDY_"
envname = prefix + item["env"] envname = prefix + item["env"]
try: try:
value = item["type"](envname) value = item["type"](envname)
@ -192,7 +157,9 @@ class Config():
if '"{}" not set'.format(envname) in str(e): if '"{}" not set'.format(envname) in str(e):
pass pass
else: else:
raise dockertidy.Exception.ConfigError("Unable to read environment variable", str(e)) raise dockertidy.Exception.ConfigError(
"Unable to read environment variable", str(e)
)
return normalized return normalized
@ -204,13 +171,9 @@ class Config():
# preset config file path # preset config file path
if envs.get("config_file"): if envs.get("config_file"):
self.config_file = self._normalize_path(envs.get("config_file")) self.config_file = self._normalize_path(envs.get("config_file"))
if envs.get("role_dir"):
self.role_dir = self._normalize_path(envs.get("role_dir"))
if args.get("config_file"): if args.get("config_file"):
self.config_file = self._normalize_path(args.get("config_file")) self.config_file = self._normalize_path(args.get("config_file"))
if args.get("role_dir"):
self.role_dir = self._normalize_path(args.get("role_dir"))
source_files = [] source_files = []
source_files.append(self.config_file) source_files.append(self.config_file)
@ -224,7 +187,9 @@ class Config():
s = stream.read() s = stream.read()
try: try:
file_dict = ruamel.yaml.safe_load(s) file_dict = ruamel.yaml.safe_load(s)
except (ruamel.yaml.composer.ComposerError, ruamel.yaml.scanner.ScannerError) as e: except (
ruamel.yaml.composer.ComposerError, ruamel.yaml.scanner.ScannerError
) as e:
message = "{} {}".format(e.context, e.problem) message = "{} {}".format(e.context, e.problem)
raise dockertidy.Exception.ConfigError( raise dockertidy.Exception.ConfigError(
"Unable to read config file {}".format(config), message "Unable to read config file {}".format(config), message
@ -240,15 +205,8 @@ class Config():
if self._validate(args): if self._validate(args):
anyconfig.merge(defaults, args, ac_merge=anyconfig.MS_DICTS) anyconfig.merge(defaults, args, ac_merge=anyconfig.MS_DICTS)
fix_files = ["output_dir", "template_dir", "custom_header"]
for file in fix_files:
if defaults[file] and defaults[file] != "":
defaults[file] = self._normalize_path(defaults[file])
if "config_file" in defaults: if "config_file" in defaults:
defaults.pop("config_file") defaults.pop("config_file")
if "role_dir" in defaults:
defaults.pop("role_dir")
defaults["logging"]["level"] = defaults["logging"]["level"].upper() defaults["logging"]["level"] = defaults["logging"]["level"].upper()
@ -261,10 +219,6 @@ class Config():
else: else:
return path return path
def _set_is_role(self):
if os.path.isdir(os.path.join(self.role_dir, "tasks")):
return True
def _validate(self, config): def _validate(self, config):
try: try:
anyconfig.validate(config, self.schema, ac_schema_safe=False) anyconfig.validate(config, self.schema, ac_schema_safe=False)
@ -285,32 +239,6 @@ class Config():
else self._add_dict_branch(tree[key] if key in tree else {}, vector[1:], value) else self._add_dict_branch(tree[key] if key in tree else {}, vector[1:], value)
return tree return tree
def get_annotations_definition(self, automatic=True):
annotations = {}
if automatic:
for k, item in self.ANNOTATIONS.items():
if "automatic" in item.keys() and item["automatic"]:
annotations[k] = item
return annotations
def get_annotations_names(self, automatic=True):
annotations = []
if automatic:
for k, item in self.ANNOTATIONS.items():
if "automatic" in item.keys() and item["automatic"]:
annotations.append(k)
return annotations
def get_template(self):
"""
Get the base dir for the template to use.
:return: str abs path
"""
template_dir = self.config.get("template_dir")
template = self.config.get("template")
return os.path.realpath(os.path.join(template_dir, template))
class SingleConfig(Config, metaclass=Singleton): class SingleConfig(Config, metaclass=Singleton):
pass pass

View File

@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Remove unused docker containers and images.""" """Remove unused docker containers and images."""
import argparse
import fnmatch import fnmatch
import logging import logging
import sys import sys
@ -12,10 +11,9 @@ import docker
import docker.errors import docker.errors
import requests.exceptions import requests.exceptions
from docker.utils import kwargs_from_env from docker.utils import kwargs_from_env
from docker_custodian.args import timedelta_type
log = logging.getLogger(__name__)
from dockertidy.Config import SingleConfig
from dockertidy.Logger import SingleLog
# This seems to be something docker uses for a null/zero date # This seems to be something docker uses for a null/zero date
YEAR_ZERO = "0001-01-01T00:00:00Z" YEAR_ZERO = "0001-01-01T00:00:00Z"
@ -23,12 +21,15 @@ YEAR_ZERO = "0001-01-01T00:00:00Z"
ExcludeLabel = namedtuple("ExcludeLabel", ["key", "value"]) ExcludeLabel = namedtuple("ExcludeLabel", ["key", "value"])
def cleanup_containers( class GarbageCollector:
client,
max_container_age, def __init__(self):
dry_run, self.config = SingleConfig()
exclude_container_labels, self.log = SingleLog()
): self.logger = SingleLog().logger
def cleanup_containers(client, max_container_age, dry_run, exclude_container_labels):
all_containers = get_all_containers(client) all_containers = get_all_containers(client)
filtered_containers = filter_excluded_containers( filtered_containers = filter_excluded_containers(
all_containers, all_containers,
@ -45,10 +46,8 @@ def cleanup_containers(
): ):
continue continue
log.info("Removing container %s %s %s" % ( log.info("Removing container %s %s %s" % (container["Id"][:16], container.get(
container["Id"][:16], "Name", "").lstrip("/"), container["State"]["FinishedAt"]))
container.get("Name", "").lstrip("/"),
container["State"]["FinishedAt"]))
if not dry_run: if not dry_run:
api_call( api_call(
@ -69,6 +68,7 @@ def filter_excluded_containers(containers, exclude_container_labels):
): ):
return False return False
return True return True
return filter(include_container, containers) return filter(include_container, containers)
@ -81,16 +81,12 @@ def should_exclude_container_with_labels(container, exclude_container_labels):
exclude_label.key, exclude_label.key,
) )
label_values_to_check = [ label_values_to_check = [
container["Labels"][matching_key] container["Labels"][matching_key] for matching_key in matching_keys
for matching_key in matching_keys
] ]
if fnmatch.filter(label_values_to_check, exclude_label.value): if fnmatch.filter(label_values_to_check, exclude_label.value):
return True return True
else: else:
if fnmatch.filter( if fnmatch.filter(container["Labels"].keys(), exclude_label.key):
container["Labels"].keys(),
exclude_label.key
):
return True return True
return False return False
@ -153,6 +149,7 @@ def cleanup_images(client, max_image_age, dry_run, exclude_set):
def filter_excluded_images(images, exclude_set): def filter_excluded_images(images, exclude_set):
def include_image(image_summary): def include_image(image_summary):
image_tags = image_summary.get("RepoTags") image_tags = image_summary.get("RepoTags")
if no_image_tags(image_tags): if no_image_tags(image_tags):
@ -166,6 +163,7 @@ def filter_excluded_images(images, exclude_set):
def filter_images_in_use(images, image_tags_in_use): def filter_images_in_use(images, image_tags_in_use):
def get_tag_set(image_summary): def get_tag_set(image_summary):
image_tags = image_summary.get("RepoTags") image_tags = image_summary.get("RepoTags")
if no_image_tags(image_tags): if no_image_tags(image_tags):
@ -180,6 +178,7 @@ def filter_images_in_use(images, image_tags_in_use):
def filter_images_in_use_by_id(images, image_ids_in_use): def filter_images_in_use_by_id(images, image_ids_in_use):
def image_not_in_use(image_summary): def image_not_in_use(image_summary):
return image_summary["Id"] not in image_ids_in_use return image_summary["Id"] not in image_ids_in_use
@ -245,6 +244,7 @@ def api_call(func, **kwargs):
def format_image(image, image_summary): def format_image(image, image_summary):
def get_tags(): def get_tags():
tags = image_summary.get("RepoTags") tags = image_summary.get("RepoTags")
if not tags or tags == ["<none>:<none>"]: if not tags or tags == ["<none>:<none>"]:
@ -275,29 +275,20 @@ def format_exclude_labels(exclude_label_args):
exclude_label_value = split_exclude_label[1] exclude_label_value = split_exclude_label[1]
else: else:
exclude_label_value = None exclude_label_value = None
exclude_labels.append( exclude_labels.append(ExcludeLabel(
ExcludeLabel(
key=exclude_label_key, key=exclude_label_key,
value=exclude_label_value, value=exclude_label_value,
) ))
)
return exclude_labels return exclude_labels
def main(): def main():
logging.basicConfig( logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
level=logging.INFO,
format="%(message)s",
stream=sys.stdout)
args = get_args() args = get_args()
client = docker.APIClient(version="auto", client = docker.APIClient(version="auto", timeout=args.timeout, **kwargs_from_env())
timeout=args.timeout,
**kwargs_from_env())
exclude_container_labels = format_exclude_labels( exclude_container_labels = format_exclude_labels(args.exclude_container_label)
args.exclude_container_label
)
if args.max_container_age: if args.max_container_age:
cleanup_containers( cleanup_containers(
@ -308,55 +299,8 @@ def main():
) )
if args.max_image_age: if args.max_image_age:
exclude_set = build_exclude_set( exclude_set = build_exclude_set(args.exclude_image, args.exclude_image_file)
args.exclude_image,
args.exclude_image_file)
cleanup_images(client, args.max_image_age, args.dry_run, exclude_set) cleanup_images(client, args.max_image_age, args.dry_run, exclude_set)
if args.dangling_volumes: if args.dangling_volumes:
cleanup_volumes(client, args.dry_run) cleanup_volumes(client, args.dry_run)
def get_args(args=None):
parser = argparse.ArgumentParser()
parser.add_argument(
"--max-container-age",
type=timedelta_type,
help="Maximum age for a container. Containers older than this age "
"will be removed. Age can be specified in any pytimeparse "
"supported format.")
parser.add_argument(
"--max-image-age",
type=timedelta_type,
help="Maxium age for an image. Images older than this age will be "
"removed. Age can be specified in any pytimeparse supported "
"format.")
parser.add_argument(
"--dangling-volumes",
action="store_true",
help="Dangling volumes will be removed.")
parser.add_argument(
"--dry-run", action="store_true",
help="Only log actions, don't remove anything.")
parser.add_argument(
"-t", "--timeout", type=int, default=60,
help="HTTP timeout in seconds for making docker API calls.")
parser.add_argument(
"--exclude-image",
action="append",
help="Never remove images with this tag.")
parser.add_argument(
"--exclude-image-file",
type=argparse.FileType("r"),
help="Path to a file which contains a list of images to exclude, one "
"image tag per line.")
parser.add_argument(
"--exclude-container-label",
action="append", type=str, default=[],
help="Never remove containers with this label key or label key=value")
return parser.parse_args(args=args)
if __name__ == "__main__":
main()

184
dockertidy/Logger.py Normal file
View File

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Global utility methods and classes."""
import logging
import os
import sys
import colorama
from pythonjsonlogger import jsonlogger
from dockertidy.Utils import Singleton
from dockertidy.Utils import to_bool
CONSOLE_FORMAT = "{}[%(levelname)s]{} %(message)s"
JSON_FORMAT = "(asctime) (levelname) (message)"
def _should_do_markup():
py_colors = os.environ.get("PY_COLORS", None)
if py_colors is not None:
return to_bool(py_colors)
return sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
colorama.init(autoreset=True, strip=not _should_do_markup())
class LogFilter(object):
"""A custom log filter which excludes log messages above the logged level."""
def __init__(self, level):
"""
Initialize a new custom log filter.
:param level: Log level limit
:returns: None
"""
self.__level = level
def filter(self, logRecord): # noqa
# https://docs.python.org/3/library/logging.html#logrecord-attributes
return logRecord.levelno <= self.__level
class MultilineFormatter(logging.Formatter):
"""Logging Formatter to reset color after newline characters."""
def format(self, record): # noqa
record.msg = record.msg.replace("\n", "\n{}... ".format(colorama.Style.RESET_ALL))
return logging.Formatter.format(self, record)
class MultilineJsonFormatter(jsonlogger.JsonFormatter):
"""Logging Formatter to remove newline characters."""
def format(self, record): # noqa
record.msg = record.msg.replace("\n", " ")
return jsonlogger.JsonFormatter.format(self, record)
class Log:
def __init__(self, level=logging.WARN, name="dockertidy", json=False):
self.logger = logging.getLogger(name)
self.logger.setLevel(level)
self.logger.addHandler(self._get_error_handler(json=json))
self.logger.addHandler(self._get_warn_handler(json=json))
self.logger.addHandler(self._get_info_handler(json=json))
self.logger.addHandler(self._get_critical_handler(json=json))
self.logger.addHandler(self._get_debug_handler(json=json))
self.logger.propagate = False
def _get_error_handler(self, json=False):
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.ERROR)
handler.addFilter(LogFilter(logging.ERROR))
handler.setFormatter(
MultilineFormatter(
self.error(CONSOLE_FORMAT.format(colorama.Fore.RED, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_warn_handler(self, json=False):
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.WARN)
handler.addFilter(LogFilter(logging.WARN))
handler.setFormatter(
MultilineFormatter(
self.warn(CONSOLE_FORMAT.format(colorama.Fore.YELLOW, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_info_handler(self, json=False):
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.INFO)
handler.addFilter(LogFilter(logging.INFO))
handler.setFormatter(
MultilineFormatter(
self.info(CONSOLE_FORMAT.format(colorama.Fore.CYAN, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_critical_handler(self, json=False):
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.CRITICAL)
handler.addFilter(LogFilter(logging.CRITICAL))
handler.setFormatter(
MultilineFormatter(
self.critical(CONSOLE_FORMAT.format(colorama.Fore.RED, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_debug_handler(self, json=False):
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)
handler.addFilter(LogFilter(logging.DEBUG))
handler.setFormatter(
MultilineFormatter(
self.critical(CONSOLE_FORMAT.format(colorama.Fore.BLUE,
colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def set_level(self, s):
self.logger.setLevel(s)
def debug(self, msg):
"""Format info messages and return string."""
return msg
def critical(self, msg):
"""Format critical messages and return string."""
return msg
def error(self, msg):
"""Format error messages and return string."""
return msg
def warn(self, msg):
"""Format warn messages and return string."""
return msg
def info(self, msg):
"""Format info messages and return string."""
return msg
def _color_text(self, color, msg):
"""
Colorize strings.
:param color: colorama color settings
:param msg: string to colorize
:returns: string
"""
return "{}{}{}".format(color, msg, colorama.Style.RESET_ALL)
def sysexit(self, code=1):
sys.exit(code)
def sysexit_with_message(self, msg, code=1):
self.logger.critical(str(msg))
self.sysexit(code)
class SingleLog(Log, metaclass=Singleton):
pass

51
dockertidy/Parser.py Normal file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Custom input type parser."""
import datetime
import environs
from dateutil import tz
from pytimeparse import timeparse
env = environs.Env()
def timedelta_validator(value):
"""Return the :class:`datetime.datetime.DateTime` for a time in the past.
:param value: a string containing a time format supported by
mod:`pytimeparse`
"""
if value is None:
return None
try:
_datetime_seconds_ago(timeparse.timeparse(value))
return value
except TypeError:
raise
def timedelta(value):
"""Return the :class:`datetime.datetime.DateTime` for a time in the past.
:param value: a string containing a time format supported by
mod:`pytimeparse`
"""
if value is None:
return None
return _datetime_seconds_ago(timeparse.timeparse(value))
def _datetime_seconds_ago(seconds):
now = datetime.datetime.now(tz.tzutc())
return now - datetime.timedelta(seconds=seconds)
@env.parser_for("timedelta_validator")
def timedelta_parser(value):
try:
timedelta_validator(value)
return value
except TypeError as e:
raise environs.EnvError(e)

View File

@ -1,55 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Global utility methods and classes.""" """Global utility methods and classes."""
import datetime
import logging
import os
import pprint
import sys
from distutils.util import strtobool from distutils.util import strtobool
import colorama
from dateutil import tz
from pythonjsonlogger import jsonlogger
from pytimeparse import timeparse
import dockertidy.Exception
CONSOLE_FORMAT = "{}[%(levelname)s]{} %(message)s"
JSON_FORMAT = "(asctime) (levelname) (message)"
def to_bool(string): def to_bool(string):
return bool(strtobool(str(string))) return bool(strtobool(str(string)))
def timedelta_type(value):
"""Return the :class:`datetime.datetime.DateTime` for a time in the past.
:param value: a string containing a time format supported by
mod:`pytimeparse`
"""
if value is None:
return None
return _datetime_seconds_ago(timeparse.timeparse(value))
def _datetime_seconds_ago(seconds):
now = datetime.datetime.now(tz.tzutc())
return now - datetime.timedelta(seconds=seconds)
def _should_do_markup():
py_colors = os.environ.get("PY_COLORS", None)
if py_colors is not None:
return to_bool(py_colors)
return sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
colorama.init(autoreset=True, strip=not _should_do_markup())
class Singleton(type): class Singleton(type):
_instances = {} _instances = {}
@ -57,183 +15,3 @@ class Singleton(type):
if cls not in cls._instances: if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls] return cls._instances[cls]
class LogFilter(object):
"""A custom log filter which excludes log messages above the logged level."""
def __init__(self, level):
"""
Initialize a new custom log filter.
:param level: Log level limit
:returns: None
"""
self.__level = level
def filter(self, logRecord): # noqa
# https://docs.python.org/3/library/logging.html#logrecord-attributes
return logRecord.levelno <= self.__level
class MultilineFormatter(logging.Formatter):
"""Logging Formatter to reset color after newline characters."""
def format(self, record): # noqa
record.msg = record.msg.replace("\n", "\n{}... ".format(colorama.Style.RESET_ALL))
return logging.Formatter.format(self, record)
class MultilineJsonFormatter(jsonlogger.JsonFormatter):
"""Logging Formatter to remove newline characters."""
def format(self, record): # noqa
record.msg = record.msg.replace("\n", " ")
return jsonlogger.JsonFormatter.format(self, record)
class Log:
def __init__(self, level=logging.WARN, name="dockertidy", json=False):
self.logger = logging.getLogger(name)
self.logger.setLevel(level)
self.logger.addHandler(self._get_error_handler(json=json))
self.logger.addHandler(self._get_warn_handler(json=json))
self.logger.addHandler(self._get_info_handler(json=json))
self.logger.addHandler(self._get_critical_handler(json=json))
self.logger.addHandler(self._get_debug_handler(json=json))
self.logger.propagate = False
def _get_error_handler(self, json=False):
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.ERROR)
handler.addFilter(LogFilter(logging.ERROR))
handler.setFormatter(MultilineFormatter(
self.error(CONSOLE_FORMAT.format(colorama.Fore.RED, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_warn_handler(self, json=False):
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.WARN)
handler.addFilter(LogFilter(logging.WARN))
handler.setFormatter(MultilineFormatter(
self.warn(CONSOLE_FORMAT.format(colorama.Fore.YELLOW, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_info_handler(self, json=False):
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.INFO)
handler.addFilter(LogFilter(logging.INFO))
handler.setFormatter(MultilineFormatter(
self.info(CONSOLE_FORMAT.format(colorama.Fore.CYAN, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_critical_handler(self, json=False):
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.CRITICAL)
handler.addFilter(LogFilter(logging.CRITICAL))
handler.setFormatter(MultilineFormatter(
self.critical(CONSOLE_FORMAT.format(colorama.Fore.RED, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def _get_debug_handler(self, json=False):
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)
handler.addFilter(LogFilter(logging.DEBUG))
handler.setFormatter(MultilineFormatter(
self.critical(CONSOLE_FORMAT.format(colorama.Fore.BLUE, colorama.Style.RESET_ALL))))
if json:
handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
return handler
def set_level(self, s):
self.logger.setLevel(s)
def debug(self, msg):
"""Format info messages and return string."""
return msg
def critical(self, msg):
"""Format critical messages and return string."""
return msg
def error(self, msg):
"""Format error messages and return string."""
return msg
def warn(self, msg):
"""Format warn messages and return string."""
return msg
def info(self, msg):
"""Format info messages and return string."""
return msg
def _color_text(self, color, msg):
"""
Colorize strings.
:param color: colorama color settings
:param msg: string to colorize
:returns: string
"""
return "{}{}{}".format(color, msg, colorama.Style.RESET_ALL)
def sysexit(self, code=1):
sys.exit(code)
def sysexit_with_message(self, msg, code=1):
self.logger.critical(str(msg))
self.sysexit(code)
class SingleLog(Log, metaclass=Singleton):
pass
class FileUtils:
@staticmethod
def create_path(path):
os.makedirs(path, exist_ok=True)
@staticmethod
def query_yes_no(question, default=True):
"""Ask a yes/no question via input() and return their answer.
"question" is a string that is presented to the user.
"default" is the presumed answer if the user just hits <Enter>.
It must be "yes" (the default), "no" or None (meaning
an answer is required of the user).
The "answer" return value is one of "yes" or "no".
"""
if default:
prompt = "[Y/n]"
else:
prompt = "[N/y]"
try:
# input() is safe in python3
choice = input("{} {} ".format(question, prompt)) or default # nosec
return to_bool(choice)
except (KeyboardInterrupt, ValueError) as e:
raise dockertidy.Exception.InputError("Error while reading input", e)

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Default package.""" """Default package."""
from importlib_metadata import PackageNotFoundError
from importlib_metadata import version from importlib_metadata import version
__author__ = "Robert Kaussow" __author__ = "Robert Kaussow"

View File

@ -1,20 +0,0 @@
import datetime
from dateutil import tz
from pytimeparse import timeparse
def timedelta_type(value):
"""Return the :class:`datetime.datetime.DateTime` for a time in the past.
:param value: a string containing a time format supported by
mod:`pytimeparse`
"""
if value is None:
return None
return datetime_seconds_ago(timeparse.timeparse(value))
def datetime_seconds_ago(seconds):
now = datetime.datetime.now(tz.tzutc())
return now - datetime.timedelta(seconds=seconds)

View File

@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""Stop long running docker iamges."""
import argparse
import logging
import sys
import dateutil.parser
import docker
import docker.errors
import requests.exceptions
from docker.utils import kwargs_from_env
from docker_custodian.args import timedelta_type
log = logging.getLogger(__name__)
def stop_containers(client, max_run_time, matcher, dry_run):
for container_summary in client.containers():
container = client.inspect_container(container_summary["Id"])
name = container["Name"].lstrip("/")
if (
matcher(name) and has_been_running_since(container, max_run_time)
):
log.info("Stopping container %s %s: running since %s" % (
container["Id"][:16],
name,
container["State"]["StartedAt"]))
if not dry_run:
stop_container(client, container["Id"])
def stop_container(client, cid):
try:
client.stop(cid)
except requests.exceptions.Timeout as e:
log.warn("Failed to stop container %s: %s" % (cid, e))
except docker.errors.APIError as ae:
log.warn("Error stopping %s: %s" % (cid, ae))
def build_container_matcher(prefixes):
def matcher(name):
return any(name.startswith(prefix) for prefix in prefixes)
return matcher
def has_been_running_since(container, min_time):
started_at = container.get("State", {}).get("StartedAt")
if not started_at:
return False
return dateutil.parser.parse(started_at) <= min_time
def main():
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
stream=sys.stdout)
opts = get_opts()
client = docker.APIClient(version="auto",
timeout=opts.timeout,
**kwargs_from_env())
matcher = build_container_matcher(opts.prefix)
stop_containers(client, opts.max_run_time, matcher, opts.dry_run)
def get_opts(args=None):
parser = argparse.ArgumentParser()
parser.add_argument(
"--max-run-time",
type=timedelta_type,
help="Maximum time a container is allows to run. Time may "
"be specified in any pytimeparse supported format."
)
parser.add_argument(
"--prefix", action="append", default=[],
help="Only stop containers which match one of the "
"prefix."
)
parser.add_argument(
"--dry-run", action="store_true",
help="Only log actions, don't stop anything."
)
parser.add_argument(
"-t", "--timeout", type=int, default=60,
help="HTTP timeout in seconds for making docker API calls."
)
opts = parser.parse_args(args=args)
if not opts.prefix:
parser.error("Running with no --prefix will match nothing.")
return opts
if __name__ == "__main__":
main()

View File

@ -9,8 +9,6 @@ except ImportError:
import mock import mock
def test_datetime_seconds_ago(now): def test_datetime_seconds_ago(now):
expected = datetime.datetime(2014, 1, 15, 10, 10, tzinfo=tz.tzutc()) expected = datetime.datetime(2014, 1, 15, 10, 10, tzinfo=tz.tzutc())
with mock.patch( with mock.patch(

View File

@ -45,27 +45,16 @@ def test_has_been_running_since_false(container, earlier_time):
assert not has_been_running_since(container, earlier_time) assert not has_been_running_since(container, earlier_time)
@mock.patch('docker_custodian.docker_autostop.build_container_matcher', @mock.patch('docker_custodian.docker_autostop.build_container_matcher', autospec=True)
autospec=True) @mock.patch('docker_custodian.docker_autostop.stop_containers', autospec=True)
@mock.patch('docker_custodian.docker_autostop.stop_containers', @mock.patch('docker_custodian.docker_autostop.get_opts', autospec=True)
autospec=True)
@mock.patch('docker_custodian.docker_autostop.get_opts',
autospec=True)
@mock.patch('docker_custodian.docker_autostop.docker', autospec=True) @mock.patch('docker_custodian.docker_autostop.docker', autospec=True)
def test_main( def test_main(mock_docker, mock_get_opts, mock_stop_containers, mock_build_matcher):
mock_docker,
mock_get_opts,
mock_stop_containers,
mock_build_matcher
):
mock_get_opts.return_value.timeout = 30 mock_get_opts.return_value.timeout = 30
main() main()
mock_get_opts.assert_called_once_with() mock_get_opts.assert_called_once_with()
mock_build_matcher.assert_called_once_with( mock_build_matcher.assert_called_once_with(mock_get_opts.return_value.prefix)
mock_get_opts.return_value.prefix) mock_stop_containers.assert_called_once_with(mock.ANY, mock_get_opts.return_value.max_run_time,
mock_stop_containers.assert_called_once_with(
mock.ANY,
mock_get_opts.return_value.max_run_time,
mock_build_matcher.return_value, mock_build_matcher.return_value,
mock_get_opts.return_value.dry_run) mock_get_opts.return_value.dry_run)
@ -79,10 +68,8 @@ def test_get_opts_with_defaults():
def test_get_opts_with_args(now): def test_get_opts_with_args(now):
with mock.patch( with mock.patch('docker_custodian.docker_autostop.timedelta_type',
'docker_custodian.docker_autostop.timedelta_type', autospec=True) as mock_timedelta_type:
autospec=True
) as mock_timedelta_type:
opts = get_opts(args=['--prefix', 'one', '--max-run-time', '24h']) opts = get_opts(args=['--prefix', 'one', '--max-run-time', '24h'])
assert opts.max_run_time == mock_timedelta_type.return_value assert opts.max_run_time == mock_timedelta_type.return_value
mock_timedelta_type.assert_called_once_with('24h') mock_timedelta_type.assert_called_once_with('24h')

View File

@ -11,7 +11,6 @@ except ImportError:
import mock import mock
class TestShouldRemoveContainer(object): class TestShouldRemoveContainer(object):
def test_is_running(self, container, now): def test_is_running(self, container, now):
@ -43,8 +42,12 @@ class TestShouldRemoveContainer(object):
def test_cleanup_containers(mock_client, now): def test_cleanup_containers(mock_client, now):
max_container_age = now max_container_age = now
mock_client.containers.return_value = [ mock_client.containers.return_value = [
{'Id': 'abcd'}, {
{'Id': 'abbb'}, 'Id': 'abcd'
},
{
'Id': 'abbb'
},
] ]
mock_containers = [ mock_containers = [
{ {
@ -66,17 +69,34 @@ def test_cleanup_containers(mock_client, now):
] ]
mock_client.inspect_container.side_effect = iter(mock_containers) mock_client.inspect_container.side_effect = iter(mock_containers)
docker_gc.cleanup_containers(mock_client, max_container_age, False, None) docker_gc.cleanup_containers(mock_client, max_container_age, False, None)
mock_client.remove_container.assert_called_once_with(container='abcd', mock_client.remove_container.assert_called_once_with(container='abcd', v=True)
v=True)
def test_filter_excluded_containers(): def test_filter_excluded_containers():
mock_containers = [ mock_containers = [
{'Labels': {'toot': ''}}, {
{'Labels': {'too': 'lol'}}, 'Labels': {
{'Labels': {'toots': 'lol'}}, 'toot': ''
{'Labels': {'foo': 'bar'}}, }
{'Labels': None}, },
{
'Labels': {
'too': 'lol'
}
},
{
'Labels': {
'toots': 'lol'
}
},
{
'Labels': {
'foo': 'bar'
}
},
{
'Labels': None
},
] ]
result = docker_gc.filter_excluded_containers(mock_containers, None) result = docker_gc.filter_excluded_containers(mock_containers, None)
assert mock_containers == list(result) assert mock_containers == list(result)
@ -88,11 +108,7 @@ def test_filter_excluded_containers():
mock_containers, mock_containers,
exclude_labels, exclude_labels,
) )
assert [ assert [mock_containers[0], mock_containers[2], mock_containers[4]] == list(result)
mock_containers[0],
mock_containers[2],
mock_containers[4]
] == list(result)
exclude_labels = [ exclude_labels = [
docker_gc.ExcludeLabel(key='too*', value='lol'), docker_gc.ExcludeLabel(key='too*', value='lol'),
] ]
@ -100,18 +116,18 @@ def test_filter_excluded_containers():
mock_containers, mock_containers,
exclude_labels, exclude_labels,
) )
assert [ assert [mock_containers[0], mock_containers[3], mock_containers[4]] == list(result)
mock_containers[0],
mock_containers[3],
mock_containers[4]
] == list(result)
def test_cleanup_images(mock_client, now): def test_cleanup_images(mock_client, now):
max_image_age = now max_image_age = now
mock_client.images.return_value = images = [ mock_client.images.return_value = images = [
{'Id': 'abcd'}, {
{'Id': 'abbb'}, 'Id': 'abcd'
},
{
'Id': 'abbb'
},
] ]
mock_images = [ mock_images = [
{ {
@ -152,8 +168,7 @@ def test_cleanup_volumes(mock_client):
docker_gc.cleanup_volumes(mock_client, False) docker_gc.cleanup_volumes(mock_client, False)
assert mock_client.remove_volume.mock_calls == [ assert mock_client.remove_volume.mock_calls == [
mock.call(name=volume['Name']) mock.call(name=volume['Name']) for volume in reversed(volumes['Volumes'])
for volume in reversed(volumes['Volumes'])
] ]
@ -205,35 +220,56 @@ def test_filter_images_in_use():
def test_filter_images_in_use_by_id(mock_client, now): def test_filter_images_in_use_by_id(mock_client, now):
mock_client._version = '1.21' mock_client._version = '1.21'
mock_client.containers.return_value = [ mock_client.containers.return_value = [
{'Id': 'abcd', 'ImageID': '1'},
{'Id': 'abbb', 'ImageID': '2'},
]
mock_containers = [
{ {
'Id': 'abcd',
'ImageID': '1'
},
{
'Id': 'abbb',
'ImageID': '2'
},
]
mock_containers = [{
'Id': 'abcd', 'Id': 'abcd',
'Name': 'one', 'Name': 'one',
'State': { 'State': {
'Running': False, 'Running': False,
'FinishedAt': '2014-01-01T01:01:01Z' 'FinishedAt': '2014-01-01T01:01:01Z'
} }
}, }, {
{
'Id': 'abbb', 'Id': 'abbb',
'Name': 'two', 'Name': 'two',
'State': { 'State': {
'Running': True, 'Running': True,
'FinishedAt': '2014-01-01T01:01:01Z' 'FinishedAt': '2014-01-01T01:01:01Z'
} }
} }]
]
mock_client.inspect_container.side_effect = iter(mock_containers) mock_client.inspect_container.side_effect = iter(mock_containers)
mock_client.images.return_value = [ mock_client.images.return_value = [
{'Id': '1', 'Created': '2014-01-01T01:01:01Z'}, {
{'Id': '2', 'Created': '2014-01-01T01:01:01Z'}, 'Id': '1',
{'Id': '3', 'Created': '2014-01-01T01:01:01Z'}, 'Created': '2014-01-01T01:01:01Z'
{'Id': '4', 'Created': '2014-01-01T01:01:01Z'}, },
{'Id': '5', 'Created': '2014-01-01T01:01:01Z'}, {
{'Id': '6', 'Created': '2014-01-01T01:01:01Z'}, 'Id': '2',
'Created': '2014-01-01T01:01:01Z'
},
{
'Id': '3',
'Created': '2014-01-01T01:01:01Z'
},
{
'Id': '4',
'Created': '2014-01-01T01:01:01Z'
},
{
'Id': '5',
'Created': '2014-01-01T01:01:01Z'
},
{
'Id': '6',
'Created': '2014-01-01T01:01:01Z'
},
] ]
mock_client.inspect_image.side_effect = lambda image: { mock_client.inspect_image.side_effect = lambda image: {
'Id': image, 'Id': image,
@ -311,7 +347,6 @@ def test_filter_excluded_images_advanced():
{ {
'RepoTags': ['user/repo-2:tag'] 'RepoTags': ['user/repo-2:tag']
}, },
] ]
expected = [ expected = [
{ {
@ -355,16 +390,11 @@ def test_remove_image_new_image_not_removed(mock_client, image, later_time):
def test_remove_image_with_tags(mock_client, image, now): def test_remove_image_with_tags(mock_client, image, now):
image_id = 'abcd' image_id = 'abcd'
repo_tags = ['user/one:latest', 'user/one:12345'] repo_tags = ['user/one:latest', 'user/one:12345']
image_summary = { image_summary = {'Id': image_id, 'RepoTags': repo_tags}
'Id': image_id,
'RepoTags': repo_tags
}
mock_client.inspect_image.return_value = image mock_client.inspect_image.return_value = image
docker_gc.remove_image(mock_client, image_summary, now, False) docker_gc.remove_image(mock_client, image_summary, now, False)
assert mock_client.remove_image.mock_calls == [ assert mock_client.remove_image.mock_calls == [mock.call(image=tag) for tag in repo_tags]
mock.call(image=tag) for tag in repo_tags
]
def test_api_call_success(): def test_api_call_success():
@ -376,39 +406,29 @@ def test_api_call_success():
def test_api_call_with_timeout(): def test_api_call_with_timeout():
func = mock.Mock( func = mock.Mock(side_effect=requests.exceptions.ReadTimeout("msg"), __name__="remove_image")
side_effect=requests.exceptions.ReadTimeout("msg"),
__name__="remove_image")
image = "abcd" image = "abcd"
with mock.patch( with mock.patch('docker_custodian.docker_gc.log', autospec=True) as mock_log:
'docker_custodian.docker_gc.log',
autospec=True) as mock_log:
docker_gc.api_call(func, image=image) docker_gc.api_call(func, image=image)
func.assert_called_once_with(image="abcd") func.assert_called_once_with(image="abcd")
mock_log.warn.assert_called_once_with('Failed to call remove_image ' mock_log.warn.assert_called_once_with('Failed to call remove_image ' + 'image=abcd msg')
+ 'image=abcd msg'
)
def test_api_call_with_api_error(): def test_api_call_with_api_error():
func = mock.Mock( func = mock.Mock(side_effect=docker.errors.APIError("Ooops",
side_effect=docker.errors.APIError( mock.Mock(status_code=409,
"Ooops", reason="Conflict"),
mock.Mock(status_code=409, reason="Conflict"),
explanation="failed"), explanation="failed"),
__name__="remove_image") __name__="remove_image")
image = "abcd" image = "abcd"
with mock.patch( with mock.patch('docker_custodian.docker_gc.log', autospec=True) as mock_log:
'docker_custodian.docker_gc.log',
autospec=True) as mock_log:
docker_gc.api_call(func, image=image) docker_gc.api_call(func, image=image)
func.assert_called_once_with(image="abcd") func.assert_called_once_with(image="abcd")
mock_log.warn.assert_called_once_with( mock_log.warn.assert_called_once_with('Error calling remove_image image=abcd '
'Error calling remove_image image=abcd '
'409 Client Error: Conflict ("failed")') '409 Client Error: Conflict ("failed")')
@ -425,13 +445,13 @@ def test_get_args_with_defaults():
def test_get_args_with_args(): def test_get_args_with_args():
with mock.patch( with mock.patch('docker_custodian.docker_gc.timedelta_type',
'docker_custodian.docker_gc.timedelta_type', autospec=True) as mock_timedelta_type:
autospec=True
) as mock_timedelta_type:
opts = docker_gc.get_args(args=[ opts = docker_gc.get_args(args=[
'--max-image-age', '30 days', '--max-image-age',
'--max-container-age', '3d', '30 days',
'--max-container-age',
'3d',
]) ])
assert mock_timedelta_type.mock_calls == [ assert mock_timedelta_type.mock_calls == [
mock.call('30 days'), mock.call('30 days'),
@ -444,8 +464,7 @@ def test_get_args_with_args():
def test_get_all_containers(mock_client): def test_get_all_containers(mock_client):
count = 10 count = 10
mock_client.containers.return_value = [mock.Mock() for _ in range(count)] mock_client.containers.return_value = [mock.Mock() for _ in range(count)]
with mock.patch('docker_custodian.docker_gc.log', with mock.patch('docker_custodian.docker_gc.log', autospec=True) as mock_log:
autospec=True) as mock_log:
containers = docker_gc.get_all_containers(mock_client) containers = docker_gc.get_all_containers(mock_client)
assert containers == mock_client.containers.return_value assert containers == mock_client.containers.return_value
mock_client.containers.assert_called_once_with(all=True) mock_client.containers.assert_called_once_with(all=True)
@ -455,8 +474,7 @@ def test_get_all_containers(mock_client):
def test_get_all_images(mock_client): def test_get_all_images(mock_client):
count = 7 count = 7
mock_client.images.return_value = [mock.Mock() for _ in range(count)] mock_client.images.return_value = [mock.Mock() for _ in range(count)]
with mock.patch('docker_custodian.docker_gc.log', with mock.patch('docker_custodian.docker_gc.log', autospec=True) as mock_log:
autospec=True) as mock_log:
images = docker_gc.get_all_images(mock_client) images = docker_gc.get_all_images(mock_client)
assert images == mock_client.images.return_value assert images == mock_client.images.return_value
mock_log.info.assert_called_with("Found %s images", count) mock_log.info.assert_called_with("Found %s images", count)
@ -464,11 +482,8 @@ def test_get_all_images(mock_client):
def test_get_dangling_volumes(mock_client): def test_get_dangling_volumes(mock_client):
count = 4 count = 4
mock_client.volumes.return_value = { mock_client.volumes.return_value = {'Volumes': [mock.Mock() for _ in range(count)]}
'Volumes': [mock.Mock() for _ in range(count)] with mock.patch('docker_custodian.docker_gc.log', autospec=True) as mock_log:
}
with mock.patch('docker_custodian.docker_gc.log',
autospec=True) as mock_log:
volumes = docker_gc.get_dangling_volumes(mock_client) volumes = docker_gc.get_dangling_volumes(mock_client)
assert volumes == mock_client.volumes.return_value['Volumes'] assert volumes == mock_client.volumes.return_value['Volumes']
mock_log.info.assert_called_with("Found %s dangling volumes", count) mock_log.info.assert_called_with("Found %s dangling volumes", count)
@ -480,7 +495,8 @@ def test_build_exclude_set():
'repo/foo:12345', 'repo/foo:12345',
'duplicate:latest', 'duplicate:latest',
] ]
exclude_image_file = StringIO(textwrap.dedent(""" exclude_image_file = StringIO(
textwrap.dedent("""
# Exclude this one because # Exclude this one because
duplicate:latest duplicate:latest
# Also this one # Also this one
@ -516,13 +532,9 @@ def test_build_exclude_set_empty():
def test_main(mock_client): def test_main(mock_client):
with mock.patch( with mock.patch('docker_custodian.docker_gc.docker.APIClient', return_value=mock_client):
'docker_custodian.docker_gc.docker.APIClient',
return_value=mock_client):
with mock.patch( with mock.patch('docker_custodian.docker_gc.get_args', autospec=True) as mock_get_args:
'docker_custodian.docker_gc.get_args',
autospec=True) as mock_get_args:
mock_get_args.return_value = mock.Mock( mock_get_args.return_value = mock.Mock(
max_image_age=100, max_image_age=100,
max_container_age=200, max_container_age=200,

View File

@ -10,9 +10,16 @@ default_section = THIRDPARTY
known_first_party = dockertidy known_first_party = dockertidy
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
force_single_line = true force_single_line = true
line_length = 110 line_length = 99
skip_glob = **/env/*,**/docs/* skip_glob = **/env/*,**/docs/*
[yapf]
based_on_style = google
column_limit = 99
dedent_closing_brackets = true
coalesce_brackets = true
split_before_logical_operator = true
[tool:pytest] [tool:pytest]
filterwarnings = filterwarnings =
ignore::FutureWarning ignore::FutureWarning

View File

@ -71,7 +71,7 @@ setup(
"colorama==0.4.3", "colorama==0.4.3",
"docker==4.2.0", "docker==4.2.0",
"docker-pycreds==0.4.0", "docker-pycreds==0.4.0",
"environs==7.2.0", "environs==7.3.0",
"idna==2.9", "idna==2.9",
"importlib-metadata==1.5.0; python_version < '3.8'", "importlib-metadata==1.5.0; python_version < '3.8'",
"ipaddress==1.0.23", "ipaddress==1.0.23",
@ -90,7 +90,7 @@ setup(
"six==1.14.0", "six==1.14.0",
"urllib3==1.25.8", "urllib3==1.25.8",
"websocket-client==0.57.0", "websocket-client==0.57.0",
"zipp==3.0.0", "zipp==3.1.0",
], ],
dependency_links=[], dependency_links=[],
setup_requires=["setuptools_scm",], setup_requires=["setuptools_scm",],