Add GUI logging workflows

The workflow works like this:

1.  Drain the tripleo Zaqar queue
2.  Format the messages as a log string
3.  Publish the string to a swift object

If the swift object exceeds 10MB, we rotate it.

This patch also includes a workflow to download the latest log via a temporary
url.  The download log action shares a lot of code with the plan export action,
so the common bits were extracted.

Implements: spec gui-logging
Implements: blueprint websocket-logging
Change-Id: I2affd39e85ccfdbaa18590de182104715cfbbed4
This commit is contained in:
Honza Pokorny 2017-05-24 15:51:04 -03:00
parent fa0b9f5208
commit 4cd60846af
11 changed files with 672 additions and 37 deletions

View File

@ -0,0 +1,5 @@
---
features:
- |
GUI logging - we added actions and workflows to support processing and
storage of logging data from tripleo-ui

View File

@ -94,6 +94,9 @@ mistral.actions =
tripleo.plan.delete = tripleo_common.actions.plan:DeletePlanAction
tripleo.plan.list = tripleo_common.actions.plan:ListPlansAction
tripleo.plan.export = tripleo_common.actions.plan:ExportPlanAction
tripleo.logging_to_swift.format_messages = tripleo_common.actions.logging_to_swift:FormatMessagesAction
tripleo.logging_to_swift.publish_ui_log_to_swift = tripleo_common.actions.logging_to_swift:PublishUILogToSwiftAction
tripleo.logging_to_swift.prepare_log_download = tripleo_common.actions.logging_to_swift:PrepareLogDownloadAction
tripleo.role.list = tripleo_common.actions.plan:ListRolesAction
tripleo.scale.delete_node = tripleo_common.actions.scale:ScaleDownAction
tripleo.swift.tempurl = tripleo_common.actions.swifthelper:SwiftTempUrlAction

View File

@ -0,0 +1,183 @@
# Copyright 2017 Red Hat, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from datetime import datetime
import json
import logging
import shutil
import tempfile
from mistral_lib import actions
from oslo_concurrency import processutils
from swiftclient import exceptions as swiftexceptions
from tripleo_common.actions import base
from tripleo_common import constants
from tripleo_common.utils import swift as swiftutils
from tripleo_common.utils import time_functions as timeutils
LOG = logging.getLogger(__name__)
class FormatMessagesAction(actions.Action):
"""Format messages as logs
Given a list of Zaqar messages from the TripleO UI, return a log-formatted
string
"""
def __init__(self, messages):
super(FormatMessagesAction, self).__init__()
self.messages = messages
def run(self, context):
lines = []
for zaqar_message in self.messages:
log_object = zaqar_message.get('body')
if not log_object:
continue
body = log_object.get('message', '')
level = log_object.get('level', 'info')
timestamp = log_object.get('timestamp', datetime.utcnow())
if isinstance(body, (dict, list,)):
body = json.dumps(body)
lines.append(
'{date} {level} {body}'.format(
date=timeutils.epoch_to_formatted_date(timestamp),
level=level,
body=body
)
)
return '\n'.join(lines)
class PublishUILogToSwiftAction(base.TripleOAction):
"""Publish logs from UI to Swift"""
def __init__(self, logging_data, logging_container):
super(PublishUILogToSwiftAction, self).__init__()
self.logging_data = logging_data
self.logging_container = logging_container
def _rotate(self, swift):
"""Optimistic log rotation
Failure to sucessfully complete log rotation doesn't cause the
entire action to fail
"""
try:
headers = swift.head_object(self.logging_container,
constants.TRIPLEO_UI_LOG_FILENAME)
if headers['content-length'] < constants.TRIPLEO_UI_LOG_FILE_SIZE:
LOG.debug("Log file hasn't reached a full size so it doesn't"
" need to be rotated.")
return
except swiftexceptions.ClientException:
LOG.debug("Couldn't get existing log file, skip log rotation.")
return
try:
files = swift.get_container(self.logging_container)[1]
except swiftexceptions.ClientException:
LOG.warn("Logging container doesn't exist, skip log rotation.")
return
largest_existing_suffix = 0
for f in files:
try:
suffix = int(f['name'].split('.')[-1])
if suffix > largest_existing_suffix:
largest_existing_suffix = suffix
except ValueError:
continue
next_suffix = largest_existing_suffix + 1
next_filename = '{}.{}'.format(
constants.TRIPLEO_UI_LOG_FILENAME, next_suffix)
try:
data = swift.get_object(self.logging_container,
constants.TRIPLEO_UI_LOG_FILENAME)[1]
swift.put_object(self.logging_container, next_filename, data)
swift.delete_object(self.logging_container,
constants.TRIPLEO_UI_LOG_FILENAME)
except swiftexceptions.ClientException as err:
msg = "Log rotation failed: %s" % err
LOG.warn(msg)
def run(self, context):
swift = self.get_object_client(context)
swiftutils.get_or_create_container(swift, self.logging_container)
self._rotate(swift)
try:
old_contents = swift.get_object(
self.logging_container,
constants.TRIPLEO_UI_LOG_FILENAME)[1]
new_contents = old_contents + '\n' + self.logging_data
except swiftexceptions.ClientException:
LOG.debug(
"There is no existing logging data, starting a new file.")
new_contents = self.logging_data
try:
swift.put_object(self.logging_container,
constants.TRIPLEO_UI_LOG_FILENAME,
new_contents)
except swiftexceptions.ClientException as err:
msg = "Failed to publish logs: %s" % err
return actions.Result(error=msg)
class PrepareLogDownloadAction(base.TripleOAction):
"""Publish all GUI logs to a temporary URL"""
def __init__(self, logging_container, downloads_container, delete_after):
super(PrepareLogDownloadAction, self).__init__()
self.logging_container = logging_container
self.downloads_container = downloads_container
self.delete_after = delete_after
def run(self, context):
swift = self.get_object_client(context)
tmp_dir = tempfile.mkdtemp()
tarball_name = 'logs-%s.tar.gz' % timeutils.timestamp()
try:
swiftutils.download_container(
swift, self.logging_container, tmp_dir)
swiftutils.create_and_upload_tarball(
swift, tmp_dir, self.downloads_container,
tarball_name, self.delete_after)
except swiftexceptions.ClientException as err:
msg = "Error attempting an operation on container: %s" % err
return actions.Result(error=msg)
except (OSError, IOError) as err:
msg = "Error while writing file: %s" % err
return actions.Result(error=msg)
except processutils.ProcessExecutionError as err:
msg = "Error while creating a tarball: %s" % err
return actions.Result(error=msg)
except Exception as err:
msg = "Error exporting logs: %s" % err
return actions.Result(error=msg)
finally:
shutil.rmtree(tmp_dir)
return tarball_name

View File

@ -13,7 +13,6 @@
# License for the specific language governing permissions and limitations
# under the License.
import logging
import os
import shutil
import tempfile
import yaml
@ -31,7 +30,6 @@ from tripleo_common import constants
from tripleo_common import exception
from tripleo_common.utils import plan as plan_utils
from tripleo_common.utils import swift as swiftutils
from tripleo_common.utils import tarball
from tripleo_common.utils.validations import pattern_validator
@ -221,46 +219,16 @@ class ExportPlanAction(base.TripleOAction):
self.delete_after = delete_after
self.exports_container = exports_container
def _download_templates(self, swift, tmp_dir):
"""Download templates to a temp folder."""
template_files = swift.get_container(self.plan)[1]
for tf in template_files:
filename = tf['name']
contents = swift.get_object(self.plan, filename)[1]
path = os.path.join(tmp_dir, filename)
dirname = os.path.dirname(path)
if not os.path.exists(dirname):
os.makedirs(dirname)
with open(path, 'w') as f:
f.write(contents)
def _create_and_upload_tarball(self, swift, tmp_dir):
"""Create a tarball containing the tmp_dir and upload it to Swift."""
tarball_name = '%s.tar.gz' % self.plan
headers = {'X-Delete-After': self.delete_after}
# make sure the root container which holds all plan exports exists
try:
swift.get_container(self.exports_container)
except swiftexceptions.ClientException:
swift.put_container(self.exports_container)
with tempfile.NamedTemporaryFile() as tmp_tarball:
tarball.create_tarball(tmp_dir, tmp_tarball.name)
swift.put_object(self.exports_container, tarball_name, tmp_tarball,
headers=headers)
def run(self, context):
swift = self.get_object_client(context)
tmp_dir = tempfile.mkdtemp()
tarball_name = '%s.tar.gz' % self.plan
try:
self._download_templates(swift, tmp_dir)
self._create_and_upload_tarball(swift, tmp_dir)
swiftutils.download_container(swift, self.plan, tmp_dir)
swiftutils.create_and_upload_tarball(
swift, tmp_dir, self.exports_container, tarball_name,
self.delete_after)
except swiftexceptions.ClientException as err:
msg = "Error attempting an operation on container: %s" % err
return actions.Result(error=msg)

View File

@ -127,3 +127,6 @@ DEFAULT_DEPLOY_RAMDISK_NAME = 'bm-deploy-ramdisk'
# The name for the swift container to host the cache for tripleo
TRIPLEO_CACHE_CONTAINER = "__cache__"
TRIPLEO_UI_LOG_FILE_SIZE = 1e7 # 10MB
TRIPLEO_UI_LOG_FILENAME = 'tripleo-ui.logs'

View File

@ -0,0 +1,161 @@
# Copyright 2017 Red Hat, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
from oslo_concurrency import processutils
from tripleo_common.actions import logging_to_swift
from tripleo_common.tests import base
class LogFormattingTest(base.TestCase):
def test_log_formatting(self):
messages = [
{
'body': {
'message': 'Test 1',
'level': 'INFO',
'timestamp': '1496322000000'
}
},
{
'body': {
'message': 'Test 2',
'level': 'WARN',
'timestamp': '1496329200000'
}
}
]
action = logging_to_swift.FormatMessagesAction(messages)
result = action.run({})
self.assertEqual(result, ('2017-06-01 13:00:00 INFO Test 1\n'
'2017-06-01 15:00:00 WARN Test 2'))
class PublishUILogToSwiftActionTest(base.TestCase):
def setUp(self):
super(PublishUILogToSwiftActionTest, self).setUp()
self.container = 'container'
self.swift = mock.MagicMock()
swift_patcher = mock.patch(
'tripleo_common.actions.base.TripleOAction.get_object_client',
return_value=self.swift)
swift_patcher.start()
self.addCleanup(swift_patcher.stop)
self.ctx = mock.MagicMock()
def test_simple_success(self):
self.swift.head_object.return_value = {
'content-length': 1
}
self.swift.get_container.return_value = (
{}, []
)
data = 'data'
action = logging_to_swift.PublishUILogToSwiftAction(
data, self.container)
action.run(self.ctx)
self.swift.get_object.assert_called_once()
self.swift.head_object.assert_called_once()
self.swift.put_object.assert_called_once()
self.swift.get_container.assert_called_once()
def test_rotate(self):
self.swift.head_object.return_value = {
'content-length': 2e7
}
self.swift.get_container.return_value = (
{}, []
)
old_data = 'old data'
new_data = 'new data'
result = old_data + '\n' + new_data
self.swift.get_object.return_value = ({}, old_data)
action = logging_to_swift.PublishUILogToSwiftAction(
new_data, self.container)
action.run(self.ctx)
self.swift.head_object.assert_called_once()
self.swift.put_object.assert_called_with(
self.container,
'tripleo-ui.logs',
result
)
class PrepareLogDownloadActionTest(base.TestCase):
def setUp(self):
super(PrepareLogDownloadActionTest, self).setUp()
self.log_files = (
'tripleo-ui.logs.2',
'tripleo-ui.logs.1',
'tripleo-ui.logs'
)
self.swift = mock.MagicMock()
self.swift.get_container.return_value = (
{'x-container-meta-usage-tripleo': 'plan'}, [
{'name': lf} for lf in self.log_files
]
)
self.swift.get_object.return_value = ({}, 'log content')
swift_patcher = mock.patch(
'tripleo_common.actions.base.TripleOAction.get_object_client',
return_value=self.swift)
swift_patcher.start()
self.addCleanup(swift_patcher.stop)
self.ctx = mock.MagicMock()
@mock.patch('tripleo_common.utils.tarball.create_tarball')
@mock.patch('tempfile.mkdtemp')
def test_run_success(self, mock_mkdtemp, mock_create_tarball):
get_object_mock_calls = [
mock.call('logging-container', lf) for lf in self.log_files
]
get_container_mock_calls = [
mock.call('logging-container')
]
mock_mkdtemp.return_value = '/tmp/test123'
action = logging_to_swift.PrepareLogDownloadAction(
'logging-container', 'downloads-container', 3600
)
action.run(self.ctx)
self.swift.get_container.assert_has_calls(get_container_mock_calls)
self.swift.get_object.assert_has_calls(
get_object_mock_calls, any_order=True)
mock_create_tarball.assert_called_once()
@mock.patch('tripleo_common.utils.tarball.create_tarball')
def test_run_error_creating_tarball(self, mock_create_tarball):
mock_create_tarball.side_effect = processutils.ProcessExecutionError
action = logging_to_swift.PrepareLogDownloadAction(
'logging-container', 'downloads-container', 3600
)
result = action.run(self.ctx)
error = "Error while creating a tarball"
self.assertIn(error, result.error)

View File

@ -15,6 +15,8 @@
import mock
from swiftclient import exceptions as swiftexceptions
from tripleo_common.tests import base
from tripleo_common.utils import swift as swift_utils
@ -75,3 +77,13 @@ class SwiftTest(base.TestCase):
self.swiftclient.get_account.assert_called()
self.swiftclient.get_container.assert_called()
self.swiftclient.delete_object.assert_not_called()
def test_get_or_create_container_create(self):
self.swiftclient.get_container.side_effect = \
swiftexceptions.ClientException('error')
swift_utils.get_or_create_container(self.swiftclient, 'abc')
self.swiftclient.put_container.assert_called()
def test_get_or_create_container_get(self):
swift_utils.get_or_create_container(self.swiftclient, 'abc')
self.swiftclient.put_container.assert_not_called()

View File

@ -0,0 +1,83 @@
# Copyright (c) 2017 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import contextlib
import datetime
from tripleo_common.tests import base
from tripleo_common.utils import time_functions
@contextlib.contextmanager
def mock_now(dt):
"""Context manager for mocking out datetime.utcnow() in unit tests.
Example:
with mock_now(datetime.datetime(2011, 2, 3, 10, 11)):
assert datetime.datetime.utcnow() \
== datetime.datetime(2011, 2, 3, 10, 11)
"""
class MockDatetime(datetime.datetime):
@classmethod
def utcnow(cls):
return dt
real_datetime = datetime.datetime
datetime.datetime = MockDatetime
try:
yield datetime.datetime
finally:
datetime.datetime = real_datetime
class TimeFunctionsTest(base.TestCase):
def test_timestamp(self):
fake_date = datetime.datetime(2017, 7, 31, 13, 0, 0)
with mock_now(fake_date):
self.assertEqual(time_functions.timestamp(), '20170731-130000')
def test_epoch_formatting(self):
self.assertEqual(
time_functions.epoch_to_formatted_date(1000),
'1970-01-01 00:00:01')
self.assertEqual(
time_functions.epoch_to_formatted_date(1000 * 60),
'1970-01-01 00:01:00')
self.assertEqual(
time_functions.epoch_to_formatted_date(1000 * 60 * 60 * 24),
'1970-01-02 00:00:00')
self.assertEqual(
time_functions.epoch_to_formatted_date(1000.0),
'1970-01-01 00:00:01')
self.assertEqual(
time_functions.epoch_to_formatted_date('1000'),
'1970-01-01 00:00:01')
self.assertRaises(
ValueError,
time_functions.epoch_to_formatted_date,
'abc')
self.assertRaises(
TypeError,
time_functions.epoch_to_formatted_date,
None)

View File

@ -14,7 +14,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import os
import tempfile
from swiftclient import exceptions as swiftexceptions
from tripleo_common import constants
from tripleo_common.utils import tarball
LOG = logging.getLogger(__name__)
def empty_container(swiftclient, name):
@ -43,3 +52,45 @@ def empty_container(swiftclient, name):
def delete_container(swiftclient, name):
empty_container(swiftclient, name)
swiftclient.delete_container(name)
def download_container(swiftclient, container, dest):
"""Download the contents of a Swift container to a directory"""
objects = swiftclient.get_container(container)[1]
for obj in objects:
filename = obj['name']
contents = swiftclient.get_object(container, filename)[1]
path = os.path.join(dest, filename)
dirname = os.path.dirname(path)
if not os.path.exists(dirname):
os.makedirs(dirname)
with open(path, 'w') as f:
f.write(contents)
def get_or_create_container(swiftclient, container):
try:
return swiftclient.get_container(container)
except swiftexceptions.ClientException:
LOG.debug("Container %s doesn't exist, creating...", container)
return swiftclient.put_container(container)
def create_and_upload_tarball(swiftclient,
tmp_dir,
container,
tarball_name,
delete_after=3600):
"""Create a tarball containing the tmp_dir and upload it to Swift."""
headers = {'X-Delete-After': delete_after}
get_or_create_container(swiftclient, container)
with tempfile.NamedTemporaryFile() as tmp_tarball:
tarball.create_tarball(tmp_dir, tmp_tarball.name)
swiftclient.put_object(container, tarball_name, tmp_tarball,
headers=headers)

View File

@ -0,0 +1,29 @@
# Copyright 2017 Red Hat, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
def timestamp():
"""Return a UTC-now timestamp as a string"""
return datetime.datetime.utcnow().strftime('%Y%m%d-%H%M%S')
def epoch_to_formatted_date(epoch):
"""Convert an epoch time to a string"""
epoch = float(epoch) / 1000
dt = datetime.datetime.utcfromtimestamp(epoch)
return dt.strftime('%Y-%m-%d %H:%M:%S')

View File

@ -462,3 +462,140 @@ workflows:
deprecated: <% $.get('formatted', []) %>
on-success:
- fail: <% $.get('status') = "FAILED" %>
publish_ui_logs_to_swift:
description: >
This workflow drains a zaqar queue, and publish its messages into a log
file in swift. This workflow is called by cron trigger.
input:
- queue_name: tripleo
- logging_queue_name: tripleo-ui-logging
- logging_container: tripleo-ui-logs
tasks:
get_messages:
action: zaqar.queue_messages
on-success: format_messages
on-error: get_messages_set_status_failed
input:
queue_name: <% $.logging_queue_name %>
publish:
messages: <% task(get_messages).result %>
format_messages:
action: tripleo.logging_to_swift.format_messages
on-success: upload_to_swift
input:
messages: <% $.messages %>
publish:
messages: <% task(format_messages).result %>
upload_to_swift:
action: tripleo.logging_to_swift.publish_ui_log_to_swift
on-success: set_status_success
on-error: upload_to_swift_set_status_failed
input:
logging_data: <% $.messages %>
logging_container: <% $.logging_container %>
set_status_success:
on-success: notify_zaqar
publish:
status: SUCCESS
message: <% task(upload_to_swift).result %>
upload_to_swift_set_status_failed:
on-success: notify_zaqar
publish:
status: FAILED
message: <% task(upload_to_swift).result %>
get_messages_set_status_failed:
on-success: notify_zaqar
publish:
status: FAILED
message: <% task(get_messages).result %>
notify_zaqar:
action: zaqar.queue_post
input:
queue_name: <% $.queue_name %>
messages:
body:
type: tripleo.plan_management.v1.publish_ui_logs_to_swift
payload:
status: <% $.status %>
message: <% $.get('message', '') %>
execution: <% execution() %>
on-success:
- fail: <% $.get('status') = "FAILED" %>
download_logs:
description: Creates a tarball with logging data
input:
- queue_name: tripleo
- logging_container: "tripleo-ui-logs"
- downloads_container: "tripleo-ui-logs-downloads"
- delete_after: 3600
tasks:
prepare_log_download:
action: tripleo.logging_to_swift.prepare_log_download
input:
logging_container: <% $.logging_container %>
downloads_container: <% $.downloads_container %>
delete_after: <% $.delete_after %>
on-success: create_tempurl
on-error: download_logs_set_status_failed
publish:
filename: <% task(prepare_log_download).result %>
create_tempurl:
action: tripleo.swift.tempurl
on-success: set_status_success
on-error: create_tempurl_set_status_failed
input:
container: <% $.downloads_container %>
obj: <% $.filename %>
valid: 3600
publish:
tempurl: <% task(create_tempurl).result %>
set_status_success:
on-success: notify_zaqar
publish:
status: SUCCESS
message: <% task(create_tempurl).result %>
tempurl: <% task(create_tempurl).result %>
download_logs_set_status_failed:
on-success: notify_zaqar
publish:
status: FAILED
message: <% task(prepare_log_download).result %>
create_tempurl_set_status_failed:
on-success: notify_zaqar
publish:
status: FAILED
message: <% task(create_tempurl).result %>
notify_zaqar:
action: zaqar.queue_post
input:
queue_name: <% $.queue_name %>
messages:
body:
type: tripleo.plan_management.v1.download_logs
payload:
status: <% $.status %>
message: <% $.get('message', '') %>
execution: <% execution() %>
tempurl: <% $.get('tempurl', '') %>
on-success:
- fail: <% $.get('status') = "FAILED" %>