Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ First, if you have a custom configuration for your own setup, set the proper var
export REMOTE_OBSERVATORY_CONFIG=backyard_config
```

## Run the config server
```console
PYTHONPATH=. python ./config/cli.py run --config-file ./conf_files/config.yaml
```

## If you want to try the software with simulators:
```console
./apps/launch_indi_simu.sh
Expand Down
3 changes: 1 addition & 2 deletions conf_files/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ observatory:
180 : 0
270 : 0
twilight_horizon: -18 # Degrees
timezone: Europe/Paris
# Paris is in the Central European Time Zone ( CET ) is 1 hours ahead of
# Greenwich Mean Time ( GMT+1 ).
gmt_offset: 60 # Minutes
# gmt_offset: 60 # Minutes
# webcam:
# rtsp_url: rtsp://user:password@192.168.0.16
scope_controller:
Expand Down
Empty file added config/__init__.py
Empty file.
156 changes: 156 additions & 0 deletions config/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import time

import click
from loguru import logger

from config import (server)
from config.client import get_config
from config.client import server_is_running
from config.client import set_config

import logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s;%(levelname)s:%(message)s')
logger = logging.getLogger(__name__)


@click.group()
@click.option('--verbose/--no-verbose',
envvar='DEBUG',
help='Turn on logger for utils, default False')
@click.option('--host',
default=None,
envvar='CONFIG_HOST',
help='The config server IP address or host name. First'
'checks cli argument, then CONFIG_HOST, then localhost.')
@click.option('--port',
default=None,
envvar='CONFIG_PORT',
help='The config server port. First checks cli argument, '
'then CONFIG_PORT, then 6563')
@click.pass_context
def config_server_cli(context, host='localhost', port=6563, verbose=False):
context.ensure_object(dict)
context.obj['host'] = host
context.obj['port'] = port

# if verbose:
# logger.enable('')


@click.command('run')
@click.option('--config-file',
default=None,
envvar='CONFIG_FILE',
help='The yaml config file to load.'
)
@click.option('--load-local/--no-load-local',
default=True,
help='Use the local config files when loading, default True.')
@click.option('--save-local/--no-save-local',
default=True,
help='If the set values should be saved to the local file, default True.')
@click.option('--heartbeat',
default=2,
help='Heartbeat interval, default 2 seconds.')
@click.pass_context
def run(context, config_file=None, save_local=True, load_local=False, heartbeat=2):
"""Runs the config server with command line options.

This function is installed as an entry_point for the module, accessible
at `config-server`.
"""
host = context.obj.get('host')
port = context.obj.get('port')
logger.debug("About to run config server")
server_process = server.config_server(
config_file,
host=host,
port=port,
load_local=load_local,
save_local=save_local,
auto_start=False
)

try:
print(f'Starting config server. Ctrl-c to stop')
server_process.start()
print(f'Config server started on server_process.pid={server_process.pid!r}. '
f'Set "config_server.running=False" or Ctrl-c to stop')

# Loop until config told to stop.
while server_is_running(host=host, port=port):
time.sleep(heartbeat)

server_process.terminate()
server_process.join(30)
except KeyboardInterrupt:
logger.info(f'Config server interrupted, shutting down {server_process.pid}')
server_process.terminate()
except Exception as e: # pragma: no cover
logger.error(f'Unable to start config server {e!r}')


@click.command('stop')
@click.pass_context
def stop(context):
"""Stops the config server by setting a flag in the server itself."""
host = context.obj.get('host')
port = context.obj.get('port')
logger.info(f'Shutting down config server on {host}:{port}')
set_config('config_server.running', False, host=host, port=port)


@click.command('get')
@click.argument('key', nargs=-1)
@click.option('--default', help='The default to return if not key is found, default None')
@click.option('--parse/--no-parse',
default=True,
help='If results should be parsed into object, default True.')
@click.pass_context
def config_getter(context, key, parse=True, default=None):
"""Get an item from the config server by key name, using dotted notation (e.g. 'location.elevation')

If no key is given, returns the entire config.
"""
host = context.obj.get('host')
port = context.obj.get('port')
try:
# The nargs=-1 makes this a tuple so we get first entry.
key = key[0]
except IndexError:
key = None
logger.debug(f'Getting config key={key!r}')
try:
config_entry = get_config(key=key, host=host, port=port, parse=parse, default=default)
except Exception as e:
logger.error(f'Error while trying to get config: {e!r}')
click.secho(f'Error while trying to get config: {e!r}', fg='red')
else:
logger.debug(f'Config server response: config_entry={config_entry!r}')
click.echo(config_entry)


@click.command('set')
@click.argument('key')
@click.argument('new_value')
@click.option('--parse/--no-parse',
default=True,
help='If results should be parsed into object.')
@click.pass_context
def config_setter(context, key, new_value, parse=True):
"""Set an item in the config server. """
host = context.obj.get('host')
port = context.obj.get('port')

logger.debug(f'Setting config key={key!r} new_value={new_value!r} on {host}:{port}')
config_entry = set_config(key, new_value, host=host, port=port, parse=parse)
click.echo(config_entry)


config_server_cli.add_command(run)
config_server_cli.add_command(stop)
config_server_cli.add_command(config_setter)
config_server_cli.add_command(config_getter)

if __name__ == '__main__':
config_server_cli() # This is where click takes control
202 changes: 202 additions & 0 deletions config/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import os

import requests
from loguru import logger
from requests.exceptions import ConnectionError

from utils.error import InvalidConfig
from utils.serializers import from_json, to_json

import logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s;%(levelname)s:%(message)s')
logger = logging.getLogger(__name__)


def server_is_running(*args, **kwargs): # pragma: no cover
"""Thin-wrapper to check server."""
try:
return get_config(endpoint='heartbeat', verbose=False, *args, **kwargs)
except Exception as e:
logger.warning(f'server_is_running error (ignore if just starting server): {e!r}')
return False


def get_config(key=None,
default=None,
host=None,
port=None,
endpoint='get-config',
parse=True,
verbose=False
):
"""Get a config item from the config server.

Return the config entry for the given ``key``. If ``key=None`` (default), return
the entire config.

Nested keys can be specified as a string, as per `scalpl <https://pypi.org/project/scalpl/>`_.

Examples:

.. doctest::

>>> get_config(key='name')
'Testing PANOPTES Unit'

>>> get_config(key='location.horizon')
<Quantity 30. deg>

>>> # With no parsing, the raw string (including quotes) is returned.
>>> get_config(key='location.horizon', parse=False)
'"30 deg"'
>>> get_config(key='cameras.devices[1].model')
'canon_gphoto2'

>>> # Returns `None` if key is not found.
>>> foobar = get_config(key='foobar')
>>> foobar is None
True

>>> # But you can supply a default.
>>> get_config(key='foobar', default='baz')
'baz'

>>> # key and default are first two parameters.
>>> get_config('foobar', 'baz')
'baz'

>>> # Can use Quantities as well.
>>> from astropy import units as u
>>> get_config('foobar', 42 * u.meter)
<Quantity 42. m>


Notes:
By default all calls to this function will log at the `trace` level because
there are some calls (e.g. during POCS operation) that will be quite noisy.

Setting `verbose=True` changes those to `debug` log levels for an individual
call.

Args:
key (str): The key to update, see Examples in :func:`get_config` for details.
default (str, optional): The config server port, defaults to 6563.
host (str, optional): The config server host. First checks for CONFIG_HOST
env var, defaults to 'localhost'.
port (str or int, optional): The config server port. First checks for CONFIG_HOST
env var, defaults to 6563.
endpoint (str, optional): The relative url endpoint to use for getting
the config items, default 'get-config'. See `server_is_running()`
for example of usage.
parse (bool, optional): If response should be parsed by
:func:`serializers.from_json`, default True.
verbose (bool, optional): Determines the output log level, defaults to
True (i.e. `debug` log level). See notes for details.
Returns:
dict: The corresponding config entry.

Raises:
Exception: Raised if the config server is not available.
"""
log_level = 'DEBUG' if verbose else 'TRACE'

host = host or os.getenv('CONFIG_HOST', 'localhost')
port = port or os.getenv('CONFIG_PORT', 6563)

url = f'http://{host}:{port}/{endpoint}'

config_entry = default

try:
logger.debug(f'Calling get_config on url={url!r} with key={key!r}')
response = requests.post(url, json={'key': key, 'verbose': verbose})
if not response.ok: # pragma: no cover
raise InvalidConfig(f'Config server returned invalid JSON: {response.content!r}')
except ConnectionError:
logger.debug('Bad connection to config-server. Check to make sure it is running.')
except Exception as e: # pragma: no cover
logger.warning(f'Problem with get_config: {e!r}')
else:
response_text = response.text.strip()
logger.debug(f'Decoded response_text={response_text!r}')
if response_text != 'null':
logger.debug(f'Received config key={key!r} response_text={response_text!r}')
if parse:
logger.debug(f'Parsing config results: response_text={response_text!r}')
config_entry = from_json(response_text)
else:
config_entry = response_text

if config_entry is None:
logger.debug(f'No config entry found, returning default={default!r}')
config_entry = default

logger.debug(f'Config key={key!r}: config_entry={config_entry!r}')
return config_entry


def set_config(key, new_value, host=None, port=None, parse=True):
"""Set config item in config server.

Given a `key` entry, update the config to match. The `key` is a dot accessible
string, as given by `scalpl <https://pypi.org/project/scalpl/>`_. See Examples in
:func:`get_config` for details.

Examples:

.. doctest::

>>> from astropy import units as u

>>> # Can use astropy units.
>>> set_config('location.horizon', 35 * u.degree)
{'location.horizon': <Quantity 35. deg>}

>>> get_config(key='location.horizon')
<Quantity 35. deg>

>>> # String equivalent works for 'deg', 'm', 's'.
>>> set_config('location.horizon', '30 deg')
{'location.horizon': <Quantity 30. deg>}

Args:
key (str): The key to update, see Examples in :func:`get_config` for details.
new_value (scalar|object): The new value for the key, can be any serializable object.
host (str, optional): The config server host. First checks for CONFIG_HOST
env var, defaults to 'localhost'.
port (str or int, optional): The config server port. First checks for CONFIG_HOST
env var, defaults to 6563.
parse (bool, optional): If response should be parsed by
:func:`serializers.from_json`, default True.

Returns:
dict: The updated config entry.

Raises:
Exception: Raised if the config server is not available.
"""
host = host or os.getenv('CONFIG_HOST', 'localhost')
port = port or os.getenv('CONFIG_PORT', 6563)
url = f'http://{host}:{port}/set-config'

json_str = to_json({key: new_value})

config_entry = None
try:
# We use our own serializer so pass as `data` instead of `json`.
logger.debug(f'Calling set_config on url={url!r}')
response = requests.post(url,
data=json_str,
headers={'Content-Type': 'application/json'}
)
if not response.ok: # pragma: no cover
raise Exception(f'Cannot access config server: {response.text}')
except Exception as e:
logger.warning(f'Problem with set_config: {e!r}')
else:
if parse:
config_entry = from_json(response.content.decode('utf8'))
else:
config_entry = response.json()

return config_entry
Loading