]> git.ipfire.org Git - thirdparty/snapper.git/commitdiff
- provide backup program for btrfs snapshots 951/head
authorArvin Schnell <aschnell@suse.de>
Tue, 5 Nov 2024 14:31:17 +0000 (15:31 +0100)
committerArvin Schnell <aschnell@suse.de>
Tue, 5 Nov 2024 14:31:17 +0000 (15:31 +0100)
41 files changed:
client/Makefile.am
client/snbk/.gitignore [new file with mode: 0644]
client/snbk/BackupConfig.cc [new file with mode: 0644]
client/snbk/BackupConfig.h [new file with mode: 0644]
client/snbk/CmdBtrfs.cc [new file with mode: 0644]
client/snbk/CmdBtrfs.h [new file with mode: 0644]
client/snbk/CmdFindmnt.cc [new file with mode: 0644]
client/snbk/CmdFindmnt.h [new file with mode: 0644]
client/snbk/CmdRealpath.cc [new file with mode: 0644]
client/snbk/CmdRealpath.h [new file with mode: 0644]
client/snbk/GlobalOptions.cc [new file with mode: 0644]
client/snbk/GlobalOptions.h [new file with mode: 0644]
client/snbk/JsonFile.cc [new file with mode: 0644]
client/snbk/JsonFile.h [new file with mode: 0644]
client/snbk/Makefile.am [new file with mode: 0644]
client/snbk/Shell.cc [new file with mode: 0644]
client/snbk/Shell.h [new file with mode: 0644]
client/snbk/TheBigThing.cc [new file with mode: 0644]
client/snbk/TheBigThing.h [new file with mode: 0644]
client/snbk/cmd-delete.cc [new file with mode: 0644]
client/snbk/cmd-list-configs.cc [new file with mode: 0644]
client/snbk/cmd-list.cc [new file with mode: 0644]
client/snbk/cmd-transfer-and-delete.cc [new file with mode: 0644]
client/snbk/cmd-transfer.cc [new file with mode: 0644]
client/snbk/cmd.h [new file with mode: 0644]
client/snbk/snbk.cc [new file with mode: 0644]
client/utils/Makefile.am
client/utils/OutputOptions.cc [new file with mode: 0644]
client/utils/OutputOptions.h
client/utils/Table.h
configure.ac
data/Makefile.am
data/backup.service [new file with mode: 0644]
data/backup.timer [new file with mode: 0644]
doc/.gitignore
doc/Makefile.am
doc/snapper-backup-configs.xml.in [new file with mode: 0644]
doc/snbk.xml.in [new file with mode: 0644]
package/snapper.changes
snapper.spec.in
snapper/SnapperDefines.h

index 59de7e6b8de9a40d30455f928e4e4c468daa4e2b..414ac83d12b40d83fe07be4f1efd7a9c95ffc5c9 100644 (file)
@@ -2,4 +2,4 @@
 # Makefile.am for snapper/client
 #
 
-SUBDIRS = utils proxy snapper mksubvolume installation-helper systemd-helper
+SUBDIRS = utils proxy snapper mksubvolume installation-helper systemd-helper snbk
diff --git a/client/snbk/.gitignore b/client/snbk/.gitignore
new file mode 100644 (file)
index 0000000..5561e1a
--- /dev/null
@@ -0,0 +1,4 @@
+*.o
+*.lo
+*.la
+snbk
diff --git a/client/snbk/BackupConfig.cc b/client/snbk/BackupConfig.cc
new file mode 100644 (file)
index 0000000..9d31a85
--- /dev/null
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <cstring>
+
+#include <snapper/SnapperDefines.h>
+#include <snapper/AppUtil.h>
+
+#include "BackupConfig.h"
+#include "JsonFile.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    const vector<string> EnumInfo<BackupConfig::TargetMode>::names({ "local", "ssh-push" });
+
+
+    BackupConfig::BackupConfig(const string& name)
+       : name(name)
+    {
+       JsonFile json_file(BACKUP_CONFIGS_DIR "/" + name + ".json");
+
+       if (!get_child_value(json_file.get_root(), "config", config))
+           SN_THROW(Exception("config entry not found in file"));
+
+       string tmp1;
+       if (!get_child_value(json_file.get_root(), "target-mode", tmp1))
+           SN_THROW(Exception("target-mode entry not found in file"));
+       if (!toValue(tmp1, target_mode, false))
+           SN_THROW(Exception("unknown target-mode"));
+
+       if (!get_child_value(json_file.get_root(), "source-path", source_path))
+           SN_THROW(Exception("source-path entry not found in file"));
+
+       if (!get_child_value(json_file.get_root(), "target-path", target_path))
+           SN_THROW(Exception("target-path entry not found in file"));
+
+       get_child_value(json_file.get_root(), "automatic", automatic);
+
+       if (target_mode == TargetMode::SSH_PUSH)
+       {
+           if (!get_child_value(json_file.get_root(), "ssh-host", ssh_host))
+               SN_THROW(Exception("ssh-host entry not found in file"));
+
+           get_child_value(json_file.get_root(), "ssh-port", ssh_port);
+           get_child_value(json_file.get_root(), "ssh-user", ssh_user);
+           get_child_value(json_file.get_root(), "ssh-identity", ssh_identity);
+       }
+    }
+
+
+    Shell
+    BackupConfig::get_source_shell() const
+    {
+       Shell source_shell;
+
+       return source_shell;
+    }
+
+
+    Shell
+    BackupConfig::get_target_shell() const
+    {
+       Shell target_shell;
+
+       if (target_mode == TargetMode::SSH_PUSH)
+       {
+           target_shell.mode = Shell::Mode::SSH;
+           target_shell.ssh_options = ssh_options();
+       }
+
+       return target_shell;
+    }
+
+
+    vector<string>
+    BackupConfig::ssh_options() const
+    {
+       vector<string> options = { ssh_host };
+
+       if (ssh_port != 0)
+           options.insert(options.end(), { "-p", to_string(ssh_port) });
+
+       if (!ssh_user.empty())
+           options.insert(options.end(), { "-l", ssh_user });
+
+       if (!ssh_identity.empty())
+           options.insert(options.end(), { "-i", ssh_identity });
+
+       return options;
+    }
+
+
+    vector<string>
+    read_backup_config_names()
+    {
+       const vector<string> filenames = glob(BACKUP_CONFIGS_DIR "/" "*.json", 0);
+       const size_t l1 = strlen(BACKUP_CONFIGS_DIR "/");
+       const size_t l2 = strlen(".json");
+
+       vector<string> names;
+
+       for (const string& filename : filenames)
+           names.push_back(filename.substr(l1, filename.size() - l1 - l2));
+
+       return names;
+    }
+
+}
diff --git a/client/snbk/BackupConfig.h b/client/snbk/BackupConfig.h
new file mode 100644 (file)
index 0000000..254bc84
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+#ifndef SNAPPER_BACKUP_CONFIG_H
+#define SNAPPER_BACKUP_CONFIG_H
+
+
+#include <string>
+#include <vector>
+
+#include <snapper/Enum.h>
+
+#include "Shell.h"
+
+
+namespace snapper
+{
+
+    using std::string;
+    using std::vector;
+
+
+    class BackupConfig
+    {
+    public:
+
+       enum class TargetMode
+       {
+           LOCAL, SSH_PUSH
+       };
+
+       BackupConfig(const string& name);
+
+       const string name;
+
+       string config;
+
+       TargetMode target_mode = TargetMode::LOCAL;
+
+       string source_path;
+       string target_path;
+
+       bool automatic = false;
+
+       string ssh_host;
+       unsigned int ssh_port = 0;
+       string ssh_user;
+       string ssh_identity;
+
+       Shell get_source_shell() const;
+       Shell get_target_shell() const;
+
+    private:
+
+       vector<string> ssh_options() const;
+
+    };
+
+
+    using BackupConfigs = vector<BackupConfig>;
+
+
+    template <> struct EnumInfo<BackupConfig::TargetMode> { static const vector<string> names; };
+
+
+    vector<string>
+    read_backup_config_names();
+
+}
+
+
+#endif
diff --git a/client/snbk/CmdBtrfs.cc b/client/snbk/CmdBtrfs.cc
new file mode 100644 (file)
index 0000000..ac98b50
--- /dev/null
@@ -0,0 +1,262 @@
+/*
+ * Copyright (c) [2004-2015] Novell, Inc.
+ * Copyright (c) [2017-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <cstring>
+#include <regex>
+#include <boost/algorithm/string.hpp>
+
+#include "snapper/SnapperTmpl.h"
+#include "snapper/SystemCmd.h"
+#include "snapper/SnapperDefines.h"
+#include "snapper/Exception.h"
+#include "snapper/Log.h"
+#include "CmdBtrfs.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    CmdBtrfsSubvolumeList::CmdBtrfsSubvolumeList(const Shell& shell, const string& mount_point)
+    {
+       SystemCmd::Args cmd_args = { BTRFS_BIN, "subvolume", "list", "-a", "-puqR", mount_point };
+       SystemCmd cmd(shellify(shell, cmd_args));
+
+       if (cmd.retcode() != 0)
+       {
+           y2err("command '" << cmd.cmd() << "' failed: " << cmd.retcode());
+           for (const string& tmp : cmd.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception("'btrfs subvolume list' failed"));
+       }
+
+       parse(cmd.get_stdout());
+    }
+
+
+    void
+    CmdBtrfsSubvolumeList::parse(const vector<string>& lines)
+    {
+       for (const string& line : lines)
+       {
+           Entry entry;
+
+           string::size_type pos1 = line.find("ID ");
+           if (pos1 == string::npos)
+               SN_THROW(Exception("could not find 'id' in 'btrfs subvolume list' output"));
+           line.substr(pos1 + strlen("ID ")) >> entry.id;
+
+           string::size_type pos2 = line.find(" parent ");
+           if (pos2 == string::npos)
+               SN_THROW(Exception("could not find 'parent' in 'btrfs subvolume list' output"));
+           line.substr(pos2 + strlen(" parent ")) >> entry.parent_id;
+
+           // Subvolume can already be deleted, in which case parent is "0"
+           // (and path "DELETED"). That is a temporary state.
+           if (entry.parent_id == 0)
+               continue;
+
+           string::size_type pos3 = line.find(" path ");
+           if (pos3 == string::npos)
+               SN_THROW(Exception("could not find 'path' in 'btrfs subvolume list' output"));
+           entry.path = line.substr(pos3 + strlen(" path "));
+           if (boost::starts_with(entry.path, "<FS_TREE>/"))
+               entry.path.erase(0, strlen("<FS_TREE>/"));
+
+           string::size_type pos4 = line.find(" uuid ");
+           if (pos4 == string::npos)
+               SN_THROW(Exception("could not find 'uuid' in 'btrfs subvolume list' output"));
+           line.substr(pos4 + strlen(" uuid ")) >> entry.uuid;
+
+           string::size_type pos5 = line.find(" parent_uuid ");
+           if (pos5 == string::npos)
+               SN_THROW(Exception("could not find 'parent_uuid' in 'btrfs subvolume list' output"));
+           line.substr(pos5 + strlen(" parent_uuid ")) >> entry.parent_uuid;
+           if (entry.parent_uuid == "-")
+               entry.parent_uuid = "";
+
+           string::size_type pos6 = line.find(" received_uuid ");
+           if (pos6 == string::npos)
+               SN_THROW(Exception("could not find 'received_uuid' in 'btrfs subvolume list' output"));
+           line.substr(pos6 + strlen(" received_uuid ")) >> entry.parent_uuid;
+           if (entry.received_uuid == "-")
+               entry.received_uuid = "";
+
+           data.push_back(entry);
+       }
+
+       // No way to get read-only flag. Only showing read-only snapshots does not help.
+
+       y2mil(*this);
+    }
+
+
+    CmdBtrfsSubvolumeList::const_iterator
+    CmdBtrfsSubvolumeList::find_entry_by_path(const string& path) const
+    {
+       for (const_iterator it = data.begin(); it != data.end(); ++it)
+       {
+           if (it->path == path)
+               return it;
+       }
+
+       return data.end();
+    }
+
+
+    std::ostream&
+    operator<<(std::ostream& s, const CmdBtrfsSubvolumeList& cmd_btrfs_subvolume_list)
+    {
+       for (const CmdBtrfsSubvolumeList::Entry& entry : cmd_btrfs_subvolume_list)
+           s << entry;
+
+       return s;
+    }
+
+
+    std::ostream&
+    operator<<(std::ostream& s, const CmdBtrfsSubvolumeList::Entry& entry)
+    {
+       s << "id:" << entry.id << " parent-id:" << entry.parent_id
+         << " path:" << entry.path << " uuid:" << entry.uuid;
+
+       if (!entry.parent_uuid.empty())
+           s << " parent-uuid:" << entry.parent_uuid;
+
+       if (!entry.received_uuid.empty())
+           s << " received-uuid:" << entry.received_uuid;
+
+       s  << '\n';
+
+       return s;
+    }
+
+
+    CmdBtrfsSubvolumeShow::CmdBtrfsSubvolumeShow(const Shell& shell, const string& mount_point)
+    {
+       SystemCmd::Args cmd_args = { BTRFS_BIN, "subvolume", "show", mount_point };
+       SystemCmd cmd(shellify(shell, cmd_args));
+
+       if (cmd.retcode() != 0)
+       {
+           y2err("command '" << cmd.cmd() << "' failed: " << cmd.retcode());
+           for (const string& tmp : cmd.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception("'btrfs subvolume show' failed"));
+       }
+
+       parse(cmd.get_stdout());
+    }
+
+
+    void
+    CmdBtrfsSubvolumeShow::parse(const vector<string>& lines)
+    {
+       static const regex uuid_regex("[ \t]*UUID:[ \t]*(" UUID_REGEX "|-)[ \t]*", regex::extended);
+       static const regex parent_uuid_regex("[ \t]*Parent UUID:[ \t]*(" UUID_REGEX "|-)[ \t]*", regex::extended);
+       static const regex received_uuid_regex("[ \t]*Received UUID:[ \t]*(" UUID_REGEX "|-)[ \t]*", regex::extended);
+       static const regex creation_time_regex("[ \t]*Creation time:[ \t]*([-+0-9: ]+)[ \t]*", regex::extended);
+       static const regex flags_regex("[ \t]*Flags:[ \t]*(" "readonly" "|-)[ \t]*", regex::extended);
+
+       smatch match;
+
+       for (const string& line : lines)
+       {
+           if (regex_match(line, match, uuid_regex))
+               uuid = match[1];
+
+           if (regex_match(line, match, parent_uuid_regex))
+           {
+               if (match[1] != "-")
+                   parent_uuid = match[1];
+           }
+
+           if (regex_match(line, match, received_uuid_regex))
+           {
+               if (match[1] != "-")
+                   received_uuid = match[1];
+           }
+
+           if (regex_match(line, match, creation_time_regex))
+               creation_time = match[1];
+
+           // so far readonly is the only flag so unclear whether several flags will be
+           // separated by space or comma
+
+           if (regex_match(line, match, flags_regex))
+               read_only = match[1] == "readonly";
+       }
+
+       if (uuid.empty())
+           SN_THROW(Exception("could not find 'uuid' in 'btrfs subvolume show' output"));
+
+       if (uuid == "-")
+       {
+           // If the btrfs was created with older kernels (whatever that means) (tested
+           // with SLES 11 SP3), the top-level subvolume does not have a UUID. Other
+           // subvolumes do have a UUID. In that case also all subvolumes are listed
+           // wrongly as snapshots of the top-level subvolume (by 'btrfs subvolume
+           // show'). The relationship between other subvolumes/snapshots seems to be
+           // fine.
+
+           y2mil("could not find 'uuid' in 'btrfs subvolume show' output - happens if "
+                 "btrfs was created with an old kernel");
+
+           uuid = "";
+       }
+
+       y2mil(*this);
+    }
+
+
+    std::ostream&
+    operator<<(std::ostream& s, const CmdBtrfsSubvolumeShow& cmd_btrfs_subvolume_show)
+    {
+       s << "uuid:" << cmd_btrfs_subvolume_show.uuid;
+
+       if (!cmd_btrfs_subvolume_show.parent_uuid.empty())
+           s << " parent-uuid:" << cmd_btrfs_subvolume_show.parent_uuid;
+
+       if (!cmd_btrfs_subvolume_show.received_uuid.empty())
+           s << " received-uuid:" << cmd_btrfs_subvolume_show.received_uuid;
+
+       if (!cmd_btrfs_subvolume_show.creation_time.empty())
+           s << " creation-time:" << cmd_btrfs_subvolume_show.creation_time;
+
+       if (cmd_btrfs_subvolume_show.read_only)
+           s << " read-only";
+
+       s  << '\n';
+
+       return s;
+    }
+
+}
diff --git a/client/snbk/CmdBtrfs.h b/client/snbk/CmdBtrfs.h
new file mode 100644 (file)
index 0000000..db10c50
--- /dev/null
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) [2004-2015] Novell, Inc.
+ * Copyright (c) [2017-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#ifndef SNAPPER_CMD_BTRFS_H
+#define SNAPPER_CMD_BTRFS_H
+
+
+#include "Shell.h"
+
+
+namespace snapper
+{
+
+    using std::string;
+    using std::vector;
+
+
+    /**
+     * Class to probe for btrfs subvolumes: Call "btrfs subvolume list
+     * <mount-point>".
+     */
+    class CmdBtrfsSubvolumeList
+    {
+    public:
+
+       static const long top_level_id = 5;
+       static const long unknown_id = -1;
+
+       CmdBtrfsSubvolumeList(const Shell& shell, const string& mount_point);
+
+       /**
+        * Entry for every subvolume (unfortunately except the top-level).
+        *
+        * Caution: parent_id and parent_uuid are something completely
+        * different - not just different ways to specify the
+        * "parent".
+        */
+       struct Entry
+       {
+           long id = unknown_id;
+           long parent_id = unknown_id;
+           string path;
+           string uuid;
+           string parent_uuid;
+           string received_uuid;
+       };
+
+       typedef vector<Entry>::value_type value_type;
+       typedef vector<Entry>::const_iterator const_iterator;
+
+       const_iterator begin() const { return data.begin(); }
+       const_iterator end() const { return data.end(); }
+
+       const_iterator find_entry_by_path(const string& path) const;
+
+       friend std::ostream& operator<<(std::ostream& s, const CmdBtrfsSubvolumeList& cmd_btrfs_subvolume_list);
+       friend std::ostream& operator<<(std::ostream& s, const Entry& entry);
+
+    private:
+
+       void parse(const vector<string>& lines);
+
+       vector<Entry> data;
+
+    };
+
+
+    /**
+     * Class to probe for btrfs subvolume information: Call "btrfs subvolume
+     * show <mount-point>".
+     */
+    class CmdBtrfsSubvolumeShow
+    {
+    public:
+
+       CmdBtrfsSubvolumeShow(const Shell& shell, const string& mount_point);
+
+       const string& get_uuid() const { return uuid; }
+       const string& get_parent_uuid() const { return parent_uuid; }
+       const string& get_received_uuid() const { return received_uuid; }
+       const string& get_creation_time() const { return creation_time; }
+       bool is_read_only() const { return read_only; }
+
+       friend std::ostream& operator<<(std::ostream& s, const CmdBtrfsSubvolumeShow&
+                                       cmd_btrfs_subvolume_show);
+
+    private:
+
+       void parse(const vector<string>& lines);
+
+       string uuid;
+       string parent_uuid;
+       string received_uuid;
+       string creation_time;   // TODO should be time_t
+       bool read_only = false;
+
+    };
+
+}
+
+#endif
diff --git a/client/snbk/CmdFindmnt.cc b/client/snbk/CmdFindmnt.cc
new file mode 100644 (file)
index 0000000..4844c8b
--- /dev/null
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include "CmdFindmnt.h"
+#include "JsonFile.h"
+
+#include "snapper/SystemCmd.h"
+#include "snapper/SnapperDefines.h"
+#include "snapper/Exception.h"
+#include "snapper/Log.h"
+
+
+namespace snapper
+{
+
+    CmdFindmnt::CmdFindmnt(const Shell& shell, const string& path)
+       : path(path)
+    {
+       SystemCmd::Args cmd_args = { FINDMNT_BIN, "--json", "--target", path };
+       SystemCmd cmd(shellify(shell, cmd_args));
+
+       if (cmd.retcode() != 0)
+       {
+           y2err("command '" << cmd.cmd() << "' failed: " << cmd.retcode());
+           for (const string& tmp : cmd.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception("'findmnt' failed"));
+       }
+
+       parse_json(cmd.get_stdout());
+    }
+
+
+    void
+    CmdFindmnt::parse_json(const vector<string>& lines)
+    {
+       JsonFile json_file(lines);
+
+       vector<json_object*> tmp1;
+
+       if (!get_child_nodes(json_file.get_root(), "filesystems", tmp1))
+           SN_THROW(Exception("\"filesystems\" not found in json output of 'findmnt'"));
+
+       for (json_object* tmp2 : tmp1)
+       {
+           if (!get_child_value(tmp2, "source", source))
+               SN_THROW(Exception("\"source\" not found or invalid"));
+
+           if (!get_child_value(tmp2, "target", target))
+               SN_THROW(Exception("\"target\" not found or invalid"));
+       }
+
+       y2mil(*this);
+    }
+
+
+    std::ostream&
+    operator<<(std::ostream& s, const CmdFindmnt& cmd_findmnt)
+    {
+       s << "path:" << cmd_findmnt.path << " source:" << cmd_findmnt.source
+         << " target:" << cmd_findmnt.target;
+
+       return s;
+    }
+
+}
diff --git a/client/snbk/CmdFindmnt.h b/client/snbk/CmdFindmnt.h
new file mode 100644 (file)
index 0000000..09cbea9
--- /dev/null
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#ifndef SNAPPER_CMD_FINDMNT_H
+#define SNAPPER_CMD_FINDMNT_H
+
+
+#include "Shell.h"
+
+
+namespace snapper
+{
+
+    using std::string;
+    using std::vector;
+
+
+    /**
+     * Class to probe for mount points: Call "findmnt --target <path>".
+     */
+    class CmdFindmnt
+    {
+    public:
+
+       CmdFindmnt(const Shell& shell, const string& path);
+
+       const string& get_source() const { return source; }
+       const string& get_target() const { return target; }
+
+       friend std::ostream& operator<<(std::ostream& s, const CmdFindmnt& cmd_findmnt);
+
+    private:
+
+       void parse_json(const vector<string>& lines);
+
+       const string path;
+
+       string source;
+       string target;
+
+    };
+
+}
+
+#endif
diff --git a/client/snbk/CmdRealpath.cc b/client/snbk/CmdRealpath.cc
new file mode 100644 (file)
index 0000000..9600fdc
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include "CmdRealpath.h"
+
+#include "snapper/SystemCmd.h"
+#include "snapper/SnapperDefines.h"
+#include "snapper/Exception.h"
+#include "snapper/Log.h"
+
+
+namespace snapper
+{
+
+    CmdRealpath::CmdRealpath(const Shell& shell, const string& path)
+       : path(path)
+    {
+       SystemCmd::Args cmd_args = { REALPATH_BIN, path };
+       SystemCmd cmd(shellify(shell, cmd_args));
+
+       if (cmd.retcode() != 0)
+       {
+           y2err("command '" << cmd.cmd() << "' failed: " << cmd.retcode());
+           for (const string& tmp : cmd.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception("'realpath' failed"));
+       }
+
+       parse(cmd.get_stdout());
+    }
+
+
+    void
+    CmdRealpath::parse(const vector<string>& lines)
+    {
+       if (lines.size() != 1)
+           SN_THROW(Exception("failed to parse output of 'realpath'"));
+
+       realpath = lines[0];
+
+       y2mil(*this);
+    }
+
+
+    std::ostream&
+    operator<<(std::ostream& s, const CmdRealpath& cmd_realpath)
+    {
+       s << "path:" << cmd_realpath.path << " realpath:" << cmd_realpath.realpath;
+
+       return s;
+    }
+
+}
diff --git a/client/snbk/CmdRealpath.h b/client/snbk/CmdRealpath.h
new file mode 100644 (file)
index 0000000..9668713
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#ifndef SNAPPER_CMD_REALPATH_H
+#define SNAPPER_CMD_REALPATH_H
+
+
+#include "Shell.h"
+
+
+namespace snapper
+{
+
+    using std::string;
+    using std::vector;
+
+
+    /**
+     * Class to probe realpath: Call "realpath <path>".
+     */
+    class CmdRealpath
+    {
+    public:
+
+       CmdRealpath(const Shell& shell, const string& path);
+
+       const string& get_realpath() const { return realpath; }
+
+       friend std::ostream& operator<<(std::ostream& s, const CmdRealpath& cmd_realpath);
+
+    private:
+
+       void parse(const vector<string>& lines);
+
+       const string path;
+
+       string realpath;
+
+    };
+
+}
+
+#endif
diff --git a/client/snbk/GlobalOptions.cc b/client/snbk/GlobalOptions.cc
new file mode 100644 (file)
index 0000000..4f9deab
--- /dev/null
@@ -0,0 +1,211 @@
+/*
+ * Copyright (c) [2019-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include "../utils/help.h"
+#include "../misc.h"
+#include "client/utils/text.h"
+#include "client/utils/TableFormatter.h"
+#include "client/utils/CsvFormatter.h"
+
+#include "GlobalOptions.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    void
+    GlobalOptions::help_global_options()
+    {
+       cout << "    " << _("Global options:") << '\n';
+
+       print_options({
+           { _("--quiet, -q"), _("Suppress normal output.") },
+           { _("--verbose, -v"), _("Increase verbosity.") },
+           { _("--debug"), _("Turn on debugging.") },
+           { _("--target-mode <target-mode>"), _("Only use backup-config with specified target-mode.") },
+           { _("--automatic"), _("Only use backup-config with automatic set.") },
+           { _("--utc"), _("Display dates and times in UTC.") },
+           { _("--iso"), _("Display dates and times in ISO format.") },
+           { _("--table-style, -t <style>"), _("Table style (integer).") },
+           { _("--machine-readable <format>"), _("Set a machine-readable output format (csv, json).") },
+           { _("--csvout"), _("Set CSV output format.") },
+           { _("--jsonout"), _("Set JSON output format.") },
+           { _("--separator <separator>"), _("Character separator for CSV output format.") },
+           { _("--no-headers"), _("No headers for CSV output format.") },
+           { _("--backup-config, -b <name>"), _("Set name of backup-config to use.") },
+           { _("--no-dbus"), _("Operate without DBus.") },
+           { _("--version"), _("Print version and exit.") }
+       });
+    }
+
+
+    GlobalOptions::GlobalOptions(GetOpts& parser)
+    {
+       const vector<Option> options = {
+           Option("quiet",                     no_argument,            'q'),
+           Option("verbose",                   no_argument,            'v'),
+           Option("debug",                     no_argument),
+           Option("target-mode",               required_argument),
+           Option("automatic",                 no_argument),
+           Option("utc",                       no_argument),
+           Option("iso",                       no_argument),
+           Option("table-style",               required_argument,      't'),
+           Option("machine-readable",          required_argument),
+           Option("csvout",                    no_argument),
+           Option("jsonout",                   no_argument),
+           Option("separator",                 required_argument),
+           Option("no-headers",                no_argument),
+           Option("backup-config",             required_argument,      'b'),
+           Option("no-dbus",                   no_argument),
+           Option("version",                   no_argument),
+           Option("help",                      no_argument,            'h')
+       };
+
+       ParsedOpts opts = parser.parse(options);
+
+       check_options(opts);
+
+       _quiet = opts.has_option("quiet");
+       _verbose = opts.has_option("verbose");
+       _debug = opts.has_option("debug");
+       _target_mode = only_target_mode_value(opts);
+       _automatic = opts.has_option("automatic");
+       _utc = opts.has_option("utc");
+       _iso = opts.has_option("iso");
+       _no_dbus = opts.has_option("no-dbus");
+       _version = opts.has_option("version");
+       _help = opts.has_option("help");
+       _table_style = table_style_value(opts);
+       _output_format = output_format_value(opts);
+       _headers = !opts.has_option("no-headers");
+       _separator = separator_value(opts);
+       _backup_config = backup_config_value(opts);
+    }
+
+
+    void
+    GlobalOptions::check_options(const ParsedOpts& opts) const
+    {
+    }
+
+
+    boost::optional<BackupConfig::TargetMode>
+    GlobalOptions::only_target_mode_value(const ParsedOpts& opts) const
+    {
+       ParsedOpts::const_iterator it = opts.find("target-mode");
+       if (it == opts.end())
+           return boost::optional<BackupConfig::TargetMode>();
+
+       BackupConfig::TargetMode target_mode;
+
+       if (!toValue(it->second, target_mode, false))
+       {
+           string error = sformat(_("Invalid target mode '%s'."), it->second.c_str()) + '\n';
+           SN_THROW(OptionsException(error));
+       }
+
+       return target_mode;
+    }
+
+
+    Style
+    GlobalOptions::table_style_value(const ParsedOpts& opts) const
+    {
+       ParsedOpts::const_iterator it = opts.find("table-style");
+       if (it == opts.end())
+           return TableFormatter::auto_style();
+
+       try
+       {
+           unsigned long value = stoul(it->second);
+
+           if (value >= Table::num_styles)
+               throw exception();
+
+           return (Style)(value);
+       }
+       catch (const exception&)
+       {
+           string error = sformat(_("Invalid table style '%s'."), it->second.c_str()) + '\n' +
+               sformat(_("Use an integer number from %d to %d."), 0, Table::num_styles - 1);
+
+           SN_THROW(OptionsException(error));
+       }
+
+       return TableFormatter::auto_style();
+    }
+
+
+    GlobalOptions::OutputFormat
+    GlobalOptions::output_format_value(const ParsedOpts& opts) const
+    {
+       if (opts.has_option("csvout"))
+           return OutputFormat::CSV;
+
+       if (opts.has_option("jsonout"))
+           return OutputFormat::JSON;
+
+       ParsedOpts::const_iterator it = opts.find("machine-readable");
+       if (it == opts.end())
+           return OutputFormat::TABLE;
+
+       OutputFormat output_format;
+       if (!toValue(it->second, output_format, false))
+       {
+           string error = sformat(_("Invalid machine readable format '%s'."), it->second.c_str()) + '\n' +
+               possible_enum_values<OutputFormat>();
+
+           SN_THROW(OptionsException(error));
+       }
+
+       return output_format;
+    }
+
+
+    string
+    GlobalOptions::separator_value(const ParsedOpts& opts) const
+    {
+       ParsedOpts::const_iterator it = opts.find("separator");
+       if (it == opts.end())
+           return CsvFormatter::default_separator;
+
+       return it->second;
+    }
+
+
+    boost::optional<string>
+    GlobalOptions::backup_config_value(const ParsedOpts& opts) const
+    {
+       ParsedOpts::const_iterator it = opts.find("backup-config");
+       if (it == opts.end())
+           return boost::optional<string>();
+
+       return it->second;
+    }
+
+
+    const vector<string> EnumInfo<GlobalOptions::OutputFormat>::names({ "table", "csv", "json" });
+
+}
diff --git a/client/snbk/GlobalOptions.h b/client/snbk/GlobalOptions.h
new file mode 100644 (file)
index 0000000..b57a623
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) [2019-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+#ifndef SNAPPER_GLOBAL_OPTIONS_H
+#define SNAPPER_GLOBAL_OPTIONS_H
+
+
+#include <string>
+#include <boost/optional.hpp>
+
+#include <snapper/Enum.h>
+
+#include "client/utils/GetOpts.h"
+#include "client/utils/Table.h"
+#include "BackupConfig.h"
+
+
+namespace snapper
+{
+
+    class GlobalOptions
+    {
+
+    public:
+
+       enum class OutputFormat { TABLE, CSV, JSON };
+
+       static void help_global_options();
+
+       GlobalOptions(GetOpts& get_opts);
+
+       bool quiet() const { return _quiet; }
+       bool verbose() const { return _verbose; }
+       bool debug() const { return _debug; }
+       boost::optional<BackupConfig::TargetMode> target_mode() { return _target_mode; }
+       bool automatic() const { return _automatic; }
+       bool utc() const { return _utc; }
+       bool iso() const { return _iso; }
+       bool no_dbus() const { return _no_dbus; }
+       bool version() const { return _version; }
+       bool help() const { return _help; }
+       Style table_style() const { return _table_style; }
+       OutputFormat output_format() const { return _output_format; }
+       string separator() const { return _separator; }
+       bool headers() const { return _headers; }
+       boost::optional<string> backup_config() const { return _backup_config; }
+
+    private:
+
+       void check_options(const ParsedOpts& parsed_opts) const;
+
+       boost::optional<BackupConfig::TargetMode> only_target_mode_value(const ParsedOpts& parsed_opts) const;
+       Style table_style_value(const ParsedOpts& parsed_opts) const;
+       OutputFormat output_format_value(const ParsedOpts& parsed_opts) const;
+       string separator_value(const ParsedOpts& parsed_opts) const;
+       boost::optional<string> backup_config_value(const ParsedOpts& parsed_opts) const;
+
+       bool _quiet;
+       bool _verbose;
+       bool _debug;
+       boost::optional<BackupConfig::TargetMode> _target_mode;
+       bool _automatic;
+       bool _utc;
+       bool _iso;
+       bool _no_dbus;
+       bool _version;
+       bool _help;
+       Style _table_style;
+       OutputFormat _output_format;
+       string _separator;
+       bool _headers;
+       boost::optional<string> _backup_config;
+
+    };
+
+
+    template <> struct EnumInfo<GlobalOptions::OutputFormat> { static const vector<string> names; };
+
+}
+
+#endif
diff --git a/client/snbk/JsonFile.cc b/client/snbk/JsonFile.cc
new file mode 100644 (file)
index 0000000..26f776f
--- /dev/null
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) [2017-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <stdio.h>
+#include <sys/stat.h>
+#include <functional>
+#include <memory>
+
+#include "snapper/Exception.h"
+#include "snapper/AppUtil.h"
+
+#include "JsonFile.h"
+
+
+namespace snapper
+{
+
+    class JsonTokener
+    {
+    public:
+
+       JsonTokener()
+           : p(json_tokener_new())
+       {
+           if (!p)
+               SN_THROW(Exception("out of memory"));
+       }
+
+       ~JsonTokener()
+       {
+           json_tokener_free(p);
+       }
+
+       json_tokener* get() { return p; }
+
+    private:
+
+       json_tokener* p;
+
+    };
+
+
+    JsonFile::JsonFile(const vector<string>& lines)
+    {
+       JsonTokener tokener;
+
+       for (const string& line : lines)
+       {
+           root = json_tokener_parse_ex(tokener.get(), line.c_str(), line.size());
+
+           switch (json_tokener_get_error(tokener.get()))
+           {
+               case json_tokener_continue:
+                   continue;
+
+               case json_tokener_success:
+                   return;
+
+               default:
+                   break;
+           }
+       }
+
+       SN_THROW(Exception("json parser failed"));
+    }
+
+
+    JsonFile::JsonFile(const string& filename)
+    {
+       FILE* fp = fopen(filename.c_str(), "r");
+       if (!fp)
+           SN_THROW(Exception(sformat("open for json file '%s' failed", filename.c_str())));
+
+       struct stat st;
+       if (fstat(fileno(fp), &st) != 0)
+       {
+           fclose(fp);
+           SN_THROW(Exception(sformat("stat for json file '%s' failed", filename.c_str())));
+       }
+
+       vector<char> data(st.st_size);
+       if (fread(data.data(), 1, st.st_size, fp) != (size_t)(st.st_size))
+       {
+           fclose(fp);
+           SN_THROW(Exception(sformat("read for json file '%s' failed", filename.c_str())));
+       }
+
+       if (fclose(fp) != 0)
+           SN_THROW(Exception(sformat("close for json file '%s' failed", filename.c_str())));
+
+       JsonTokener tokener;
+
+       root = json_tokener_parse_ex(tokener.get(), data.data(), st.st_size);
+
+       if (json_tokener_get_error(tokener.get()) != json_tokener_success)
+       {
+           json_object_put(root);
+           SN_THROW(Exception(sformat("parsing json file '%s' failed", filename.c_str())));
+       }
+
+       if (tokener.get()->char_offset != st.st_size)
+       {
+           json_object_put(root);
+           SN_THROW(Exception(sformat("excessive content in json file '%s'", filename.c_str())));
+       }
+    }
+
+
+    JsonFile::~JsonFile()
+    {
+       json_object_put(root);
+    }
+
+
+    template<>
+    bool
+    get_child_value(json_object* parent, const char* name, string& value)
+    {
+       json_object* child;
+
+       if (!json_object_object_get_ex(parent, name, &child))
+           return false;
+
+       if (!json_object_is_type(child, json_type_string))
+           return false;
+
+       value = json_object_get_string(child);
+
+       return true;
+    }
+
+
+    template<>
+    bool
+    get_child_value(json_object* parent, const char* name, bool& value)
+    {
+       json_object* child;
+
+       if (!json_object_object_get_ex(parent, name, &child))
+           return false;
+
+       if (!json_object_is_type(child, json_type_boolean))
+           return false;
+
+       value = json_object_get_boolean(child);
+
+       return true;
+    }
+
+
+    template<>
+    bool
+    get_child_value(json_object* parent, const char* name, unsigned int& value)
+    {
+       static_assert(sizeof(unsigned int) <= 4, "unsigned int wider than 32 bit");
+
+       json_object* child;
+
+       if (!json_object_object_get_ex(parent, name, &child))
+           return false;
+
+       if (!json_object_is_type(child, json_type_int) && !json_object_is_type(child, json_type_string))
+           return false;
+
+       value = json_object_get_int(child);
+
+       return true;
+    }
+
+
+    bool
+    get_child_nodes(json_object* parent, const char* name, vector<json_object*>& children)
+    {
+       json_object* child;
+
+       if (!json_object_object_get_ex(parent, name, &child))
+           return false;
+
+       if (!json_object_is_type(child, json_type_array))
+           return false;
+
+       children.clear();
+
+       size_t s = json_object_array_length(child);
+       for (size_t i = 0; i < s; ++i)
+           children.push_back(json_object_array_get_idx(child, i));
+
+       return true;
+    }
+
+}
diff --git a/client/snbk/JsonFile.h b/client/snbk/JsonFile.h
new file mode 100644 (file)
index 0000000..23eab9c
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) [2017-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#ifndef SNAPPER_JSON_FILE_H
+#define SNAPPER_JSON_FILE_H
+
+
+#include <json-c/json.h>
+#include <string>
+#include <vector>
+#include <boost/noncopyable.hpp>
+
+
+namespace snapper
+{
+
+    using std::string;
+    using std::vector;
+
+
+    /*
+     * The user might expect more use of const here but in the end it does not work out
+     * since json_object_get_string does not take a const (likely due to reference
+     * counting).
+     */
+
+
+    class JsonFile : private boost::noncopyable
+    {
+
+    public:
+
+       JsonFile(const vector<string>& lines);
+
+       JsonFile(const string& filename);
+
+       ~JsonFile();
+
+       json_object* get_root() { return root; }
+
+    private:
+
+       json_object* root = nullptr;
+
+    };
+
+
+    template<typename Type> bool
+    get_child_value(json_object* parent, const char* name, Type& value);
+
+
+    bool
+    get_child_nodes(json_object* parent, const char* name, vector<json_object*>& children);
+
+}
+
+
+#endif
diff --git a/client/snbk/Makefile.am b/client/snbk/Makefile.am
new file mode 100644 (file)
index 0000000..a1c245b
--- /dev/null
@@ -0,0 +1,31 @@
+#
+# Makefile.am for snapper/client/snbk
+#
+
+AM_CPPFLAGS = -I$(top_srcdir) $(DBUS_CFLAGS) $(JSON_C_CFLAGS)
+
+sbin_PROGRAMS = snbk
+
+snbk_SOURCES =                                                 \
+       snbk.cc                                         \
+       cmd.h                                           \
+       cmd-list-configs.cc                             \
+       cmd-list.cc                                     \
+       cmd-transfer.cc                                 \
+       cmd-delete.cc                                   \
+       cmd-transfer-and-delete.cc                      \
+       BackupConfig.cc         BackupConfig.h          \
+       TheBigThing.cc          TheBigThing.h           \
+       GlobalOptions.cc        GlobalOptions.h         \
+       Shell.cc                Shell.h                 \
+       CmdBtrfs.cc             CmdBtrfs.h              \
+       CmdFindmnt.cc           CmdFindmnt.h            \
+       CmdRealpath.cc          CmdRealpath.h           \
+       JsonFile.cc             JsonFile.h
+
+snbk_LDADD =                           \
+       ../../snapper/libsnapper.la     \
+       ../../dbus/libdbus.la           \
+       ../proxy/libproxy.la            \
+       ../proxy/libclient.la           \
+        $(JSON_C_LIBS)
diff --git a/client/snbk/Shell.cc b/client/snbk/Shell.cc
new file mode 100644 (file)
index 0000000..d9bfb95
--- /dev/null
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include "snapper/SnapperDefines.h"
+#include "snapper/Exception.h"
+
+#include "Shell.h"
+
+
+namespace snapper
+{
+
+    namespace
+    {
+
+       // TODO move to libsnapper?
+
+       string
+       quote(const vector<string>& strs)
+       {
+           string ret;
+
+           for (vector<string>::const_iterator it = strs.begin(); it != strs.end(); ++it)
+           {
+               if (it != strs.begin())
+                   ret.append(" ");
+               ret.append(SystemCmd::quote(*it));
+           }
+
+           return ret;
+       }
+
+
+       string
+       quote(const SystemCmd::Args& args)
+       {
+           return quote(args.get_values());
+       }
+
+    }
+
+
+    SystemCmd::Args
+    shellify(const Shell& shell, const SystemCmd::Args& args1)
+    {
+       switch (shell.mode)
+       {
+           case Shell::Mode::DIRECT:
+           {
+               return args1;
+           }
+
+           case Shell::Mode::SSH:
+           {
+               SystemCmd::Args args2 = { SSH_BIN };
+               args2 << shell.ssh_options << quote(args1);
+               return args2;
+           }
+       }
+
+       SN_THROW(Exception("invalid shell mode"));
+       __builtin_unreachable();
+    }
+
+
+    SystemCmd::Args
+    shellify_pipe(const SystemCmd::Args& args1, const Shell& shell2, const SystemCmd::Args& args2)
+    {
+       string tmp1 = quote(args1);
+       string tmp2 = quote(args2);
+
+       switch (shell2.mode)
+       {
+           case Shell::Mode::DIRECT:
+           {
+               return SystemCmd::Args { SH_BIN, "-c", tmp1 + " | " + tmp2 };
+           }
+
+           case Shell::Mode::SSH:
+           {
+               return SystemCmd::Args { SH_BIN, "-c", tmp1 + " | " + SSH_BIN " " +
+                   quote(shell2.ssh_options) + " " + quote(tmp2) };
+           }
+       }
+
+       SN_THROW(Exception("invalid shell mode"));
+       __builtin_unreachable();
+    }
+
+}
diff --git a/client/snbk/Shell.h b/client/snbk/Shell.h
new file mode 100644 (file)
index 0000000..d16b625
--- /dev/null
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#ifndef SNAPPER_SHELL_H
+#define SNAPPER_SHELL_H
+
+
+#include <string>
+#include <vector>
+
+#include "snapper/SystemCmd.h"
+
+
+namespace snapper
+{
+
+    using std::string;
+    using std::vector;
+
+
+    struct Shell
+    {
+       enum class Mode
+       {
+           DIRECT, SSH
+       };
+
+       Mode mode = Mode::DIRECT;
+       vector<string> ssh_options;
+    };
+
+
+    SystemCmd::Args
+    shellify(const Shell& shell, const SystemCmd::Args& args);
+
+
+    SystemCmd::Args
+    shellify_pipe(const SystemCmd::Args& args1, const Shell& shell2, const SystemCmd::Args& args2);
+
+}
+
+#endif
diff --git a/client/snbk/TheBigThing.cc b/client/snbk/TheBigThing.cc
new file mode 100644 (file)
index 0000000..9f9f7f0
--- /dev/null
@@ -0,0 +1,505 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <iostream>
+#include <regex>
+#include <boost/algorithm/string/predicate.hpp>
+
+#include "snapper/SystemCmd.h"
+#include "snapper/SnapperDefines.h"
+#include "snapper/SnapperTmpl.h"
+
+#include "../proxy/proxy.h"
+#include "../utils/text.h"
+
+#include "CmdBtrfs.h"
+#include "CmdFindmnt.h"
+#include "CmdRealpath.h"
+#include "BackupConfig.h"
+#include "TheBigThing.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    const vector<string> EnumInfo<TheBigThing::SourceState>::names({
+       "missing", "read-only", "read-write"
+    });
+
+
+    const vector<string> EnumInfo<TheBigThing::TargetState>::names({
+       "missing", "valid", "invalid"
+    });
+
+
+    void
+    TheBigThing::transfer(const BackupConfig& backup_config, const TheBigThings& the_big_things,
+                         bool quiet)
+    {
+       if (!quiet)
+           cout << sformat(_("Transferring snapshot %d."), num) << '\n';
+
+       if (source_state == SourceState::MISSING)
+           SN_THROW(Exception(_("Snapshot not on source.")));
+
+       if (target_state != TargetState::MISSING)
+           SN_THROW(Exception(_("Snapshot already on target.")));
+
+       // Create directory on target.
+
+       SystemCmd::Args cmd1_args = { MKDIR_BIN, backup_config.target_path + "/" + to_string(num) };
+       SystemCmd cmd1(shellify(backup_config.get_target_shell(), cmd1_args));
+       if (cmd1.retcode() != 0)
+       {
+           y2err("command '" << cmd1.cmd() << "' failed: " << cmd1.retcode());
+           for (const string& tmp : cmd1.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd1.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception(_("'mkdir' failed.")));
+       }
+
+       // Copy info.xml to target.
+
+       switch (backup_config.target_mode)
+       {
+           case BackupConfig::TargetMode::LOCAL:
+           {
+               SystemCmd::Args cmd2_args = { CP_BIN, backup_config.source_path + "/" SNAPSHOTS_NAME "/" +
+                   to_string(num) + "/info.xml", backup_config.target_path + "/" + to_string(num) + "/" };
+               SystemCmd cmd2(shellify(backup_config.get_target_shell(), cmd2_args));
+               if (cmd2.retcode() != 0)
+               {
+                   y2err("command '" << cmd2.cmd() << "' failed: " << cmd2.retcode());
+                   for (const string& tmp : cmd2.get_stdout())
+                       y2err(tmp);
+                   for (const string& tmp : cmd2.get_stderr())
+                       y2err(tmp);
+
+                   SN_THROW(Exception(_("'cp info.xml' failed.")));
+               }
+           }
+           break;
+
+           case BackupConfig::TargetMode::SSH_PUSH:
+           {
+               SystemCmd::Args cmd2_args = { SCP_BIN };
+               if (backup_config.ssh_port != 0)
+                   cmd2_args << "-P" << to_string(backup_config.ssh_port);
+               if (!backup_config.ssh_identity.empty())
+                   cmd2_args << "-i" << backup_config.ssh_identity;
+               cmd2_args << backup_config.source_path + "/" SNAPSHOTS_NAME "/" + to_string(num) + "/info.xml"
+                         << (backup_config.ssh_user.empty() ? "" : "a") + backup_config.ssh_host + ":" +
+                   backup_config.target_path + "/" + to_string(num) + "/";
+               SystemCmd cmd2(cmd2_args);
+               if (cmd2.retcode() != 0)
+               {
+                   y2err("command '" << cmd2.cmd() << "' failed: " << cmd2.retcode());
+                   for (const string& tmp : cmd2.get_stdout())
+                       y2err(tmp);
+                   for (const string& tmp : cmd2.get_stderr())
+                       y2err(tmp);
+
+                   SN_THROW(Exception(_("'scp info.xml' failed.")));
+               }
+           }
+           break;
+       }
+
+       // Copy snapshot to target.
+
+       TheBigThings::const_iterator it1 = the_big_things.find_send_parent(*this);
+
+       SystemCmd::Args cmd3a_args = { BTRFS_BIN, "send" };
+       if (it1 != the_big_things.end())
+           cmd3a_args << "-p" << backup_config.source_path + "/" SNAPSHOTS_NAME "/" +
+               to_string(it1->num) + "/" SNAPSHOT_NAME;
+       cmd3a_args << backup_config.source_path + "/" SNAPSHOTS_NAME "/" + to_string(num) + "/" SNAPSHOT_NAME;
+
+       SystemCmd::Args cmd3b_args = { BTRFS_BIN, "receive", backup_config.target_path + "/" + to_string(num) };
+
+       y2deb("source: " << cmd3a_args.get_values());
+       y2deb("target: " << cmd3b_args.get_values());
+
+       SystemCmd cmd3(shellify_pipe(cmd3a_args, backup_config.get_target_shell(), cmd3b_args));
+       if (cmd3.retcode() != 0)
+       {
+           y2err("command '" << cmd3.cmd() << "' failed: " << cmd3.retcode());
+           for (const string& tmp : cmd3.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd3.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception(_("'btrfs send | btrfs receive' failed.")));
+       }
+
+       target_state = TargetState::VALID;
+    }
+
+
+    void
+    TheBigThing::remove(const BackupConfig& backup_config, bool quiet)
+    {
+       if (!quiet)
+           cout << sformat(_("Deleting snapshot %d."), num) << '\n';
+
+       if (target_state == TargetState::MISSING)
+           SN_THROW(Exception(_("Snapshot not on target.")));
+
+       // Delete snapshot on target.
+
+       SystemCmd::Args cmd1_args = { BTRFS_BIN, "subvolume", "delete", backup_config.target_path + "/" +
+           to_string(num) + "/" SNAPSHOT_NAME };
+       SystemCmd cmd1(shellify(backup_config.get_target_shell(), cmd1_args));
+       if (cmd1.retcode() != 0)
+       {
+           y2err("command '" << cmd1.cmd() << "' failed: " << cmd1.retcode());
+           for (const string& tmp : cmd1.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd1.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception(_("'btrfs subvolume delete' failed.")));
+       }
+
+       // Remove info.xml on target.
+
+       SystemCmd::Args cmd2_args = { RM_BIN, backup_config.target_path + "/" + to_string(num) + "/info.xml" };
+       SystemCmd cmd2(shellify(backup_config.get_target_shell(), cmd2_args));
+       if (cmd2.retcode() != 0)
+       {
+           y2err("command '" << cmd2.cmd() << "' failed: " << cmd2.retcode());
+           for (const string& tmp : cmd2.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd2.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception(_("'rm info.xml' failed.")));
+       }
+
+       // Remove directory on target.
+
+       SystemCmd::Args cmd3_args = { RMDIR_BIN, backup_config.target_path + "/" + to_string(num) };
+       SystemCmd cmd3(shellify(backup_config.get_target_shell(), cmd3_args));
+       if (cmd3.retcode() != 0)
+       {
+           y2err("command '" << cmd3.cmd() << "' failed: " << cmd3.retcode());
+           for (const string& tmp : cmd3.get_stdout())
+               y2err(tmp);
+           for (const string& tmp : cmd3.get_stderr())
+               y2err(tmp);
+
+           SN_THROW(Exception(_("'rmdir' failed.")));
+       }
+
+       target_state = TargetState::MISSING;
+    }
+
+
+    bool
+    operator<(const TheBigThing& lhs, const TheBigThing& rhs)
+    {
+       return lhs.num < rhs.num;
+    }
+
+
+    TheBigThings::TheBigThings(const BackupConfig& backup_config, ProxySnappers* snappers, bool verbose)
+       : snapper(snappers->getSnapper(backup_config.config)), locker(snapper)
+    {
+       if (backup_config.source_path != snapper->getConfig().getSubvolume())
+           SN_THROW(Exception(_("Path mismatch between backup-config and config.")));
+
+       probe_source(backup_config, verbose);
+       probe_target(backup_config, verbose);
+
+       sort(the_big_things.begin(), the_big_things.end());
+    }
+
+
+    void
+    TheBigThings::probe_source(const BackupConfig& backup_config, bool verbose)
+    {
+       const Shell shell_source = backup_config.get_source_shell();
+
+       // Query snapshots on source from snapperd.
+
+       if (verbose)
+           cout << _("Probing source snapshots.") << endl;
+
+       if (backup_config.source_path != snapper->getConfig().getSubvolume())
+           SN_THROW(Exception(_("Path mismatch.")));
+
+       const ProxySnapshots& source_snapshots = snapper->getSnapshots();
+
+       if (verbose)
+           cout << _("Probing extra information for source snapshots.") << endl;
+
+       for (const ProxySnapshot& source_snapshot : source_snapshots)
+       {
+           unsigned int num = source_snapshot.getNum();
+           if (num == 0)
+               continue;
+
+           // Query additional information (uuids, read-only) from btrfs.
+
+           CmdBtrfsSubvolumeShow extra(shell_source, backup_config.source_path + "/" SNAPSHOTS_NAME "/" +
+                                       to_string(num) + "/" SNAPSHOT_NAME);
+
+           TheBigThing the_big_thing(num);
+           the_big_thing.date = source_snapshot.getDate();
+           the_big_thing.source_state = extra.is_read_only() ? TheBigThing::SourceState::READ_ONLY :
+               TheBigThing::SourceState::READ_WRITE;
+           the_big_thing.source_uuid = extra.get_uuid();
+           the_big_thing.source_parent_uuid = extra.get_parent_uuid();
+           the_big_thing.source_received_uuid = extra.get_received_uuid();
+           the_big_thing.source_creation_time = extra.get_creation_time();
+
+           the_big_things.push_back(the_big_thing);
+       }
+    }
+
+
+    void
+    TheBigThings::probe_target(const BackupConfig& backup_config, bool verbose)
+    {
+       const Shell shell_target = backup_config.get_target_shell();
+
+       // Query snapshots on target from btrfs.
+
+       if (verbose)
+           cout << _("Probing target snapshots.") << endl;
+
+       // In case the target-path is a symbolic link (or includes things like "/../") we
+       // need a lookup for the realpath first.
+
+       CmdRealpath cmd_realpath(shell_target, backup_config.target_path);
+       const string target_path = cmd_realpath.get_realpath();
+
+       CmdFindmnt cmd_findmnt(shell_target, target_path);
+       const string mount_point = cmd_findmnt.get_target();
+
+       if (target_path.size() < mount_point.size())
+           SN_THROW(Exception("unsupported target-path setup"));
+
+       if (!boost::starts_with(target_path, mount_point))
+           SN_THROW(Exception("unsupported target-path setup"));
+
+       CmdBtrfsSubvolumeList target_snapshots(shell_target, mount_point);
+
+       string start;
+       if (target_path != mount_point)
+           start = target_path.substr(mount_point.size() + 1) + "/";
+
+       if (verbose)
+           cout << _("Probing extra information for target snapshots.") << endl;
+
+       static const regex num_regex("([0-9]+)/snapshot", regex::extended);
+
+       for (const CmdBtrfsSubvolumeList::Entry& target_snapshot : target_snapshots)
+       {
+           if (!boost::starts_with(target_snapshot.path, start))
+               continue;
+
+           string path = target_snapshot.path.substr(start.size());
+
+           smatch match;
+
+           if (!regex_match(path, match, num_regex))
+           {
+               string error = sformat(_("Invalid subvolume path '%s' on target."), path.c_str());
+               SN_THROW(Exception(error));
+           }
+
+           unsigned int num = stoi(match[1]);
+
+           // Query additional information (receive-uuid, read-only) from btrfs.
+
+           CmdBtrfsSubvolumeShow y(shell_target, target_path + "/" + path);
+
+           bool is_read_only = y.is_read_only();
+           if (!is_read_only)
+           {
+               y2deb(num << " not read-only, maybe interrupted transfer");
+           }
+
+           vector<TheBigThing>::iterator it = find(num);
+           if (it != end())
+           {
+               // Wrong receive-uuid can happen when a snapshots is transferred, then removed
+               // and a new one with the same number is generated.
+
+               // When a snapshot is restored using btrfs send and receive the received
+               // uuid of the source is identical to the received uuid of the target -
+               // not the uuid of the target. In that case the target is also valid.
+
+               bool correct_uuid = false;
+
+               if (!y.get_received_uuid().empty())
+               {
+                   if (it->source_uuid == y.get_received_uuid())
+                       correct_uuid = true;
+                   else if (it->source_received_uuid == y.get_received_uuid())
+                       correct_uuid = true;
+
+                   if (!correct_uuid)
+                   {
+                       y2deb(num << " wrong uuid, maybe snapshot number reuse");
+                   }
+               }
+
+               if (correct_uuid && is_read_only)
+                   it->target_state = TheBigThing::TargetState::VALID;
+               else
+                   it->target_state = TheBigThing::TargetState::INVALID;
+           }
+           else
+           {
+               TheBigThing the_big_thing(num);
+
+               // Cannot check received-uuid so assume valid.
+
+               if (is_read_only)
+                   the_big_thing.target_state = TheBigThing::TargetState::VALID;
+               else
+                   the_big_thing.target_state = TheBigThing::TargetState::INVALID;
+
+               it = the_big_things.insert(the_big_things.end(), the_big_thing);
+           }
+
+           it->target_uuid = y.get_uuid();
+           it->target_parent_uuid = y.get_parent_uuid();
+           it->target_received_uuid = y.get_received_uuid();
+           it->target_creation_time = y.get_creation_time();
+       }
+    }
+
+
+    void
+    TheBigThings::transfer(const BackupConfig& backup_config, bool quiet, bool verbose)
+    {
+       for (TheBigThing& the_big_thing : the_big_things)
+       {
+           if (the_big_thing.source_state == TheBigThing::SourceState::READ_ONLY)
+           {
+               if (the_big_thing.target_state == TheBigThing::TargetState::INVALID)
+               {
+                   the_big_thing.remove(backup_config, quiet);
+               }
+
+               if (the_big_thing.target_state == TheBigThing::TargetState::MISSING)
+               {
+                   the_big_thing.transfer(backup_config, *this, quiet);
+               }
+           }
+       }
+    }
+
+
+    void
+    TheBigThings::remove(const BackupConfig& backup_config, bool quiet, bool verbose)
+    {
+       for (TheBigThing& the_big_thing : the_big_things)
+       {
+           if (the_big_thing.target_state == TheBigThing::TargetState::INVALID)
+           {
+               the_big_thing.remove(backup_config, quiet);
+           }
+
+           if (the_big_thing.source_state == TheBigThing::SourceState::MISSING &&
+               the_big_thing.target_state != TheBigThing::TargetState::MISSING)
+           {
+               the_big_thing.remove(backup_config, quiet);
+           }
+       }
+    }
+
+
+    vector<TheBigThing>::iterator
+    TheBigThings::find(unsigned int num)
+    {
+       return find_if(begin(), end(), [num](const TheBigThing& the_big_thing) {
+           return the_big_thing.num == num;
+       });
+    }
+
+
+    TheBigThings::const_iterator
+    TheBigThings::find_send_parent(const TheBigThing& the_big_thing) const
+    {
+       typedef vector<TheBigThing>::const_reverse_iterator const_reverse_iterator;
+
+       // Find the direct parent or a previous snapshots with the same parent UUID (more
+       // a sibling).
+
+       for (const_reverse_iterator it1 = the_big_things.rbegin(); it1 != the_big_things.rend(); ++it1)
+       {
+           if (it1->num >= the_big_thing.num)
+               continue;
+
+           if (it1->source_state != TheBigThing::SourceState::READ_ONLY ||
+               it1->target_state != TheBigThing::TargetState::VALID)
+               continue;
+
+           if (it1->source_uuid == the_big_thing.source_parent_uuid)
+               // base() is a bit surprising, compensate that
+               return (it1 + 1).base();
+
+           if (it1->source_parent_uuid == the_big_thing.source_parent_uuid)
+               return (it1 + 1).base();
+       }
+
+       // Find the direct parent of the direct parent. This case can happen after a
+       // rollback.
+
+       for (const_reverse_iterator it1 = the_big_things.rbegin(); it1 != the_big_things.rend(); ++it1)
+       {
+           if (it1->num >= the_big_thing.num)
+               continue;
+
+           // Here the direct parent itself might be read-write and thus not available on
+           // the target at all.
+
+           if (it1->source_uuid == the_big_thing.source_parent_uuid)
+           {
+               for (const_reverse_iterator it2 = the_big_things.rbegin(); it2 != the_big_things.rend(); ++it2)
+               {
+                   if (it2->num >= it1->num)
+                       continue;
+
+                   if (it2->source_state != TheBigThing::SourceState::READ_ONLY ||
+                       it2->target_state != TheBigThing::TargetState::VALID)
+                       continue;
+
+                   if (it2->source_uuid == it1->source_parent_uuid)
+                       return (it2 + 1).base();
+               }
+           }
+       }
+
+       return end();
+    }
+
+}
diff --git a/client/snbk/TheBigThing.h b/client/snbk/TheBigThing.h
new file mode 100644 (file)
index 0000000..766d6af
--- /dev/null
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+#ifndef SNAPPER_THE_BIG_THING_H
+#define SNAPPER_THE_BIG_THING_H
+
+
+#include <string>
+#include <vector>
+
+#include "../proxy/proxy.h"
+#include "../proxy/locker.h"
+
+
+namespace snapper
+{
+
+    using std::string;
+    using std::vector;
+
+
+    class BackupConfig;
+    class TheBigThings;
+
+
+    class TheBigThing
+    {
+    public:
+
+       // snapshots on target are always read-only if valid
+
+       enum class SourceState { MISSING, READ_ONLY, READ_WRITE };
+       enum class TargetState { MISSING, VALID, INVALID };
+
+       TheBigThing(unsigned int num) : num(num) {}
+
+       void transfer(const BackupConfig& backup_config, const TheBigThings& the_big_things, bool quiet);
+
+       void remove(const BackupConfig& backup_config, bool quiet);
+
+       unsigned int num;
+       time_t date = 0;        // as reported by snapper
+
+       SourceState source_state = SourceState::MISSING;
+       TargetState target_state = TargetState::MISSING;
+
+       string source_uuid;
+       string source_parent_uuid;
+       string source_received_uuid;
+       string source_creation_time;
+
+       string target_uuid;
+       string target_parent_uuid;
+       string target_received_uuid;
+       string target_creation_time;
+
+    };
+
+
+    template <> struct EnumInfo<TheBigThing::SourceState> { static const vector<string> names; };
+
+    template <> struct EnumInfo<TheBigThing::TargetState> { static const vector<string> names; };
+
+
+    class TheBigThings
+    {
+    public:
+
+       /**
+        * Queries the snapshots on the source and target. Also gets a ProxySnapper and
+        * locks it.
+        */
+       TheBigThings(const BackupConfig& backup_config, ProxySnappers* snappers, bool verbose);
+
+       void transfer(const BackupConfig& backup_config, bool quiet, bool verbose);
+
+       void remove(const BackupConfig& backup_config, bool quiet, bool verbose);
+
+       typedef vector<TheBigThing>::iterator iterator;
+       typedef vector<TheBigThing>::const_iterator const_iterator;
+
+       iterator begin() { return the_big_things.begin(); }
+       const_iterator begin() const { return the_big_things.begin(); }
+
+       iterator end() { return the_big_things.end(); }
+       const_iterator end() const { return the_big_things.end(); }
+
+       iterator find(unsigned int num);
+
+       /**
+        * Detect a suitable parent for btrfs send. Return end() iff none is found.
+        */
+       const_iterator find_send_parent(const TheBigThing& the_big_thing) const;
+
+    private:
+
+       const ProxySnapper* snapper;
+       const Locker locker;
+
+       vector<TheBigThing> the_big_things;
+
+       void probe_source(const BackupConfig& backup_config, bool verbose);
+       void probe_target(const BackupConfig& backup_config, bool verbose);
+
+    };
+
+}
+
+#endif
diff --git a/client/snbk/cmd-delete.cc b/client/snbk/cmd-delete.cc
new file mode 100644 (file)
index 0000000..7aac869
--- /dev/null
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <iostream>
+#include <regex>
+
+#include <snapper/AppUtil.h>
+
+#include "../proxy/errors.h"
+#include "../utils/text.h"
+
+#include "BackupConfig.h"
+#include "GlobalOptions.h"
+#include "TheBigThing.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    void
+    help_delete()
+    {
+       cout << "  " << _("Delete:") << '\n'
+            << "\t" << _("snbk delete [numbers]") << '\n'
+            << '\n';
+    }
+
+
+    void
+    command_delete(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                  ProxySnappers* snappers)
+    {
+       static const regex num_regex("[0-9]+", regex::extended);
+
+       ParsedOpts opts = get_opts.parse("delete", GetOpts::no_options);
+
+       vector<unsigned int> nums;
+
+       while (get_opts.has_args())
+       {
+           string arg = get_opts.pop_arg();
+
+           if (!regex_match(arg, num_regex))
+               SN_THROW(Exception(_("Failed to parse number.")));
+
+           nums.push_back(stoi(arg));
+       }
+
+       unsigned int errors = 0;
+
+       for (const BackupConfig& backup_config : backup_configs)
+       {
+           if (!global_options.quiet())
+               cout << sformat(_("Running delete for backup config '%s'."),
+                               backup_config.name.c_str()) << endl;
+
+           try
+           {
+               TheBigThings the_big_things(backup_config, snappers, global_options.verbose());
+
+               if (nums.empty())
+               {
+                   the_big_things.remove(backup_config, global_options.quiet(), global_options.quiet());
+               }
+               else
+               {
+                   for (unsigned int num : nums)
+                   {
+                       TheBigThings::iterator it = the_big_things.find(num);
+                       if (it == the_big_things.end())
+                       {
+                           string error = sformat(_("Snapshot number %d not found."), num);
+                           SN_THROW(Exception(error));
+                       }
+
+                       it->remove(backup_config, global_options.quiet());
+                   }
+               }
+           }
+           catch (const DBus::ErrorException& e)
+           {
+               SN_CAUGHT(e);
+
+               cerr << error_description(e) << endl;
+
+               ++errors;
+           }
+           catch (const Exception& e)
+           {
+               SN_CAUGHT(e);
+
+               cerr << e.what() << '\n';
+
+               cerr << sformat(_("Running delete for backup config '%s' failed."),
+                               backup_config.name.c_str()) << endl;
+
+               ++errors;
+           }
+       }
+
+       if (errors != 0)
+       {
+           string error = sformat(_("Running delete failed for %d of %ld backup config.",
+                                    "Running delete failed for %d of %ld backup configs.",
+                                    backup_configs.size()), errors, backup_configs.size());
+
+           SN_THROW(Exception(error));
+       }
+    }
+
+}
diff --git a/client/snbk/cmd-list-configs.cc b/client/snbk/cmd-list-configs.cc
new file mode 100644 (file)
index 0000000..06099bd
--- /dev/null
@@ -0,0 +1,289 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <iostream>
+
+#include "../utils/text.h"
+#include "../utils/TableFormatter.h"
+#include "../utils/CsvFormatter.h"
+#include "../utils/JsonFormatter.h"
+#include "../utils/OutputOptions.h"
+#include "../proxy/proxy.h"
+
+#include "BackupConfig.h"
+#include "GlobalOptions.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    void
+    help_list_configs()
+    {
+       cout << "  " << _("List configs:") << '\n'
+            << "\t" << _("snbk list-configs") << '\n'
+            << '\n';
+    }
+
+
+    namespace
+    {
+
+       enum class Column
+       {
+           NAME, CONFIG, TARGET_MODE, AUTOMATIC, SOURCE_PATH, TARGET_PATH, SSH_HOST,
+           SSH_USER, SSH_PORT, SSH_IDENTITY
+       };
+
+
+       Cell
+       header_for(Column column)
+       {
+           switch (column)
+           {
+               case Column::NAME:
+                   return Cell(_("Name"));
+
+               case Column::CONFIG:
+                   return Cell(_("Config"));
+
+               case Column::TARGET_MODE:
+                   return Cell(_("Target Mode"));
+
+               case Column::AUTOMATIC:
+                   return Cell(_("Automatic"));
+
+               case Column::SOURCE_PATH:
+                   return Cell(_("Source Path"));
+
+               case Column::TARGET_PATH:
+                   return Cell(_("Target Path"));
+
+               case Column::SSH_HOST:
+                   return Cell(_("SSH Host"), Id::SSH_HOST);
+
+               case Column::SSH_USER:
+                   return Cell(_("SSH User"), Id::SSH_USER);
+
+               case Column::SSH_PORT:
+                   return Cell(_("SSH Port"), Id::SSH_PORT);
+
+               case Column::SSH_IDENTITY:
+                   return Cell(_("SSH Identity"), Id::SSH_IDENTITY);
+           }
+
+           SN_THROW(Exception("invalid column value"));
+           __builtin_unreachable();
+       }
+
+
+       boost::any
+       value_for_as_any(Column column, const BackupConfig& backup_config)
+       {
+           switch (column)
+           {
+               case Column::NAME:
+                   return backup_config.name;
+
+               case Column::CONFIG:
+                   return backup_config.config;
+
+               case Column::TARGET_MODE:
+                   return toString(backup_config.target_mode);
+
+               case Column::AUTOMATIC:
+                   return backup_config.automatic;
+
+               case Column::SOURCE_PATH:
+                   return backup_config.source_path;
+
+               case Column::TARGET_PATH:
+                   return backup_config.target_path;
+
+               case Column::SSH_HOST:
+                   if (backup_config.ssh_host.empty())
+                       return nullptr;
+                   return backup_config.ssh_host;
+
+               case Column::SSH_USER:
+                   if (backup_config.ssh_user.empty())
+                       return nullptr;
+                   return backup_config.ssh_user;
+
+               case Column::SSH_PORT:
+                   if (backup_config.ssh_port == 0)
+                       return nullptr;
+                   return to_string(backup_config.ssh_port);
+
+               case Column::SSH_IDENTITY:
+                   if (backup_config.ssh_identity.empty())
+                       return nullptr;
+                   return backup_config.ssh_identity;
+           }
+
+           SN_THROW(Exception("invalid column value"));
+           __builtin_unreachable();
+       }
+
+
+       string
+       value_for_as_string(const OutputOptions& output_options, Column column, const BackupConfig& backup_config)
+       {
+           return any_to_string(output_options, value_for_as_any(column, backup_config));
+       }
+
+
+       json_object*
+       value_for_as_json(const OutputOptions& output_options, Column column, const BackupConfig& backup_config)
+       {
+           return any_to_json(output_options, value_for_as_any(column, backup_config));
+       }
+
+
+       void
+       output_table(const GlobalOptions& global_options, const vector<Column>& columns,
+                    const BackupConfigs& backup_configs)
+       {
+           OutputOptions output_options(global_options.utc(), global_options.iso(), true);
+
+           TableFormatter formatter(global_options.table_style());
+
+           for (Column column : columns)
+               formatter.header().push_back(header_for(column));
+
+           formatter.auto_visibility().push_back(Id::SSH_HOST);
+           formatter.auto_visibility().push_back(Id::SSH_USER);
+           formatter.auto_visibility().push_back(Id::SSH_PORT);
+           formatter.auto_visibility().push_back(Id::SSH_IDENTITY);
+
+           for (const BackupConfig& backup_config : backup_configs)
+           {
+               vector<string> row;
+
+               for (Column column : columns)
+                   row.push_back(value_for_as_string(output_options, column, backup_config));
+
+               formatter.rows().push_back(row);
+           }
+
+           cout << formatter;
+       }
+
+
+       void
+       output_csv(const GlobalOptions& global_options, const vector<Column>& columns,
+                  const BackupConfigs& backup_configs)
+       {
+           OutputOptions output_options(global_options.utc(), global_options.iso(), false);
+
+           CsvFormatter formatter(global_options.separator(), global_options.headers());
+
+           for (Column column : columns)
+               formatter.header().push_back(toString(column));
+
+           for (const BackupConfig& backup_config : backup_configs)
+           {
+               vector<string> row;
+
+               for (Column column : columns)
+                   row.push_back(value_for_as_string(output_options, column, backup_config));
+
+               formatter.rows().push_back(row);
+           }
+
+           cout << formatter;
+       }
+
+
+       void
+       output_json(const GlobalOptions& global_options, const vector<Column>& columns,
+                   const BackupConfigs& backup_configs)
+       {
+           OutputOptions output_options(global_options.utc(), global_options.iso(), false);
+
+           JsonFormatter formatter;
+
+           json_object* json_backup_configs = json_object_new_array();
+           json_object_object_add(formatter.root(), "backup-configs", json_backup_configs);
+
+           for (const BackupConfig& backup_config : backup_configs)
+           {
+               json_object* json_backup_config = json_object_new_object();
+               json_object_array_add(json_backup_configs, json_backup_config);
+
+               for (const Column& column : columns)
+               {
+                   json_object* tmp = value_for_as_json(output_options, column, backup_config);
+                   if (tmp)
+                       json_object_object_add(json_backup_config, toString(column).c_str(), tmp);
+               }
+           }
+
+           cout << formatter;
+       }
+
+    }
+
+
+    void
+    command_list_configs(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                        ProxySnappers* snappers)
+    {
+       ParsedOpts opts = get_opts.parse("list-configs", GetOpts::no_options);
+
+       vector<Column> columns = { Column::NAME, Column::CONFIG, Column::TARGET_MODE,
+           Column::AUTOMATIC, Column::SOURCE_PATH, Column::TARGET_PATH, Column::SSH_HOST,
+           Column::SSH_USER, Column::SSH_PORT, Column::SSH_IDENTITY };
+
+       if (get_opts.has_args())
+       {
+           SN_THROW(OptionsException(_("Command 'list-configs' does not take arguments.")));
+       }
+
+       switch (global_options.output_format())
+       {
+           case GlobalOptions::OutputFormat::TABLE:
+               output_table(global_options, columns, backup_configs);
+               break;
+
+           case GlobalOptions::OutputFormat::CSV:
+               output_csv(global_options, columns, backup_configs);
+               break;
+
+           case GlobalOptions::OutputFormat::JSON:
+               output_json(global_options, columns, backup_configs);
+               break;
+       }
+    }
+
+
+    template <> struct EnumInfo<Column> { static const vector<string> names; };
+
+    const vector<string> EnumInfo<Column>::names({
+       "name", "config", "target-mode", "automatic", "source-path", "target-path", "ssh-host",
+       "ssh-user", "ssh-port", "ssh-identity"
+    });
+
+}
diff --git a/client/snbk/cmd-list.cc b/client/snbk/cmd-list.cc
new file mode 100644 (file)
index 0000000..f6760f6
--- /dev/null
@@ -0,0 +1,397 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <iostream>
+
+#include "snapper/AppUtil.h"
+#include "snapper/SnapperDefines.h"
+
+#include "../utils/text.h"
+#include "../utils/TableFormatter.h"
+#include "../utils/CsvFormatter.h"
+#include "../utils/JsonFormatter.h"
+#include "../utils/OutputOptions.h"
+
+// a collision with client/proxy/errors.h
+#ifdef error_description
+#undef error_description
+#endif
+
+#include "../proxy/proxy.h"
+#include "../proxy/errors.h"
+
+#include "BackupConfig.h"
+#include "GlobalOptions.h"
+#include "TheBigThing.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    void
+    help_list()
+    {
+       cout << "  " << _("List:") << '\n'
+            << "\t" << _("snbk list") << '\n'
+            << '\n';
+    }
+
+
+    namespace
+    {
+
+       enum class Column
+       {
+           NAME, NUMBER, DATE, SOURCE_STATE, SOURCE_UUID, SOURCE_PARENT_UUID, SOURCE_RECEIVED_UUID,
+           SOURCE_CREATION_TIME, TARGET_STATE, TARGET_UUID, TARGET_PARENT_UUID, TARGET_RECEIVED_UUID,
+           TARGET_CREATION_TIME
+       };
+
+
+       Cell
+       header_for(Column column)
+       {
+           switch (column)
+           {
+               case Column::NAME:
+                   return Cell(_("Name"));
+
+               case Column::NUMBER:
+                   return Cell(_("#"), Id::NUMBER, Align::RIGHT);
+
+               case Column::DATE:
+                   return Cell(_("Date"));
+
+               case Column::SOURCE_STATE:
+                   return Cell(_("Source State"));
+
+               case Column::SOURCE_UUID:
+                   return Cell(_("(Source) UUID"));
+
+               case Column::SOURCE_PARENT_UUID:
+                   return Cell(_("(Source) Parent UUID"));
+
+               case Column::SOURCE_RECEIVED_UUID:
+                   return Cell(_("(Source) Received UUID"));
+
+               case Column::SOURCE_CREATION_TIME:
+                   return Cell(_("(Source) Creation Time"));
+
+               case Column::TARGET_STATE:
+                   return Cell(_("Target State"));
+
+               case Column::TARGET_UUID:
+                   return Cell(_("(Target) UUID"));
+
+               case Column::TARGET_PARENT_UUID:
+                   return Cell(_("(Target) Parent UUID"));
+
+               case Column::TARGET_RECEIVED_UUID:
+                   return Cell(_("(Target) Received UUID"));
+
+               case Column::TARGET_CREATION_TIME:
+                   return Cell(_("(Target) Creation Time"));
+           }
+
+           SN_THROW(Exception("invalid column value"));
+           __builtin_unreachable();
+       }
+
+
+       boost::any
+       value_for_as_any(const OutputOptions& output_options, Column column, const BackupConfig& backup_config,
+                        const TheBigThing& the_big_thing)
+       {
+           const string::size_type uuid_cutoff = 14;
+
+           switch (column)
+           {
+               case Column::NAME:
+                   return backup_config.name;
+
+               case Column::NUMBER:
+                   return the_big_thing.num;
+
+               case Column::DATE:
+                   if (the_big_thing.date == 0)
+                       return nullptr;
+                   return datetime(the_big_thing.date, output_options.utc, output_options.iso);
+
+               case Column::SOURCE_STATE:
+                   if (the_big_thing.source_state == TheBigThing::SourceState::MISSING)
+                       return nullptr;
+                   return toString(the_big_thing.source_state);
+
+               case Column::SOURCE_UUID:
+                   if (the_big_thing.source_uuid.empty())
+                       return nullptr;
+                   return the_big_thing.source_uuid.substr(0, uuid_cutoff);
+
+               case Column::SOURCE_PARENT_UUID:
+                   if (the_big_thing.source_parent_uuid.empty())
+                       return nullptr;
+                   return the_big_thing.source_parent_uuid.substr(0, uuid_cutoff);
+
+               case Column::SOURCE_RECEIVED_UUID:
+                   if (the_big_thing.source_received_uuid.empty())
+                       return nullptr;
+                   return the_big_thing.source_received_uuid.substr(0, uuid_cutoff);
+
+               case Column::SOURCE_CREATION_TIME:
+                   return the_big_thing.source_creation_time;
+
+               case Column::TARGET_STATE:
+                   if (the_big_thing.target_state == TheBigThing::TargetState::MISSING)
+                       return nullptr;
+                   return toString(the_big_thing.target_state);
+
+               case Column::TARGET_UUID:
+                   if (the_big_thing.target_uuid.empty())
+                       return nullptr;
+                   return the_big_thing.target_uuid.substr(0, uuid_cutoff);
+
+               case Column::TARGET_PARENT_UUID:
+                   if (the_big_thing.target_parent_uuid.empty())
+                       return nullptr;
+                   return the_big_thing.target_parent_uuid.substr(0, uuid_cutoff);
+
+               case Column::TARGET_RECEIVED_UUID:
+                   if (the_big_thing.target_received_uuid.empty())
+                       return nullptr;
+                   return the_big_thing.target_received_uuid.substr(0, uuid_cutoff);
+
+               case Column::TARGET_CREATION_TIME:
+                   return the_big_thing.target_creation_time;
+           }
+
+           SN_THROW(Exception("invalid column value"));
+           __builtin_unreachable();
+       }
+
+
+       string
+       value_for_as_string(const OutputOptions& output_options, Column column, const BackupConfig& backup_config,
+                           const TheBigThing& the_big_thing)
+       {
+           return any_to_string(output_options, value_for_as_any(output_options, column, backup_config,
+                                                                 the_big_thing));
+       }
+
+
+       json_object*
+       value_for_as_json(const OutputOptions& output_options, Column column, const BackupConfig& backup_config,
+                         const TheBigThing& the_big_thing)
+       {
+           return any_to_json(output_options, value_for_as_any(output_options, column, backup_config,
+                                                               the_big_thing));
+       }
+
+
+       // The loop over the backup-configs could be moved from the three output_*
+       // functions to command_list. One disadvantage is that the output for the table
+       // mode is delayed until all backup-configs have been probed.
+
+
+       void
+       output_table(const GlobalOptions& global_options, const vector<Column>& columns,
+                    const BackupConfigs& backup_configs, ProxySnappers* snappers)
+       {
+           OutputOptions output_options(global_options.utc(), global_options.iso(), true);
+
+           unsigned int errors = 0;
+
+           bool first_table = true;
+
+           for (const BackupConfig& backup_config : backup_configs)
+           {
+               if (!first_table)
+                   cout << endl;
+
+               if (backup_configs.size() > 1)
+               {
+                   cout << "Backup-config:" << backup_config.name << ", config:" << backup_config.config
+                        << ", source-path:" << backup_config.source_path << ", target-mode:"
+                        << toString(backup_config.target_mode) << endl;
+               }
+
+               try
+               {
+                   TheBigThings the_big_things(backup_config, snappers, global_options.verbose());
+
+                   TableFormatter formatter(global_options.table_style());
+
+                   for (Column column : columns)
+                       formatter.header().push_back(header_for(column));
+
+                   for (const TheBigThing& the_big_thing : the_big_things)
+                   {
+                       vector<string> row;
+
+                       for (Column column : columns)
+                           row.push_back(value_for_as_string(output_options, column, backup_config, the_big_thing));
+
+                       formatter.rows().push_back(row);
+                   }
+
+                   first_table = false;
+
+                   cout << formatter;
+               }
+               catch (const DBus::ErrorException& e)
+               {
+                   SN_CAUGHT(e);
+
+                   cerr << error_description(e) << endl;
+
+                   ++errors;
+               }
+               catch (const Exception& e)
+               {
+                   SN_CAUGHT(e);
+
+                   cerr << e.what() << endl;
+
+                   ++errors;
+               }
+           }
+
+           if (errors != 0)
+           {
+               string error = sformat(_("Running list failed for %d of %ld backup config.",
+                                        "Running list failed for %d of %ld backup configs.",
+                                        backup_configs.size()), errors, backup_configs.size());
+
+               SN_THROW(Exception(error));
+           }
+       }
+
+
+       void
+       output_csv(const GlobalOptions& global_options, const vector<Column>& columns,
+                  const BackupConfigs& backup_configs, ProxySnappers* snappers)
+       {
+           OutputOptions output_options(global_options.utc(), global_options.iso(), false);
+
+           CsvFormatter formatter(global_options.separator(), global_options.headers());
+
+           for (Column column : columns)
+               formatter.header().push_back(toString(column));
+
+           for (const BackupConfig& backup_config : backup_configs)
+           {
+               TheBigThings the_big_things(backup_config, snappers, global_options.verbose());
+
+               for (const TheBigThing& the_big_thing : the_big_things)
+               {
+                   vector<string> row;
+
+                   for (Column column : columns)
+                       row.push_back(value_for_as_string(output_options, column, backup_config, the_big_thing));
+
+                   formatter.rows().push_back(row);
+               }
+           }
+
+           cout << formatter;
+       }
+
+
+       void
+       output_json(const GlobalOptions& global_options, const vector<Column>& columns,
+                   const BackupConfigs& backup_configs, ProxySnappers* snappers)
+       {
+           OutputOptions output_options(global_options.utc(), global_options.iso(), false);
+
+           JsonFormatter formatter;
+
+           json_object* json_snapshots = json_object_new_array();
+           json_object_object_add(formatter.root(), "snapshots", json_snapshots);
+
+           for (const BackupConfig& backup_config : backup_configs)
+           {
+               TheBigThings the_big_things(backup_config, snappers, global_options.verbose());
+
+               for (const TheBigThing& the_big_thing : the_big_things)
+               {
+                   json_object* json_snapshot = json_object_new_object();
+                   json_object_array_add(json_snapshots, json_snapshot);
+
+                   for (const Column& column : columns)
+                   {
+                       json_object* tmp =  value_for_as_json(output_options, column, backup_config, the_big_thing);
+                       if (tmp)
+                           json_object_object_add(json_snapshot, toString(column).c_str(), tmp);
+                   }
+               }
+           }
+
+           cout << formatter;
+       }
+
+    }
+
+
+    void
+    command_list(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                ProxySnappers* snappers)
+    {
+       ParsedOpts opts = get_opts.parse("list", GetOpts::no_options);
+
+       vector<Column> columns = { Column::NUMBER, Column::DATE, Column::SOURCE_STATE, Column::TARGET_STATE };
+
+       if (global_options.output_format() != GlobalOptions::OutputFormat::TABLE)
+           columns.insert(columns.begin(), Column::NAME);
+
+       if (get_opts.has_args())
+       {
+           SN_THROW(OptionsException(_("Command 'list' does not take arguments.")));
+       }
+
+       switch (global_options.output_format())
+       {
+           case GlobalOptions::OutputFormat::TABLE:
+               output_table(global_options, columns, backup_configs, snappers);
+               break;
+
+           case GlobalOptions::OutputFormat::CSV:
+               output_csv(global_options, columns, backup_configs, snappers);
+               break;
+
+           case GlobalOptions::OutputFormat::JSON:
+               output_json(global_options, columns, backup_configs, snappers);
+               break;
+       }
+    }
+
+
+    template <> struct EnumInfo<Column> { static const vector<string> names; };
+
+    const vector<string> EnumInfo<Column>::names({
+       "name", "number", "date", "source-state", "source-uuid", "source-parent-uuid", "source-received-uuid",
+       "source-creation-time", "target-state", "target-uuid", "target-parent-uuid", "target-received-uuid",
+       "target-creation-time"
+    });
+
+}
diff --git a/client/snbk/cmd-transfer-and-delete.cc b/client/snbk/cmd-transfer-and-delete.cc
new file mode 100644 (file)
index 0000000..e1e6ffb
--- /dev/null
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <iostream>
+
+#include <snapper/AppUtil.h>
+
+#include "../proxy/errors.h"
+#include "../utils/text.h"
+
+#include "BackupConfig.h"
+#include "GlobalOptions.h"
+#include "TheBigThing.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    void
+    help_transfer_and_delete()
+    {
+       cout << "  " << _("Transfer and delete:") << '\n'
+            << "\t" << _("snbk transfer-and-delete") << '\n'
+            << '\n';
+    }
+
+
+    void
+    command_transfer_and_delete(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                               ProxySnappers* snappers)
+    {
+       ParsedOpts opts = get_opts.parse("transfer-and-delete", GetOpts::no_options);
+
+       if (get_opts.has_args())
+       {
+           SN_THROW(OptionsException(_("Command 'transfer-and-delete' does not take arguments.")));
+       }
+
+       unsigned int errors = 0;
+
+       for (const BackupConfig& backup_config : backup_configs)
+       {
+           if (!global_options.quiet())
+               cout << sformat(_("Running transfer and delete for backup config '%s'."),
+                               backup_config.name.c_str()) << endl;
+
+           try
+           {
+               TheBigThings the_big_things(backup_config, snappers, global_options.verbose());
+
+               the_big_things.transfer(backup_config, global_options.quiet(), global_options.quiet());
+               the_big_things.remove(backup_config, global_options.quiet(), global_options.quiet());
+           }
+           catch (const DBus::ErrorException& e)
+           {
+               SN_CAUGHT(e);
+
+               cerr << error_description(e) << endl;
+
+               ++errors;
+           }
+           catch (const Exception& e)
+           {
+               SN_CAUGHT(e);
+
+               cerr << e.what() << '\n';
+
+               cerr << sformat(_("Running transfer and delete for backup config '%s' failed."),
+                               backup_config.name.c_str()) << endl;
+
+               ++errors;
+           }
+       }
+
+       if (errors != 0)
+       {
+           string error = sformat(_("Running transfer and delete failed for %d of %ld backup config.",
+                                    "Running transfer and delete failed for %d of %ld backup configs.",
+                                    backup_configs.size()), errors, backup_configs.size());
+
+           SN_THROW(Exception(error));
+       }
+    }
+
+}
diff --git a/client/snbk/cmd-transfer.cc b/client/snbk/cmd-transfer.cc
new file mode 100644 (file)
index 0000000..d4242d3
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <iostream>
+#include <regex>
+
+#include <snapper/AppUtil.h>
+
+#include "../proxy/errors.h"
+#include "../utils/text.h"
+
+#include "BackupConfig.h"
+#include "GlobalOptions.h"
+#include "TheBigThing.h"
+#include "cmd.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    void
+    help_transfer()
+    {
+       cout << "  " << _("Transfer:") << '\n'
+            << "\t" << _("snbk transfer [numbers]") << '\n'
+            << '\n';
+    }
+
+
+    void
+    command_transfer(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                    ProxySnappers* snappers)
+    {
+       static const regex num_regex("[0-9]+", regex::extended);
+
+       ParsedOpts opts = get_opts.parse("transfer", GetOpts::no_options);
+
+       vector<unsigned int> nums;
+
+       while (get_opts.has_args())
+       {
+           string arg = get_opts.pop_arg();
+
+           if (!regex_match(arg, num_regex))
+               SN_THROW(Exception(_("Failed to parse number.")));
+
+           nums.push_back(stoi(arg));
+       }
+
+       unsigned int errors = 0;
+
+       for (const BackupConfig& backup_config : backup_configs)
+       {
+           if (!global_options.quiet())
+               cout << sformat(_("Running transfer for backup config '%s'."),
+                               backup_config.name.c_str()) << endl;
+
+           try
+           {
+               TheBigThings the_big_things(backup_config, snappers, global_options.verbose());
+
+               if (nums.empty())
+               {
+                   the_big_things.transfer(backup_config, global_options.quiet(), global_options.quiet());
+               }
+               else
+               {
+                   for (unsigned int num : nums)
+                   {
+                       TheBigThings::iterator it = the_big_things.find(num);
+                       if (it == the_big_things.end())
+                       {
+                           string error = sformat(_("Snapshot number %d not found."), num);
+                           SN_THROW(Exception(error));
+                       }
+
+                       it->transfer(backup_config, the_big_things, global_options.quiet());
+                   }
+               }
+           }
+           catch (const DBus::ErrorException& e)
+           {
+               SN_CAUGHT(e);
+
+               cerr << error_description(e) << endl;
+
+               ++errors;
+           }
+           catch (const Exception& e)
+           {
+               SN_CAUGHT(e);
+
+               cerr << e.what() << '\n';
+
+               cerr << sformat(_("Running transfer for backup config '%s' failed."),
+                               backup_config.name.c_str()) << endl;
+
+               ++errors;
+           }
+       }
+
+       if (errors != 0)
+       {
+           string error = sformat(_("Running transfer failed for %d of %ld backup config.",
+                                    "Running transfer failed for %d of %ld backup configs.",
+                                    backup_configs.size()), errors, backup_configs.size());
+
+           SN_THROW(Exception(error));
+       }
+    }
+
+}
diff --git a/client/snbk/cmd.h b/client/snbk/cmd.h
new file mode 100644 (file)
index 0000000..5a5a9c8
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) [2011-2015] Novell, Inc.
+ * Copyright (c) [2016-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include "config.h"
+
+#include "../proxy/proxy.h"
+
+
+namespace snapper
+{
+
+    void
+    help_list_configs();
+
+    void
+    command_list_configs(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                        ProxySnappers* snappers);
+
+
+    void
+    help_list();
+
+    void
+    command_list(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                ProxySnappers* snappers);
+
+
+    void
+    help_transfer();
+
+    void
+    command_transfer(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                    ProxySnappers* snappers);
+
+
+    void
+    help_delete();
+
+    void
+    command_delete(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                  ProxySnappers* snappers);
+
+
+    void
+    help_transfer_and_delete();
+
+    void
+    command_transfer_and_delete(const GlobalOptions& global_options, GetOpts& get_opts, BackupConfigs& backup_configs,
+                               ProxySnappers* snappers);
+
+}
diff --git a/client/snbk/snbk.cc b/client/snbk/snbk.cc
new file mode 100644 (file)
index 0000000..da0704f
--- /dev/null
@@ -0,0 +1,242 @@
+/*
+ * Copyright (c) 2024 SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include <iostream>
+
+#include "snapper/Logger.h"
+#include <snapper/SnapperTmpl.h>
+#include <snapper/Enum.h>
+#include "snapper/SnapperDefines.h"
+
+#include "../utils/text.h"
+#include "../utils/GetOpts.h"
+
+#include "GlobalOptions.h"
+#include "BackupConfig.h"
+#include "cmd.h"
+
+
+using namespace std;
+using namespace snapper;
+
+
+struct Cmd
+{
+    typedef void (*cmd_func_t)(const GlobalOptions& global_options, GetOpts& get_opts,
+                              BackupConfigs& backup_configs, ProxySnappers* snappers);
+
+    typedef void (*help_func_t)();
+
+    Cmd(const string& name, cmd_func_t cmd_func, help_func_t help_func, bool needs_snapper)
+       : name(name), cmd_func(cmd_func), help_func(help_func), needs_snapper(needs_snapper)
+    {}
+
+    Cmd(const string& name, const vector<string>& aliases, cmd_func_t cmd_func, help_func_t help_func,
+       bool needs_snapper)
+       : name(name), aliases(aliases), cmd_func(cmd_func), help_func(help_func),
+         needs_snapper(needs_snapper)
+    {}
+
+    const string name;
+    const vector<string> aliases;
+    const cmd_func_t cmd_func;
+    const help_func_t help_func;
+    const bool needs_snapper;
+};
+
+
+static bool log_debug = false;
+
+
+void
+log_do(LogLevel level, const string& component, const char* file, const int line, const char* func,
+       const string& text)
+{
+    cerr << text << endl;
+}
+
+
+bool
+log_query(LogLevel level, const string& component)
+{
+    return log_debug || level == ERROR;
+}
+
+
+void help() __attribute__ ((__noreturn__));
+
+
+void
+help(const vector<Cmd>& cmds, GetOpts& get_opts)
+{
+    get_opts.parse("help", GetOpts::no_options);
+    if (get_opts.has_args())
+    {
+       cerr << _("Command 'help' does not take arguments.") << endl;
+       exit(EXIT_FAILURE);
+    }
+
+    cout << _("usage: snbk [--global-options] <command> [command-arguments]") << '\n'
+        << endl;
+
+    GlobalOptions::help_global_options();
+
+    for (const Cmd& cmd : cmds)
+       (*cmd.help_func)();
+
+    exit(EXIT_SUCCESS);
+}
+
+
+vector<string>
+get_backup_configs(const GlobalOptions& global_options, const Cmd* cmd)
+{
+    if (cmd->name == "list-configs")
+       return read_backup_config_names();
+
+    if (global_options.backup_config())
+       return { global_options.backup_config().value() };
+
+    const vector<string> names = read_backup_config_names();
+    if (names.empty())
+       SN_THROW(Exception(_("No backup configs found.")));
+
+    return names;
+}
+
+
+int
+main(int argc, char** argv)
+{
+    try
+    {
+       locale::global(locale(""));
+    }
+    catch (const runtime_error& e)
+    {
+       cerr << _("Failed to set locale.") << endl;
+    }
+
+    setLogDo(&log_do);
+    setLogQuery(&log_query);
+
+    const vector<Cmd> cmds = {
+       Cmd("list-configs", command_list_configs, help_list_configs, false),
+       Cmd("list", { "ls" }, command_list, help_list, true),
+       Cmd("transfer", command_transfer, help_transfer, true),
+       Cmd("delete", { "remove", "rm" }, command_delete, help_delete, true),
+       Cmd("transfer-and-delete", command_transfer_and_delete, help_transfer_and_delete, true),
+    };
+
+    try
+    {
+       GetOpts get_opts(argc, argv);
+
+       GlobalOptions global_options(get_opts);
+
+       if (global_options.debug())
+       {
+           log_debug = true;
+       }
+
+       if (global_options.version())
+       {
+           cout << "snbk " << Snapper::compileVersion() << endl;
+           exit(EXIT_SUCCESS);
+       }
+
+       if (global_options.help())
+       {
+           help(cmds, get_opts);
+       }
+
+       if (!get_opts.has_args())
+       {
+           cerr << _("No command provided.") << endl
+                << _("Try 'snbk --help' for more information.") << endl;
+           exit(EXIT_FAILURE);
+       }
+
+       const char* command = get_opts.pop_arg();
+
+       vector<Cmd>::const_iterator cmd = cmds.begin();
+       while (cmd != cmds.end() && (cmd->name != command && !contains(cmd->aliases, command)))
+           ++cmd;
+
+       if (cmd == cmds.end())
+       {
+           cerr << sformat(_("Unknown command '%s'."), command) << endl
+                << _("Try 'snbk --help' for more information.") << endl;
+           exit(EXIT_FAILURE);
+       }
+
+       try
+       {
+           const vector<string> names = get_backup_configs(global_options, &*cmd);
+
+           BackupConfigs backup_configs;
+
+           for (const string& name : names)
+           {
+               BackupConfig backup_config(name);
+
+               if (global_options.target_mode() &&
+                   backup_config.target_mode != global_options.target_mode().value())
+                   continue;
+
+               if (global_options.automatic() && !backup_config.automatic)
+                   continue;
+
+               backup_configs.push_back(backup_config);
+           }
+
+           unique_ptr<ProxySnappers> snappers;
+
+           if (cmd->needs_snapper)
+           {
+               snappers.reset(new ProxySnappers(global_options.no_dbus() ? ProxySnappers::createLib("/") :
+                                                ProxySnappers::createDbus()));
+           }
+
+           (*cmd->cmd_func)(global_options, get_opts, backup_configs, snappers.get());
+       }
+       catch (const Exception& e)
+       {
+           SN_CAUGHT(e);
+
+           cerr << e.what() << endl;
+
+           exit(EXIT_FAILURE);
+       }
+    }
+    catch (const OptionsException& e)
+    {
+       SN_CAUGHT(e);
+
+       cerr << e.what() << '\n'
+            << _("Try 'snbk --help' for more information.") << endl;
+
+       exit(EXIT_FAILURE);
+    }
+
+    exit(EXIT_SUCCESS);
+}
index a872192fcf2bc9391f92276898ef3047045c821e..57179e8c93f37b1505d9932728c3bc94d913f17a 100644 (file)
@@ -19,6 +19,6 @@ libutils_la_SOURCES =                                 \
        TableFormatter.cc       TableFormatter.h        \
        CsvFormatter.cc         CsvFormatter.h          \
        JsonFormatter.cc        JsonFormatter.h         \
-       OutputOptions.h
+       OutputOptions.cc        OutputOptions.h
 
 libutils_la_LIBADD = ../../snapper/libsnapper.la -ltinfo
diff --git a/client/utils/OutputOptions.cc b/client/utils/OutputOptions.cc
new file mode 100644 (file)
index 0000000..aa950fa
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) [2011-2015] Novell, Inc.
+ * Copyright (c) [2016-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact Novell, Inc.
+ *
+ * To contact Novell about this file by physical or electronic mail, you may
+ * find current contact information at www.novell.com.
+ */
+
+
+#include "OutputOptions.h"
+
+#include <snapper/Exception.h>
+
+#include "text.h"
+
+
+namespace snapper
+{
+
+    using namespace std;
+
+
+    string
+    any_to_string(const OutputOptions& output_options, const boost::any& value)
+    {
+       if (value.type() == typeid(nullptr_t))
+       {
+           return "";
+       }
+
+       if (value.type() == typeid(bool))
+       {
+           if (output_options.human)
+               return boost::any_cast<bool>(value) ? _("yes") : _("no");
+           else
+               return boost::any_cast<bool>(value) ? "yes" : "no";
+       }
+
+       if (value.type() == typeid(unsigned int))
+       {
+           return to_string(boost::any_cast<unsigned int>(value));
+       }
+
+       if (value.type() == typeid(string))
+       {
+           return boost::any_cast<string>(value).c_str();
+       }
+
+       SN_THROW(Exception("invalid type in any_to_string"));
+       __builtin_unreachable();
+    }
+
+
+    json_object*
+    any_to_json(const OutputOptions& output_options, const boost::any& value)
+    {
+       if (value.type() == typeid(nullptr_t))
+       {
+           return nullptr;
+       }
+
+       if (value.type() == typeid(bool))
+       {
+           return json_object_new_boolean(boost::any_cast<bool>(value));
+       }
+
+       if (value.type() == typeid(unsigned int))
+       {
+           return json_object_new_int(boost::any_cast<unsigned int>(value));
+       }
+
+       if (value.type() == typeid(string))
+       {
+           return json_object_new_string(boost::any_cast<string>(value).c_str());
+       }
+
+       SN_THROW(Exception("invalid type in any_to_json"));
+       __builtin_unreachable();
+    }
+
+}
index fccb1c7a756f052f346372129b79b1420980b692..3df41ba37fae1301eda151b77ad93aa404e7dfd2 100644 (file)
  */
 
 
+#include <string>
+#include <boost/any.hpp>
+#include <json-c/json.h>
+
+
 namespace snapper
 {
 
+    using std::string;
+
+
     /**
      * Just a collection of some variables defining the output.
      */
@@ -42,4 +50,14 @@ namespace snapper
 
     };
 
+
+    // TODO extend functions and use in client/snapper
+
+    string
+    any_to_string(const OutputOptions& output_options, const boost::any& value);
+
+
+    json_object*
+    any_to_json(const OutputOptions& output_options, const boost::any& value);
+
 }
index ac7ad197da221a6f8361b2b1122b8055da615c60..6eb1741c6968baf22722d2d807c23d76035d1728 100644 (file)
@@ -43,7 +43,8 @@ namespace snapper
 
     enum class Id
     {
-       NONE, NAME, TYPE, NUMBER, PRE_NUMBER, POST_NUMBER, DESCRIPTION, USERDATA
+       NONE, NAME, TYPE, NUMBER, PRE_NUMBER, POST_NUMBER, DESCRIPTION, USERDATA, SSH_HOST, SSH_USER, SSH_PORT,
+       SSH_IDENTITY
     };
 
 
index 11fcf31e518b473e423a9d0b612f07035a4280a1..0865b9e6c4b07625fc624445b7d37a5af09d6a96 100644 (file)
@@ -222,6 +222,7 @@ AC_CONFIG_FILES([
        client/mksubvolume/Makefile
        client/installation-helper/Makefile
        client/systemd-helper/Makefile
+       client/snbk/Makefile
        scripts/Makefile
        pam/Makefile
        data/Makefile
@@ -229,6 +230,8 @@ AC_CONFIG_FILES([
        doc/snapper.xml:doc/snapper.xml.in
        doc/snapperd.xml:doc/snapperd.xml.in
        doc/snapper-configs.xml:doc/snapper-configs.xml.in
+       doc/snbk.xml:doc/snbk.xml.in
+       doc/snapper-backup-configs.xml:doc/snapper-backup-configs.xml.in
        doc/snapper-zypp-plugin.xml:doc/snapper-zypp-plugin.xml.in
        doc/snapper-zypp-plugin.conf.xml:doc/snapper-zypp-plugin.conf.xml.in
        doc/pam_snapper.xml:doc/pam_snapper.xml.in
index 114be4d0c886285f79da3338831b106f0857273d..b0442009f7275437005e775a2863f83194df03e6 100644 (file)
@@ -5,12 +5,15 @@
 EXTRA_DIST = sysconfig.snapper base.txt lvm.txt x11.txt snapper.logrotate      \
        default-config org.opensuse.Snapper.conf org.opensuse.Snapper.service   \
        zypp-plugin.conf timeline.service timeline.timer cleanup.service        \
-       cleanup.timer boot.service boot.timer snapperd.service
+       cleanup.timer boot.service boot.timer backup.service backup.timer       \
+       snapperd.service
 
 install-data-local:
        install -D -m 644 snapper.logrotate $(DESTDIR)/etc/logrotate.d/snapper
 
        install -d -m 755 $(DESTDIR)/etc/snapper/configs
+       install -d -m 755 $(DESTDIR)/etc/snapper/backup-configs
+       install -d -m 755 $(DESTDIR)/etc/snapper/certs
 
        install -d -m 755 $(DESTDIR)/usr/lib/snapper/plugins
 
@@ -32,6 +35,8 @@ if ENABLE_SYSTEMD
        install -D -m 644 cleanup.timer $(DESTDIR)/usr/lib/systemd/system/snapper-cleanup.timer
        install -D -m 644 boot.service $(DESTDIR)/usr/lib/systemd/system/snapper-boot.service
        install -D -m 644 boot.timer $(DESTDIR)/usr/lib/systemd/system/snapper-boot.timer
+       install -D -m 644 backup.service $(DESTDIR)/usr/lib/systemd/system/snapper-backup.service
+       install -D -m 644 backup.timer $(DESTDIR)/usr/lib/systemd/system/snapper-backup.timer
        install -D -m 644 snapperd.service $(DESTDIR)/usr/lib/systemd/system/snapperd.service
 endif
 
diff --git a/data/backup.service b/data/backup.service
new file mode 100644 (file)
index 0000000..cb136fb
--- /dev/null
@@ -0,0 +1,15 @@
+[Unit]
+Description=Backup of Snapper Snapshots
+Documentation=man:snbk(8)
+After=nss-user-lookup.target
+
+[Service]
+Type=simple
+WorkingDirectory=/root
+ExecStart=/usr/sbin/snbk --verbose --automatic transfer-and-delete
+
+CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_SYS_ADMIN CAP_SYS_MODULE CAP_IPC_LOCK CAP_SYS_NICE
+LockPersonality=true
+NoNewPrivileges=false
+ProtectHostname=true
+RestrictRealtime=true
diff --git a/data/backup.timer b/data/backup.timer
new file mode 100644 (file)
index 0000000..a50d4fc
--- /dev/null
@@ -0,0 +1,11 @@
+
+[Unit]
+Description=Backup of Snapper Snapshots
+Documentation=man:snbk(5)
+
+[Timer]
+OnCalendar=hourly
+
+[Install]
+WantedBy=timers.target
+
index a173076552544c80515b71d765c549eb5af4ff4e..fc98d3fb55751c306aa883717986f0a575014d4d 100644 (file)
@@ -1,6 +1,8 @@
 snapper.xml
 snapperd.xml
 snapper-configs.xml
+snbk.xml
+snapper-backup-configs.xml
 snapper-zypp-plugin.xml
 snapper-zypp-plugin.conf.xml
 pam_snapper.xml
@@ -8,6 +10,8 @@ mksubvolume.xml
 snapper.8
 snapperd.8
 snapper-configs.5
+snbk.8
+snapper-backup-configs.5
 snapper-zypp-plugin.8
 snapper-zypp-plugin.conf.5
 pam_snapper.8
@@ -15,6 +19,8 @@ mksubvolume.8
 snapper.html
 snapperd.html
 snapper-configs.html
+snbk.html
+snapper-backup-configs.html
 snapper-zypp-plugin.html
 snapper-zypp-plugin.conf.html
 pam_snapper.html
index 3823b2f1e91cad3f0932d45de256fe6c0886899d..522ce63c869945f2f3f8cfbea9da0ebb8422839d 100644 (file)
@@ -4,7 +4,7 @@
 
 if ENABLE_DOC
 
-man_MANS = snapper.8 snapperd.8 snapper-configs.5
+man_MANS = snapper.8 snapperd.8 snapper-configs.5 snbk.8 snapper-backup-configs.5
 
 if HAVE_PAM
 man_MANS += pam_snapper.8
diff --git a/doc/snapper-backup-configs.xml.in b/doc/snapper-backup-configs.xml.in
new file mode 100644 (file)
index 0000000..49f6ab4
--- /dev/null
@@ -0,0 +1,153 @@
+<?xml version="1.0" encoding="utf-8"?>
+<refentry id='snapper-backup-configs5'>
+
+  <refentryinfo>
+    <date>2024-11-05</date>
+  </refentryinfo>
+
+  <refmeta>
+    <refentrytitle>snapper-backup-configs</refentrytitle>
+    <manvolnum>5</manvolnum>
+    <refmiscinfo class='date'>2024-11-05</refmiscinfo>
+    <refmiscinfo class='version'>@VERSION@</refmiscinfo>
+    <refmiscinfo class='manual'>Filesystem Snapshot Management</refmiscinfo>
+  </refmeta>
+
+  <refnamediv>
+    <refname>snapper-backup-configs</refname>
+    <refpurpose>Configuration files for snapper backup configs</refpurpose>
+  </refnamediv>
+
+  <refsect1 id='description'>
+    <title>DESCRIPTION</title>
+    <para>Each file <filename>/etc/snapper/backup-configs/*.json</filename> describes a
+    snapper backup config.</para>
+    <para>The file uses JSON syntax containing a single object with
+    key-value pairs.</para>
+  </refsect1>
+
+  <refsect1 id='key-value-pairs'>
+    <title>KEY VALUE PAIRS</title>
+
+    <para>The following is a list of keys that can be present in the
+    configuration file.</para>
+
+    <variablelist>
+      <varlistentry>
+       <term><option>config</option></term>
+        <listitem>
+          <para>Name of the snapper config.</para>
+        </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>target-mode</option></term>
+       <listitem>
+         <para>Either local or ssh-push.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>source-path</option></term>
+       <listitem>
+         <para>Path of the subvolume or mount point.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>target-path</option></term>
+       <listitem>
+         <para>Path of the subvolume or mount point.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>automatic</option></term>
+       <listitem>
+         <para>Boolean for enabling automatic transfer and delete
+         for the backup config using a systemd timer service.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>ssh-host</option></term>
+       <listitem>
+         <para>Name of the target host. Required for target mode
+         ssh-push.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>ssh-port</option></term>
+       <listitem>
+         <para>Port of the target host. Optional for target mode
+         ssh-push.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>ssh-user</option></term>
+       <listitem>
+         <para>User on the target host. Optional for target mode
+         ssh-push.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>ssh-identity</option></term>
+       <listitem>
+         <para>An ssh-identity to access the host without requiring a
+         password or passphrase. Optional for target mode ssh-push.</para>
+       </listitem>
+      </varlistentry>
+    </variablelist>
+  </refsect1>
+
+  <refsect1 id='examples'>
+    <title>EXAMPLES</title>
+    <para>An example backup config for local backups:
+    <programlisting>
+{
+    "config": "root",
+    "target-mode": "local",
+    "automatic": true,
+    "source-path": "/",
+    "target-path": "/backups/root"
+}
+    </programlisting>
+    </para>
+
+    <para>An example backup config for remote backups via ssh-push:
+    <programlisting>
+{
+    "config": "root",
+    "target-mode": "ssh-push",
+    "automatic": false,
+    "source-path": "/",
+    "target-path": "/backups/eberich/root",
+    "ssh-host": "backups.example.com",
+    "ssh-identity": "/etc/snapper/certs/id_ecdsa"
+}
+    </programlisting>
+    </para>
+  </refsect1>
+
+  <refsect1 id='homepage'>
+    <title>HOMEPAGE</title>
+    <para><ulink url='http://snapper.io/'>http://snapper.io/</ulink></para>
+  </refsect1>
+
+  <refsect1 id='authors'>
+    <title>AUTHORS</title>
+    <para>Arvin Schnell <email>aschnell@suse.com</email></para>
+  </refsect1>
+
+  <refsect1 id='see_also'>
+    <title>SEE ALSO</title>
+    <para>
+      <citerefentry><refentrytitle>snbk</refentrytitle><manvolnum>8</manvolnum></citerefentry>,
+      <citerefentry><refentrytitle>snapper</refentrytitle><manvolnum>8</manvolnum></citerefentry>
+    </para>
+  </refsect1>
+
+</refentry>
diff --git a/doc/snbk.xml.in b/doc/snbk.xml.in
new file mode 100644 (file)
index 0000000..cb74e01
--- /dev/null
@@ -0,0 +1,360 @@
+<?xml version="1.0" encoding="utf-8"?>
+<refentry id='snbk8' xmlns:xlink="http://www.w3.org/1999/xlink">
+
+  <refentryinfo>
+    <date>2024-11-05</date>
+  </refentryinfo>
+
+  <refmeta>
+    <refentrytitle>snbk</refentrytitle>
+    <manvolnum>8</manvolnum>
+    <refmiscinfo class='date'>2024-11-05</refmiscinfo>
+    <refmiscinfo class='version'>@VERSION@</refmiscinfo>
+    <refmiscinfo class='manual'>Filesystem Snapshot Management</refmiscinfo>
+  </refmeta>
+
+  <refnamediv>
+    <refname>snbk</refname>
+    <refpurpose>Command-line program to backup snapshots of snapper</refpurpose>
+  </refnamediv>
+
+  <refsynopsisdiv id='synopsis'>
+    <cmdsynopsis>
+      <command>snbk</command>
+      <arg choice='opt'><replaceable>--global-opts</replaceable></arg>
+      <arg choice='plain'><replaceable>command</replaceable></arg>
+      <arg choice='opt'><replaceable>command-arguments</replaceable></arg>
+    </cmdsynopsis>
+    <cmdsynopsis>
+      <command>snbk</command>
+      <arg choice='req'>--help</arg>
+    </cmdsynopsis>
+  </refsynopsisdiv>
+
+  <refsect1 id='description'>
+    <title>DESCRIPTION</title>
+    <para>Snbk is a command-line program to backup snapshot of snapper. It can
+    transfer and delete backup snapshots on local and remote btrfs filesystems.</para>
+  </refsect1>
+
+  <refsect1 id='concepts'>
+    <title>CONCEPTS</title>
+
+    <refsect2 id='backup-configurations'>
+      <title>Backup Configurations</title>
+      <para>For each snapper config there can be several backup
+      configs. Each backup config defines backups of the snapper snapshots at
+      one location, either local or remote, see
+      <citerefentry><refentrytitle>snapper-backup-configs</refentrytitle><manvolnum>5</manvolnum></citerefentry>
+      for possible settings.</para>
+    </refsect2>
+
+    <refsect2 id='snapshot-statuses'>
+      <title>Snapshot Statuses</title>
+
+      <para>Each snapshot has a status on the source and on the
+      target. Possible values for the source are:</para>
+
+      <glosslist>
+        <glossentry>
+          <glossterm>(empty)</glossterm>
+          <glossdef>
+           <para>The snapshot is missing and thus considered obsolete
+           on the target. It will be deleted on the target by the next delete
+           command.</para>
+         </glossdef>
+        </glossentry>
+        <glossentry>
+          <glossterm>read-only</glossterm>
+          <glossdef>
+           <para>The snapshot is read-only.</para>
+          </glossdef>
+        </glossentry>
+        <glossentry>
+          <glossterm>read-write</glossterm>
+          <glossdef>
+           <para>The snapshot is read-write and thus cannot be backed-up.</para>
+          </glossdef>
+        </glossentry>
+      </glosslist>
+
+      <para>Possible values for the target are:</para>
+      <glosslist>
+        <glossentry>
+          <glossterm>(empty)</glossterm>
+          <glossdef>
+           <para>The snapshot is missing. If the source snapshot is
+           read-only it will be transferred to the target by the
+           next transfer command.</para>
+          </glossdef>
+        </glossentry>
+        <glossentry>
+          <glossterm>valid</glossterm>
+          <glossdef>
+           <para>The snapshot is valid. That implies it is read-only.</para>
+          </glossdef>
+        </glossentry>
+        <glossentry>
+          <glossterm>invalid</glossterm>
+          <glossdef>
+           <para>The snapshot is invalid. Either the received UUID is
+           wrong or it is read-write. That can happen if the transfer
+           was interrupted. The next transfer command will try to
+           transfer the snapshot again.</para>
+          </glossdef>
+        </glossentry>
+      </glosslist>
+    </refsect2>
+
+  </refsect1>
+
+  <refsect1 id='global_options'>
+    <title>GLOBAL OPTIONS</title>
+    <variablelist>
+      <varlistentry>
+       <term><option>-q, --quiet</option></term>
+       <listitem>
+         <para>Suppress normal output. Error messages will still be printed, though.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>-v, --verbose</option></term>
+       <listitem>
+         <para>Increase verbosity.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--debug</option></term>
+       <listitem>
+         <para>Turn on debugging.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--utc</option></term>
+       <listitem>
+         <para>Display dates and times in UTC. By default, local time is used.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--iso</option></term>
+       <listitem>
+         <para>Display dates and times in ISO format. ISO format is always used for machine-readable
+         outputs.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>-t, --table-style <replaceable>style</replaceable></option></term>
+       <listitem>
+         <para>Specifies table style. Table style is identified by an integer number.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--machine-readable <replaceable>format</replaceable></option></term>
+       <listitem>
+         <para>Specifies a machine-readable output format. Possible options are csv and json.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--csvout</option></term>
+       <listitem>
+         <para>Sets CSV output format. See
+         <link xlink:href="https://tools.ietf.org/html/rfc4180">RFC 4180</link>
+         for the details, except lines end with a LF, not CR+LF.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--jsonout</option></term>
+       <listitem>
+         <para>Sets JSON output format.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--separator <replaceable>character</replaceable></option></term>
+       <listitem>
+         <para>Specifies the character separator for CSV output format.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--no-headers</option></term>
+       <listitem>
+         <para>Suppress headers for CSV output format.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>-b, --backup-config <replaceable>name</replaceable></option></term>
+       <listitem>
+         <para>Use specified configuration instead of all configurations.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--no-dbus</option></term>
+       <listitem>
+         <para>Operate without a DBus connection.</para>
+         <para>Use with caution.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--target-mode <replaceable>name</replaceable></option></term>
+       <listitem>
+         <para>Only operate on backup configs with the specified target mode.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--automatic</option></term>
+       <listitem>
+         <para>Only operate on backup configs which have the
+         automatic flag set.</para>
+       </listitem>
+      </varlistentry>
+      <varlistentry>
+       <term><option>--version</option></term>
+       <listitem>
+         <para>Print version and exit.</para>
+       </listitem>
+      </varlistentry>
+    </variablelist>
+  </refsect1>
+
+  <refsect1 id='commands'>
+    <title>COMMANDS</title>
+
+    <para>Snbk provides a number of <emphasis>commands</emphasis>. Each
+    command accepts the options listed in the <link
+    linkend='global_options'>GLOBAL OPTIONS</link> section. These options
+    must be specified <emphasis>before</emphasis> the command name. In
+    addition, many commands have specific arguments, which are listed in
+    this section. These command-specific arguments must be specified
+    <emphasis>after</emphasis> the name of the command.</para>
+
+    <variablelist>
+
+      <varlistentry>
+       <term><option>help</option></term>
+       <listitem>
+         <para>Show short help text.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>list-configs</option></term>
+       <listitem>
+         <para>List available configurations.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>list (ls)</option></term>
+       <listitem>
+         <para>List snapshots.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>transfer [<replaceable>number</replaceable>]</option></term>
+       <listitem>
+         <para>Transfer all missing snapshots or the specified
+         snapshot to the target.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>delete (remove|rm) [<replaceable>number</replaceable>]</option></term>
+       <listitem>
+         <para>Delete all obsolete snapshots or the specified
+         snapshot from the target.</para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term><option>transfer-and-delete</option></term>
+       <listitem>
+         <para>Combines transfer and delete.</para>
+       </listitem>
+      </varlistentry>
+
+    </variablelist>
+  </refsect1>
+
+  <refsect1 id='restore'>
+    <title>RESTORE</title>
+    <para>So far a restore has to be done manually. There are several
+    methods to do a restore, e.g. the backup can be mounted and then
+    copied or rsync can be used. Here we provide an example on how to use
+    btrfs send and receive to restore a snapshot on the source
+    system. In general using btrfs send and receive is a bit
+    tricky.</para>
+    <para>When using target-mode local:
+      <programlisting>
+# mkdir /.snapshots/42
+# cp /backups/root/42/info.xml /.snapshots/42/
+# btrfs send /backups/root/42/snapshot | btrfs receive /.snapshots/42
+      </programlisting>
+      When using target-mode ssh-push:
+      <programlisting>
+# mkdir /.snapshots/42
+# scp backups.example.com:/backups/eberich/root/42/info.xml /.snapshots/42
+# ssh backups.example.com btrfs send /backups/eberich/root/42/snapshot | btrfs receive /.snapshots/42
+      </programlisting>
+    </para>
+    <para>If the system was reinstalled it is unfortunately in general
+    not possibly to simply use the restored snapshot as the new default
+    snapshot since some files, e.g. /etc/fstab, likely need
+    modifications. Also unfortunately for other subvolumes than root a
+    rollback is not supported.</para>
+    <para>If there are snapshots on the source to speed up the
+    operation you can also use the -p option for btrfs send.</para>
+  </refsect1>
+
+  <refsect1 id='notes'>
+    <title>NOTES</title>
+    <para>The content of snapshots transferred must not be changed on
+    the source system. Normally this is ensured since the snapshots are
+    read-only. But it is possible to change snapshots to read-write. This
+    can cause error during transfers in the future.</para>
+  </refsect1>
+
+  <refsect1 id='permissions'>
+    <title>PERMISSIONS</title>
+    <para>Since the target-mode ssh-push needs root permissions on the
+    target it is recommended to use a dedicated machine or container
+    as a target.</para>
+  </refsect1>
+
+  <refsect1 id='files'>
+    <title>FILES</title>
+    <variablelist>
+      <varlistentry>
+       <term><filename>/etc/snapper/backup-configs</filename></term>
+       <listitem>
+         <para>Directory containing configuration files.</para>
+       </listitem>
+      </varlistentry>
+    </variablelist>
+  </refsect1>
+
+  <refsect1 id='exit_status'>
+    <title>EXIT STATUS</title>
+    <para>Normally the exit status is 0. If an error occurred the exit
+    status is 1.</para>
+  </refsect1>
+
+  <refsect1 id='homepage'>
+    <title>HOMEPAGE</title>
+    <para><ulink url='http://snapper.io/'>http://snapper.io/</ulink></para>
+  </refsect1>
+
+  <refsect1 id='authors'>
+    <title>AUTHORS</title>
+    <para>Arvin Schnell <email>aschnell@suse.com</email></para>
+  </refsect1>
+
+  <refsect1 id='see_also'>
+    <title>SEE ALSO</title>
+    <para>
+      <citerefentry><refentrytitle>snapper-backup-configs</refentrytitle><manvolnum>5</manvolnum></citerefentry>,
+      <citerefentry><refentrytitle>snapper</refentrytitle><manvolnum>8</manvolnum></citerefentry>,
+      <citerefentry role="nolink"><refentrytitle>btrfs-send</refentrytitle><manvolnum>8</manvolnum></citerefentry>
+    </para>
+  </refsect1>
+
+</refentry>
index 0adb8faa85fdbc0fe748f078a4d16903bf0c7d07..92b8d3dcfe8da0b4c3fccf7168488bc7d4065c13 100644 (file)
@@ -1,3 +1,8 @@
+-------------------------------------------------------------------
+Tue Nov 05 10:50:50 CET 2024 - aschnell@suse.com
+
+- provide backup program for btrfs snapshots
+
 -------------------------------------------------------------------
 Fri Oct 25 08:48:46 CEST 2024 - aschnell@suse.com
 
index ca1f502fbd633556f61ec72b1e9d9cd974518286..db51f8422a58f871db3e966bd04514d299eb90cf 100644 (file)
@@ -224,7 +224,8 @@ test -f /etc/logrotate.d/snapper.rpmsave && mv -v /etc/logrotate.d/snapper.rpmsa
 %else
 %config(noreplace) %{_sysconfdir}/logrotate.d/snapper
 %endif
-%{_unitdir}/snapper*.*
+%{_unitdir}/snapper-{timeline,cleanup,boot}.*
+%{_unitdir}/snapperd.service
 %if 0%{?suse_version} <= 1500
 %dir %{_datadir}/dbus-1/system.d
 %endif
@@ -341,10 +342,27 @@ for i in zypp-plugin.conf ; do
     test -f /etc/snapper/${i}.rpmsave && mv -v /etc/snapper/${i}.rpmsave /etc/snapper/${i} ||:
 done
 
+%package -n snapper-backup
+Requires:       snapper = %version
+Summary:        A backup program for snapper
+Group:          System/Packages
+
+%description -n snapper-backup
+A backup program for snapshots created by snapper.
+
+%files -n snapper-backup
+%{_sbindir}/snbk
+%dir %{_sysconfdir}/snapper/backup-configs
+%dir %{_sysconfdir}/snapper/certs
+%{_unitdir}/snapper-{backup}.*
+%{_mandir}/*/snbk.8*
+%{_mandir}/*/snapper-backup-configs.5*
+
 %package -n pam_snapper
 Requires:       pam
 Requires:       snapper = %version
 Summary:        PAM module for calling snapper
+Requires:       util-linux-systemd
 Group:          System/Packages
 
 %description -n pam_snapper
index 6285c1567d053efaf9df2447308bdf9acf5b7525..a97fac5948dad41d5ff97b239fcddf0e1ac01209 100644 (file)
@@ -30,6 +30,7 @@
 #define SYSCONFIG_FILE CONF_DIR "/snapper"
 
 #define CONFIGS_DIR "/etc/snapper/configs"
+#define BACKUP_CONFIGS_DIR "/etc/snapper/backup-configs"
 
 #define ETC_CONFIG_TEMPLATE_DIR "/etc/snapper/config-templates"
 #define USR_CONFIG_TEMPLATE_DIR "/usr/share/snapper/config-templates"
 // commands
 
 #define SH_BIN "/bin/sh"
+#define SSH_BIN "/usr/bin/ssh"
 
 #define BTRFS_BIN "/usr/sbin/btrfs"
 
 #define SYSTEMCTL_BIN "/usr/bin/systemctl"
 
+#define FINDMNT_BIN "/usr/bin/findmnt"
+#define REALPATH_BIN "/usr/bin/realpath"
+
+#define CP_BIN "/usr/bin/cp"
+#define SCP_BIN "/usr/bin/scp"
+#define MKDIR_BIN "/usr/bin/mkdir"
+
+#define RM_BIN "/usr/bin/rm"
+#define RMDIR_BIN "/usr/bin/rmdir"
+
 
 // keys from the config files
 
@@ -71,4 +83,9 @@
 #define KEY_TIMELINE_CREATE "TIMELINE_CREATE"
 
 
+// regexes
+
+#define UUID_REGEX "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
+
+
 #endif