From 456abc2aa4ae10981c2aec2d2e6d975ef30fb8d6 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 28 May 2019 14:39:39 -0500 Subject: T1397: Rewrite the config merge script Add the script vyos-merge-config.py to separate the merge function from the config load script and remove dependency on XorpConfigParser. --- src/helpers/vyos-merge-config.py | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100755 src/helpers/vyos-merge-config.py (limited to 'src/helpers') diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py new file mode 100755 index 000000000..f0d5d1595 --- /dev/null +++ b/src/helpers/vyos-merge-config.py @@ -0,0 +1,96 @@ +#!/usr/bin/python3 + +# Copyright 2019 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +import sys +import os +import subprocess +import vyos.defaults +import vyos.remote +from vyos.config import Config +from vyos.configtree import ConfigTree + + +if (len(sys.argv) < 2): + print("Need config file name to merge.") + print("Usage: merge [config path]") + sys.exit(0) + +file_name = sys.argv[1] + +configdir = vyos.defaults.directories['config'] + +protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp'] + +if any(x in file_name for x in protocols): + config_file = vyos.remote.get_remote_config(file_name) + if not config_file: + sys.exit("No config file by that name.") +else: + canonical_path = "{0}/{1}".format(configdir, file_name) + first_err = None + try: + with open(canonical_path, 'r') as f: + config_file = f.read() + except Exception as err: + first_err = err + try: + with open(file_name, 'r') as f: + config_file = f.read() + except Exception as err: + print(first_err) + print(err) + sys.exit(1) + +path = None +if (len(sys.argv) > 2): + path = " ".join(sys.argv[2:]) + +merge_config_tree = ConfigTree(config_file) + +effective_config = Config() + +output_effective_config = effective_config.show_config() +effective_config_tree = ConfigTree(output_effective_config) + +effective_cmds = effective_config_tree.to_commands() +merge_cmds = merge_config_tree.to_commands() + +effective_cmd_list = effective_cmds.splitlines() +merge_cmd_list = merge_cmds.splitlines() + +effective_cmd_set = set(effective_cmd_list) +add_cmds = [ cmd for cmd in merge_cmd_list if cmd not in effective_cmd_set ] + +if path: + if not effective_config.exists(path): + print("path {} does not exist in running config; will use " + "root.".format(path)) + else: + add_cmds = [ cmd for cmd in add_cmds if path in cmd ] + +for cmd in add_cmds: + cmd = "/opt/vyatta/sbin/my_" + cmd + + try: + subprocess.check_call(cmd, shell=True) + except subprocess.CalledProcessError as err: + print("Called process error: {}.".format(err)) + +if effective_config.session_changed(): + print("Merge complete. Use 'commit' to make changes effective.") +else: + print("No configuration changes to commit.") -- cgit v1.2.3 From 6763170830010c8cea2f17daee5f46b9203dab56 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Thu, 30 May 2019 13:46:08 -0500 Subject: T1334: Migration script runner rewrite Python script and support code to replace the vyatta_config_migrate.pl script. --- python/vyos/defaults.py | 6 +- python/vyos/formatversions.py | 109 +++++++++++++++++++++ python/vyos/migrator.py | 190 ++++++++++++++++++++++++++++++++++++ python/vyos/systemversions.py | 39 ++++++++ src/helpers/run-config-migration.py | 83 ++++++++++++++++ src/helpers/system-versions-foot.py | 39 ++++++++ 6 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 python/vyos/formatversions.py create mode 100644 python/vyos/migrator.py create mode 100644 python/vyos/systemversions.py create mode 100755 src/helpers/run-config-migration.py create mode 100755 src/helpers/system-versions-foot.py (limited to 'src/helpers') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 0603efc42..da363b8e1 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -16,7 +16,11 @@ directories = { "data": "/usr/share/vyos/", - "config": "/opt/vyatta/etc/config" + "config": "/opt/vyatta/etc/config", + "current": "/opt/vyatta/etc/config-migrate/current", + "migrate": "/opt/vyatta/etc/config-migrate/migrate", } cfg_group = 'vyattacfg' + +cfg_vintage = 'vyatta' diff --git a/python/vyos/formatversions.py b/python/vyos/formatversions.py new file mode 100644 index 000000000..29117a5d3 --- /dev/null +++ b/python/vyos/formatversions.py @@ -0,0 +1,109 @@ +# Copyright 2019 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +import sys +import os +import re +import fileinput + +def read_vyatta_versions(config_file): + config_file_versions = {} + + with open(config_file, 'r') as config_file_handle: + for config_line in config_file_handle: + if re.match(r'/\* === vyatta-config-version:.+=== \*/$', config_line): + if not re.match(r'/\* === vyatta-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s+=== \*/$', config_line): + raise ValueError("malformed configuration string: " + "{}".format(config_line)) + + for pair in re.findall(r'([\w,-]+)@(\d+)', config_line): + config_file_versions[pair[0]] = int(pair[1]) + + + return config_file_versions + +def read_vyos_versions(config_file): + config_file_versions = {} + + with open(config_file, 'r') as config_file_handle: + for config_line in config_file_handle: + if re.match(r'// vyos-config-version:.+', config_line): + if not re.match(r'// vyos-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s*', config_line): + raise ValueError("malformed configuration string: " + "{}".format(config_line)) + + for pair in re.findall(r'([\w,-]+)@(\d+)', config_line): + config_file_versions[pair[0]] = int(pair[1]) + + return config_file_versions + +def remove_versions(config_file): + """ + Remove old version string. + """ + for line in fileinput.input(config_file, inplace=True): + if re.match(r'/\* Warning:.+ \*/$', line): + continue + if re.match(r'/\* === vyatta-config-version:.+=== \*/$', line): + continue + if re.match(r'/\* Release version:.+ \*/$', line): + continue + if re.match('// vyos-config-version:.+', line): + continue + if re.match('// Warning:.+', line): + continue + if re.match('// Release version:.+', line): + continue + sys.stdout.write(line) + +def format_versions_string(config_versions): + cfg_keys = list(config_versions.keys()) + cfg_keys.sort() + + component_version_strings = [] + + for key in cfg_keys: + cfg_vers = config_versions[key] + component_version_strings.append('{}@{}'.format(key, cfg_vers)) + + separator = ":" + component_version_string = separator.join(component_version_strings) + + return component_version_string + +def write_vyatta_versions_foot(config_file, component_version_string, + os_version_string): + if config_file: + with open(config_file, 'a') as config_file_handle: + config_file_handle.write('/* Warning: Do not remove the following line. */\n') + config_file_handle.write('/* === vyatta-config-version: "{}" === */\n'.format(component_version_string)) + config_file_handle.write('/* Release version: {} */\n'.format(os_version_string)) + else: + sys.stdout.write('/* Warning: Do not remove the following line. */\n') + sys.stdout.write('/* === vyatta-config-version: "{}" === */\n'.format(component_version_string)) + sys.stdout.write('/* Release version: {} */\n'.format(os_version_string)) + +def write_vyos_versions_foot(config_file, component_version_string, + os_version_string): + if config_file: + with open(config_file, 'a') as config_file_handle: + config_file_handle.write('// Warning: Do not remove the following line.\n') + config_file_handle.write('// vyos-config-version: "{}"\n'.format(component_version_string)) + config_file_handle.write('// Release version: {}\n'.format(os_version_string)) + else: + sys.stdout.write('// Warning: Do not remove the following line.\n') + sys.stdout.write('// vyos-config-version: "{}"\n'.format(component_version_string)) + sys.stdout.write('// Release version: {}\n'.format(os_version_string)) + diff --git a/python/vyos/migrator.py b/python/vyos/migrator.py new file mode 100644 index 000000000..2d4bc7ffc --- /dev/null +++ b/python/vyos/migrator.py @@ -0,0 +1,190 @@ +# Copyright 2019 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +import sys +import os +import subprocess +import vyos.version +import vyos.defaults +import vyos.systemversions as systemversions +import vyos.formatversions as formatversions + +class MigratorError(Exception): + pass + +class Migrator(object): + def __init__(self, config_file, force=False, set_vintage=None): + self._config_file = config_file + self._force = force + self._set_vintage = set_vintage + self._config_file_vintage = None + self._changed = False + + def read_config_file_versions(self): + """ + Get component versions from config file footer and set vintage; + return empty dictionary if config string is missing. + """ + cfg_file = self._config_file + component_versions = {} + + cfg_versions = formatversions.read_vyatta_versions(cfg_file) + + if cfg_versions: + self._config_file_vintage = 'vyatta' + component_versions = cfg_versions + + cfg_versions = formatversions.read_vyos_versions(cfg_file) + + if cfg_versions: + self._config_file_vintage = 'vyos' + component_versions = cfg_versions + + return component_versions + + def update_vintage(self): + old_vintage = self._config_file_vintage + + if self._set_vintage: + self._config_file_vintage = self._set_vintage + + if not self._config_file_vintage: + self._config_file_vintage = vyos.defaults.cfg_vintage + + if self._config_file_vintage not in ['vyatta', 'vyos']: + raise MigratorError("Unknown vintage.") + + if self._config_file_vintage == old_vintage: + return False + else: + return True + + def run_migration_scripts(self, config_file_versions, system_versions): + """ + Run migration scripts iteratively, until config file version equals + system component version. + """ + cfg_versions = config_file_versions + sys_versions = system_versions + + sys_keys = list(sys_versions.keys()) + sys_keys.sort() + + rev_versions = {} + + for key in sys_keys: + sys_ver = sys_versions[key] + if key in cfg_versions: + cfg_ver = cfg_versions[key] + else: + cfg_ver = 0 + + migrate_script_dir = os.path.join( + vyos.defaults.directories['migrate'], key) + + while cfg_ver < sys_ver: + next_ver = cfg_ver + 1 + + migrate_script = os.path.join(migrate_script_dir, + '{}-to-{}'.format(cfg_ver, next_ver)) + + try: + subprocess.check_output([migrate_script, + self._config_file]) + except FileNotFoundError: + pass + except subprocess.CalledProcessError as err: + print("Called process error: {}.".format(err)) + sys.exit(1) + + cfg_ver = next_ver + + rev_versions[key] = cfg_ver + + return rev_versions + + def write_config_file_versions(self, cfg_versions): + """ + Write new versions string. + """ + versions_string = formatversions.format_versions_string(cfg_versions) + + os_version_string = vyos.version.get_version() + + if self._config_file_vintage == 'vyatta': + formatversions.write_vyatta_versions_foot(self._config_file, + versions_string, + os_version_string) + + if self._config_file_vintage == 'vyos': + formatversions.write_vyos_versions_foot(self._config_file, + versions_string, + os_version_string) + + def run(self): + """ + Gather component versions from config file and system. + Run migration scripts. + Update vintage ('vyatta' or 'vyos'), if needed. + If changed, remove old versions string from config file, and + write new versions string. + """ + cfg_file = self._config_file + + cfg_versions = self.read_config_file_versions() + if self._force: + # This will force calling all migration scripts: + cfg_versions = {} + + sys_versions = systemversions.get_system_versions() + + rev_versions = self.run_migration_scripts(cfg_versions, sys_versions) + + if rev_versions != cfg_versions: + self._changed = True + + if self.update_vintage(): + self._changed = True + + if not self._changed: + return + + formatversions.remove_versions(cfg_file) + + self.write_config_file_versions(rev_versions) + + +class VirtualMigrator(Migrator): + def __init__(self, config_file, vintage='vyos'): + super().__init__(config_file, set_vintage = vintage) + + def run(self): + cfg_file = self._config_file + + cfg_versions = self.read_config_file_versions() + if not cfg_versions: + raise MigratorError("Config file has no version information;" + " virtual migration not possible.") + + if self.update_vintage(): + self._changed = True + + if not self._changed: + return + + formatversions.remove_versions(cfg_file) + + self.write_config_file_versions(cfg_versions) + diff --git a/python/vyos/systemversions.py b/python/vyos/systemversions.py new file mode 100644 index 000000000..9b3f4f413 --- /dev/null +++ b/python/vyos/systemversions.py @@ -0,0 +1,39 @@ +# Copyright 2019 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +import os +import re +import sys +import vyos.defaults + +def get_system_versions(): + """ + Get component versions from running system; critical failure if + unable to read migration directory. + """ + system_versions = {} + + try: + version_info = os.listdir(vyos.defaults.directories['current']) + except OSError as err: + print("OS error: {}".format(err)) + sys.exit(1) + + for info in version_info: + if re.match(r'[\w,-]+@\d+', info): + pair = info.split('@') + system_versions[pair[0]] = int(pair[1]) + + return system_versions diff --git a/src/helpers/run-config-migration.py b/src/helpers/run-config-migration.py new file mode 100755 index 000000000..a57a19cdf --- /dev/null +++ b/src/helpers/run-config-migration.py @@ -0,0 +1,83 @@ +#!/usr/bin/python3 + +# Copyright 2019 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +import os +import sys +import argparse +import datetime +import subprocess +from vyos.migrator import Migrator, VirtualMigrator + +def main(): + argparser = argparse.ArgumentParser( + formatter_class=argparse.RawTextHelpFormatter) + argparser.add_argument('config_file', type=str, + help="configuration file to migrate") + argparser.add_argument('--force', action='store_true', + help="Force calling of all migration scripts.") + argparser.add_argument('--set-vintage', type=str, + choices=['vyatta', 'vyos'], + help="Set the format for the config version footer in config" + " file:\n" + "set to 'vyatta':\n" + "(for '/* === vyatta-config-version ... */' format)\n" + "or 'vyos':\n" + "(for '// vyos-config-version ...' format).") + argparser.add_argument('--virtual', action='store_true', + help="Update the format of the trailing comments in" + " config file,\nfrom 'vyatta' to 'vyos'; no migration" + " scripts are run.") + args = argparser.parse_args() + + config_file_name = args.config_file + force_on = args.force + vintage = args.set_vintage + virtual = args.virtual + + if not os.access(config_file_name, os.R_OK): + print("Read error: {}.".format(config_file_name)) + sys.exit(1) + + if not os.access(config_file_name, os.W_OK): + print("Write error: {}.".format(config_file_name)) + sys.exit(1) + + separator = "." + backup_file_name = separator.join([config_file_name, + '{0:%Y-%m-%d-%H%M%S}'.format(datetime.datetime.now()), + 'pre-migration']) + + try: + subprocess.check_call(['cp', '-p', config_file_name, + backup_file_name]) + except subprocess.CalledProcessError as err: + print("Called process error: {}.".format(err)) + sys.exit(1) + + if not virtual: + migration = Migrator(config_file_name, force=force_on, + set_vintage=vintage) + else: + migration = VirtualMigrator(config_file_name) + + migration.run() + + if not migration._changed: + os.remove(backup_file_name) + +if __name__ == '__main__': + main() diff --git a/src/helpers/system-versions-foot.py b/src/helpers/system-versions-foot.py new file mode 100755 index 000000000..c33e41d79 --- /dev/null +++ b/src/helpers/system-versions-foot.py @@ -0,0 +1,39 @@ +#!/usr/bin/python3 + +# Copyright 2019 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +import sys +import vyos.formatversions as formatversions +import vyos.systemversions as systemversions +import vyos.defaults +import vyos.version + +sys_versions = systemversions.get_system_versions() + +component_string = formatversions.format_versions_string(sys_versions) + +os_version_string = vyos.version.get_version() + +sys.stdout.write("\n\n") +if vyos.defaults.cfg_vintage == 'vyos': + formatversions.write_vyos_versions_foot(None, component_string, + os_version_string) +elif vyos.defaults.cfg_vintage == 'vyatta': + formatversions.write_vyatta_versions_foot(None, component_string, + os_version_string) +else: + formatversions.write_vyatta_versions_foot(None, component_string, + os_version_string) -- cgit v1.2.3 From aa88d5192338e2263516de791665f819a00b5c36 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 12 Jun 2019 11:29:07 -0500 Subject: T1397: escape backslashes in output passed to configtree The ouput of config.show_config (cli-shell-api showConfig) does not escape backslashes, whereas configtree expects escaped backslashes. Values containing unescaped backslashes consequently lead to a parsing error; cf. T1001. --- src/helpers/vyos-merge-config.py | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src/helpers') diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py index f0d5d1595..bb2919de2 100755 --- a/src/helpers/vyos-merge-config.py +++ b/src/helpers/vyos-merge-config.py @@ -64,6 +64,10 @@ merge_config_tree = ConfigTree(config_file) effective_config = Config() output_effective_config = effective_config.show_config() +# showConfig (called by config.show_config() does not escape +# backslashes, which configtree expects; cf. T1001. +output_effective_config = output_effective_config.replace("\\", "\\\\") + effective_config_tree = ConfigTree(output_effective_config) effective_cmds = effective_config_tree.to_commands() -- cgit v1.2.3 From 441f95d499f42f57b3a15d78aec826f794fab59f Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 12 Jun 2019 12:04:34 -0500 Subject: T1397: use revised migration method --- python/vyos/migrator.py | 2 ++ src/helpers/vyos-merge-config.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) (limited to 'src/helpers') diff --git a/python/vyos/migrator.py b/python/vyos/migrator.py index 2d4bc7ffc..59d68f0f7 100644 --- a/python/vyos/migrator.py +++ b/python/vyos/migrator.py @@ -165,6 +165,8 @@ class Migrator(object): self.write_config_file_versions(rev_versions) + def config_changed(self): + return self._changed class VirtualMigrator(Migrator): def __init__(self, config_file, vintage='vyos'): diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py index bb2919de2..e97a1c08d 100755 --- a/src/helpers/vyos-merge-config.py +++ b/src/helpers/vyos-merge-config.py @@ -18,8 +18,10 @@ import sys import os import subprocess +import tempfile import vyos.defaults import vyos.remote +import vyos.migrator from vyos.config import Config from vyos.configtree import ConfigTree @@ -59,6 +61,16 @@ path = None if (len(sys.argv) > 2): path = " ".join(sys.argv[2:]) +with tempfile.NamedTemporaryFile() as file_to_migrate: + with open(file_to_migrate.name, 'w') as fd: + fd.write(config_file) + + migration = vyos.migrator.Migrator(file_to_migrate.name) + migration.run() + if migration.config_changed(): + with open(file_to_migrate.name, 'r') as fd: + config_file = fd.read() + merge_config_tree = ConfigTree(config_file) effective_config = Config() -- cgit v1.2.3 From bd6f3f6534a93f8a8c64e06967c24d4c3827c517 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 12 Jun 2019 12:45:00 -0500 Subject: T1397: check for path argument in both effective and merge config The merge config script restores the ability to restrict changes to a specified path. In the initial implementation, the path was checked for validity only with respect to the effective config; fix to allow valid paths from merge config as well. --- src/helpers/vyos-merge-config.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) (limited to 'src/helpers') diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py index e97a1c08d..e9a14ae98 100755 --- a/src/helpers/vyos-merge-config.py +++ b/src/helpers/vyos-merge-config.py @@ -57,10 +57,6 @@ else: print(err) sys.exit(1) -path = None -if (len(sys.argv) > 2): - path = " ".join(sys.argv[2:]) - with tempfile.NamedTemporaryFile() as file_to_migrate: with open(file_to_migrate.name, 'w') as fd: fd.write(config_file) @@ -91,12 +87,19 @@ merge_cmd_list = merge_cmds.splitlines() effective_cmd_set = set(effective_cmd_list) add_cmds = [ cmd for cmd in merge_cmd_list if cmd not in effective_cmd_set ] -if path: - if not effective_config.exists(path): - print("path {} does not exist in running config; will use " - "root.".format(path)) +path = None +if (len(sys.argv) > 2): + path = sys.argv[2:] + if (not effective_config_tree.exists(path) and not + merge_config_tree.exists(path)): + print("path {} does not exist in either effective or merge" + " config; will use root.".format(path)) + path = None else: - add_cmds = [ cmd for cmd in add_cmds if path in cmd ] + path = " ".join(path) + +if path: + add_cmds = [ cmd for cmd in add_cmds if path in cmd ] for cmd in add_cmds: cmd = "/opt/vyatta/sbin/my_" + cmd -- cgit v1.2.3