Skip to content

Commit

Permalink
Run arbitrary Borg commands with new "borgmatic borg" action (#425).
Browse files Browse the repository at this point in the history
  • Loading branch information
witten committed Jun 18, 2021
1 parent b37dd1a commit cf8882f
Show file tree
Hide file tree
Showing 13 changed files with 436 additions and 130 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
*.pyc
*.swp
.cache
.coverage
.coverage*
.pytest_cache
.tox
__pycache__
Expand Down
2 changes: 2 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
1.5.15.dev0
* #419: Document use case of running backups conditionally based on laptop power level:
https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/
* #425: Run arbitrary Borg commands with new "borgmatic borg" action. See the documentation for
more information: https://torsion.org/borgmatic/docs/how-to/run-arbitrary-borg-commands/

1.5.14
* #390: Add link to Hetzner storage offering from the documentation.
Expand Down
45 changes: 45 additions & 0 deletions borgmatic/borg/borg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import logging

from borgmatic.borg.flags import make_flags
from borgmatic.execute import execute_command

logger = logging.getLogger(__name__)


REPOSITORYLESS_BORG_COMMANDS = {'serve', None}


def run_arbitrary_borg(
repository, storage_config, options, archive=None, local_path='borg', remote_path=None
):
'''
Given a local or remote repository path, a storage config dict, a sequence of arbitrary
command-line Borg options, and an optional archive name, run an arbitrary Borg command on the
given repository/archive.
'''
lock_wait = storage_config.get('lock_wait', None)

try:
options = options[1:] if options[0] == '--' else options
borg_command = options[0]
command_options = tuple(options[1:])
except IndexError:
borg_command = None
command_options = ()

repository_archive = '::'.join((repository, archive)) if repository and archive else repository

full_command = (
(local_path,)
+ ((borg_command,) if borg_command else ())
+ ((repository_archive,) if borg_command and repository_archive else ())
+ command_options
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ make_flags('remote-path', remote_path)
+ make_flags('lock-wait', lock_wait)
)

return execute_command(
full_command, output_log_level=logging.WARNING, borg_local_path=local_path,
)
3 changes: 2 additions & 1 deletion borgmatic/borg/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ def create_archive(
extra_borg_options = storage_config.get('extra_borg_options', {}).get('create', '')

full_command = (
(local_path, 'create')
tuple(local_path.split(' '))
+ ('create',)
+ _make_pattern_flags(location_config, pattern_file.name if pattern_file else None)
+ _make_exclude_flags(location_config, exclude_file.name if exclude_file else None)
+ (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ())
Expand Down
93 changes: 54 additions & 39 deletions borgmatic/commands/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@
'restore': ['--restore', '-r'],
'list': ['--list', '-l'],
'info': ['--info', '-i'],
'borg': [],
}


def parse_subparser_arguments(unparsed_arguments, subparsers):
'''
Given a sequence of arguments, and a subparsers object as returned by
argparse.ArgumentParser().add_subparsers(), give each requested action's subparser a shot at
parsing all arguments. This allows common arguments like "--repository" to be shared across
multiple subparsers.
Given a sequence of arguments and a dict from subparser name to argparse.ArgumentParser
instance, give each requested action's subparser a shot at parsing all arguments. This allows
common arguments like "--repository" to be shared across multiple subparsers.
Return the result as a dict mapping from subparser name to a parsed namespace of arguments.
Return the result as a tuple of (a dict mapping from subparser name to a parsed namespace of
arguments, a list of remaining arguments not claimed by any subparser).
'''
arguments = collections.OrderedDict()
remaining_arguments = list(unparsed_arguments)
Expand All @@ -35,7 +36,12 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
for alias in aliases
}

for subparser_name, subparser in subparsers.choices.items():
# If the "borg" action is used, skip all other subparsers. This avoids confusion like
# "borg list" triggering borgmatic's own list action.
if 'borg' in unparsed_arguments:
subparsers = {'borg': subparsers['borg']}

for subparser_name, subparser in subparsers.items():
if subparser_name not in remaining_arguments:
continue

Expand All @@ -47,59 +53,45 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
for value in vars(parsed).values():
if isinstance(value, str):
if value in subparsers.choices:
if value in subparsers:
remaining_arguments.remove(value)
elif isinstance(value, list):
for item in value:
if item in subparsers.choices:
if item in subparsers:
remaining_arguments.remove(item)

arguments[canonical_name] = parsed

# If no actions are explicitly requested, assume defaults: prune, create, and check.
if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments:
for subparser_name in ('prune', 'create', 'check'):
subparser = subparsers.choices[subparser_name]
subparser = subparsers[subparser_name]
parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
arguments[subparser_name] = parsed

return arguments


def parse_global_arguments(unparsed_arguments, top_level_parser, subparsers):
'''
Given a sequence of arguments, a top-level parser (containing subparsers), and a subparsers
object as returned by argparse.ArgumentParser().add_subparsers(), parse and return any global
arguments as a parsed argparse.Namespace instance.
'''
# Ask each subparser, one by one, to greedily consume arguments. Any arguments that remain
# are global arguments.
remaining_arguments = list(unparsed_arguments)
present_subparser_names = set()

for subparser_name, subparser in subparsers.choices.items():
if subparser_name not in remaining_arguments:
# Now ask each subparser, one by one, to greedily consume arguments.
for subparser_name, subparser in subparsers.items():
if subparser_name not in arguments.keys():
continue

present_subparser_names.add(subparser_name)
subparser = subparsers[subparser_name]
unused_parsed, remaining_arguments = subparser.parse_known_args(remaining_arguments)

# If no actions are explicitly requested, assume defaults: prune, create, and check.
if (
not present_subparser_names
and '--help' not in unparsed_arguments
and '-h' not in unparsed_arguments
):
for subparser_name in ('prune', 'create', 'check'):
subparser = subparsers.choices[subparser_name]
unused_parsed, remaining_arguments = subparser.parse_known_args(remaining_arguments)
# Special case: If "borg" is present in the arguments, consume all arguments after (+1) the
# "borg" action.
if 'borg' in arguments:
borg_options_index = remaining_arguments.index('borg') + 1
arguments['borg'].options = remaining_arguments[borg_options_index:]
remaining_arguments = remaining_arguments[:borg_options_index]

# Remove the subparser names themselves.
for subparser_name in present_subparser_names:
for subparser_name, subparser in subparsers.items():
if subparser_name in remaining_arguments:
remaining_arguments.remove(subparser_name)

return top_level_parser.parse_args(remaining_arguments)
return (arguments, remaining_arguments)


class Extend_action(Action):
Expand Down Expand Up @@ -510,8 +502,7 @@ def parse_arguments(*unparsed_arguments):
)
list_group = list_parser.add_argument_group('list arguments')
list_group.add_argument(
'--repository',
help='Path of repository to list, defaults to the configured repository if there is only one',
'--repository', help='Path of repository to list, defaults to the configured repositories',
)
list_group.add_argument('--archive', help='Name of archive to list (or "latest")')
list_group.add_argument(
Expand Down Expand Up @@ -601,8 +592,32 @@ def parse_arguments(*unparsed_arguments):
)
info_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')

arguments = parse_subparser_arguments(unparsed_arguments, subparsers)
arguments['global'] = parse_global_arguments(unparsed_arguments, top_level_parser, subparsers)
borg_parser = subparsers.add_parser(
'borg',
aliases=SUBPARSER_ALIASES['borg'],
help='Run an arbitrary Borg command',
description='Run an arbitrary Borg command based on borgmatic\'s configuration',
add_help=False,
)
borg_group = borg_parser.add_argument_group('borg arguments')
borg_group.add_argument(
'--repository',
help='Path of repository to pass to Borg, defaults to the configured repositories',
)
borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")')
borg_group.add_argument(
'--',
metavar='OPTION',
dest='options',
nargs='+',
help='Options to pass to Borg, command first ("create", "list", etc). "--" is optional. To specify the repository or the archive, you must use --repository or --archive instead of providing them here.',
)
borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')

arguments, remaining_arguments = parse_subparser_arguments(
unparsed_arguments, subparsers.choices
)
arguments['global'] = top_level_parser.parse_args(remaining_arguments)

if arguments['global'].excludes_filename:
raise ValueError(
Expand Down
17 changes: 17 additions & 0 deletions borgmatic/commands/borgmatic.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import colorama
import pkg_resources

from borgmatic.borg import borg as borg_borg
from borgmatic.borg import check as borg_check
from borgmatic.borg import create as borg_create
from borgmatic.borg import environment as borg_environment
Expand Down Expand Up @@ -543,6 +544,22 @@ def run_actions(
)
if json_output:
yield json.loads(json_output)
if 'borg' in arguments:
if arguments['borg'].repository is None or validate.repositories_match(
repository, arguments['borg'].repository
):
logger.warning('{}: Running arbitrary Borg command'.format(repository))
archive_name = borg_list.resolve_archive_name(
repository, arguments['borg'].archive, storage, local_path, remote_path
)
borg_borg.run_arbitrary_borg(
repository,
storage,
options=arguments['borg'].options,
archive=archive_name,
local_path=local_path,
remote_path=remote_path,
)


def load_configurations(config_filenames, overrides=None):
Expand Down
2 changes: 1 addition & 1 deletion docs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM python:3.8-alpine3.12 as borgmatic
COPY . /app
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN borgmatic --help > /command-line.txt \
&& for action in init prune create check extract mount umount restore list info; do \
&& for action in init prune create check extract export-tar mount umount restore list info borg; do \
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
&& borgmatic "$action" --help >> /command-line.txt; done

Expand Down
2 changes: 1 addition & 1 deletion docs/how-to/develop-on-borgmatic.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: How to develop on borgmatic
eleventyNavigation:
key: Develop on borgmatic
parent: How-to guides
order: 11
order: 12
---
## Source code

Expand Down
94 changes: 94 additions & 0 deletions docs/how-to/run-arbitrary-borg-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
title: How to run arbitrary Borg commands
eleventyNavigation:
key: Run arbitrary Borg commands
parent: How-to guides
order: 10
---
## Running Borg with borgmatic

Borg has several commands (and options) that borgmatic does not currently
support. Sometimes though, as a borgmatic user, you may find yourself wanting
to take advantage of these off-the-beaten-path Borg features. You could of
course drop down to running Borg directly. But then you'd give up all the
niceties of your borgmatic configuration. You could file a [borgmatic
ticket](https://torsion.org/borgmatic/#issues) or even a [pull
request](https://torsion.org/borgmatic/#contributing) to add the feature. But
what if you need it *now*?

That's where borgmatic's support for running "arbitrary" Borg commands comes
in. Running Borg commands with borgmatic takes advantage of the following, all
based on your borgmatic configuration files or command-line arguments:

* configured repositories (automatically runs your Borg command once for each
one)
* local and remote Borg binary paths
* SSH settings and Borg environment variables
* lock wait settings
* verbosity


### borg action

The way you run Borg with borgmatic is via the `borg` action. Here's a simple
example:

```bash
borgmatic borg break-lock
```

(No `borg` action in borgmatic? Time to upgrade!)

This runs Borg's `break-lock` command once on each configured borgmatic
repository. Notice how the repository isn't present in the specified Borg
options, as that part is provided by borgmatic.

You can also specify Borg options for relevant commands:

```bash
borgmatic borg list --progress
```

This runs Borg's `list` command once on each configured borgmatic
repository. However, the native `borgmatic list` action should be preferred
for most use.

What if you only want to run Borg on a single configured borgmatic repository
when you've got several configured? Not a problem.

```bash
borgmatic borg --repository repo.borg break-lock
```

And what about a single archive?

```bash
borgmatic borg --archive your-archive-name list
```

### Limitations

borgmatic's `borg` action is not without limitations:

* The Borg command you want to run (`create`, `list`, etc.) *must* come first
after the `borg` action. If you have any other Borg options to specify,
provide them after. For instance, `borgmatic borg list --progress` will work,
but `borgmatic borg --progress list` will not.
* borgmatic supplies the repository/archive name to Borg for you (based on
your borgmatic configuration or the `borgmatic borg --repository`/`--archive`
arguments), so do not specify the repository/archive otherwise.
* The `borg` action will not currently work for any Borg commands like `borg
serve` that do not accept a repository/archive name.
* Do not specify any global borgmatic arguments to the right of the `borg`
action. (They will be passed to Borg instead of borgmatic.) If you have
global borgmatic arguments, specify them *before* the `borg` action.
* Unlike other borgmatic actions, you cannot combine the `borg` action with
other borgmatic actions. This is to prevent ambiguity in commands like
`borgmatic borg list`, in which `list` is both a valid Borg command and a
borgmatic action. In this case, only the Borg command is run.
* Unlike normal borgmatic actions that support JSON, the `borg` action will
not disable certain borgmatic logs to avoid interfering with JSON output.

In general, this `borgmatic borg` feature should be considered an escape
valve—a feature of second resort. In the long run, it's preferable to wrap
Borg commands with borgmatic actions that can support them fully.
2 changes: 1 addition & 1 deletion docs/how-to/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: How to upgrade borgmatic
eleventyNavigation:
key: Upgrade borgmatic
parent: How-to guides
order: 10
order: 11
---
## Upgrading

Expand Down
Loading

0 comments on commit cf8882f

Please sign in to comment.