diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 946e14e..ba76804 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,24 @@ repos: - - repo: local + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 hooks: - - id: format-and-lint - name: Check formatting and linting - entry: mobidata-bw-proxy-proxy:latest ruff ./app - language: docker_image - types: [ python ] + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff-format + - id: ruff + args: [ --fix, --exit-non-zero-on-fix ] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + exclude: ^(tests|dev)/ + additional_dependencies: [ + 'types-PyYAML', + ] diff --git a/addons.py b/addons.py index 628e3eb..2f19768 100644 --- a/addons.py +++ b/addons.py @@ -1,7 +1,19 @@ """ MobiData BW Proxy Copyright (c) 2023, binary butterfly GmbH -All rights reserved. + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. """ from app import App diff --git a/app/__init__.py b/app/__init__.py index e84c3e1..1c36f67 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,19 @@ """ MobiData BW Proxy Copyright (c) 2023, binary butterfly GmbH -All rights reserved. + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. """ from .app import App diff --git a/app/app.py b/app/app.py index c78b458..1de307d 100644 --- a/app/app.py +++ b/app/app.py @@ -1,31 +1,55 @@ """ MobiData BW Proxy Copyright (c) 2023, binary butterfly GmbH -All rights reserved. + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. """ import json import logging +import traceback from importlib import import_module from inspect import isclass from json import JSONDecodeError +from logging.config import dictConfig from pathlib import Path from pkgutil import iter_modules from typing import Dict, List -from mitmproxy.http import HTTPFlow +from mitmproxy.http import HTTPFlow, Response from app.base_converter import BaseConverter from app.config_helper import ConfigHelper +from app.utils import ContextHelper +from app.utils.context_helper import context_helper +from app.utils.default_json_encoder import DefaultJSONEncoder -logger = logging.getLogger('converters.requests') +logger = logging.getLogger(__name__) class App: + config_helper: ConfigHelper + context_helper: ContextHelper + json_converters: Dict[str, List[BaseConverter]] def __init__(self): self.config_helper = ConfigHelper() + self.context_helper = context_helper + + # configure logging + dictConfig(self.config_helper.get('LOGGING')) self.json_converters = {} @@ -48,29 +72,60 @@ def __init__(self): self.json_converters[hostname].append(obj) def request(self, flow: HTTPFlow): + self.context_helper.initialize_context() + if flow.request.host in self.config_helper.get('HTTP_TO_HTTPS_HOSTS', []): flow.request.scheme = 'https' flow.request.port = 443 + self.context_helper.set_attribute('url.host', flow.request.host) + self.context_helper.set_attribute('url.scheme', flow.request.scheme) + self.context_helper.set_attribute('url.port', flow.request.port) + self.context_helper.set_attribute('url.path', flow.request.path) + def response(self, flow: HTTPFlow): # Log requests - logger.info(f'{flow.request.method} {flow.request.url}: HTTP {"-" if flow.response is None else flow.response.status_code}') + logger.debug(f'{flow.request.method} {flow.request.url}: HTTP {"-" if flow.response is None else flow.response.status_code}') # if there is no converter for the requested host, don't do anything if flow.request.host not in self.json_converters: + logger.warning('No JSON converter for request.') return # try to load the response. If there is any error, return. - if not flow.response or not flow.response.text: + if not flow.response: + logger.warning('No response for request.') return + + response: Response = flow.response + + if not response.text: + logger.warning('Empty response for request.') + return + try: - json_data = json.loads(flow.response.text) + json_data = json.loads(response.text) except (JSONDecodeError, TypeError): + logger.warning(f'Invalid JSON in request: {response.text}.') return # iterate all converters and apply them for json_converter in self.json_converters[flow.request.host]: - json_data = json_converter.convert(data=json_data, path=flow.request.path) + self.context_helper.set_attribute('converter', json_converter.__class__.__name__) + try: + json_data = json_converter.convert(data=json_data, path=flow.request.path) + except Exception as e: + logger.error( + f'Converter {json_converter.__class__.__name__} threw an exception {e.__class__.__name__}: {e}', + extra={ + 'attributes': { + # This needs to be json_data, as json_data is maybe already transformed + 'data': json.dumps(json_data), + 'traceback': traceback.format_exc(), + }, + }, + ) + return # set the returning json content - flow.response.text = json.dumps(json_data) + flow.response.text = json.dumps(json_data, cls=DefaultJSONEncoder) diff --git a/app/base_converter.py b/app/base_converter.py index 9dfe8c8..1af3491 100644 --- a/app/base_converter.py +++ b/app/base_converter.py @@ -1,7 +1,19 @@ """ MobiData BW Proxy Copyright (c) 2023, binary butterfly GmbH -All rights reserved. + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. """ from abc import ABC, abstractmethod diff --git a/app/config_helper.py b/app/config_helper.py index 22f630d..87256d9 100644 --- a/app/config_helper.py +++ b/app/config_helper.py @@ -1,7 +1,19 @@ """ MobiData BW Proxy Copyright (c) 2023, binary butterfly GmbH -All rights reserved. + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. """ from pathlib import Path @@ -22,5 +34,5 @@ def __init__(self): else: self._config = {} - def get(self, key: str, default: Any) -> Any: + def get(self, key: str, default: Any = None) -> Any: return self._config.get(key, default) diff --git a/app/converters/donkey_free_bike_status_fix.py b/app/converters/donkey_free_bike_status_fix.py index 6be4fb9..71df8ef 100644 --- a/app/converters/donkey_free_bike_status_fix.py +++ b/app/converters/donkey_free_bike_status_fix.py @@ -1,7 +1,19 @@ """ MobiData BW Proxy Copyright (c) 2023, systect Holger Bruch -All rights reserved. + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. """ import logging @@ -10,7 +22,7 @@ from app.base_converter import BaseConverter -logger = logging.getLogger('converters.DonkeyFreeBikeStatusConverter') +logger = logging.getLogger(__name__) class DonkeyFreeBikeStatusConverter(BaseConverter): diff --git a/app/utils/__init__.py b/app/utils/__init__.py index e69de29..c52d3bc 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -0,0 +1,20 @@ +""" +MobiData BW Proxy +Copyright (c) 2023, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +from .context_helper import Context, ContextHelper +from .gbfs_util import update_stations_availability_status diff --git a/app/utils/context_helper.py b/app/utils/context_helper.py new file mode 100644 index 0000000..6a232e6 --- /dev/null +++ b/app/utils/context_helper.py @@ -0,0 +1,69 @@ +""" +MobiData BW Proxy +Copyright (c) 2025, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +import secrets +from dataclasses import dataclass, field + + +@dataclass +class Context: + trace_id: str + span_id: str + attributes: dict[str, str | int | float] = field(default_factory=dict) + + +class InitializationRequiredException(Exception): ... + + +class ContextHelper: + _current_context: Context | None + + def initialize_context(self, trace_id: str | None = None, span_id: str | None = None): + if trace_id is None: + trace_id = secrets.token_hex(16) + if span_id is None: + span_id = secrets.token_hex(8) + self._current_context = Context(trace_id=trace_id, span_id=span_id) + + def set_attribute(self, key: str, value: str | int | float): + if self._current_context is None: + raise InitializationRequiredException() + + self._current_context.attributes[key] = value + + def get_current_attributes(self) -> dict[str, str | int | float]: + if self._current_context is None: + raise InitializationRequiredException() + + return self._current_context.attributes + + def get_current_trace_id(self) -> str: + if self._current_context is None: + raise InitializationRequiredException() + + return self._current_context.trace_id + + def get_current_span_id(self) -> str: + if self._current_context is None: + raise InitializationRequiredException() + + return self._current_context.span_id + + +# The ContextHelper is initialized globally for logging system access +context_helper = ContextHelper() diff --git a/app/utils/default_json_encoder.py b/app/utils/default_json_encoder.py new file mode 100644 index 0000000..161da21 --- /dev/null +++ b/app/utils/default_json_encoder.py @@ -0,0 +1,40 @@ +""" +MobiData BW Proxy +Copyright (c) 2025, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +import json +from datetime import date, datetime +from decimal import Decimal +from enum import Enum + + +class DefaultJSONEncoder(json.JSONEncoder): + """ + Custom JSON encoder for this app. + """ + + def default(self, obj): + if isinstance(obj, datetime): + return obj.strftime('%Y-%m-%dT%H:%M:%SZ') + if isinstance(obj, date): + return obj.isoformat() + if isinstance(obj, Decimal): + return str(obj) + if isinstance(obj, Enum): + return obj.value + + return obj.__dict__ diff --git a/app/utils/gbfs_util.py b/app/utils/gbfs_util.py index 7b4b7d6..6c73332 100644 --- a/app/utils/gbfs_util.py +++ b/app/utils/gbfs_util.py @@ -1,14 +1,26 @@ """ MobiData BW Proxy Copyright (c) 2023, systect Holger Bruch -All rights reserved. + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. """ import logging from collections import Counter from typing import Any, Callable, Dict, List, Optional -logger = logging.getLogger('utils.gbfs_utils') +logger = logging.getLogger(__name__) def update_stations_availability_status(station_status: List[Dict], vehicles: List[Dict]) -> None: diff --git a/app/utils/logging/__init__.py b/app/utils/logging/__init__.py new file mode 100644 index 0000000..4f9ff95 --- /dev/null +++ b/app/utils/logging/__init__.py @@ -0,0 +1,21 @@ +""" +MobiData BW Proxy +Copyright (c) 2025, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +from .base_attribute_formatter import BaseAttributeFormatter +from .context_open_telemetry_formatter import ContextOpenTelemetryFormatter +from .open_telemetry_formatter import OpenTelemetryFormatter diff --git a/app/utils/logging/base_attribute_formatter.py b/app/utils/logging/base_attribute_formatter.py new file mode 100644 index 0000000..e55712f --- /dev/null +++ b/app/utils/logging/base_attribute_formatter.py @@ -0,0 +1,84 @@ +""" +MobiData BW Proxy +Copyright (c) 2025, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +import functools +import json +import logging +import secrets +import string +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any + +from app.utils.default_json_encoder import DefaultJSONEncoder + + +class BaseAttributeFormatter(logging.Formatter, ABC): + label_allowed_chars: str = f'{string.ascii_letters}{string.digits}_.' + service_name: str + prefix: str + + def __init__(self, *args, prefix: str, service_name: str, **kwargs): + super().__init__(*args, **kwargs) + self.prefix = prefix + self.service_name = service_name + + def format(self, record: logging.LogRecord) -> str: + return json.dumps(self.build_payload(record), cls=DefaultJSONEncoder) + + def add_additional_attributes(self, record_attributes: dict[str, Any]): + pass + + @staticmethod + def get_trace_id() -> str: + return secrets.token_hex(16) + + @staticmethod + def get_span_id() -> str: + return secrets.token_hex(8) + + @functools.lru_cache(256) # noqa: B019 + def format_label(self, label: str | Enum) -> str | None: + if isinstance(label, Enum): + label = label.name + + # The label uses way fewer characters then allowed, just a-z0-9_ + cleaned_label = ''.join(char for char in label if char in self.label_allowed_chars) + if len(cleaned_label) == 0: + return None + return f'{self.prefix}.{cleaned_label.lower()}' + + def build_attributes(self, record: logging.LogRecord) -> dict[str, Any]: + record_attributes: dict[str, Any] = {} + + self.add_additional_attributes(record_attributes) + + if hasattr(record, 'attributes'): + record_attributes.update(getattr(record, 'attributes')) + + attributes = {} + + for attribute_name, attribute_value in record_attributes.items(): + cleared_name = self.format_label(attribute_name) + if cleared_name is not None: + attributes[cleared_name] = attribute_value + + return attributes + + @abstractmethod + def build_payload(self, record: logging.LogRecord) -> dict[str, Any]: ... diff --git a/app/utils/logging/context_attributes_mixin.py b/app/utils/logging/context_attributes_mixin.py new file mode 100644 index 0000000..5632e64 --- /dev/null +++ b/app/utils/logging/context_attributes_mixin.py @@ -0,0 +1,35 @@ +""" +MobiData BW Proxy +Copyright (c) 2025, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +from typing import Any + +from app.utils.context_helper import context_helper + + +class ContextAttributesMixin: + @staticmethod + def add_additional_attributes(record_attributes: dict[str, Any]): + record_attributes.update(context_helper.get_current_attributes()) + + @staticmethod + def get_trace_id() -> str: + return context_helper.get_current_trace_id() + + @staticmethod + def get_span_id() -> str: + return context_helper.get_current_span_id() diff --git a/app/utils/logging/context_open_telemetry_formatter.py b/app/utils/logging/context_open_telemetry_formatter.py new file mode 100644 index 0000000..a630f26 --- /dev/null +++ b/app/utils/logging/context_open_telemetry_formatter.py @@ -0,0 +1,23 @@ +""" +MobiData BW Proxy +Copyright (c) 2025, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +from .context_attributes_mixin import ContextAttributesMixin +from .open_telemetry_formatter import OpenTelemetryFormatter + + +class ContextOpenTelemetryFormatter(ContextAttributesMixin, OpenTelemetryFormatter): ... diff --git a/app/utils/logging/open_telemetry_formatter.py b/app/utils/logging/open_telemetry_formatter.py new file mode 100644 index 0000000..a37b1df --- /dev/null +++ b/app/utils/logging/open_telemetry_formatter.py @@ -0,0 +1,51 @@ +""" +MobiData BW Proxy +Copyright (c) 2025, binary butterfly GmbH + +Licensed under the EUPL, Version 1.2 or – as soon they will be approved by +the European Commission - subsequent versions of the EUPL (the "Licence"); +You may not use this work except in compliance with the Licence. +You may obtain a copy of the Licence at: + +https://joinup.ec.europa.eu/software/page/eupl + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licence for the specific language governing permissions and +limitations under the Licence. +""" + +import logging + +from .base_attribute_formatter import BaseAttributeFormatter + + +class OpenTelemetryFormatter(BaseAttributeFormatter): + log_level_mapping: dict[int, int] = { + logging.DEBUG: 5, + logging.INFO: 10, + logging.WARNING: 15, + logging.ERROR: 20, + logging.CRITICAL: 25, + } + + def build_payload(self, record: logging.LogRecord) -> dict: + return { + 'Timestamp': int(record.created * 1e9), + 'Attributes': { + **self.build_attributes(record), + 'logger.file_name': record.filename, + 'logger.module_path': record.name, + 'logger.module': record.module, + }, + 'Resource': { + 'service.name': self.service_name, + 'service.pid': record.process, + }, + 'TraceId': self.get_trace_id(), + 'SpanId': self.get_span_id(), + 'SeverityText': 'WARN' if record.levelno == logging.WARNING else record.levelname, + 'SeverityNumber': self.log_level_mapping[record.levelno], + 'Body': record.msg, + } diff --git a/config_dist_dev.yaml b/config_dist_dev.yaml index aaaadb8..fb382ca 100644 --- a/config_dist_dev.yaml +++ b/config_dist_dev.yaml @@ -8,3 +8,29 @@ HTTP_TO_HTTPS_HOSTS: - gbfs.prod.sharedmobility.ch - api.voiapp.io - gbfs.api.ridedott.com + +# This logging config sets up stdout and stderr output with OpenTelemetry format +LOGGING: + version: 1 + formatters: + open_telemetry: + (): app.utils.logging.ContextOpenTelemetryFormatter + prefix: proxy + service_name: IPL Proxy + handlers: + console_stdout: + class: logging.StreamHandler + level: DEBUG + formatter: open_telemetry + stream: ext://sys.stdout + console_stderr: + class: logging.StreamHandler + level: ERROR + formatter: open_telemetry + stream: ext://sys.stderr + loggers: + app: + level: DEBUG + handlers: + - console_stdout + - console_stderr diff --git a/docker-compose.yaml b/docker-compose.yaml index e78d363..786f2f3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,6 +14,6 @@ services: - .:/app environment: HOME: /app - command: mitmdump -s addons.py + command: mitmdump -s addons.py --set termlog_verbosity=error --set flow_detail=0 restart: on-failure user: *local-user