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

start/stop/status and daemonization #1598

Merged
merged 12 commits into from
Jun 7, 2017
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 .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ script:
- tox -e $TOX_ENV

after_success:
- coverage combine
- codecov

notifications:
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ Changes are ordered reverse-chronologically.

0.5
---

- Update all user logging related timestamps to a custom datetime field that includes timezone info
- Added daemon mode (system service) to run ``kolibri start`` in background (default!) #1548
- Implemented ``kolibri stop`` and ``kolibri status`` #1548
- Newly imported channels are given a 'last_updated' timestamp
- Add progress annotation for topics, lazily loaded to increase page load performance

Expand Down
2 changes: 1 addition & 1 deletion kolibri/deployment/default/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

from .base import * # noqa

KOLIBRI_SKIP_AUTO_DATABASE_MIGRATION = True
KOLIBRI_SKIP_AUTO_DATABASE_MIGRATION = False
224 changes: 194 additions & 30 deletions kolibri/utils/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,37 @@

import importlib # noqa
import logging # noqa
import os # noqa
import signal # noqa
import sys # noqa

# Do this before importing anything else, we need to add bundled requirements
# from the distributed version in case it exists before importing anything
# else.
# TODO: Do we want to manage the path at an even more fundametal place like
# TODO: Do we want to manage the path at an even more fundamental place like
# kolibri.__init__ !? Load order will still matter...
import os # noqa
import signal # noqa
import sys # noqa
from logging import config as logging_config # noqa

import kolibri # noqa
from kolibri import dist as kolibri_dist # noqa
sys.path = [
os.path.realpath(os.path.dirname(kolibri_dist.__file__))
] + sys.path

# Setup path in case we are running with dependencies bundled into Kolibri
# (NOTE: This *must* come before imports below, of django etc, or whl/pex will fail)
sys.path = [os.path.realpath(os.path.dirname(kolibri_dist.__file__))
] + sys.path
# Set default env
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "kolibri.deployment.default.settings.base"
)
os.environ.setdefault(
"KOLIBRI_HOME", os.path.join(os.path.expanduser("~"), ".kolibri")
)
os.environ.setdefault("KOLIBRI_LISTEN_PORT", "8008")

import django # noqa
from django.core.management import call_command # noqa
from docopt import docopt # noqa

from . import server # noqa
from .system import become_daemon # noqa

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.


# This was added in
# https://github.com/learningequality/kolibri/pull/580
Expand All @@ -41,7 +49,7 @@
www.learningequality.org

Usage:
kolibri start [--foreground --watch] [--port=<port>] [options]
kolibri start [--foreground] [--port=<port>] [options]
kolibri stop [options]
kolibri restart [options]
kolibri status [options]
Expand Down Expand Up @@ -100,15 +108,6 @@

""".format(usage="\n".join(map(lambda x: " " + x, USAGE.split("\n"))))

# Set default env
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "kolibri.deployment.default.settings.base"
)
os.environ.setdefault(
"KOLIBRI_HOME", os.path.join(os.path.expanduser("~"), ".kolibri")
)
os.environ.setdefault("KOLIBRI_LISTEN_PORT", "8008")

logger = logging.getLogger(__name__)

KOLIBRI_HOME = os.environ['KOLIBRI_HOME']
Expand All @@ -125,15 +124,30 @@ class PluginDoesNotExist(Exception):

def initialize(debug=False):
"""
Always called before running commands
Currently, always called before running commands. This may change in case
commands that conflict with this behavior show up.

:param: debug: Tells initialization to setup logging etc.
"""

# TODO: We'll move this to a more deliberate location whereby we can
# ensure that some parts of kolibri can run without the whole django stack
django.setup()

setup_logging(debug=debug)

if not os.path.isfile(VERSION_FILE):
_first_run()
else:
version = open(VERSION_FILE, "r").read()
if kolibri.__version__ != version.strip():

This comment was marked as spam.

logger.info(
"Version was {old}, new version: {new}".format(
old=version,
new=kolibri.__version__
)
)
update()


def _first_run():
Expand All @@ -152,12 +166,13 @@ def _first_run():
"to wait a bit while we create a blank database...\n\n"
)

django.setup()

from kolibri.core.settings import SKIP_AUTO_DATABASE_MIGRATION, DEFAULT_PLUGINS

# We need to migrate the database before enabling plugins, because they
# might depend on database readiness.
if not SKIP_AUTO_DATABASE_MIGRATION:
call_command("migrate", interactive=False)

This comment was marked as spam.

call_command("migrate", interactive=False, database="default")
call_command("migrate", interactive=False, database="ormq")

for plugin_module in DEFAULT_PLUGINS:
try:
Expand All @@ -167,13 +182,156 @@ def _first_run():

logger.info("Automatically enabling applications.")

# Finally collect static assets and run migrations again
update()


def update():
"""
Called whenever a version change in kolibri is detected

TODO: We should look at version numbers of external plugins, too!
"""
logger.info("Running update routines for new version...")
call_command("collectstatic", interactive=False)
call_command("collectstatic_js_reverse", interactive=False)

from kolibri.core.settings import SKIP_AUTO_DATABASE_MIGRATION

if not SKIP_AUTO_DATABASE_MIGRATION:
call_command("migrate", interactive=False, database="default")
call_command("migrate", interactive=False, database="ormq")

with open(VERSION_FILE, "w") as f:
f.write(kolibri.__version__)


def start(port=8080, daemon=True):
"""
Start the server on given port.

:param: port: Port number (default: 8080)
:param: daemon: Fork to background process (default: True)
"""

if not daemon:
logger.info("Running 'kolibri start' in foreground...")
else:
logger.info("Running 'kolibri start' as daemon (system service)")

# TODO: moved from server.start() but not sure where it should ideally be
# located. Question is if it should be run every time the server is started
# or if it depends on some kind of state change.
from kolibri.content.utils.annotation import update_channel_metadata_cache

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

update_channel_metadata_cache()

# Daemonize at this point, no more user output is needed
if daemon:

kwargs = {}
# Truncate the file
open(server.DAEMON_LOG, "w").truncate()
logger.info(
"Going to daemon mode, logging to {0}".format(server.DAEMON_LOG)
)
kwargs['out_log'] = server.DAEMON_LOG
kwargs['err_log'] = server.DAEMON_LOG
become_daemon(**kwargs)

server.start(port=port)


def stop():
"""
Stops the server unless it isn't running
"""
try:
pid, __, __ = server.get_status()
server.stop(pid=pid)
stopped = True
except server.NotRunning as e:
verbose_status = "{msg:s} ({code:d})".format(
code=e.status_code,
msg=status.codes[e.status_code]
)
if e.status_code == server.STATUS_STOPPED:
logger.info("Already stopped: {}".format(verbose_status))
stopped = True
elif e.status_code == server.STATUS_STARTING_UP:
logger.error(
"Not stopped: {}".format(verbose_status)
)
sys.exit(e.status_code)
else:
logger.error(
"During graceful shutdown, server says: {}".format(
verbose_status
)
)
logger.error(
"Not responding, killing with force"
)
server.stop(force=True)
stopped = True

if stopped:
logger.info("Server stopped")
sys.exit(0)


def status():
"""
Check the server's status. For possible statuses, see the status dictionary
status.codes

Status *always* outputs the current status in the first line of stderr.
The following lines contain optional information such as the addresses where
the server is listening.

TODO: We can't guarantee the above behavior because of the django stack
being loaded regardless

:returns: status_code, key has description in status.codes
"""
status_code, urls = server.get_urls()

if status_code == server.STATUS_RUNNING:
sys.stderr.write("{msg:s} (0)\n".format(msg=status.codes[0]))
sys.stderr.write("Kolibri running on:\n\n")
for addr in urls:
sys.stderr.write("\t{}\n".format(addr))
return server.STATUS_RUNNING
else:
verbose_status = status.codes[status_code]
sys.stderr.write("{msg:s} ({code:d})\n".format(
code=status_code, msg=verbose_status))
return status_code


status.codes = {
server.STATUS_RUNNING: 'OK, running',
server.STATUS_STOPPED: 'Stopped',
server.STATUS_STARTING_UP: 'Starting up',
server.STATUS_NOT_RESPONDING: 'Not responding',
server.STATUS_FAILED_TO_START:
'Failed to start (check log file: {0})'.format(server.DAEMON_LOG),
server.STATUS_UNCLEAN_SHUTDOWN: 'Unclean shutdown',
server.STATUS_UNKNOWN_INSTANCE: 'Unknown KA Lite running on port',
server.STATUS_SERVER_CONFIGURATION_ERROR: 'KA Lite server configuration error',
server.STATUS_PID_FILE_READ_ERROR: 'Could not read PID file',
server.STATUS_PID_FILE_INVALID: 'Invalid PID file',
server.STATUS_UNKNOWN: 'Could not determine status',
}


def setup_logging(debug=False):
"""Configures logging in cases where a Django environment is not supposed
to be configured"""
"""
Configures logging in cases where a Django environment is not supposed
to be configured.

TODO: This is really confusing, importing django settings is allowed to
fail when debug=False, but if it's true it can fail?
"""
try:
from django.conf.settings import LOGGING
except ImportError:
Expand All @@ -183,7 +341,7 @@ def setup_logging(debug=False):
settings.DEBUG = True
LOGGING['handlers']['console']['level'] = 'DEBUG'
LOGGING['loggers']['kolibri']['level'] = 'DEBUG'
logging_config.dictConfig(LOGGING)
logging.config.dictConfig(LOGGING)
logger.debug("Debug mode is on!")


Expand Down Expand Up @@ -314,9 +472,6 @@ def main(args=None):
to use main() for integration tests in order to test the argument API.
"""

# ensure that Django is set up before we do anything else
django.setup()

signal.signal(signal.SIGINT, signal.SIG_DFL)

arguments, django_args = parse_args(args)
Expand All @@ -342,7 +497,16 @@ def main(args=None):

if arguments['start']:
port = int(arguments['--port'] or 8080)
server.start(port=port)
start(port, daemon=not arguments['--foreground'])
return

if arguments['stop']:
stop()
return

if arguments['status']:
status_code = status()
sys.exit(status_code)
return

if arguments['language'] and arguments['setdefault']:
Expand Down
Loading