Skip to content

Commit af1fe91

Browse files
committed
split backup map & backup jobs
1 parent fe8a5b0 commit af1fe91

4 files changed

Lines changed: 84 additions & 116 deletions

File tree

README.md

Lines changed: 10 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@
55
> Azure Blob Storage), and pushes structured telemetry to Grafana Loki and
66
> Prometheus - all from a single, provider-agnostic codebase.
77
8-
98
- 🗜️ **Single ZIP archive** per backup run using Python's built-in `zipfile`
109
- ☁️ **Provider-agnostic** storage layer - swap S3 for Azure with one env var
1110
- 📋 **JSON-structured logs** parsed natively by Grafana Loki
1211
- 🛡️ **ZIP path-traversal protection** during restore
1312
- 🔒 **Dual Azure auth** - shared-key or service-principal
1413
- 📊 **Prometheus metrics** pushed to Pushgateway after every run
1514

16-
1715
## Installation
1816
### Install from sources
1917
* create venv
@@ -31,73 +29,20 @@
3129
* create `.env` file
3230
* AWS/S3
3331
```
34-
# Core
35-
MODE=export
36-
NAMESPACE=tenant-sphinx
37-
PROVIDER=s3
38-
BUCKET=velero
39-
BACKUP_DIR=/tmp/clustersnap
40-
REMOTE_PREFIX=clustersnap/backups
41-
LOG_LEVEL=INFO
42-
43-
# AWS S3 / MinIO / SeaweedFS
44-
AWS_ACCESS_KEY_ID=minioadmin
45-
AWS_SECRET_ACCESS_KEY="minio&é&é&"
46-
AWS_REGION=us-east-1
47-
# Remove or leave blank for real AWS S3; set to MinIO / SeaweedFS address otherwise:
48-
S3_ENDPOINT_URL=http://localhost:9000 minio-api.local
49-
50-
# Observability
51-
PUSHGATEWAY_URL=http://localhost:9091
32+
tofill
5233
```
5334
5435
* Azure Blob Storage (shared-key)
5536
```
56-
# Core
57-
MODE=export
58-
NAMESPACE=tenant-alpha
59-
PROVIDER=azure
60-
BUCKET=clustersnap-backups
61-
BACKUP_DIR=/tmp/clustersnap
62-
REMOTE_PREFIX=clustersnap/backups
63-
LOG_LEVEL=INFO
64-
65-
# Azure Blob (shared-key)
66-
AZURE_STORAGE_ACCOUNT=mystorageaccount
67-
AZURE_STORAGE_KEY=<your-base64-account-key>
68-
69-
# Observability
70-
PUSHGATEWAY_URL=http://localhost:9091
71-
```
72-
73-
* Azure Blob Storage (service-principal)
74-
```
75-
# Core
76-
MODE=export
77-
NAMESPACE=tenant-alpha
78-
PROVIDER=azure
79-
BUCKET=clustersnap-backups
80-
BACKUP_DIR=/tmp/clustersnap
81-
REMOTE_PREFIX=clustersnap/backups
82-
LOG_LEVEL=INFO
83-
84-
# Azure Blob (service-principal)
85-
AZURE_STORAGE_ACCOUNT=mystorageaccount
86-
AZURE_CLIENT_ID=<app-registration-client-id>
87-
AZURE_CLIENT_SECRET=<app-registration-client-secret>
88-
AZURE_TENANT_ID=<azure-tenant-id>
89-
90-
# Observability
91-
PUSHGATEWAY_URL=http://localhost:9091
37+
tofill
9238
```
9339
94-
9540
* run
9641
```
9742
python -m src.main
9843
```
9944
100-
## Install locally with Docker
45+
<!-- ## Install locally with Docker
10146
* build
10247
```
10348
docker build . --tag clustersnap:local
@@ -106,7 +51,7 @@
10651
* run
10752
```
10853
docker run clustersnap:local
109-
```
54+
``` -->
11055
11156
## Install on Kubernetes (pod)
11257
* create secret to pull image
@@ -117,14 +62,18 @@
11762
--docker-password=GITHUB_PAT \
11863
--docker-email=GITHUB_ACCOUNT
11964
```
65+
* create secret for remote storage access
66+
* edit `src/templates/backup.secret.yaml` with your own config
67+
* apply
68+
```
69+
NAMESPACE=tenant-test0 envsubst < src/templates/backup.secret.yaml | kubectl apply -f -
70+
```
12071
* create pod
12172
```
12273
NAMESPACE=tenant-test0 envsubst < tests/resources.yaml | kubectl apply -f -
12374
```
12475
125-
12676
## Usage
127-
12877
* local
12978
```
13079
python -m src.main --c CONTEXT -n NAMESPACE
@@ -135,9 +84,7 @@ just run the image
13584
13685
13786
## Developpers
138-
13987
### Architecture
140-
14188
```
14289
clustersnap/
14390
├── src/
@@ -170,19 +117,6 @@ clustersnap/
170117
```
171118
Clustersnap -> Look for existing Deployments/Statefuleset -> List attached PVC -> List volumes in each PVC
172119
```
173-
```
174-
CronJob trigger
175-
176-
177-
main.py --mode export
178-
179-
├─► config.py - validate all env vars (fail fast)
180-
├─► storage/<provider> - health check (HeadBucket / GetContainerProperties)
181-
├─► exporter.py - zip /data/src-disks → /tmp/clustersnap/<ns>/<ts>.zip
182-
├─► storage.upload() - stream ZIP to bucket
183-
├─► metrics.py - record success / duration / size
184-
└─► metrics.push() - push to Prometheus Pushgateway
185-
```
186120
187121
### Workflow of import
188122
tofill

src/helpers/kubernetes.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55

66
class Kubernetes:
77

8-
def __init__(self, context: str = None, given_namespace: str = None):
9-
self.given_namespace = given_namespace
8+
def __init__(self, context: str, namespace: str):
9+
self.context = context
10+
self.namespace = namespace
1011
try:
1112
# Connect from CLI (based on kubeconfig)
1213
if context:
@@ -22,21 +23,18 @@ def __init__(self, context: str = None, given_namespace: str = None):
2223
print(f"error: {e}")
2324

2425

25-
def get_current_namespace(self) -> str:
26-
# Use namespace if given from args
27-
if self.given_namespace:
28-
return self.given_namespace
26+
def get_context(self) -> str:
27+
if self.context:
28+
return self.context
2929

30-
# Or fallback on current namespace if running in a pod
31-
ns_path = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
32-
if os.path.exists(ns_path):
33-
with open(ns_path, "r") as f:
34-
return f.read().strip()
35-
return "default"
30+
31+
def get_namespace(self) -> str:
32+
if self.namespace:
33+
return self.namespace
3634

3735

3836
def list_pvc(self) -> list:
39-
current_ns = self.get_current_namespace()
37+
current_ns = self.get_namespace()
4038
pvc_list = []
4139
try:
4240
response = self.client_corev1.list_namespaced_persistent_volume_claim(namespace=current_ns)
@@ -54,7 +52,7 @@ def list_pvc(self) -> list:
5452

5553
# Look for PVC from Deployments and StatefulSets
5654
def get_pvc(self, pvc: str) -> list:
57-
current_ns = self.get_current_namespace()
55+
current_ns = self.get_namespace()
5856
targets = []
5957

6058
try:
@@ -110,15 +108,32 @@ def _list_pvc_mount_points(self, pvc: str, pod_spec) -> list:
110108
return results
111109

112110

111+
# Check if a secret exists in a namespace
112+
def check_secret_exists(self, secret: str) -> bool:
113+
current_ns = self.get_namespace()
114+
try:
115+
self.client_corev1.read_namespaced_secret(name=secret, namespace=current_ns)
116+
return True
117+
except Exception as e:
118+
if e.status == 404:
119+
return False
120+
print(f"error: {e}")
121+
return False
113122

114123

115124
def create_backup_job(self, pvc: str, mounted_path: str) -> bool:
116-
current_ns = self.get_current_namespace()
117-
125+
current_ns = self.get_namespace()
126+
127+
# Ensure secret clustersnap-config exists. It must contains the remote storage access
128+
secret_required = "clustersnap-config"
129+
if not self.check_secret_exists(secret_required):
130+
print(f"error: Secret '{secret_required}' is missing in namespace '{current_ns}'. Cancelling backup job.")
131+
return False
132+
118133
job_name = f"backup-{pvc}"[-63:].lower().strip("-")
119134
print(f"starting backup job: {job_name}")
120135

121-
template_path = os.path.join(os.path.dirname(__file__), "../..", "templates", "backup-job.yaml")
136+
template_path = os.path.join(os.path.dirname(__file__), "../..", "templates", "backup.job.yaml")
122137

123138
try:
124139
with open(template_path, "r") as f:

src/main.py

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,11 @@
33
from src.helpers.kubernetes import Kubernetes
44

55

6-
def main():
7-
# # If an argument is provided = we're using from CLI
8-
# try:
9-
parser = argparse.ArgumentParser(description="Backup Kubernetes volumes")
10-
parser.add_index = False
11-
parser.add_argument("-c", "--context", type=str, default=None, help="Local kubectl context")
12-
parser.add_argument("-n", "--namespace", type=str, default=None, help="Kubernetes namespace")
13-
args = parser.parse_args()
14-
15-
kubernetes = Kubernetes(context=args.context, given_namespace=args.namespace)
16-
print(f"context: {args.context}")
17-
18-
# # If no argument is provided = we're in Kubernetes
19-
# except:
20-
# kubernetes = Kubernetes()
21-
# print(f"context: local")
22-
23-
current_ns = kubernetes.get_current_namespace()
6+
# Get a YAML map of what will be backuped
7+
def get_backup_map(kubernetes: Kubernetes):
8+
current_ns = kubernetes.get_namespace()
249

10+
print(f"context: {kubernetes.get_context()}")
2511
print(f"namespaces:")
2612
print(f"- name: {current_ns}")
2713

@@ -30,6 +16,7 @@ def main():
3016
print("error: no PVC found or unauthorized access.")
3117
return 0
3218

19+
# Loop to display the backup map
3320
print(f" claims:")
3421
for pvc in kubernetes.list_pvc():
3522
print(f" - name: {pvc['name']}")
@@ -44,13 +31,42 @@ def main():
4431
print(f" path: {m['mount_path']}")
4532
print(f" read_only: {m['read_only']}")
4633
print(f" resource: {m['resource']}")
47-
48-
# volume_name_clean = print(f"{m['resource'].split("/")[1]}-{m['container_name']}-{m['mount_path'].replace('/','')}")
49-
# kubernetes.create_backup_job(pvc=pvc['name'], mounted_path=m['mount_path'], volume_name=m['volume_name_clean'])
50-
51-
# kubernetes.create_backup_job(pvc=pvc['name'], mounted_path=m['mount_path'])
5234
else:
5335
print(f" mounts: {mounts}")
5436

37+
38+
# Run a backup job
39+
def run_backup_job(kubernetes: Kubernetes):
40+
# Display the map before running the job
41+
get_backup_map(kubernetes)
42+
43+
for pvc in kubernetes.list_pvc():
44+
mounts = kubernetes.get_pvc(pvc['name'])
45+
if mounts:
46+
for m in mounts:
47+
kubernetes.create_backup_job(pvc=pvc['name'], mounted_path=m['mount_path'])
48+
49+
50+
def main():
51+
parser = argparse.ArgumentParser(description="Backup Kubernetes volumes")
52+
parser.add_index = False
53+
parser.add_argument("-c", "--context", type=str, default=None, help="Local kubectl context")
54+
parser.add_argument("-n", "--namespace", type=str, default=None, help="Kubernetes namespace")
55+
parser.add_argument("-j", "--job", action="store_true", help="Run a backup job")
56+
parser.add_argument("-m", "--map", action="store_true", help="Get the backup map")
57+
args = parser.parse_args()
58+
59+
kubernetes = Kubernetes(context=args.context, namespace=args.namespace)
60+
61+
if args.job:
62+
run_backup_job(kubernetes)
63+
64+
elif args.map:
65+
get_backup_map(kubernetes)
66+
67+
else:
68+
print('please run with --job or --map (--help for more info)')
69+
70+
5571
if __name__ == "__main__":
5672
sys.exit(main())

src/templates/backup.job.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,19 @@ spec:
2222
secretKeyRef:
2323
name: clustersnap-config
2424
key: storage-address
25+
optional: false
2526
- name: STORAGE_USERNAME
2627
valueFrom:
2728
secretKeyRef:
2829
name: clustersnap-config
2930
key: storage-username
31+
optional: false
3032
- name: STORAGE_PASSWORD
3133
valueFrom:
3234
secretKeyRef:
3335
name: clustersnap-config
3436
key: storage-password
37+
optional: false
3538
volumes:
3639
- name: volume-to-backup
3740
persistentVolumeClaim:

0 commit comments

Comments
 (0)