Add a "config show" action to display computed borgmatic configuration as YAML or JSON (#1218).

This commit is contained in:
Dan Helfman
2026-02-14 21:30:53 -08:00
parent ad8d074eff
commit d532fc0f88
14 changed files with 292 additions and 52 deletions
+6
View File
@@ -1,3 +1,9 @@
2.1.3.dev0
* #1218: Add a "config show" action to display computed borgmatic configuration as YAML or JSON,
handy for fetching borgmatic configuration from external scripts. See the documentation for more
information:
https://torsion.org/borgmatic/reference/command-line/actions/config-show/
2.1.2
* #1231: If a source file is deleted during a "spot" check, consider the file as non-matching
and move on instead of immediately failing the entire check.
+44
View File
@@ -0,0 +1,44 @@
import json
import logging
import sys
import borgmatic.config.generate
import borgmatic.logger
logger = logging.getLogger(__name__)
def run_show(show_arguments, configs):
'''
Given the show arguments as an argparse.Namespace instance and a dict of configuration filename
to corresponding parsed configuration, run the "show" action. That consists of rendering and
logging the computed configuration as YAML, separating the configuration for each file with
"---".
If show_arguments.option is set, limit the results to the value of that single option. If
show_arguments.json is True, render the results as JSON with one array element per configuration
file.
'''
borgmatic.logger.add_custom_log_levels()
if show_arguments.json:
sys.stdout.write(
json.dumps(
[
config.get(show_arguments.option) if show_arguments.option else config
for config in configs.values()
]
)
)
return
for config in configs.values():
if len(configs) > 1:
logger.answer('---')
logger.answer(
borgmatic.config.generate.render_configuration(
config.get(show_arguments.option) if show_arguments.option else config
).rstrip()
)
+23
View File
@@ -1261,6 +1261,29 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
help='Show this help message and exit',
)
config_show_parser = config_parsers.add_parser(
'show',
help='Show the computed configuration for each file specified with --config (see borgmatic --help)',
description='Show the computed configuration for each file specified with --config (see borgmatic --help)',
add_help=False,
)
config_show_group = config_show_parser.add_argument_group('config show arguments')
config_show_group.add_argument(
'--option',
help='Show the value of a single named configuration option instead of the entire configuration',
)
config_show_group.add_argument(
'--json',
action='store_true',
help='Show the configuration as JSON with one array element per configuration file',
)
config_show_group.add_argument(
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
export_tar_parser = action_parsers.add_parser(
'export-tar',
aliases=ACTION_ALIASES['export-tar'],
+40 -47
View File
@@ -17,6 +17,7 @@ import borgmatic.actions.check
import borgmatic.actions.compact
import borgmatic.actions.config.bootstrap
import borgmatic.actions.config.generate
import borgmatic.actions.config.show
import borgmatic.actions.config.validate
import borgmatic.actions.create
import borgmatic.actions.delete
@@ -810,18 +811,18 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
'''
add_custom_log_levels()
if 'bootstrap' in arguments:
try:
# No configuration file is needed for bootstrap.
local_borg_version = borg_version.local_borg_version(
{},
arguments['bootstrap'].local_path,
)
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records('Error getting local Borg version', error)
return
try:
if 'bootstrap' in arguments:
try:
# No configuration file is needed for bootstrap.
local_borg_version = borg_version.local_borg_version(
{},
arguments['bootstrap'].local_path,
)
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records('Error getting local Borg version', error)
return
try:
borgmatic.actions.config.bootstrap.run_bootstrap(
arguments['bootstrap'],
arguments['global'],
@@ -835,17 +836,10 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
return
if 'generate' in arguments:
try:
if 'generate' in arguments:
borgmatic.actions.config.generate.run_generate(
arguments['generate'],
arguments['global'],
@@ -858,29 +852,22 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
if 'validate' in arguments:
if configuration_parse_errors:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='Configuration validation failed',
name=logger.name,
),
)
return
try:
if 'validate' in arguments:
if configuration_parse_errors:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='Configuration validation failed',
name=logger.name,
),
)
return
borgmatic.actions.config.validate.run_validate(arguments['validate'], configs)
yield logging.makeLogRecord(
@@ -891,14 +878,20 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
return
if 'show' in arguments:
borgmatic.actions.config.show.run_show(arguments['show'], configs)
return
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
def collect_configuration_run_summary_logs(configs, config_paths, arguments, log_file_path): # noqa: PLR0912
+12 -1
View File
@@ -153,6 +153,9 @@ def transform_optional_configuration(rendered_config, comment_out=True):
return '\n'.join(lines)
RUAMEL_YAML_END_OF_DOCUMENT_MARKER = '...\n'
def render_configuration(config):
'''
Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
@@ -160,7 +163,15 @@ def render_configuration(config):
dumper = ruamel.yaml.YAML(typ='rt')
dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT)
rendered = io.StringIO()
dumper.dump(config, rendered)
dumper.dump(
config,
rendered,
# Dumping certain values (integers, for instance) causes ruamel.yaml to append an
# end-of-document "..." marker. Strip it.
transform=lambda dumped: dumped[: -len(RUAMEL_YAML_END_OF_DOCUMENT_MARKER)]
if dumped.endswith(RUAMEL_YAML_END_OF_DOCUMENT_MARKER)
else dumped,
)
return rendered.getvalue()
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
RUN pip install --break-system-packages --no-cache /app && borgmatic config generate && borgmatic config generate --destination /etc/borgmatic --split && chmod +r /etc/borgmatic/*.yaml
RUN mkdir /command-line \
&& borgmatic --help > /command-line/global.txt \
&& for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate borg; do \
&& for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" "config show" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate borg; do \
borgmatic $action --help > /command-line/${action/ /-}.txt; done
RUN /app/docs/fetch-contributors >> /contributors.html
+21
View File
@@ -64,6 +64,27 @@ suppressed so as not to interfere with the captured JSON. Also note that JSON
output only shows up at the console and not in syslog.
### Getting configuration
<span class="minilink minilink-addedin">New in version 2.1.3</span> If you want
to consume borgmatic's computed configuration in your scripts, use the [`config
show`
action](https://torsion.org/borgmatic/reference/command-line/actions/config-show/).
Here's an example:
```bash
borgmatic config show --json
```
That outputs borgmatic's entire configuration as JSON with one array element per
configuration file.
Or you can ask for the value of a particular option:
```bash
borgmatic config show --option repositories --json
```
### Latest backups
All borgmatic actions that accept an `--archive` flag allow you to specify an
@@ -0,0 +1,17 @@
---
title: config show
eleventyNavigation:
key: config show
parent: 🎬 Actions
---
{% include snippet/command-line/sample.md %}
```
{% include borgmatic/command-line/config-show.txt %}
```
## Related documentation
* [Scripting borgmatic](https://torsion.org/borgmatic/how-to/monitor-your-backups/#scripting-borgmatic)
+3
View File
@@ -317,3 +317,6 @@ a default location.
This will output the merged configuration as borgmatic sees it, which can be
helpful for understanding how your includes work in practice.
Also see the [`config show`
action](https://torsion.org/borgmatic/reference/command-line/actions/config-show/).
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "2.1.2"
version = "2.1.3.dev0"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
@@ -292,6 +292,12 @@ def test_render_configuration_converts_configuration_to_yaml_string():
assert yaml_string == 'foo: bar\n'
def test_render_configuration_strips_ruamel_yaml_end_of_document_marker():
yaml_string = module.render_configuration(33)
assert yaml_string == '33\n'
def test_write_configuration_does_not_raise():
flexmock(os.path).should_receive('exists').and_return(False)
flexmock(os).should_receive('makedirs')
+79
View File
@@ -0,0 +1,79 @@
from flexmock import flexmock
import borgmatic.logger
from borgmatic.actions.config import show as module
def test_run_show_with_single_configuration_file_does_not_separate_output():
log_lines = []
borgmatic.logger.add_custom_log_levels()
def fake_logger_answer(message):
log_lines.append(message)
flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer)
show_arguments = flexmock(option=None, json=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return(
'output'
)
module.run_show(show_arguments, configs={'test.yaml': {}})
assert log_lines == ['output']
def test_run_show_with_multiple_configuration_files_separates_output():
log_lines = []
borgmatic.logger.add_custom_log_levels()
def fake_logger_answer(message):
log_lines.append(message)
flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer)
show_arguments = flexmock(option=None, json=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return(
'output'
).and_return('other')
module.run_show(show_arguments, configs={'test.yaml': {}, 'other.yaml': {}})
assert log_lines == ['---', 'output', '---', 'other']
def test_run_show_with_option_limits_output():
log_lines = []
borgmatic.logger.add_custom_log_levels()
def fake_logger_answer(message):
log_lines.append(message)
flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer)
show_arguments = flexmock(option='foo', json=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').with_args(
33
).and_return('33')
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').with_args(
None
).and_return('null')
module.run_show(show_arguments, configs={'test.yaml': {'foo': 33, 'bar': 44}, 'other.yaml': {}})
assert log_lines == ['---', '33', '---', 'null']
def test_run_show_with_json_outputs_json():
flexmock(borgmatic.logger).should_receive('add_custom_log_levels')
show_arguments = flexmock(option=None, json=True)
flexmock(module.sys.stdout).should_receive('write').with_args(
'[{"foo": 33}, {"bar": 44}]'
).once()
module.run_show(show_arguments, configs={'test.yaml': {'foo': 33}, 'other.yaml': {'bar': 44}})
def test_run_show_with_json_and_option_limits_json():
flexmock(borgmatic.logger).should_receive('add_custom_log_levels')
show_arguments = flexmock(option='foo', json=True)
flexmock(module.sys.stdout).should_receive('write').with_args('[33, null]').once()
module.run_show(show_arguments, configs={'test.yaml': {'foo': 33}, 'other.yaml': {'bar': 44}})
+2 -2
View File
@@ -5,13 +5,13 @@ from borgmatic.actions.config import validate as module
def test_run_validate_does_not_raise():
validate_arguments = flexmock(show=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration')
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return('')
module.run_validate(validate_arguments, flexmock())
def test_run_validate_with_show_does_not_raise():
validate_arguments = flexmock(show=True)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration')
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return('')
module.run_validate(validate_arguments, {'test.yaml': flexmock(), 'other.yaml': flexmock()})
+37
View File
@@ -2058,6 +2058,43 @@ def test_collect_highlander_action_summary_logs_error_on_run_validate_failure():
assert {log.levelno for log in logs} == {logging.CRITICAL}
def test_collect_highlander_action_summary_logs_nothing_additional_for_success_with_show():
flexmock(module.borgmatic.actions.config.show).should_receive('run_show')
arguments = {
'show': flexmock(),
'global': flexmock(),
}
logs = tuple(
module.collect_highlander_action_summary_logs(
{'test.yaml': {}},
arguments=arguments,
configuration_parse_errors=False,
),
)
assert not logs
def test_collect_highlander_action_summary_logs_error_on_run_show_failure():
flexmock(module.borgmatic.actions.config.show).should_receive('run_show').and_raise(
ValueError,
)
arguments = {
'show': flexmock(),
'global': flexmock(),
}
logs = tuple(
module.collect_highlander_action_summary_logs(
{'test.yaml': {}},
arguments=arguments,
configuration_parse_errors=False,
),
)
assert {log.levelno for log in logs} == {logging.CRITICAL}
def test_collect_configuration_run_summary_logs_info_for_success():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks').with_args(