Skip to content

Commit

Permalink
es: backup and restore
Browse files Browse the repository at this point in the history
This PR provides CLI commands to backup and restore elasticsearch indices and data.

* Installs CLI commands in `setup.py`.
* Creates CLI commands for creating a repository, creating a snapshot and restoring a snapshot.
* Mount a temp path in elasticsearch docker service and add the `path.repo` configuration to make snapshot working.

Co-Authored-by: Sébastien Délèze <sebastien.deleze@rero.ch>
  • Loading branch information
Sébastien Délèze committed Jan 20, 2021
1 parent 7f0c098 commit 2301628
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 1 deletion.
3 changes: 3 additions & 0 deletions docker-services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ services:
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "discovery.type=single-node"
- "indices.query.bool.max_clause_count=3000"
- "path.repo=/var/tmp"
ulimits:
memlock:
soft: -1
Expand All @@ -92,6 +93,8 @@ services:
ports:
- "9200:9200"
- "9300:9300"
volumes:
- /var/tmp
kibana:
image: docker.elastic.co/kibana/kibana-oss:7.9.1
environment:
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
'documents = sonar.modules.documents.cli.documents:documents',
'oaiharvester = \
sonar.modules.documents.cli.oaiharvester:oaiharvester',
'utils = sonar.modules.cli:utils'
'utils = sonar.modules.cli:utils',
'es = sonar.elasticsearch.cli:es'
],
'invenio_base.apps': [
'sonar = sonar.modules:Sonar',
Expand Down
18 changes: 18 additions & 0 deletions sonar/elasticsearch/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
#
# Swiss Open Access Repository
# Copyright (C) 2019 RERO
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Interactions with Elasticsearch."""
139 changes: 139 additions & 0 deletions sonar/elasticsearch/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
#
# Swiss Open Access Repository
# Copyright (C) 2019 RERO
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""CLI for Elasticsearch."""

import datetime

import click
from flask.cli import with_appcontext
from invenio_search import current_search_client


def abort_if_false(ctx, param, value):
"""Abort command is value is False."""
if not value:
ctx.abort()


@click.group()
def es():
"""Commands for ES."""


@click.group()
def snapshot():
"""Snapshot commands."""


@click.command()
@click.option('--repository', 'repository', default='backup')
@with_appcontext
def create_repository(repository):
"""Create repository for snapshot.
:param repository: Repository name.
"""
click.secho('Create a repository for snapshots')

try:
current_search_client.snapshot.create_repository(
repository, {
'type': 'fs',
'settings': {
'location': repository
}
})
click.secho('Done', fg='green')
except Exception as exception:
click.secho(str(exception), fg='red')


@click.command()
@click.option('--repository', 'repository', default='backup')
@click.option('--name', 'name')
@click.option('--wait/--no-wait', default=False)
@with_appcontext
def backup(repository, name, wait):
"""Backup elasticsearch data.
:param repository: Repository name.
:param name: Name of the snapshot.
:param wait: Wait for completion.
"""
click.secho('Backup elasticsearch data')

# If no name, create a snapshot with the current date
if not name:
name = 'snapshot-{date}'.format(
date=datetime.date.today().strftime('%Y-%m-%d'))

click.secho('Create a snapshot with name {name}'.format(name=name))

try:
# Remove old backup with the same name
try:
current_search_client.snapshot.delete(repository, name)
except Exception:
pass

# Backup data
current_search_client.snapshot.create(repository,
name,
wait_for_completion=wait)

click.secho('Done', fg='green')
except Exception as exception:
click.secho(str(exception), fg='red')


@click.command()
@click.option('--repository', 'repository', default='backup')
@click.option('--name', 'name', required=True)
@click.option('--wait/--no-wait', default=False)
@click.option('--yes-i-know',
is_flag=True,
callback=abort_if_false,
expose_value=False,
prompt='Do you really want to restore a snapshot?')
@with_appcontext
def restore(repository, name, wait):
"""Restore elasticsearch data.
:param repository: Repository name.
:param name: Name of the snapshot.
:param wait: Wait for completion.
"""
click.secho('Restore elasticsearch data')
try:
# Remove all indices
current_search_client.indices.delete('_all')

# Restore the snapshot
current_search_client.snapshot.restore(repository,
name,
wait_for_completion=wait)

click.secho('Done', fg='green')
except Exception as exception:
click.secho(str(exception), fg='red')


snapshot.add_command(create_repository)
snapshot.add_command(backup)
snapshot.add_command(restore)
es.add_command(snapshot)
99 changes: 99 additions & 0 deletions tests/unit/elasticsearch/test_elasticsearch_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
#
# Swiss Open Access Repository
# Copyright (C) 2019 RERO
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Test Elasticsearch cli commands."""

import datetime

from click.testing import CliRunner
from invenio_search import current_search_client
from mock import patch

from sonar.elasticsearch.cli import backup, create_repository, restore


def test_create_repository(app, script_info):
"""Test create repository."""
runner = CliRunner()

# OK
result = runner.invoke(create_repository, obj=script_info)
assert result.output == 'Create a repository for snapshots\nDone\n'

# Repository creation failed
with patch(
'invenio_search.current_search_client.snapshot.create_repository',
side_effect=Exception('Mocked error')):
result = runner.invoke(create_repository, obj=script_info)
assert result.output == 'Create a repository for snapshots\nMocked ' \
'error\n'


def test_backup(app, script_info):
"""Test backup."""
runner = CliRunner()

# OK
current_search_client.snapshot.create_repository('backup', {
'type': 'fs',
'settings': {
'location': 'backup'
}
})
result = runner.invoke(backup, ['--name', 'test'], obj=script_info)
assert result.output == 'Backup elasticsearch data\nDone\n'

# Snapshot with no name
result = runner.invoke(backup, obj=script_info)
assert 'snapshot-{date}'.format(
date=datetime.date.today().strftime('%Y-%m-%d')) in result.output

# Not existing repository
current_search_client.snapshot.delete('backup', 'test')
current_search_client.snapshot.delete_repository('backup')
result = runner.invoke(backup, obj=script_info)
assert 'repository_missing_exception' in result.output


def test_restore(app, script_info):
"""Test restore."""
runner = CliRunner()

current_search_client.snapshot.create_repository('backup', {
'type': 'fs',
'settings': {
'location': 'backup'
}
})
result = runner.invoke(backup, ['--name', 'test', '--wait'],
obj=script_info)

# OK
result = runner.invoke(restore, ['--name', 'test', '--yes-i-know'],
obj=script_info)
assert result.output == 'Restore elasticsearch data\nDone\n'

# Unexisting snapshot
result = runner.invoke(restore, ['--name', 'unexisting', '--yes-i-know'],
obj=script_info)
assert 'snapshot does not exist' in result.output

# Aborting
result = runner.invoke(restore, ['--name', 'test'],
obj=script_info,
input='N')
assert 'Aborted!\n' in result.output

0 comments on commit 2301628

Please sign in to comment.