py-pve-cloud-backup 0.0.1__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.

Potentially problematic release.


This version of py-pve-cloud-backup might be problematic. Click here for more details.

@@ -0,0 +1,169 @@
1
+ from tinydb import TinyDB
2
+ import subprocess
3
+ from pathlib import Path
4
+ import logging
5
+ from shared import IMAGE_META_DB_PATH, STACK_META_DB_PATH, BACKUP_DIR, copy_backup_generic, RBD_REPO_TYPES
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
+ FILE_REPO_TYPES = ["nextcloud", "git"] # different borg archives for each
23
+
24
+
25
+ def init_backup_dir():
26
+ if ENV == "TESTING":
27
+ Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True)
28
+
29
+ for repo_type in RBD_REPO_TYPES:
30
+ repo_path = f"{BACKUP_DIR}/borg-{repo_type}"
31
+ Path(repo_path).mkdir(parents=True, exist_ok=True)
32
+
33
+ # init borg repo, is ok to fail if it already exists
34
+ subprocess.run(["borg", "init", "--encryption=none", repo_path])
35
+
36
+ for file_type in FILE_REPO_TYPES:
37
+ repo_path = f"{BACKUP_DIR}/borg-{file_type}"
38
+ Path(repo_path).mkdir(parents=True, exist_ok=True)
39
+
40
+ # init borg repo, is ok to fail if it already exists
41
+ subprocess.run(["borg", "init", "--encryption=none", repo_path])
42
+
43
+ if ENV == 'PRODUCTION':
44
+ copy_backup_generic()
45
+
46
+
47
+ class Command(Enum):
48
+ ARCHIVE = 1
49
+ IMAGE_META = 2
50
+ STACK_META = 3
51
+
52
+
53
+ lock_dict = {}
54
+
55
+ def get_lock(lock_type):
56
+ if lock_type not in RBD_REPO_TYPES and lock_type not in FILE_REPO_TYPES and lock_type not in ["stack", "image"]:
57
+ raise Exception(f"Unknown type {lock_type}")
58
+
59
+ if lock_type not in lock_dict:
60
+ lock_dict[lock_type] = asyncio.Lock()
61
+
62
+ return lock_dict[lock_type]
63
+
64
+
65
+ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
66
+ addr = writer.get_extra_info('peername')
67
+ logger.info(f"Connection from {addr}")
68
+
69
+ command = Command(struct.unpack('B', await reader.read(1))[0])
70
+ logger.info(f"{addr} send command: {command}")
71
+
72
+ try:
73
+ match command:
74
+ case Command.ARCHIVE:
75
+ # each archive request starts with a pickled dict containing parameters
76
+ dict_size = struct.unpack('!I', (await reader.readexactly(4)))[0]
77
+ req_dict = pickle.loads((await reader.readexactly(dict_size)))
78
+ logger.info(req_dict)
79
+
80
+ # extract the parameters
81
+ borg_archive_type = req_dict["borg_archive_type"] # borg locks
82
+ archive_name = req_dict["archive_name"]
83
+ timestamp = req_dict["timestamp"]
84
+
85
+ # lock locally, we have one borg archive per archive type
86
+ async with get_lock(borg_archive_type):
87
+ borg_archive = f"{BACKUP_DIR}/borg-{borg_archive_type}::{archive_name}_{timestamp}"
88
+ logger.info(f"accuired lock {borg_archive_type}")
89
+
90
+ # send continue signal, meaning we have the lock and export can start.
91
+ writer.write(b'\x01') # signal = 0x01 means "continue"
92
+ await writer.drain()
93
+ logger.debug("send go")
94
+
95
+ # initialize the borg subprocess we will pipe the received content to
96
+ # decompressor = zlib.decompressobj()
97
+ decompressor = zstd.ZstdDecompressor().decompressobj()
98
+ borg_proc = await asyncio.create_subprocess_exec(
99
+ "borg", "create", "--compression", "zstd,1",
100
+ "--stdin-name", req_dict["stdin_name"],
101
+ borg_archive, "-",
102
+ stdin=asyncio.subprocess.PIPE
103
+ )
104
+
105
+ # read compressed chunks
106
+ while True:
107
+ # client first always sends chunk size
108
+ chunk_size = struct.unpack("!I", (await reader.readexactly(4)))[0]
109
+ if chunk_size == 0:
110
+ break # client sends 0 chunk size at the end to signal that its finished uploading
111
+ chunk = await reader.readexactly(chunk_size)
112
+
113
+ # decompress and write
114
+ decompressed_chunk = decompressor.decompress(chunk)
115
+ if decompressed_chunk:
116
+ borg_proc.stdin.write(decompressed_chunk)
117
+ await borg_proc.stdin.drain()
118
+
119
+ # the decompressor does not always return a decompressed chunk but might retain
120
+ # and return empty. at the end we need to call flush to get everything out
121
+ borg_proc.stdin.write(decompressor.flush())
122
+ await borg_proc.stdin.drain()
123
+
124
+ # close the proc stdin pipe, writer gets closed in finally
125
+ borg_proc.stdin.close()
126
+ exit_code = await borg_proc.wait()
127
+
128
+ if exit_code != 0:
129
+ raise Exception(f"Borg failed with code {exit_code}")
130
+
131
+ case Command.STACK_META:
132
+ # read meta dict size
133
+ dict_size = struct.unpack('!I', (await reader.readexactly(4)))[0]
134
+ meta_dict = pickle.loads((await reader.readexactly(dict_size)))
135
+
136
+ async with get_lock("stack"):
137
+ meta_db = TinyDB(STACK_META_DB_PATH)
138
+ meta_db.insert(meta_dict)
139
+
140
+ case Command.IMAGE_META:
141
+ dict_size = struct.unpack('!I', (await reader.readexactly(4)))[0]
142
+ meta_dict = pickle.loads((await reader.readexactly(dict_size)))
143
+
144
+ async with get_lock("image"):
145
+ meta_db = TinyDB(IMAGE_META_DB_PATH)
146
+ meta_db.insert(meta_dict)
147
+
148
+ except asyncio.IncompleteReadError as e:
149
+ logger.error("Client disconnected", e)
150
+ finally:
151
+ writer.close()
152
+ # dont await on server side
153
+
154
+
155
+ async def run():
156
+ init_backup_dir()
157
+
158
+ server = await asyncio.start_server(handle_client, "0.0.0.0", 8888)
159
+ addr = server.sockets[0].getsockname()
160
+ logger.info(f"Serving on {addr}")
161
+ async with server:
162
+ await server.serve_forever()
163
+
164
+
165
+ def main():
166
+ asyncio.run(run())
167
+
168
+
169
+
@@ -0,0 +1,221 @@
1
+ import argparse
2
+ import logging
3
+ from kubernetes import client
4
+ from kubernetes.config.kube_config import KubeConfigLoader
5
+ import 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
+
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("brctl")
19
+
20
+
21
+ def list_backup_details(args):
22
+ print(f"listing details for {args.timestamp}")
23
+ timestamp_archives = shared.get_image_metas(args, args.timestamp)
24
+
25
+ metas = timestamp_archives[args.timestamp]
26
+
27
+ # first we group metas
28
+ k8s_stacks = {}
29
+ vm_stacks = {}
30
+
31
+ for meta in metas:
32
+ if meta["type"] == "k8s":
33
+ if meta["stack"] not in k8s_stacks:
34
+ k8s_stacks[meta["stack"]] = []
35
+
36
+ k8s_stacks[meta["stack"]].append(meta)
37
+
38
+ elif meta["type"] in ["lxc", "qemu"]:
39
+ if meta["stack"] not in vm_stacks:
40
+ vm_stacks[meta["stack"]] = []
41
+
42
+ vm_stacks[meta["stack"]].append(meta)
43
+
44
+ else:
45
+ raise Exception(f"Invalid meta type found - meta {meta}")
46
+
47
+
48
+ for k8s_stack, k8s_metas in k8s_stacks.items():
49
+ print(f" - k8s stack {k8s_stack}:")
50
+
51
+ # get stack meta and decode stack namespace secrets
52
+ stack_meta_db = TinyDB(f"{args.backup_path}stack-meta-db.json")
53
+ Meta = Query()
54
+
55
+ stack_meta = stack_meta_db.get((Meta.timestamp == args.timestamp) & (Meta.stack == k8s_stack) & (Meta.type == "k8s"))
56
+
57
+ namespace_secret_dict = pickle.loads(base64.b64decode(stack_meta["namespace_secret_dict_b64"]))
58
+
59
+ namespace_k8s_metas = {}
60
+
61
+ # group metas by namespace
62
+ for meta in k8s_metas:
63
+ if meta["namespace"] not in namespace_k8s_metas:
64
+ namespace_k8s_metas[meta["namespace"]] = []
65
+
66
+ namespace_k8s_metas[meta["namespace"]].append(meta)
67
+
68
+ for namespace, k8s_metas in namespace_k8s_metas.items():
69
+ print(f" - namespace {namespace}:")
70
+ print(f" - volumes:")
71
+ for meta in k8s_metas:
72
+ pvc_name = meta["pvc_name"]
73
+ pool = meta["pool"]
74
+ storage_class = meta["storage_class"]
75
+ print(f" - {pvc_name}, pool {pool}, storage class {storage_class}")
76
+
77
+ print(f" - secrets:")
78
+ for secret in namespace_secret_dict[namespace]:
79
+ secret_name = secret["metadata"]["name"]
80
+ print(f" - {secret_name}")
81
+
82
+
83
+ for stack, vm_metas in vm_stacks.items():
84
+ print(f" - vm stack {stack}:")
85
+
86
+ for meta in vm_metas:
87
+ vmid = meta["vmid"]
88
+ pool = meta["pool"]
89
+ image_name = meta["image_name"]
90
+ type = meta["type"]
91
+ print(f" - vmid {vmid}, disk {image_name}, pool {pool}, vm type {type}")
92
+
93
+
94
+ def list_backups(args):
95
+ timestamp_archives = shared.get_image_metas(args)
96
+
97
+ print("available backup timestamps (ids):")
98
+
99
+ for timestamp in sorted(timestamp_archives):
100
+ print(f"- timestamp {timestamp}")
101
+
102
+
103
+
104
+ def restore_vms(args):
105
+ print(f"restoring {args.timestamp}")
106
+
107
+ logger.debug(f"pool mappings {args.pool_mapping}")
108
+
109
+ # init proxmoxer
110
+ if args.proxmox_host and args.proxmox_private_key:
111
+ proxmox = ProxmoxAPI(
112
+ args.proxmox_host, user="root", backend='ssh_paramiko', private_key_file=args.proxmox_private_key
113
+ )
114
+ else:
115
+ proxmox = ProxmoxAPI(
116
+ "localhost", user="root", backend='ssh_paramiko'
117
+ )
118
+
119
+ metas = shared.get_image_metas(args, args.timestamp)[args.timestamp]
120
+
121
+ metas_grouped = shared.group_image_metas(metas, ["lxc", "qemu"], "vmid")
122
+ stack_metas = shared.get_stack_metas(args, args.timestamp, ["lxc", "qemu"], "vmid")
123
+
124
+ shared.restore_images(metas_grouped, stack_metas, args, proxmox)
125
+
126
+
127
+ # this assumes you first restored the virtual machines
128
+ # and extracted a fitting kubeconfig passing it via --kubeconfig
129
+ def restore_k8s(args):
130
+ print(f"restoring {args.timestamp}")
131
+
132
+ metas = shared.get_image_metas(args, args.timestamp)[args.timestamp]
133
+
134
+ metas_grouped = shared.group_image_metas(metas, ["k8s"], "namespace", args.k8s_stack_name)
135
+
136
+ stack_meta_db = TinyDB(f"{args.backup_path}stack-meta-db.json")
137
+ Meta = Query()
138
+
139
+ stack_meta = stack_meta_db.get((Meta.timestamp == args.timestamp) & (Meta.stack == args.k8s_stack_name) & (Meta.type == "k8s"))
140
+ logger.debug(f"stack meta {stack_meta}")
141
+ namespace_secret_dict = pickle.loads(base64.b64decode(stack_meta["namespace_secret_dict_b64"]))
142
+
143
+ # user can manually specify it
144
+ if args.kubeconfig_new:
145
+ with open(args.kubeconfig_new, "r") as file:
146
+ kubeconfig_dict = yaml.safe_load(file)
147
+ else:
148
+ # restore into original k8s cluster
149
+ master_ipv4 = stack_meta["master_ip"]
150
+ kubeconfig_dict = yaml.safe_load(stack_meta["raw_kubeconfig"])
151
+
152
+ # override the connection ip as it is set to localhost on the machines
153
+ kubeconfig_dict["clusters"][0]["cluster"]["server"] = f"https://{master_ipv4}:6443"
154
+
155
+ logger.debug(f"kubeconfig dict {pformat(kubeconfig_dict)}")
156
+
157
+ # init kube client
158
+ loader = KubeConfigLoader(config_dict=kubeconfig_dict)
159
+ configuration = client.Configuration()
160
+ loader.load_and_set(configuration)
161
+
162
+ # Create a client from this configuration
163
+ api_client = client.ApiClient(configuration)
164
+
165
+ # run the restore
166
+ shared.restore_pvcs(metas_grouped, namespace_secret_dict, args, api_client)
167
+
168
+
169
+ # dynamic backup path function for the --backup-path argument
170
+ def backup_path(value):
171
+ if value == "":
172
+ return ""
173
+
174
+ if value.endswith("/"):
175
+ return value
176
+ else:
177
+ return value + "/"
178
+
179
+
180
+ # purpose of these tools is disaster recovery into an identical pve + ceph system
181
+ # assumes to be run on a pve system, but can be passed pve host and path to ssh key aswell
182
+ def main():
183
+ parser = argparse.ArgumentParser(description="CLI for restoring backups.")
184
+
185
+ base_parser = argparse.ArgumentParser(add_help=False)
186
+ base_parser.add_argument("--backup-path", type=backup_path, default=".", help="Path of the mounted backup drive/dir.")
187
+ base_parser.add_argument("--proxmox-host", type=str, help="Proxmox host, if not run directly on a pve node.")
188
+ base_parser.add_argument("--proxmox-private-key", type=str, help="Path to pve root private key, for connecting to remote pve.")
189
+
190
+ subparsers = parser.add_subparsers(dest="command", required=True)
191
+
192
+ list_parser = subparsers.add_parser("list-backups", help="List available backups.", parents=[base_parser])
193
+ list_parser.set_defaults(func=list_backups)
194
+
195
+ list_detail_parser = subparsers.add_parser("backup-details", help="List details of a backup.", parents=[base_parser])
196
+ list_detail_parser.add_argument("--timestamp", type=str, help="Timestamp of the backup to list details of.", required=True)
197
+ list_detail_parser.set_defaults(func=list_backup_details)
198
+
199
+ restore_parser = subparsers.add_parser("restore-vms", help="Restore vm (qemu/lxc) backups.", parents=[base_parser])
200
+ restore_parser.add_argument("--timestamp", type=str, help="Timestamp of the backup to restore.", required=True)
201
+ restore_parser.add_argument("--stack-names", type=str, default="", help="Specific stacks to restore, CSV, acts as a filter.")
202
+ restore_parser.add_argument("--pool-mapping", action="append", help="Define pool mappings (old to new), for example ssd:nvme.")
203
+ restore_parser.set_defaults(func=restore_vms)
204
+
205
+ 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])
206
+ k8s_restore_parser.add_argument("--timestamp", type=str, help="Timestamp of the backup to restore.", required=True)
207
+ k8s_restore_parser.add_argument("--k8s-stack-name", type=str, help="Stack name of k8s stack that will be restored into.", required=True)
208
+ k8s_restore_parser.add_argument("--kubeconfig-new", type=str, help="Optional kubeconfig for new cluster restores.")
209
+ 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.")
210
+ 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.")
211
+ 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.")
212
+ k8s_restore_parser.add_argument("--auto-delete", action="store_true", help="When passed existing pvcs in namespace will automatically get deleted before restoring.")
213
+ 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.")
214
+ k8s_restore_parser.set_defaults(func=restore_k8s)
215
+
216
+ args = parser.parse_args()
217
+ args.func(args)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()