Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/pycodestyle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
python-version: ["3.14"]
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
python-version: ["3.14"]
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/python_unittests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

Expand Down
96 changes: 51 additions & 45 deletions check_container_stats_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ def send_http_get(
) -> dict:
""" Prepare HTTP post reqest to be sent to docker socket, evluate HTTP response """

cmd: str = (f'GET { endpoint } HTTP/1.1\r\n'
f'Host: { host }\r\n'
f'User-Agent: { useragent }\r\n'
cmd: str = (f'GET {endpoint} HTTP/1.1\r\n'
f'Host: {host}\r\n'
f'User-Agent: {useragent}\r\n'
f'Accept: application/json\r\n'
f'Connection: close\r\n\r\n')

Expand Down Expand Up @@ -122,9 +122,9 @@ def send_http_get(
response["http_response"]: dict = json.loads(buf_lines[-1])

if response["http_status"] != 200:
exit_plugin(2, (f'Daemon API v{ response["http_header_api-version"] } returned HTTP '
f'{ response["http_status"] } while fetching { endpoint }: '
f'{ response["http_response"] }'), '')
exit_plugin(2, (f'Daemon API v{response["http_header_api-version"]} returned HTTP '
f'{response["http_status"]} while fetching {endpoint}: '
f'{response["http_response"]}'), '')

return response

Expand Down Expand Up @@ -167,19 +167,25 @@ def send_socket_cmd(cmd: str, socketfile: str) -> str:
buf += data.decode()

# Shut down sending
sock.shutdown(socket.SHUT_WR)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
# catch "OSError: [Errno 57] Socket is not connected" on MacOS
# Apparently Docker on MacOS shuts down the socket connection at this point already due to our
# "Connection: close" header
pass

# Close socket connection
sock.close()

except FileNotFoundError:
exit_plugin(3, f'Socket file { socketfile } not found!', "")
exit_plugin(3, f'Socket file {socketfile} not found!', "")
except PermissionError:
exit_plugin(3, f'Access to socket file { socketfile } denied!', "")
exit_plugin(3, f'Access to socket file {socketfile} denied!', "")
except (TimeoutError, socket.timeout):
exit_plugin(3, f'Connection to socket { socketfile } timed out!', "")
exit_plugin(3, f'Connection to socket {socketfile} timed out!', "")
except ConnectionError as err:
exit_plugin(3, f'Error during socket connection: { err }', "")
exit_plugin(3, f'Error during socket connection: {err}', "")

return buf

Expand Down Expand Up @@ -238,15 +244,15 @@ def set_state(newstate: int, state: int) -> int:
def convert_bytes_to_pretty(raw_bytes: int) -> str:
""" converts raw bytes into human readable output """
if raw_bytes >= 1099511627776:
output = f'{ round(raw_bytes / 1024 **4, 2) }TiB'
output = f'{round(raw_bytes / 1024 ** 4, 2)}TiB'
elif raw_bytes >= 1073741824:
output = f'{ round(raw_bytes / 1024 **3, 2) }GiB'
output = f'{round(raw_bytes / 1024 ** 3, 2)}GiB'
elif raw_bytes >= 1048576:
output = f'{ round(raw_bytes / 1024 **2, 2) }MiB'
output = f'{round(raw_bytes / 1024 ** 2, 2)}MiB'
elif raw_bytes >= 1024:
output = f'{ round(raw_bytes / 1024, 2) }KiB'
output = f'{round(raw_bytes / 1024, 2)}KiB'
elif raw_bytes < 1024:
output = f'{ raw_bytes }B'
output = f'{raw_bytes}B'
else:
# Theoretically impossible, prevent pylint possibly-used-before-assignment
raise ValueError('Impossible value in convert_bytes_to_pretty()')
Expand All @@ -258,25 +264,25 @@ def get_container_from_name(args: Arguments) -> dict:

# Query all containers that match the given name from /containers/json
containers: dict = send_http_get(
f'/v1.45/containers/json?all=true&filters={{"name":["{ args.container_name }"]}}',
f'/v1.51/containers/json?all=true&filters={{"name":["{args.container_name}"]}}',
socketfile=args.socket
)

if len(containers["http_response"]) == 0:
exit_plugin(2, f'No container matched name { args.container_name }', '')
exit_plugin(2, f'No container matched name {args.container_name}', '')
elif args.wildcard is True and len(containers["http_response"]) > 1:
exit_plugin(2, f'Multiple containers matched wildcard name { args.container_name }', '')
exit_plugin(2, f'Multiple containers matched wildcard name {args.container_name}', '')

if args.wildcard is True:
# We previously checked than wildcard name matched only one container - so this must be it
container_info: dict = containers["http_response"][0]
else:
# loop over returned containers and check for match
for cnt in containers["http_response"]:
if f'/{ args.container_name }' in cnt["Names"]:
if f'/{args.container_name}' in cnt["Names"]:
container_info: dict = cnt
if 'container_info' not in locals():
exit_plugin(2, f'No container matched name { args.container_name }', '')
exit_plugin(2, f'No container matched name {args.container_name}', '')

return container_info

Expand All @@ -289,13 +295,13 @@ def calc_container_metrics(info: dict, stats: dict) -> dict:

try:
# Extract container name and id from api response
container.update({"name": f'{ info["Names"][0][1:] }'})
container.update({"id": f'{ info["Id"][:12] }'})
container.update({"id_long": f'{ info["Id"] }'})
container.update({"name": f'{info["Names"][0][1:]}'})
container.update({"id": f'{info["Id"][:12]}'})
container.update({"id_long": f'{info["Id"]}'})

# Get container state
container.update({"state": f'{ info["State"] }'})
container.update({"status": f'{ info["Status"] }'})
container.update({"state": f'{info["State"]}'})
container.update({"status": f'{info["Status"]}'})

# Get process statistics
container.update({"pid_count": stats["pids_stats"].get("current", 0)})
Expand Down Expand Up @@ -353,7 +359,7 @@ def calc_container_metrics(info: dict, stats: dict) -> dict:
container.update({"blk_io": {"r": blkio_r, "w": blkio_w}})

except KeyError as err:
exit_plugin(2, f'Error while extracting values from JSON response: { err }', "")
exit_plugin(2, f'Error while extracting values from JSON response: {err}', "")

return container

Expand All @@ -367,45 +373,45 @@ def main():
# Get daemon API version
server_version: dict = send_http_get('/version', socketfile=args.socket)["http_response"]

# Check of daemon is compatible with API v1.45
if tuple(server_version["MinAPIVersion"].split('.')) > ("1", "45"):
exit_plugin(2, (f'This plugin requires a docker daemon supporting API version 1.45 - '
# Check of daemon is compatible with API v1.51
if tuple(server_version["MinAPIVersion"].split('.')) >= ("1", "51"):
exit_plugin(2, (f'This plugin requires a docker daemon supporting API version 1.51 - '
f'Minimum supported version of this docker daemon is '
f'{ server_version["MinAPIVersion"] }'), '')
f'{server_version["MinAPIVersion"]}'), '')

# Get container id for name from /containers/json
# Returns Container JSON object as dict
container_info: dict = get_container_from_name(args)

# Check if container is running, if not we can exit early without perfdata
if container_info["State"] != "running":
exit_plugin(2, f'Container { container_info["Names"][0][1:] } is { container_info["Status"] }', '')
exit_plugin(2, f'Container {container_info["Names"][0][1:]} is {container_info["Status"]}', '')

# Get container stats
container_stats: dict = send_http_get(
f'/v1.45/containers/{ container_info["Id"] }/stats?stream=false&one-shot=false',
f'/v1.51/containers/{container_info["Id"]}/stats?stream=false&one-shot=false',
socketfile=args.socket
)["http_response"]

# Hand raw API responses over to calc_container_metrics to extract attributes and derive metrics
container: dict = calc_container_metrics(container_info, container_stats)

# Construct perfdata and output
output = (f"{ container['name'] } ({ container['id'] }) is { container['status'] } - "
f"CPU: { container['cpu_pct'] }%, "
f"Memory: { convert_bytes_to_pretty(container['memory']['used']) }, "
output = (f"{container['name']} ({container['id']}) is {container['status']} - "
f"CPU: {container['cpu_pct']}%, "
f"Memory: {convert_bytes_to_pretty(container['memory']['used'])}, "
f"PIDs: {container['pid_count']}")

perfdata = (f" | cpu={ container['cpu_pct'] }%;{ args.cpuwarn or '' };"
f"{ args.cpucrit or '' };; "
f"pids={ container['pid_count'] };{ args.pidwarn or '' };"
f"{ args.pidcrit or '' };0;{ container['pid_limit'] } "
perfdata = (f" | cpu={container['cpu_pct']}%;{args.cpuwarn or ''};"
f"{args.cpucrit or ''};; "
f"pids={container['pid_count']};{args.pidwarn or ''};"
f"{args.pidcrit or ''};0;{container['pid_limit']} "
f"mem={container['memory']['used']}B;{args.memwarn or ''};"
f"{args.memcrit or ''};0;{ container['memory']['available'] } "
f"net_send={ container['net_io']['tx'] }B;;;; "
f"net_recv={ container['net_io']['rx'] }B;;;; "
f"disk_read={ container['blk_io']['r'] }B;;;; "
f"disk_write={ container['blk_io']['w'] }B;;;; ")
f"{args.memcrit or ''};0;{container['memory']['available']} "
f"net_send={container['net_io']['tx']}B;;;; "
f"net_recv={container['net_io']['rx']}B;;;; "
f"disk_read={container['blk_io']['r']}B;;;; "
f"disk_write={container['blk_io']['w']}B;;;; ")

# Set initial return code
returncode = 0
Expand Down
66 changes: 36 additions & 30 deletions check_docker_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,25 @@ def send_socket_cmd(cmd: str, socketfile: str) -> str:
buf += data.decode()

# Shut down sending
sock.shutdown(socket.SHUT_WR)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
# catch "OSError: [Errno 57] Socket is not connected" on MacOS
# Apparently Docker on MacOS shuts down the socket connection at this point already due to our
# "Connection: close" header
pass

# Close socket connection
sock.close()

except FileNotFoundError:
exit_plugin(3, f'Socket file { socketfile } not found!', "")
exit_plugin(3, f'Socket file {socketfile} not found!', "")
except PermissionError:
exit_plugin(3, f'Access to socket file { socketfile } denied!', "")
exit_plugin(3, f'Access to socket file {socketfile} denied!', "")
except (TimeoutError, socket.timeout):
exit_plugin(3, f'Connection to socket { socketfile } timed out!', "")
exit_plugin(3, f'Connection to socket {socketfile} timed out!', "")
except ConnectionError as err:
exit_plugin(3, f'Error during socket connection: { err }', "")
exit_plugin(3, f'Error during socket connection: {err}', "")

return buf

Expand Down Expand Up @@ -150,9 +156,9 @@ async def send_http_get(
host: str = 'localhost', useragent: str = 'check_docker_system.py') -> dict:
""" Prepare HTTP post reqest to be sent to docker socket """

cmd: str = (f'GET { endpoint } HTTP/1.1\r\n'
f'Host: { host }\r\n'
f'User-Agent: { useragent }\r\n'
cmd: str = (f'GET {endpoint} HTTP/1.1\r\n'
f'Host: {host}\r\n'
f'User-Agent: {useragent}\r\n'
f'Accept: application/json\r\n'
f'Connection: close\r\n\r\n')

Expand Down Expand Up @@ -222,15 +228,15 @@ def parse_docker_sysinfo(docker_sysinfo: dict) -> dict:
def convert_bytes_to_pretty(raw_bytes: int):
""" converts raw bytes into human readable output """
if raw_bytes >= 1099511627776:
output = f'{ round(raw_bytes / 1024 **4, 2) }TiB'
output = f'{round(raw_bytes / 1024 ** 4, 2)}TiB'
elif raw_bytes >= 1073741824:
output = f'{ round(raw_bytes / 1024 **3, 2) }GiB'
output = f'{round(raw_bytes / 1024 ** 3, 2)}GiB'
elif raw_bytes >= 1048576:
output = f'{ round(raw_bytes / 1024 **2, 2) }MiB'
output = f'{round(raw_bytes / 1024 ** 2, 2)}MiB'
elif raw_bytes >= 1024:
output = f'{ round(raw_bytes / 1024, 2) }KiB'
output = f'{round(raw_bytes / 1024, 2)}KiB'
elif raw_bytes < 1024:
output = f'{ raw_bytes }B'
output = f'{raw_bytes}B'
else:
# Theoretically impossible, prevent pylint possibly-used-before-assignment
raise ValueError('Impossible value in convert_bytes_to_pretty()')
Expand Down Expand Up @@ -283,9 +289,9 @@ def main():

# Check HTTP response code
if state["http_status"] not in [200]:
exit_plugin(3, f'Docker socket returned HTTP { state["http_status"] }: { state["http_response"] }', '')
exit_plugin(3, f'Docker socket returned HTTP {state["http_status"]}: {state["http_response"]}', '')
elif volumes["http_status"] not in [200]:
exit_plugin(3, f'Docker socket returned HTTP { volumes["http_status"] }: { volumes["http_response"] }', '')
exit_plugin(3, f'Docker socket returned HTTP {volumes["http_status"]}: {volumes["http_response"]}', '')

if args.debug is True:
print(json.dumps(state, indent=4))
Expand All @@ -296,7 +302,7 @@ def main():
docker_sysinfo: dict = json.loads(state["http_response"])
docker_volinfo: dict = json.loads(volumes["http_response"])
except json.decoder.JSONDecodeError as err:
exit_plugin(3, f'Unable to parse valid JSON from docker daemon response: { err }', '')
exit_plugin(3, f'Unable to parse valid JSON from docker daemon response: {err}', '')

engine_state = parse_docker_sysinfo(docker_sysinfo)
volcount: int = len(docker_volinfo["Volumes"])
Expand All @@ -316,22 +322,22 @@ def main():
state = set_state(1, state)

output = (
f'Containers: { engine_state["containers"]["total"] } '
f'(Running: { engine_state["containers"]["running"] }, Paused: { engine_state["containers"]["paused"] }, '
f'Stopped: { engine_state["containers"]["stopped"] }), Images: { engine_state["images"] }, '
f'Volumes: { volcount }, '
f'Docker version { engine_state["server_version"] } running with '
f'{ engine_state["cpus"] } CPUs and { convert_bytes_to_pretty(engine_state["memory"]) } memory'
f'Containers: {engine_state["containers"]["total"]} '
f'(Running: {engine_state["containers"]["running"]}, Paused: {engine_state["containers"]["paused"]}, '
f'Stopped: {engine_state["containers"]["stopped"]}), Images: {engine_state["images"]}, '
f'Volumes: {volcount}, '
f'Docker version {engine_state["server_version"]} running with '
f'{engine_state["cpus"]} CPUs and {convert_bytes_to_pretty(engine_state["memory"])} memory'
)
perfdata = (
f'\'containers_running\'={ engine_state["containers"]["running"] };;;0;'
f'{ engine_state["containers"]["total"] } '
f'\'containers_paused\'={ engine_state["containers"]["paused"] };{ args.maxpaused or "" };;0;'
f'{ engine_state["containers"]["total"] } '
f'\'containers_stopped\'={ engine_state["containers"]["stopped"] };{ args.maxstopped or "" };;0;'
f'{ engine_state["containers"]["total"] } '
f'\'images\'={ engine_state["images"] };{ args.maximages or "" };;0; '
f'\'volumes\'={ volcount };{ args.maxvolumes or "" };;0;'
f'\'containers_running\'={engine_state["containers"]["running"]};;;0;'
f'{engine_state["containers"]["total"]} '
f'\'containers_paused\'={engine_state["containers"]["paused"]};{args.maxpaused or ""};;0;'
f'{engine_state["containers"]["total"]} '
f'\'containers_stopped\'={engine_state["containers"]["stopped"]};{args.maxstopped or ""};;0;'
f'{engine_state["containers"]["total"]} '
f'\'images\'={engine_state["images"]};{args.maximages or ""};;0; '
f'\'volumes\'={volcount};{args.maxvolumes or ""};;0;'
)

exit_plugin(state, output, perfdata)
Expand Down