Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve parsing of warnings and errors #811

Merged
merged 6 commits into from
Mar 3, 2024
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
3 changes: 2 additions & 1 deletion changelogs/fragments/810-compose-errors.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
bugfixes:
- "docker_compose_v2 - do not fail when non-fatal errors occur. This can happen when pulling an image fails, but then the image can be built
for another service. Docker Compose emits an error in that case, but ``docker compose up`` still completes successfully
(https://github.com/ansible-collections/community.docker/issues/807, https://github.com/ansible-collections/community.docker/pull/810)."
(https://github.com/ansible-collections/community.docker/issues/807, https://github.com/ansible-collections/community.docker/pull/810,
https://github.com/ansible-collections/community.docker/pull/811)."
3 changes: 3 additions & 0 deletions changelogs/fragments/811-compose-v2-logfmt.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
bugfixes:
- "docker_compose_v2* modules - parse ``logfmt`` warnings emitted by Docker Compose (https://github.com/ansible-collections/community.docker/issues/787, https://github.com/ansible-collections/community.docker/pull/811)."
- "docker_compose_v2* modules - correctly parse ``Warning`` events emitted by Docker Compose (https://github.com/ansible-collections/community.docker/issues/807, https://github.com/ansible-collections/community.docker/pull/811)."
208 changes: 208 additions & 0 deletions plugins/module_utils/_logfmt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Copyright (c) 2024, Felix Fontein <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

"""
Parse go logfmt messages.

See https://pkg.go.dev/github.com/kr/logfmt?utm_source=godoc for information on the format.
"""

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type


# The format is defined in https://pkg.go.dev/github.com/kr/logfmt?utm_source=godoc
# (look for "EBNFish")


class InvalidLogFmt(Exception):
pass


class _Mode(object):
GARBAGE = 0
KEY = 1
EQUAL = 2
IDENT_VALUE = 3
QUOTED_VALUE = 4


_ESCAPE_DICT = {
'"': '"',
'\\': '\\',
"'": "'",
'/': '/',
'b': '\b',
'f': '\f',
'n': '\n',
'r': '\r',
't': '\t',
}

_HEX_DICT = {
'0': 0,
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
'a': 0xA,
'b': 0xB,
'c': 0xC,
'd': 0xD,
'e': 0xE,
'f': 0xF,
'A': 0xA,
'B': 0xB,
'C': 0xC,
'D': 0xD,
'E': 0xE,
'F': 0xF,
}


def _is_ident(cur):
return cur > ' ' and cur not in ('"', '=')


class _Parser(object):
def __init__(self, line):
self.line = line
self.index = 0
self.length = len(line)

def done(self):
return self.index >= self.length

def cur(self):
return self.line[self.index]

def next(self):
self.index += 1

def prev(self):
self.index -= 1

def parse_unicode_sequence(self):
if self.index + 6 > self.length:
raise InvalidLogFmt('Not enough space for unicode escape')
if self.line[self.index:self.index + 2] != '\\u':
raise InvalidLogFmt('Invalid unicode escape start')
v = 0
for i in range(self.index + 2, self.index + 6):
v <<= 4
try:
v += _HEX_DICT[self.line[self.index]]
except KeyError:
raise InvalidLogFmt('Invalid unicode escape digit {digit!r}'.format(digit=self.line[self.index]))
self.index += 6
return chr(v)


def parse_line(line, logrus_mode=False):
result = {}
parser = _Parser(line)
key = []
value = []
mode = _Mode.GARBAGE

def handle_kv(has_no_value=False):
k = ''.join(key)
v = None if has_no_value else ''.join(value)
result[k] = v
del key[:]
del value[:]

def parse_garbage(cur):
if _is_ident(cur):
return _Mode.KEY
parser.next()
return _Mode.GARBAGE

def parse_key(cur):
if _is_ident(cur):
key.append(cur)
parser.next()
return _Mode.KEY
elif cur == '=':
parser.next()
return _Mode.EQUAL
else:
if logrus_mode:
raise InvalidLogFmt('Key must always be followed by "=" in logrus mode')
handle_kv(has_no_value=True)
parser.next()
return _Mode.GARBAGE

def parse_equal(cur):
if _is_ident(cur):
value.append(cur)
parser.next()
return _Mode.IDENT_VALUE
elif cur == '"':
parser.next()
return _Mode.QUOTED_VALUE
else:
handle_kv()
parser.next()
return _Mode.GARBAGE

def parse_ident_value(cur):
if _is_ident(cur):
value.append(cur)
parser.next()
return _Mode.IDENT_VALUE
else:
handle_kv()
parser.next()
return _Mode.GARBAGE

def parse_quoted_value(cur):
if cur == '\\':
parser.next()
if parser.done():
raise InvalidLogFmt('Unterminated escape sequence in quoted string')
cur = parser.cur()
if cur in _ESCAPE_DICT:
value.append(_ESCAPE_DICT[cur])
elif cur != 'u':
raise InvalidLogFmt('Unknown escape sequence {seq!r}'.format(seq='\\' + cur))
else:
parser.prev()
value.append(parser.parse_unicode_sequence())
parser.next()
return _Mode.QUOTED_VALUE
elif cur == '"':
handle_kv()
parser.next()
return _Mode.GARBAGE
elif cur < ' ':
raise InvalidLogFmt('Control characters in quoted string are not allowed')
else:
value.append(cur)
parser.next()
return _Mode.QUOTED_VALUE

parsers = {
_Mode.GARBAGE: parse_garbage,
_Mode.KEY: parse_key,
_Mode.EQUAL: parse_equal,
_Mode.IDENT_VALUE: parse_ident_value,
_Mode.QUOTED_VALUE: parse_quoted_value,
}
while not parser.done():
mode = parsers[mode](parser.cur())
if mode == _Mode.KEY and logrus_mode:
raise InvalidLogFmt('Key must always be followed by "=" in logrus mode')
if mode == _Mode.KEY or mode == _Mode.EQUAL:
handle_kv(has_no_value=True)
elif mode == _Mode.IDENT_VALUE:
handle_kv()
elif mode == _Mode.QUOTED_VALUE:
raise InvalidLogFmt('Unterminated quoted string')
return result
74 changes: 61 additions & 13 deletions plugins/module_utils/compose_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

from ansible_collections.community.docker.plugins.module_utils.util import DockerBaseClass
from ansible_collections.community.docker.plugins.module_utils.version import LooseVersion
from ansible_collections.community.docker.plugins.module_utils._logfmt import (
InvalidLogFmt as _InvalidLogFmt,
parse_line as _parse_logfmt_line,
)


DOCKER_COMPOSE_FILES = ('compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml')
Expand Down Expand Up @@ -56,6 +60,9 @@
DOCKER_STATUS_ERROR = frozenset((
'Error',
))
DOCKER_STATUS_WARNING = frozenset((
'Warning',
))
DOCKER_STATUS_WAITING = frozenset((
'Waiting',
))
Expand Down Expand Up @@ -145,10 +152,23 @@ def from_docker_compose_event(cls, resource_type):
r'\s+'
r'(?P<status>%s)'
r'\s*'
r'(?P<msg>\S.*\S)?'
r'$'
% '|'.join(re.escape(status) for status in DOCKER_STATUS_ERROR)
)

_RE_WARNING_EVENT = re.compile(
r'^'
r'\s*'
r'(?P<resource_id>\S+)'
r'\s+'
r'(?P<status>%s)'
r'\s*'
r'(?P<msg>\S.*\S)?'
r'$'
% '|'.join(re.escape(status) for status in DOCKER_STATUS_WARNING)
)

_RE_CONTINUE_EVENT = re.compile(
r'^'
r'\s*'
Expand Down Expand Up @@ -193,7 +213,7 @@ def from_docker_compose_event(cls, resource_type):
MINIMUM_COMPOSE_VERSION = '2.18.0'


def _extract_event(line):
def _extract_event(line, warn_function=None):
match = _RE_RESOURCE_EVENT.match(line)
if match is not None:
status = match.group('status')
Expand All @@ -205,48 +225,72 @@ def _extract_event(line):
match.group('resource_id'),
status,
msg,
)
), True
match = _RE_PULL_EVENT.match(line)
if match:
return Event(
ResourceType.SERVICE,
match.group('service'),
match.group('status'),
None,
)
), True
match = _RE_ERROR_EVENT.match(line)
if match:
return Event(
ResourceType.UNKNOWN,
match.group('resource_id'),
match.group('status'),
None,
)
match.group('msg') or None,
), True
match = _RE_WARNING_EVENT.match(line)
if match:
if warn_function:
if match.group('msg'):
msg = '{rid}: {msg}'
else:
msg = 'Unspecified warning for {rid}'
warn_function(msg.format(rid=match.group('resource_id'), msg=match.group('msg')))
return None, True
match = _RE_PULL_PROGRESS.match(line)
if match:
return Event(
ResourceType.IMAGE_LAYER,
match.group('layer'),
match.group('status'),
None,
)
), True
match = _RE_SKIPPED_EVENT.match(line)
if match:
return Event(
ResourceType.UNKNOWN,
match.group('resource_id'),
'Skipped',
match.group('msg'),
)
), True
match = _RE_BUILD_START_EVENT.match(line)
if match:
return Event(
ResourceType.SERVICE,
match.group('resource_id'),
'Building',
None,
)
return None
), True
return None, False


def _extract_logfmt_event(line, warn_function=None):
try:
result = _parse_logfmt_line(line, logrus_mode=True)
except _InvalidLogFmt:
return None, False
if 'time' not in result or 'level' not in result or 'msg' not in result:
return None, False
if result['level'] == 'warning':
if warn_function:
warn_function(result['msg'])
return None, True
# TODO: no idea what to do with this
return None, False


def _warn_missing_dry_run_prefix(line, warn_missing_dry_run_prefix, warn_function):
Expand Down Expand Up @@ -303,7 +347,7 @@ def parse_events(stderr, dry_run=False, warn_function=None):
line = line[len(_DRY_RUN_MARKER):].lstrip()
else:
warn_missing_dry_run_prefix = True
event = _extract_event(line)
event, parsed = _extract_event(line, warn_function=warn_function)
if event is not None:
events.append(event)
if event.status in DOCKER_STATUS_ERROR:
Expand All @@ -312,6 +356,8 @@ def parse_events(stderr, dry_run=False, warn_function=None):
error_event = None
_warn_missing_dry_run_prefix(line, warn_missing_dry_run_prefix, warn_function)
continue
elif parsed:
continue
match = _RE_BUILD_PROGRESS_EVENT.match(line)
if match:
# Ignore this
Expand All @@ -323,6 +369,11 @@ def parse_events(stderr, dry_run=False, warn_function=None):
if index_event is not None:
index, event = index_event
events[-1] = _concat_event_msg(event, match.group('msg'))
event, parsed = _extract_logfmt_event(line, warn_function=warn_function)
if event is not None:
events.append(event)
elif parsed:
continue
if error_event is not None:
# Unparsable line that apparently belongs to the previous error event
events[-1] = _concat_event_msg(error_event, line)
Expand Down Expand Up @@ -397,9 +448,6 @@ def emit_warnings(events, warn_function):
def is_failed(events, rc):
if rc:
return True
for event in events:
if event.status in DOCKER_STATUS_ERROR:
return True
return False


Expand Down
Loading
Loading