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

NAS-130325 / 24.10 / Add ability to follow logs of a container of an app #14104

Merged
merged 4 commits into from
Jul 30, 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
1 change: 1 addition & 0 deletions src/middlewared/middlewared/plugins/apps/ix_apps/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def translate_resources_to_desired_workflow(app_resources: dict) -> dict:
'port_config': container_ports_config,
'state': state,
'volume_mounts': [v.__dict__ for v in volume_mounts],
'id': container['Id'],
})
workloads['used_ports'].extend(container_ports_config)
volumes.update(volume_mounts)
Expand Down
87 changes: 87 additions & 0 deletions src/middlewared/middlewared/plugins/apps/logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import errno

import docker.errors
from dateutil.parser import parse, ParserError
from docker.models.containers import Container

from middlewared.event import EventSource
from middlewared.schema import Dict, Int, Str
from middlewared.service import CallError
from middlewared.validators import Range

from .ix_apps.docker.utils import get_docker_client


class AppContainerLogsFollowTailEventSource(EventSource):

"""
Retrieve logs of a container/service in an app.

Name of app and id of container/service is required.
Optionally `tail_lines` and `limit_bytes` can be specified.

`tail_lines` is an option to select how many lines of logs to retrieve for the said container. It
defaults to 500. If set to `null`, it will retrieve complete logs of the container.
"""
ACCEPTS = Dict(
Int('tail_lines', default=500, validators=[Range(min_=1)], null=True),
Str('app_name', required=True),
Str('container_id', required=True),
)
RETURNS = Dict(
Str('data', required=True),
Str('timestamp', required=True, null=True)
)

def __init__(self, *args, **kwargs):
super(AppContainerLogsFollowTailEventSource, self).__init__(*args, **kwargs)
self.logs_stream = None

def validate_log_args(self, app_name, container_id) -> Container:
app = self.middleware.call_sync('app.get_instance', app_name)
if app['state'] != 'RUNNING':
raise CallError(f'App "{app_name}" is not running')

if not any(c['id'] == container_id for c in app['active_workloads']['container_details']):
raise CallError(f'Container "{container_id}" not found in app "{app_name}"', errno=errno.ENOENT)

docker_client = get_docker_client()
try:
container = docker_client.containers.get(container_id)
except docker.errors.NotFound:
raise CallError(f'Container "{container_id}" not found')
sonicaj marked this conversation as resolved.
Show resolved Hide resolved

return container

def run_sync(self):
app_name = self.arg['app_name']
container_id = self.arg['container_id']
tail_lines = self.arg['tail_lines'] or 'all'

container = self.validate_log_args(app_name, container_id)
self.logs_stream = container.logs(stream=True, follow=True, timestamps=True, tail=tail_lines)

for log_entry in map(bytes.decode, self.logs_stream):
# Event should contain a timestamp in RFC3339 format, we should parse it and supply it
# separately so UI can highlight the timestamp giving us a cleaner view of the logs
timestamp = log_entry.split(maxsplit=1)[0].strip()
try:
timestamp = str(parse(timestamp))
except (TypeError, ParserError):
timestamp = None
else:
log_entry = log_entry.split(maxsplit=1)[-1].lstrip()

self.send_event('ADDED', fields={'data': log_entry, 'timestamp': timestamp})

async def cancel(self):
await super().cancel()
if self.logs_stream:
await self.middleware.run_in_thread(self.logs_stream.close)

async def on_finish(self):
self.logs_stream = None


def setup(middleware):
middleware.register_event_source('app.container_log_follow', AppContainerLogsFollowTailEventSource)
Loading