py-pve-cloud-backup 0.5.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.5.11"
@@ -0,0 +1,164 @@
1
+ from tinydb import TinyDB
2
+ import subprocess
3
+ from pathlib import Path
4
+ import logging
5
+ from pve_cloud_backup.daemon.shared import IMAGE_META_DB_PATH, STACK_META_DB_PATH, BACKUP_BASE_DIR, copy_backup_generic
6
+ import os
7
+ from enum import Enum
8
+ import asyncio
9
+ import struct
10
+ import pickle
11
+ import zstandard as zstd
12
+
13
+
14
+ log_level_str = os.getenv("LOG_LEVEL", "INFO").upper()
15
+ log_level = getattr(logging, log_level_str, logging.INFO)
16
+
17
+ logging.basicConfig(level=log_level)
18
+ logger = logging.getLogger("bdd")
19
+
20
+ ENV = os.getenv("ENV", "TESTING")
21
+
22
+ BACKUP_TYPES = ["k8s", "nextcloud", "git", "postgres"]
23
+
24
+
25
+ def init_backup_dir(backup_dir):
26
+ repo_path = f"{BACKUP_BASE_DIR}/borg-{backup_dir}"
27
+ Path(repo_path).mkdir(parents=True, exist_ok=True)
28
+
29
+ # init borg repo, is ok to fail if it already exists
30
+ subprocess.run(["borg", "init", "--encryption=none", repo_path])
31
+
32
+
33
+ class Command(Enum):
34
+ ARCHIVE = 1
35
+ IMAGE_META = 2
36
+ STACK_META = 3
37
+
38
+
39
+ lock_dict = {}
40
+
41
+ # to prevent from writing to the same borg archive parallel
42
+ def get_lock(backup_dir):
43
+ if backup_dir not in lock_dict:
44
+ lock_dict[backup_dir] = asyncio.Lock()
45
+
46
+ return lock_dict[backup_dir]
47
+
48
+
49
+ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
50
+ addr = writer.get_extra_info('peername')
51
+ logger.info(f"Connection from {addr}")
52
+
53
+ command = Command(struct.unpack('B', await reader.read(1))[0])
54
+ logger.info(f"{addr} send command: {command}")
55
+
56
+ try:
57
+ match command:
58
+ case Command.ARCHIVE:
59
+ # each archive request starts with a pickled dict containing parameters
60
+ dict_size = struct.unpack('!I', (await reader.readexactly(4)))[0]
61
+ req_dict = pickle.loads((await reader.readexactly(dict_size)))
62
+ logger.info(req_dict)
63
+
64
+ # extract the parameters
65
+ borg_archive_type = req_dict["borg_archive_type"] # borg locks
66
+ archive_name = req_dict["archive_name"]
67
+ timestamp = req_dict["timestamp"]
68
+
69
+ if borg_archive_type not in BACKUP_TYPES:
70
+ raise Exception("Unknown backup type " + borg_archive_type)
71
+
72
+ if borg_archive_type == "k8s":
73
+ backup_dir = f"k8s/" + req_dict["namespace"]
74
+ else:
75
+ backup_dir = borg_archive_type
76
+
77
+ init_backup_dir(backup_dir)
78
+
79
+ # lock locally, we have one borg archive per archive type
80
+ async with get_lock(backup_dir):
81
+ borg_archive = f"{BACKUP_BASE_DIR}/borg-{backup_dir}::{archive_name}_{timestamp}"
82
+ logger.info(f"accuired lock {backup_dir}")
83
+
84
+ # send continue signal, meaning we have the lock and export can start.
85
+ writer.write(b'\x01') # signal = 0x01 means "continue"
86
+ await writer.drain()
87
+ logger.debug("send go")
88
+
89
+ # initialize the borg subprocess we will pipe the received content to
90
+ # decompressor = zlib.decompressobj()
91
+ decompressor = zstd.ZstdDecompressor().decompressobj()
92
+ borg_proc = await asyncio.create_subprocess_exec(
93
+ "borg", "create", "--compression", "zstd,1",
94
+ "--stdin-name", req_dict["stdin_name"],
95
+ borg_archive, "-",
96
+ stdin=asyncio.subprocess.PIPE
97
+ )
98
+
99
+ # read compressed chunks
100
+ while True:
101
+ # client first always sends chunk size
102
+ chunk_size = struct.unpack("!I", (await reader.readexactly(4)))[0]
103
+ if chunk_size == 0:
104
+ break # client sends 0 chunk size at the end to signal that its finished uploading
105
+ chunk = await reader.readexactly(chunk_size)
106
+
107
+ # decompress and write
108
+ decompressed_chunk = decompressor.decompress(chunk)
109
+ if decompressed_chunk:
110
+ borg_proc.stdin.write(decompressed_chunk)
111
+ await borg_proc.stdin.drain()
112
+
113
+ # the decompressor does not always return a decompressed chunk but might retain
114
+ # and return empty. at the end we need to call flush to get everything out
115
+ borg_proc.stdin.write(decompressor.flush())
116
+ await borg_proc.stdin.drain()
117
+
118
+ # close the proc stdin pipe, writer gets closed in finally
119
+ borg_proc.stdin.close()
120
+ exit_code = await borg_proc.wait()
121
+
122
+ if exit_code != 0:
123
+ raise Exception(f"Borg failed with code {exit_code}")
124
+
125
+ case Command.STACK_META:
126
+ # read meta dict size
127
+ dict_size = struct.unpack('!I', (await reader.readexactly(4)))[0]
128
+ meta_dict = pickle.loads((await reader.readexactly(dict_size)))
129
+
130
+ async with get_lock("stack"):
131
+ meta_db = TinyDB(STACK_META_DB_PATH)
132
+ meta_db.insert(meta_dict)
133
+
134
+ case Command.IMAGE_META:
135
+ dict_size = struct.unpack('!I', (await reader.readexactly(4)))[0]
136
+ meta_dict = pickle.loads((await reader.readexactly(dict_size)))
137
+
138
+ async with get_lock("image"):
139
+ meta_db = TinyDB(IMAGE_META_DB_PATH)
140
+ meta_db.insert(meta_dict)
141
+
142
+ except asyncio.IncompleteReadError as e:
143
+ logger.error("Client disconnected", e)
144
+ finally:
145
+ writer.close()
146
+ # dont await on server side
147
+
148
+
149
+ async def run():
150
+ server = await asyncio.start_server(handle_client, "0.0.0.0", 8888)
151
+ addr = server.sockets[0].getsockname()
152
+ logger.info(f"Serving on {addr}")
153
+ async with server:
154
+ await server.serve_forever()
155
+
156
+
157
+ def main():
158
+ if ENV == 'PRODUCTION':
159
+ copy_backup_generic()
160
+
161
+ asyncio.run(run())
162
+
163
+
164
+
@@ -0,0 +1,180 @@
1
+ import argparse
2
+ import logging
3
+ from kubernetes import client
4
+ from kubernetes.config.kube_config import KubeConfigLoader
5
+ import pve_cloud_backup.daemon.shared as shared
6
+ from proxmoxer import ProxmoxAPI
7
+ from tinydb import TinyDB, Query
8
+ from pprint import pformat
9
+ import yaml
10
+ import pickle
11
+ import base64
12
+ import os
13
+ import json
14
+
15
+
16
+ log_level_str = os.getenv("LOG_LEVEL", "INFO").upper()
17
+ log_level = getattr(logging, log_level_str, logging.INFO)
18
+
19
+ logging.basicConfig(level=log_level)
20
+ logger = logging.getLogger("brctl")
21
+
22
+
23
+ def list_backup_details(args):
24
+ print(f"listing details for {args.timestamp}")
25
+ timestamp_archives = shared.get_image_metas(args, args.timestamp)
26
+
27
+ metas = timestamp_archives[args.timestamp]
28
+
29
+ # first we group metas
30
+ k8s_stacks = {}
31
+
32
+ for meta in metas:
33
+ if meta["type"] == "k8s":
34
+ if meta["stack"] not in k8s_stacks:
35
+ k8s_stacks[meta["stack"]] = []
36
+
37
+ k8s_stacks[meta["stack"]].append(meta)
38
+ else:
39
+ raise Exception(f"Invalid meta type found - meta {meta}")
40
+
41
+
42
+ for k8s_stack, k8s_metas in k8s_stacks.items():
43
+ print(f" - k8s stack {k8s_stack}:")
44
+
45
+ # get stack meta and decode stack namespace secrets
46
+ stack_meta_db = TinyDB(f"{args.backup_path}stack-meta-db.json")
47
+ Meta = Query()
48
+
49
+ stack_meta = stack_meta_db.get((Meta.timestamp == args.timestamp) & (Meta.stack == k8s_stack) & (Meta.type == "k8s"))
50
+
51
+ namespace_secret_dict = pickle.loads(base64.b64decode(stack_meta["namespace_secret_dict_b64"]))
52
+
53
+ namespace_k8s_metas = {}
54
+
55
+ # group metas by namespace
56
+ for meta in k8s_metas:
57
+ if meta["namespace"] not in namespace_k8s_metas:
58
+ namespace_k8s_metas[meta["namespace"]] = []
59
+
60
+ namespace_k8s_metas[meta["namespace"]].append(meta)
61
+
62
+ for namespace, k8s_metas in namespace_k8s_metas.items():
63
+ print(f" - namespace {namespace}:")
64
+ print(f" - volumes:")
65
+ for meta in k8s_metas:
66
+ pvc_name = meta["pvc_name"]
67
+ pool = meta["pool"]
68
+ storage_class = meta["storage_class"]
69
+ print(f" - {pvc_name}, pool {pool}, storage class {storage_class}")
70
+
71
+ print(f" - secrets:")
72
+ for secret in namespace_secret_dict[namespace]:
73
+ secret_name = secret["metadata"]["name"]
74
+ print(f" - {secret_name}")
75
+
76
+
77
+ def list_backups(args):
78
+ timestamp_archives = shared.get_image_metas(args)
79
+
80
+ if args.json:
81
+ print(json.dumps(sorted(timestamp_archives)))
82
+ return
83
+
84
+ print("available backup timestamps (ids):")
85
+
86
+ for timestamp in sorted(timestamp_archives):
87
+ print(f"- timestamp {timestamp}")
88
+
89
+
90
+ # this assumes you first restored the virtual machines
91
+ # and extracted a fitting kubeconfig passing it via --kubeconfig
92
+ def restore_k8s(args):
93
+ print(f"restoring {args.timestamp}")
94
+
95
+ metas = shared.get_image_metas(args, args.timestamp)[args.timestamp]
96
+
97
+ metas_grouped = shared.group_image_metas(metas, ["k8s"], "namespace", args.k8s_stack_name)
98
+
99
+ stack_meta_db = TinyDB(f"{args.backup_path}stack-meta-db.json")
100
+ Meta = Query()
101
+
102
+ stack_meta = stack_meta_db.get((Meta.timestamp == args.timestamp) & (Meta.stack == args.k8s_stack_name) & (Meta.type == "k8s"))
103
+ logger.debug(f"stack meta {stack_meta}")
104
+ namespace_secret_dict = pickle.loads(base64.b64decode(stack_meta["namespace_secret_dict_b64"]))
105
+
106
+ # user can manually specify it
107
+ if args.kubeconfig_new:
108
+ with open(args.kubeconfig_new, "r") as file:
109
+ kubeconfig_dict = yaml.safe_load(file)
110
+ else:
111
+ # restore into original k8s cluster
112
+ master_ipv4 = stack_meta["master_ip"]
113
+ kubeconfig_dict = yaml.safe_load(stack_meta["raw_kubeconfig"])
114
+
115
+ # override the connection ip as it is set to localhost on the machines
116
+ kubeconfig_dict["clusters"][0]["cluster"]["server"] = f"https://{master_ipv4}:6443"
117
+
118
+ logger.debug(f"kubeconfig dict {pformat(kubeconfig_dict)}")
119
+
120
+ # init kube client
121
+ loader = KubeConfigLoader(config_dict=kubeconfig_dict)
122
+ configuration = client.Configuration()
123
+ loader.load_and_set(configuration)
124
+
125
+ # Create a client from this configuration
126
+ api_client = client.ApiClient(configuration)
127
+
128
+ # run the restore
129
+ shared.restore_pvcs(metas_grouped, namespace_secret_dict, args, api_client)
130
+
131
+
132
+ # dynamic backup path function for the --backup-path argument
133
+ def backup_path(value):
134
+ if value == "":
135
+ return ""
136
+
137
+ if value.endswith("/"):
138
+ return value
139
+ else:
140
+ return value + "/"
141
+
142
+
143
+ # purpose of these tools is disaster recovery into an identical pve + ceph system
144
+ # assumes to be run on a pve system, but can be passed pve host and path to ssh key aswell
145
+ def main():
146
+ parser = argparse.ArgumentParser(description="CLI for restoring backups.")
147
+
148
+ base_parser = argparse.ArgumentParser(add_help=False)
149
+ base_parser.add_argument("--backup-path", type=backup_path, default=".", help="Path of the mounted backup drive/dir.")
150
+ base_parser.add_argument("--proxmox-host", type=str, help="Proxmox host, if not run directly on a pve node.")
151
+ base_parser.add_argument("--proxmox-private-key", type=str, help="Path to pve root private key, for connecting to remote pve.")
152
+
153
+ subparsers = parser.add_subparsers(dest="command", required=True)
154
+
155
+ list_parser = subparsers.add_parser("list-backups", help="List available backups.", parents=[base_parser])
156
+ list_parser.add_argument("--json", action="store_true", help="Outputs the available timestamps as json.")
157
+ list_parser.set_defaults(func=list_backups)
158
+
159
+ list_detail_parser = subparsers.add_parser("backup-details", help="List details of a backup.", parents=[base_parser])
160
+ list_detail_parser.add_argument("--timestamp", type=str, help="Timestamp of the backup to list details of.", required=True)
161
+ list_detail_parser.set_defaults(func=list_backup_details)
162
+
163
+ k8s_restore_parser = subparsers.add_parser("restore-k8s", help="Restore k8s csi backups. If pvcs with same name exist, test-restore will be appended to pvc name.", parents=[base_parser])
164
+ k8s_restore_parser.add_argument("--timestamp", type=str, help="Timestamp of the backup to restore.", required=True)
165
+ k8s_restore_parser.add_argument("--k8s-stack-name", type=str, help="Stack name of k8s stack that will be restored into.", required=True)
166
+ k8s_restore_parser.add_argument("--kubeconfig-new", type=str, help="Optional kubeconfig for new cluster restores.")
167
+ k8s_restore_parser.add_argument("--namespaces", type=str, default="", help="Specific namespaces to restore, CSV, acts as a filter. Use with --pool-mapping for controlled migration of pvcs.")
168
+ k8s_restore_parser.add_argument("--pool-sc-mapping", action="append", help="Define pool storage class mappings (old to new), for example old-pool:new-pool/new-storage-class-name.")
169
+ k8s_restore_parser.add_argument("--namespace-mapping", action="append", help="Namespaces that should be restored into new namespace names old-namespace:new-namespace.")
170
+ k8s_restore_parser.add_argument("--auto-scale", action="store_true", help="When passed deployments and stateful sets will automatically get scaled down and back up again for restore.")
171
+ k8s_restore_parser.add_argument("--auto-delete", action="store_true", help="When passed existing pvcs in namespace will automatically get deleted before restoring.")
172
+ k8s_restore_parser.add_argument("--secret-pattern", action="append", help="Define as many times as you need, for example namespace/deployment* (glob style). Will overwrite secret data of matching existing.")
173
+ k8s_restore_parser.set_defaults(func=restore_k8s)
174
+
175
+ args = parser.parse_args()
176
+ args.func(args)
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()