Skip to content

Commit ad03e4d

Browse files
committed
add Azure storage support
1 parent 8e29798 commit ad03e4d

11 files changed

Lines changed: 290 additions & 773 deletions

File tree

Dockerfile

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ COPY LICENSE.md /app
55
COPY requirements.txt /app
66
COPY src /app/src
77

8-
# RUN pip freeze > requirements.txt \
9-
# && pip install -r requirements.txt
10-
118
RUN pip install -r requirements.txt
129

1310
CMD ["python", "-m", "src.main", "--job"]

README.md

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -88,29 +88,10 @@ just run the image
8888
```
8989
clustersnap/
9090
├── src/
91-
│ ├── main.py # CLI entrypoint (--mode export | import)
92-
│ ├── config.py # Pydantic v2 environment validation
93-
│ ├── storage/
94-
│ │ ├── base.py # Abstract BaseStorageProvider
95-
│ │ ├── s3.py # AWS S3 / MinIO / SeaweedFS
96-
│ │ └── azure.py # Azure Blob Storage (shared-key + SP)
97-
│ ├── backup/
98-
│ │ ├── exporter.py # Zip → Upload → Cleanup
99-
│ │ └── importer.py # Download → Validate → Extract
100-
│ └── telemetry/
101-
│ ├── logger.py # JSON formatter for Grafana Loki
102-
│ └── metrics.py # Prometheus counters, histograms, gauges
103-
├── deploy/
104-
│ └── clustersnap/ # Helm chart
105-
│ ├── Chart.yaml
106-
│ ├── values.yaml
107-
│ └── templates/
108-
│ ├── configmap.yaml
109-
│ ├── secret.yaml
110-
│ └── cronjob.yaml
111-
├── Dockerfile
112-
├── requirements.txt
113-
└── README.md
91+
│ ├── main.py # Entrypoint for CLI & Kubernetes main pod
92+
│ ├── worker.py # Entrypoint for Kubernetes job pods
93+
│ └── helpers/
94+
│ ├── kubernetes.py # Kubernetes class
11495
```
11596
11697
### Workflow of export

src/helpers/kubernetes.py

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def check_secret_exists(self, secret: str) -> bool:
134134
self.client_corev1.read_namespaced_secret(name=secret, namespace=current_ns)
135135
return True
136136
except Exception as e:
137-
if e.status == 404:
137+
if hasattr(e, 'status') and e.status == 404:
138138
return False
139139
print(f"error: {e}")
140140
return False
@@ -156,16 +156,6 @@ def create_backup_job(self, pvc_name: str, mounted_path: str) -> bool:
156156
print(f"starting backup job: {job_name}")
157157

158158

159-
# Remove existing jobs
160-
try:
161-
batch_api = client.BatchV1Api(self.client_corev1.api_client)
162-
batch_api.delete_namespaced_job(name=job_name, namespace=current_ns, propagation_policy="Background")
163-
time.sleep(2)
164-
except ApiException as e:
165-
if e.status != 404: # detect other errors that "not found"
166-
print(f"error: {e}")
167-
168-
169159
# Apply YAML template file
170160
project_root = Path(__file__).resolve().parents[2]
171161
template_path = f"{project_root}/templates/backup.job.yaml"
@@ -203,7 +193,7 @@ def delete_backup_job(self, pvc_name: str) -> bool:
203193
try:
204194
batch_api = client.BatchV1Api(self.client_corev1.api_client)
205195
batch_api.delete_namespaced_job(name=job_name, namespace=current_ns, propagation_policy="Background")
206-
print(f" 🧹 Old job '{job_name}' successfully removed.")
196+
print(f" Old job '{job_name}' successfully removed.")
207197
return True
208198
except ApiException as e:
209199
if e.status == 404:
@@ -219,44 +209,39 @@ def wait_for_jobs_deletion(self, pvc_names: list, timeout: int = 60) -> bool:
219209
current_ns = self.get_namespace()
220210
batch_api = client.BatchV1Api(self.client_corev1.api_client)
221211

222-
# On construit la liste des noms de jobs qu'on attend de voir disparaître
223212
jobs_to_wait = [f"backup-{pvc}"[-63:].lower().strip("-") for pvc in pvc_names]
224-
225-
print(f" ⏳ Waiting for {len(jobs_to_wait)} old jobs to be fully purged from cluster...", end="", flush=True)
213+
print(f" Waiting for {len(jobs_to_wait)} old jobs to be fully purged from cluster...", end="", flush=True)
226214

227215
start_time = time.time()
228216
while time.time() - start_time < timeout:
229217
active_jobs = []
230218

231219
for job_name in jobs_to_wait:
232220
try:
233-
# On tente de lire le job
234221
batch_api.read_namespaced_job(name=job_name, namespace=current_ns)
235-
# Si on arrive ici, c'est que le job existe encore sur le cluster
236222
active_jobs.append(job_name)
237223
except ApiException as e:
238224
if e.status == 404:
239-
# Le job est bien supprimé, on ne fait rien
240225
continue
241226
else:
242-
print(f"\n ❌ Error checking job {job_name}: {e}")
227+
print(f"\n error: checking job {job_name}: {e}")
243228
return False
244229

245-
# Si la liste des jobs encore actifs est vide, c'est qu'ils ont tous disparu !
246230
if not active_jobs:
247-
print(" Done! 🟢")
231+
print(" Done!")
248232
return True
249233

250234
print(".", end="", flush=True)
251-
time.sleep(2) # On réinterroge toutes les 2 secondes
235+
time.sleep(2)
252236

253-
print("\n ❌ Timeout reached while waiting for old jobs deletion.")
237+
print("\n error: timeout reached while waiting for old jobs deletion.")
254238
return False
255239

256240

257-
258-
# Get replicas quantity of a Statefulset or Deployment
259241
def get_replicas(self, resource_type: str, name: str) -> int:
242+
"""
243+
Get replicas quantity of a Statefulset or Deployment
244+
"""
260245
current_ns = self.get_namespace()
261246
try:
262247
if resource_type.lower() == "statefulset":
@@ -273,8 +258,10 @@ def get_replicas(self, resource_type: str, name: str) -> int:
273258
return 0
274259

275260

276-
# Scale replicas quantity of a Statefulset or Deployment
277261
def scale_replicas(self, resource_type: str, name: str, replicas: int) -> bool:
262+
"""
263+
Scale replicas quantity of a Statefulset or Deployment
264+
"""
278265
current_ns = self.get_namespace()
279266
body = {"spec": {"replicas": replicas}}
280267

@@ -293,8 +280,10 @@ def scale_replicas(self, resource_type: str, name: str, replicas: int) -> bool:
293280
return False
294281

295282

296-
# Hook to ensure replicas are scaled to 0
297283
def wait_for_scale_down(self, resource_type: str, name: str, timeout: int = 120) -> bool:
284+
"""
285+
Hook to ensure replicas are scaled to 0
286+
"""
298287
current_ns = self.get_namespace()
299288
resource = f"{resource_type}/{name}"
300289

@@ -323,4 +312,4 @@ def wait_for_scale_down(self, resource_type: str, name: str, timeout: int = 120)
323312
return False
324313

325314
print(f"error: timeout reached while waiting for scale down of {resource}")
326-
return False
315+
return False

src/main.py

Lines changed: 30 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from src.helpers.kubernetes import Kubernetes
55

66

7-
# Get a YAML map of what will be backuped
87
def get_backup_map(kubernetes: Kubernetes):
8+
"""
9+
Get a YAML map of what will be backuped
10+
"""
911
current_ns = kubernetes.get_namespace()
1012

1113
print(f"context: {kubernetes.get_context()}")
@@ -17,7 +19,6 @@ def get_backup_map(kubernetes: Kubernetes):
1719
print("error: no PVC found or unauthorized access.")
1820
return 0
1921

20-
# Loop to display the backup map
2122
print(f" claims:")
2223
for pvc in kubernetes.list_pvc():
2324
print(f" - name: {pvc['name']}")
@@ -36,101 +37,59 @@ def get_backup_map(kubernetes: Kubernetes):
3637
print(f" mounts: {mounts}")
3738

3839

39-
# Run a backup job
4040
def run_backup_job(kubernetes: Kubernetes):
41-
# Display the map before running the job
42-
get_backup_map(kubernetes)
43-
44-
# for pvc in kubernetes.list_pvc():
45-
# pvc_name = pvc['name']
46-
47-
# targets = kubernetes.get_pvc(pvc_name)
48-
# if not targets:
49-
# print(f"info: no active workload config found for {pvc_name}. Skipping.")
50-
# continue
51-
52-
# target = targets[0]
53-
# mounted_path = target['mount_path']
54-
# resource = target['resource']
55-
# resource_type, resource_name = resource.split('/')
56-
# resource_initial_replicas = kubernetes.get_replicas(resource_type, resource_name)
57-
58-
# print(f"replicas quantity found for {resource}: {resource_initial_replicas}")
59-
60-
# is_running = resource_initial_replicas > 0
61-
# if is_running:
62-
# kubernetes.scale_replicas(resource_type, resource_name, replicas=0)
63-
# if not kubernetes.wait_for_scale_down(resource_type, resource_name):
64-
# print(f"error: scale down fail for {resource}. Aborting backup of {pvc_name}")
65-
# continue
66-
# else:
67-
# print(f"info: {resource} is already scaled down. Safe to proceed.")
68-
69-
# job_success = kubernetes.create_backup_job(pvc=pvc_name, mounted_path=mounted_path)
70-
# if is_running:
71-
# kubernetes.scale_replicas(resource_type, resource_name, replicas=resource_initial_replicas)
72-
# print(f"restored replicas of {resource} {resource_initial_replicas}")
73-
74-
# print('')
75-
76-
7741
"""
78-
Orchestrateur séquentiel global :
79-
Phase 0: Nettoyage + Attente de suppression des vieux Jobs
80-
Phase 1: Collecte des infos + Ordre de Scale Down (0)
81-
Phase 2: Attente de l'extinction complète de la prod
82-
Phase 3: Lancement de tous les nouveaux Jobs en parallèle via K8s
83-
Phase 4: Restauration (Scale Up) de la prod à son état initial
42+
Global sequential orchestrator:
43+
Phase 0: Cleanup + wait for deletion of old Jobs
44+
Phase 1: Gather configuration + Trigger Scale Down (0)
45+
Phase 2: Wait for complete production workload termination
46+
Phase 3: Trigger all new backup Jobs concurrently via K8s control plane
47+
Phase 4: Restore (Scale Up) production workloads to original layout states
8448
"""
85-
# Récupération de la liste des PVCs du namespace
49+
get_backup_map(kubernetes)
50+
8651
pvcs = kubernetes.list_pvc()
8752
if not pvcs:
8853
print("error: no PVC found to backup.")
8954
return
9055

9156
print(f"\n==================================================")
92-
print(f"Starting Sequential Backup Orchestration for {len(pvcs)} PVCs")
57+
print(f"Starting Sequential Backup Orchestration for {len(pvcs)} PVCs")
9358
print(f"==================================================")
9459

9560
# ==================================================
96-
# PHASE 0 : NETTOYAGE GLOBAL DES ANCIENS JOBS
61+
# PHASE 0 : GLOBAL JOB CLEANUP
9762
# ==================================================
98-
print("\n🧹 PHASE 0: Purging any existing/stale backup jobs...")
63+
print("\nPHASE 0: Purging any existing/stale backup jobs...")
9964
pvc_names = []
10065

10166
for pvc in pvcs:
10267
pvc_name = pvc['name']
10368
pvc_names.append(pvc_name)
104-
# Envoie l'ordre de suppression à l'API (non-bloquant)
10569
kubernetes.delete_backup_job(pvc_name=pvc_name)
10670

107-
# Validation dynamique de la disparition des anciens Jobs
10871
if not kubernetes.wait_for_jobs_deletion(pvc_names=pvc_names, timeout=45):
109-
print(" ⚠️ Warning: Some old jobs might still be terminating. Proceeding anyway...")
72+
print(" warn: Some old jobs might still be terminating. Proceeding anyway...")
11073

111-
# Structure pour mémoriser l'état initial de chaque workload
11274
backup_workloads = {}
11375

11476
# ==================================================
115-
# PHASE 1 : ANALYSE ET ENVOI DU SCALE DOWN (0)
77+
# PHASE 1 : WORKLOAD ASSESSMENT AND SCALE DOWN
11678
# ==================================================
117-
print("\n🛑 PHASE 1: Capturing states and scaling down workloads...")
79+
print("\nPHASE 1: Capturing states and scaling down workloads...")
11880
for pvc in pvcs:
11981
pvc_name = pvc['name']
12082
targets = kubernetes.get_pvc(pvc_name)
12183
if not targets:
122-
print(f" ⚠️ No active workload config found for {pvc_name}. Skipping.")
84+
print(f" warn: No active workload config found for {pvc_name}. Skipping.")
12385
continue
12486

125-
# On prend la première configuration trouvée liée au PVC
12687
target = targets[0]
127-
resource_key = target['resource'] # Ex: "StatefulSet/postgresql"
88+
resource_key = target['resource']
12889
resource_type, resource_name = resource_key.split('/')
12990
mounted_path = target['mount_path']
13091

131-
# Évite de traiter deux fois le même StatefulSet si celui-ci a plusieurs PVCs
13292
if resource_key not in backup_workloads:
133-
# On lit et on stocke le nombre de répliques en cours (ex: 1, 2, 3...)
13493
initial_replicas = kubernetes.get_replicas(resource_type, resource_name)
13594

13695
backup_workloads[resource_key] = {
@@ -141,56 +100,49 @@ def run_backup_job(kubernetes: Kubernetes):
141100
"mounted_path": mounted_path
142101
}
143102

144-
# Si l'application tourne, on demande à K8s de l'éteindre
145103
if initial_replicas > 0:
146104
kubernetes.scale_replicas(resource_type, resource_name, replicas=0)
147105
else:
148-
print(f" 🌙 {resource_key} is already stopped (Night mode).")
106+
print(f" info: {resource_key} is already stopped (Night mode).")
149107

150108
# ==================================================
151-
# PHASE 2 : ATTENTE DE L'EXTINCTION GLOBALE
109+
# PHASE 2 : WAIT FOR COMPLETE EXCLUSIVITY
152110
# ==================================================
153-
print("\n⏳ PHASE 2: Waiting for all workloads to be fully stopped...")
111+
print("\nPHASE 2: Waiting for all workloads to be fully stopped...")
154112
for resource_key, info in backup_workloads.items():
155113
if info["initial_replicas"] > 0:
156-
# Bloque le script tant que les conteneurs de l'application ne sont pas à 0
157114
kubernetes.wait_for_scale_down(info["resource_type"], info["resource_name"])
158115

159116
# ==================================================
160-
# PHASE 3 : LANCEMENT DE TOUS LES BACKUPS D'UN COUP
117+
# PHASE 3 : TRIGGER CONCURRENT BACKUPS
161118
# ==================================================
162-
print("\n🚀 PHASE 3: Triggering all Kubernetes backup jobs simultaneously...")
119+
print("\nPHASE 3: Triggering all Kubernetes backup jobs simultaneously...")
163120
for resource_key, info in backup_workloads.items():
164-
# K8s instancie les Jobs en tâche de fond (le traitement disque se fait en parallèle sur le cluster)
165121
kubernetes.create_backup_job(pvc_name=info["pvc_name"], mounted_path=info["mounted_path"])
166122

167-
# Temporisation de sécurité pour laisser le temps aux Pods de backup de démarrer,
168-
# d'accrocher le volume et de poser leurs verrous de lecture.
169-
print(" 🛌 Allowing a 5s safety buffer for backup containers to initialize...")
123+
print(" info: Allowing a 5s safety buffer for backup containers to initialize...")
170124
time.sleep(5)
171125

172126
# ==================================================
173-
# PHASE 4 : RESTAURATION (SCALE UP) DE LA PRODUCTION
127+
# PHASE 4 : PRODUCTION WORKLOAD RESTORATION
174128
# ==================================================
175-
print("\n🔄 PHASE 4: Restoring production workloads to initial states...")
129+
print("\nPHASE 4: Restoring production workloads to initial states...")
176130
for resource_key, info in backup_workloads.items():
177131
if info["initial_replicas"] > 0:
178-
# On remet l'application exactement dans son état de départ (ex: replicas=1)
179132
kubernetes.scale_replicas(
180133
info["resource_type"],
181134
info["resource_name"],
182135
replicas=info["initial_replicas"]
183136
)
184-
print(f" {resource_key} restored to {info['initial_replicas']} replicas.")
137+
print(f" info: {resource_key} restored to {info['initial_replicas']} replicas.")
185138

186139
print(f"\n==================================================")
187-
print(f"🏁 Sequential orchestration process finished successfully.")
140+
print(f"Sequential orchestration process finished successfully.")
188141
print(f"==================================================")
189142

190143

191144
def main():
192145
parser = argparse.ArgumentParser(description="Backup Kubernetes volumes")
193-
parser.add_index = False
194146
parser.add_argument("-c", "--context", type=str, default=None, help="Local kubectl context")
195147
parser.add_argument("-n", "--namespace", type=str, default=None, help="Kubernetes namespace")
196148
parser.add_argument("-j", "--job", action="store_true", help="Run a backup job")
@@ -210,4 +162,4 @@ def main():
210162

211163

212164
if __name__ == "__main__":
213-
sys.exit(main())
165+
sys.exit(main())

0 commit comments

Comments
 (0)