-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathppdm_k8s_self_service.py
More file actions
1450 lines (1163 loc) · 43 KB
/
Copy pathppdm_k8s_self_service.py
File metadata and controls
1450 lines (1163 loc) · 43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
PowerProtect Data Manager Kubernetes Self-Service Script.
Provides ad-hoc backup and restore for Kubernetes namespaces
through Dell PowerProtect Data Manager REST API.
Author: Idan Kentor <idan.kentor@dell.com>
Copyright: [2026] [Idan Kentor]
Examples:
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a list -ns myns
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a list -ns myns -o json
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a backup -ns myns -ret "7 days"
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a restore -ns myns -rt rto
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a restore -ns myns -rt rtn \
-cl mycluster
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a restore -ns myns -rt rtn \
-cl mycluster -target-ns myns-restore
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a restore -ns myns -rt rtn \
-alt-cluster upstream_cl1
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a restore -ns myns -rt rtn \
-skip-metadata
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a restore -ns myns -rt rtn \
-skip-pvcs
python ppdm_k8s_self_service.py -ppdm 10.0.0.1 \
-p pass -a monitor -activity_id <uuid>
"""
import argparse
import json
import logging
import logging.handlers
import os
import time
from datetime import datetime
import requests
import urllib3
urllib3.disable_warnings()
logger = logging.getLogger("ppdm_k8s_self_service")
def setup_logger(log_file):
"""Configure logging to file only"""
logger.setLevel(logging.INFO)
logger.handlers.clear()
try:
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024,
backupCount=5
)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - "
"%(funcName)s:%(lineno)d - %(message)s"
))
logger.addHandler(file_handler)
separator = "=" * 80
logger.info(separator)
logger.info("PPDM K8s Self-Service Script Started")
logger.info(
"Execution Time: %s",
datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
logger.info(separator)
except (OSError, IOError) as error:
print(f"-> Could not create log file: {error}")
def get_args():
"""Get command line arguments"""
parser = argparse.ArgumentParser(
description="PPDM Kubernetes Self-Service"
)
parser.add_argument(
"-ppdm", "--ppdm",
required=True,
help="PPDM server FQDN or IP"
)
parser.add_argument(
"-u", "--username", default="admin",
help="PPDM username (default: admin)"
)
parser.add_argument(
"-p", "--password",
help="PPDM password"
)
parser.add_argument(
"-envpassword", action="store_true",
help="Read password from PPDM_PASSWORD env var"
)
parser.add_argument(
"-a", "--action", required=True,
choices=["list", "backup", "restore",
"monitor", "discovery"],
help="Action to perform"
)
parser.add_argument(
"-ns", "--namespace",
help="Kubernetes namespace"
)
parser.add_argument(
"-cl", "--cluster",
help="Kubernetes Cluster name (case-insensitive)"
)
parser.add_argument(
"-ret", "--retention",
help="Retention (e.g., '7 days', '2 weeks')"
)
parser.add_argument(
"-full", action="store_true",
help="Force full backup"
)
parser.add_argument(
"-rt", "--restore-type",
choices=["rto", "rte", "rtn"],
help=(
"Restore type: "
"rto=restore to original namespace, "
"rte=restore to existing namespace, "
"rtn=restore to new namespace. "
"Use -alt-cluster for cross-cluster restores "
"(rte/rtn only)"
)
)
parser.add_argument(
"-copy-id", "--copy-id",
help="Specific backup copy ID"
)
parser.add_argument(
"-timestamp",
help="Restore by timestamp (YYYY-MM-DD [HH:MM:SS])"
)
parser.add_argument(
"-timeframe",
help="Filter copies by age: '<N> hours|days|weeks'"
)
parser.add_argument(
"-target-ns", "--target-ns",
help="Target namespace for rtn/rte restore"
)
parser.add_argument(
"-alt-cluster",
help="Alternate cluster name for restore "
"(case-insensitive)"
)
parser.add_argument(
"-cluster-id", "--cluster-id",
help="Target cluster ID for cross-cluster restore"
)
parser.add_argument(
"-include-cluster", "--include-cluster-resources",
action="store_true",
help="Include cluster-scoped resources"
)
parser.add_argument(
"-no-overwrite-pvc", action="store_true",
help="Do not overwrite existing PVCs"
)
parser.add_argument(
"-exclude-k8s-vm", action="append", default=[],
help="Exclude specific K8s VM (repeatable)"
)
parser.add_argument(
"-power-on-vm", action="store_true",
help="Power on VMs after restore (default: off)"
)
parser.add_argument(
"-restore-bios-uuid", action="store_true",
help="Restore BIOS UUID (default: off)"
)
parser.add_argument(
"-skip-metadata", action="store_true",
help="Restore PVCs only, skip namespace metadata and VMs"
)
parser.add_argument(
"-skip-pvcs", action="store_true",
help="Restore namespace metadata only, skip PVCs"
)
parser.add_argument(
"-storage-class",
help="Target storage class for restore"
)
parser.add_argument(
"-nmonitor", action="store_true",
help="Do not monitor activity after starting"
)
parser.add_argument(
"-activity_id",
help="Activity ID to monitor"
)
parser.add_argument(
"-aidfile",
help="File containing activity ID"
)
parser.add_argument(
"-outfile",
help="File to save activity ID"
)
parser.add_argument(
"-o", "--output",
choices=["text", "json", "yaml"],
default="text",
help="Output format for list action (default: text)"
)
parser.add_argument(
"-log", "--log-file",
default="ppdm_k8s_self_service.log",
help="Log file path"
)
return parser.parse_args()
def sanitize_payload(payload_str, uri):
"""Truncate payload to 300 chars, redact login"""
if uri.endswith("/login") and payload_str:
return "*** REDACTED ***"
if not payload_str:
return payload_str
preview = payload_str[:300].rstrip()
if len(payload_str) >= 300:
preview += "..."
return preview
def log_response(verb, uri, response):
"""Log response body summary (300 chars)"""
try:
content = response.text
preview = content[:300].rstrip()
if len(content) >= 300:
preview += "..."
logger.info(
"%s %s: %d bytes - %s",
verb, uri, len(response.content), preview
)
except Exception:
logger.info(
"%s %s: %d bytes (non-text)",
verb, uri, len(response.content)
)
def init_rest_call(verb, uri, token, payload=None,
params=None):
"""Generic REST call handler"""
code = {200, 201, 202, 204}
headers = {"Content-Type": "application/json"}
if not uri.endswith("/login") and token:
headers["Authorization"] = f"Bearer {token}"
payload_str = (
json.dumps(payload) if payload is not None else None
)
logger.info(
"REST Call: %s %s, Params: %s, Payload: %s",
verb, uri, params,
sanitize_payload(payload_str, uri)
)
response = None
try:
response = requests.request(
verb, uri,
headers=headers,
params=params,
data=payload_str,
verify=False,
timeout=90,
)
response.raise_for_status()
except requests.exceptions.ConnectionError as err:
logger.error("Connection error: %s - %s", uri, err)
print(f"-> Error connecting to {uri}")
return False
except requests.exceptions.Timeout as err:
logger.error("Timeout: %s - %s", uri, err)
print(f"-> Connection timed out: {uri}")
return False
except requests.exceptions.RequestException as err:
if not response:
logger.error("No response: %s %s - %s",
verb, uri, err)
return False
if response.status_code in (401, 502):
logger.error("REST failed: %s %s - %d",
verb, uri, response.status_code)
return False
logger.error("REST failed: %s %s - %d - %s",
verb, uri, response.status_code, err)
return False
if response.status_code not in code:
raise requests.exceptions.HTTPError(
f"Failed: {uri}, code: {response.status_code}, "
f"body: {response.text}"
)
logger.info(
"REST OK: %s %s, %d, %.3fs",
verb, uri, response.status_code,
response.elapsed.total_seconds()
)
if not response.content:
return True
log_response(verb, uri, response)
if uri.endswith("/login"):
return response.json()["access_token"]
try:
return response.json()
except ValueError:
return response.content
def authenticate(server, user, password):
"""Login to PPDM"""
uri = f"https://{server}:8443/api/v2/login"
token = init_rest_call(
"POST", uri, None,
{"username": user, "password": password}
)
if token:
logger.info("Login successful")
else:
logger.error("Login failed")
print("-> Authentication failed")
return token
def check_version(version_str):
"""Check if PPDM version >= 19.16 (v3 API)"""
try:
parts = str(version_str).split(".")
major = int(parts[0])
minor = int(parts[1].split("-")[0])
except (ValueError, IndexError, AttributeError):
return False
return (major > 19) or (major == 19 and minor >= 16)
def get_api_base(server, token):
"""Determine API base URI from PPDM version (v3 only)"""
uri = f"https://{server}:8443/api/v2"
response = init_rest_call("GET", f"{uri}/nodes", token)
if not response or not response.get("content"):
raise SystemExit(
"Error: Cannot determine PPDM version"
)
version = response["content"][0].get("version")
if not check_version(version):
raise SystemExit(
f"Error: PPDM version {version} < 19.16 "
"(v3 API required)"
)
return f"https://{server}:8443/api/v3"
def get_namespace_asset(uri, token, namespace, cluster=None):
"""Get namespace asset ID, protection policy, and cluster info"""
query = (
'type eq "KUBERNETES_NAMESPACE" and '
f'name eq "{namespace}"'
)
if cluster:
query += f' and inventorySourceRef.name eq "{cluster}"'
endpoint = f"{uri}/assets"
params = {"filter": query}
response = init_rest_call("GET", endpoint, token, params=params)
if not response or not response.get("content"):
raise SystemExit(
f"Namespace not found: {namespace}"
+ (f" on cluster '{cluster}'" if cluster else "")
)
content = response["content"]
if len(content) > 1 and not cluster:
names = []
for a in content:
names.append(a.get("inventorySourceRef", {}).get("name", "?"))
msg = (
f"Namespace '{namespace}' found on multiple clusters: "
f"{', '.join(names)}. Use -cl <cluster-name> to specify."
)
logger.error(msg)
raise SystemExit(msg)
asset = content[0]
cluster_info = {}
inventory_source = asset.get("inventorySourceRef", {})
if inventory_source.get("id"):
cluster_info = {
"id": inventory_source.get("id", ""),
"name": inventory_source.get("name", "Unknown")
}
return (
asset["id"],
asset.get("protectionPolicyRef", {}),
cluster_info
)
def format_output(data, fmt):
"""Format and print data as text, json, or yaml"""
if fmt == "json":
print(json.dumps(data, indent=2))
return
if fmt == "yaml":
lines = []
for key, value in data.items():
if isinstance(value, list):
lines.append(f"{key}:")
for item in value:
if isinstance(item, dict):
first = True
for k, v in item.items():
prefix = " - " if first else " "
lines.append(f"{prefix}{k}: {v}")
first = False
else:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {value}")
print("\n".join(lines))
return
title_map = {
"pvcs_in_latest_copy": "PVCs in latest copy",
"vms_in_latest_copy": "VMs in latest copy"
}
label_map = {
"size_gb": "Size GB",
"storage_class": "Storage Class"
}
for key, value in data.items():
if isinstance(value, list):
if not value:
continue
display_title = title_map.get(key, key.replace("_", " ").title())
print(f"\n{display_title}:")
print("-" * 60)
print()
if value and isinstance(value[0], dict):
headers = list(value[0].keys())
header_labels = []
for h in headers:
if h in label_map:
header_labels.append(label_map[h])
else:
header_labels.append(h.replace("_", " ").title())
col_widths = []
for i, label in enumerate(header_labels):
max_width = len(label)
for item in value:
val_str = str(list(item.values())[i])
max_width = max(max_width, len(val_str))
col_widths.append(max_width)
header_parts = []
for i, label in enumerate(header_labels):
header_parts.append(label.ljust(col_widths[i]))
print(f" {' '.join(header_parts)}")
for item in value:
row_parts = []
for i, val in enumerate(item.values()):
row_parts.append(str(val).ljust(col_widths[i]))
print(f" {' '.join(row_parts)}")
print()
else:
for item in value:
print(f" {item}")
print()
else:
print(f"{key}: {value}")
def namespace_exists(uri, token, namespace):
"""Check if a namespace is known to PPDM (discovered from K8s)"""
query = (
'type eq "KUBERNETES_NAMESPACE" and '
f'name eq "{namespace}"'
)
endpoint = f"{uri}/assets"
params = {"filter": query}
response = init_rest_call("GET", endpoint, token, params=params)
if response and response.get("content"):
return True
return False
def get_cluster_id(uri, token, cluster_name):
"""Resolve K8s cluster name to inventory source ID"""
endpoint = f"{uri}/inventory-sources"
params = {"filter": 'type eq "KUBERNETES_CLUSTER"'}
response = init_rest_call(
"GET", endpoint, token, params=params
)
if not response or not response.get("content"):
return None
for cluster in response["content"]:
if cluster.get("name", "").lower() == cluster_name.lower():
return cluster["id"]
return None
def validate_cluster_id(uri, token, cluster_id):
"""Validate that cluster ID exists in inventory sources"""
endpoint = f"{uri}/inventory-sources/{cluster_id}"
response = init_rest_call("GET", endpoint, token)
return bool(response)
def get_storage_classes(uri, token, cluster_id, copy_id):
"""Get available storage classes for PVCs in a copy"""
endpoint = (
f"{uri}/kubernetes-clusters/{cluster_id}"
f"/pvc-storage-class-mappings"
)
params = {"copyId": copy_id}
response = init_rest_call("GET", endpoint, token, params=params)
if not response:
return {}
result = {}
for entry in response.get("content", []):
pvc_name = entry.get("pvcName")
classes = entry.get("storageClasses", [])
if pvc_name and classes:
result[pvc_name] = classes
return result
def parse_timeframe(timeframe):
"""Parse '<N> hours|days|weeks' into seconds"""
multipliers = {
"hours": 3600, "days": 86400, "weeks": 604800
}
parts = timeframe.strip().lower().split()
if len(parts) == 2 and parts[0].isdigit():
unit = parts[1].rstrip("s") + "s"
if unit in multipliers:
return int(parts[0]) * multipliers[unit]
raise SystemExit(
"Error: timeframe format is '<N> hours|days|weeks'"
)
def get_copies(uri, token, asset_id, timeframe=None):
"""Get available backup copies for an asset via copies-search"""
endpoint = f"{uri}/copies-search"
query = (
f'assetRef.id eq "{asset_id}"'
' and location in ("LOCAL", "LOCAL_RECALLED")'
' and replica eq false'
' and not state in'
' ("DELETED", "DELETING", "UNAVAILABLE")'
)
if timeframe:
cutoff = time.time() - parse_timeframe(timeframe)
cutoff_str = time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(cutoff)
)
query += f' and backupTime ge "{cutoff_str}"'
payload = {
"filter": query,
"orderby": "backupTime DESC"
}
response = init_rest_call("POST", endpoint, token, payload)
if not response or not response.get("content"):
return []
copies = response["content"]
for copy in copies:
logger.info(
"Copy %s: state=%s location=%s backupTime=%s",
copy.get("id", "?")[:8],
copy.get("state"),
copy.get("location"),
copy.get("backupTime", "")
)
return copies
def get_backup_stage_id(uri, token, policy_id):
"""Get backup objective ID from a protection policy"""
endpoint = f"{uri}/protection-policies/{policy_id}"
response = init_rest_call("GET", endpoint, token)
if not response:
raise SystemExit("Could not retrieve policy")
for obj in response.get("objectives", []):
if obj.get("type") == "BACKUP":
return obj.get("id")
raise SystemExit("No backup stage found in policy")
def build_retention(retention_str):
"""Build v3 retention JSON from string like '7 days'"""
text = retention_str.strip().lower()
num = ""
for char in text:
if char.isdigit():
num += char
else:
break
if not num:
raise ValueError(
"Use format: 'N days|weeks|months'"
)
value = int(num)
unit = text[len(num):].strip().rstrip('s')
if unit not in ("day", "week", "month"):
raise ValueError("Unit must be day, week, or month")
return [{"time": [{
"type": "RETENTION",
"unitValue": value,
"unitType": unit.upper()
}]}]
def build_backup_payload(asset_id, policy_id, stage_id,
retention, full=False):
"""Build v3 backup payload"""
level = "FULL" if full else "SYNTHETIC_FULL"
return {
"source": {
"assetIds": [asset_id],
"protectionGroupIds": []
},
"policy": {
"id": policy_id,
"objectives": [{
"id": stage_id,
"operation": {"backupLevel": level},
"retentions": build_retention(retention)
}]
}
}
def build_restore_payload(restore_type, copy_id,
namespace, args,
pvc_names=(), k8s_vms=(),
storage_class_map=None):
"""Build restore payload from args"""
type_map = {
"rto": "RESTORE_TO_ORIGINAL",
"rte": "TO_EXISTING",
"rtn": "TO_ALTERNATE"
}
restore_action = type_map[restore_type]
if args.alt_cluster:
restore_action = "TO_ALTERNATE"
payload = {
"restoreType": restore_action,
"copyIds": [copy_id],
"description": (
f"Restore namespace {restore_type}: "
f"{namespace or 'original'}"
)
}
if restore_type in ("rte", "rtn"):
target = {
"namespace": namespace,
"skipNamespaceResources": args.skip_metadata,
"targetInventorySourceId": args.cluster_id,
"overwritePersistentVolumeClaim": (
not args.no_overwrite_pvc
),
"persistentVolumeClaims": []
}
if not args.skip_pvcs:
for pvc_name in pvc_names:
pvc_entry = {"name": pvc_name}
if (storage_class_map
and pvc_name in storage_class_map):
pvc_entry["alternateStorageClass"] = (
storage_class_map[pvc_name]
)
target["persistentVolumeClaims"].append(pvc_entry)
if k8s_vms and not args.skip_metadata:
target["overwriteVmConfig"] = True
target["k8sVirtualMachineNames"] = list(k8s_vms)
if args.exclude_k8s_vm:
target["excludeK8sVirtualMachines"] = (
args.exclude_k8s_vm
)
payload["restoredCopiesDetails"] = {
"targetK8sInfo": target
}
payload["options"] = {
"includeClusterResources": (
args.include_cluster_resources
),
"powerOnVm": args.power_on_vm,
"restoreBiosUuid": args.restore_bios_uuid,
"includeVirtualMachineResources": (
bool(k8s_vms) and not args.skip_metadata
)
}
return payload
def parse_timestamp(raw):
"""Parse a PPDM timestamp string into a datetime, or None"""
ppdm_time_formats = (
"%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"
)
for time_format in ppdm_time_formats:
try:
return datetime.strptime(raw, time_format)
except ValueError:
continue
return None
def get_copy_timestamp(copy):
"""Extract backup timestamp from a copy as datetime, or None"""
raw = copy.get("backupTime", "") or copy.get("creationTime", "")
return parse_timestamp(raw) if raw else None
def select_copy(copies, copy_id=None, timestamp=None):
"""Select a backup copy by ID, timestamp, or latest"""
if copy_id:
for copy in copies:
if copy["id"] == copy_id:
return copy
raise SystemExit("Copy ID not found")
if timestamp:
target = None
user_time_formats = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d")
for time_format in user_time_formats:
try:
target = datetime.strptime(
timestamp, time_format
)
break
except ValueError:
continue
if not target:
raise SystemExit("Invalid timestamp format")
timed_copies = []
for copy in copies:
copy_time = get_copy_timestamp(copy)
if copy_time:
timed_copies.append((copy, copy_time))
if not timed_copies:
raise SystemExit("No copy matches timestamp")
best_match, _ = min(
timed_copies,
key=lambda pair: abs(
(target - pair[1]).total_seconds()
)
)
return best_match
return copies[0]
def fmt_time(copy):
"""Format copy timestamp for display"""
backup_time = get_copy_timestamp(copy)
if backup_time:
return backup_time.strftime("%Y-%m-%d %H:%M:%S")
return "Unknown"
def start_discovery(uri, token, cluster_id):
"""Trigger K8s cluster discovery"""
endpoint = f"{uri}/discoveries"
payload = {
"start": f"/inventory-sources/{cluster_id}",
"level": "DataCopies"
}
response = init_rest_call("POST", endpoint, token, payload)
if not response:
raise SystemExit("Failed to start discovery")
task_id = response.get("taskId")
if not task_id:
logger.error("Discovery response: %s",
json.dumps(response, indent=2)
if isinstance(response, dict)
else str(response)[:500])
raise SystemExit("Discovery response missing taskId")
return task_id
def perform_backup(uri, token, payload):
"""Start ad-hoc backup"""
endpoint = f"{uri}/protections"
response = init_rest_call("POST", endpoint, token, payload)
if not response:
raise SystemExit("Failed to start backup")
try:
return response["results"][0]["activityId"]
except (KeyError, IndexError, TypeError):
logger.error("Unexpected backup response: %s",
json.dumps(response, indent=2)
if isinstance(response, dict)
else str(response)[:500])
raise SystemExit("Backup response missing activityId")
def perform_restore(uri, token, payload):
"""Start ad-hoc restore"""
endpoint = f"{uri}/restored-copies"
response = init_rest_call("POST", endpoint, token, payload)
if not response:
raise SystemExit("Failed to start restore")
activity_id = response.get("activityId")
if not activity_id:
logger.error("Unexpected restore response: %s",
json.dumps(response, indent=2)
if isinstance(response, dict)
else str(response)[:500])
raise SystemExit("Restore response missing activityId")
return activity_id
def monitor_activity(uri, token, activity_id,
timeout=1800):
"""Poll activity status until done or timeout"""
if not activity_id:
print("-> No activity ID to monitor")
return "UNKNOWN"
logger.info("Monitoring activity %s", activity_id)
interval = 15
start = time.time()
activity_uri = f"{uri}/activities/{activity_id}"
while time.time() - start < timeout:
response = init_rest_call("GET", activity_uri, token)
if not response:
time.sleep(interval)
continue
state = response.get("state")
progress = response.get("progress", 0)
timestamp = time.strftime("%H:%M:%S")
if state == "COMPLETED":
result = response.get("result", {})
status = result.get("status", "UNKNOWN")
elapsed = int(time.time() - start)
mins, secs = divmod(elapsed, 60)
logger.info("Activity %s: %s (%s)",
activity_id, state, status)
print(f"-> {timestamp} Activity {activity_id}: {status} "
f"({mins}m {secs}s)")
if status == "FAILED":
base = activity_uri.rsplit("/", 1)[0]
resp = init_rest_call(
"GET", base, token,
params={"filter":