Skip to content

Commit 02dcffe

Browse files
committed
pg_dump WORKS
1 parent 073f450 commit 02dcffe

6 files changed

Lines changed: 261 additions & 176 deletions

File tree

README.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,28 @@ clustersnap-backup-map.yaml: |
151151
152152
153153
#### Tweaks
154-
Everything in the backup-map can be tweaked, but here is a short description of the most useful keys
155-
156-
| key | value | info
157-
|---------------------------|---------------|------
158-
| workloads[] | *[]* | Nothing to backup
159-
| claim[] | *[]* | Nothing to backup
160-
| workloads[].replicas | *integer* | Clustersnap must scale replicas=0 to perform the backups. This value is used to restore the right replicas quantity
161-
| claims[].attachment-node | *node-name* | Useful when data are stored on specifics nodes (and not on a remote storage), jobs must run on same nodes as PVC
162-
| claims[].mounts[].path | *path* | Mount point where to look for data to backup
163-
| claims[].backup-type | **volume** | The backup job will create a `.tar.gz containing data of the mounted paths` found in the PVC
164-
| claims[].backup-type | **pg_dump** | The backup job will use `pg_dump to create a .sql` dump of all the databases found in the PVC
154+
Everything in the backup-map can be tweaked Here is the list of configurable keys:
155+
156+
| key | value | info
157+
|-------------------------------------------|-----------------|------
158+
| **`claims[]`** | *list* | List of PersistentVolumeClaims (PVCs) to orchestrate within the namespace. If empty = nothing to backup.
159+
| **`claims[].attachment-node`** | *string* | Name of the Kubernetes node where the PVC is attached. Useful if storage is node-constrained.
160+
| **`claims[].mounts[].path`** | *string* | Container mount path where data to back up is located.
161+
| **`claims[].backup-type`** | **`volume`** | The backup job will create a `.tar.gz containing data of the mounted paths` found in the PVC
162+
| **`claims[].backup-type`** | **`pg_dump`** | The backup job will use `pg_dump to create a .sql` dump of all the databases found in the PVC
163+
| **`claims[].postgresql`** | *dictionary* | Configuration block for PostgreSQL database access.
164+
| **`claims[].postgresql.host`** | *string* | Hardcoded PostgreSQL host/IP *(Overrides `host-env`)*. *Default: Dynamic Pod IP.*
165+
| **`claims[].postgresql.host-env`** | *string* | Name of the workload environment variable containing the host from original database pod.
166+
| **`claims[].postgresql.port`** | *integer* | Hardcoded connection port *(Overrides `port-env`)*. *Default: 5432.*
167+
| **`claims[].postgresql.port-env`** | *string* | Name of the workload environment variable containing the port from original database pod.
168+
| **`claims[].postgresql.user`** | *string* | Hardcoded PostgreSQL username *(Overrides `user-env`)*.
169+
| **`claims[].postgresql.user-env`** | *string* | Name of the workload environment variable containing the user from original database pod.
170+
| **`claims[].postgresql.password-env`** | *string* | **[REQUIRED]** Name of the workload environment variable containing the password (or mounted secret file).
171+
| **`claims[].postgresql.db`** | *string* | Hardcoded target database name *(Overrides `db-env`)*.
172+
| **`claims[].postgresql.db-env`** | *string* | Name of the workload environment variable containing the database name.
173+
174+
> [!CAUTION]
175+
> **Security Warning:** The plain-text `password` key is **strictly forbidden** inside the `backup-map` to prevent plain-text credential leaks in a ConfigMap. You **must** use `password-env` to reference your workload's environment variable.
165176
166177
167178
### Workflows

src/kubernetes/base.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,26 @@ def get_pvc(self, pvc_name: str) -> list:
9191
return targets
9292

9393

94+
def get_pod_ip(self, pvc_name: str) -> str:
95+
"""Find the exact IP of the running Pod mounted on the target PVC."""
96+
try:
97+
pods = self.core_v1.list_namespaced_pod(namespace=self.namespace)
98+
for pod in pods.items:
99+
if pod.status.phase != "Running":
100+
continue
101+
102+
if pod.spec.volumes:
103+
for vol in pod.spec.volumes:
104+
if vol.persistent_volume_claim and vol.persistent_volume_claim.claim_name == pvc_name:
105+
if pod.status.pod_ip:
106+
print(f" info: detected active Pod '{pod.metadata.name}' at IP {pod.status.pod_ip}")
107+
return pod.status.pod_ip
108+
except Exception as e:
109+
print(f" warn: failed to resolve active pod IP for PVC '{pvc_name}': {e}")
110+
111+
raise RuntimeError(f"Could not find any running Pod attached to PVC '{pvc_name}' in namespace '{self.namespace}'")
112+
113+
94114
def get_image_pull_secrets(self, resource_type: str, resource_name: str) -> list:
95115
"""Dynamically extract imagePullSecrets names defined on a specific workload resource."""
96116
try:
@@ -368,9 +388,9 @@ def _launch_generic_job(self, template_path: str, replacements: dict):
368388
clean_env = []
369389
final_overrides = {}
370390

371-
# If container is database-dumper and Python prepared a dumper_env_list
372-
if c_name == "database-dumper" and replacements.get("DUMPER_ENV_LIST"):
373-
raw_env = replacements["DUMPER_ENV_LIST"]
391+
# Read dumper_env_list correctly from replacements dictionary
392+
if c_name == "database-dumper" and replacements.get("dumper_env_list"):
393+
raw_env = replacements["dumper_env_list"]
374394
else:
375395
raw_env = container.get("env") or []
376396

src/kubernetes/job_postgresql.py

Lines changed: 202 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import yaml
12
from src.kubernetes.base import KubernetesBase
3+
import src.utils.backup_map as bpm
24

35

46
class PostgresBackupJob(KubernetesBase):
@@ -14,19 +16,33 @@ def get_job_name(self, pvc_name: str, suffix: str = "pgdump") -> str:
1416

1517

1618
def create_backup_job(self, pvc_name: str, mounted_path: str, node_name: str, **kwargs):
17-
"""Backup PostgreSQL database with pg_dump."""
18-
postgres_image = kwargs.get("postgres_image")
19-
if not postgres_image:
20-
raise ValueError("postgres_image parameter is required.")
19+
"""Backup PostgreSQL database over network with pg_dump using exact workload configuration."""
20+
workload_type = kwargs.get("workload_type")
21+
workload_name = kwargs.get("workload_name")
22+
target_ip = kwargs.get("target_ip")
23+
master_ns = kwargs.get("master_ns")
24+
25+
if not workload_type or not workload_name or not target_ip:
26+
raise ValueError("workload_type, workload_name, and target_ip are required for PostgresBackupJob.")
27+
28+
postgres_image = self.get_workload_image(workload_type, workload_name)
29+
dumper_env_list, original_env_from = self._build_strict_postgres_job_env(
30+
pvc_name=pvc_name,
31+
target_ip=target_ip,
32+
workload_type=workload_type,
33+
workload_name=workload_name,
34+
master_ns=master_ns
35+
)
36+
2137
replacements = {
2238
"JOB_NAME": self.get_job_name(pvc_name),
2339
"NAMESPACE": self.namespace,
2440
"MOUNTED_PATH": mounted_path,
2541
"PVC_NAME": pvc_name,
2642
"POSTGRES_IMAGE": postgres_image,
2743
"IMAGE_PULL_SECRETS_LIST": kwargs.get("image_pull_secrets", []),
28-
"DUMPER_ENV_LIST": kwargs.get("dumper_env_list", []),
29-
"ORIGINAL_ENV_FROM": kwargs.get("original_env_from", []),
44+
"dumper_env_list": dumper_env_list,
45+
"ORIGINAL_ENV_FROM": original_env_from,
3046
"AFFINITY_DICT": {
3147
"nodeAffinity": {
3248
"requiredDuringSchedulingIgnoredDuringExecution": {
@@ -47,6 +63,185 @@ def create_backup_job(self, pvc_name: str, mounted_path: str, node_name: str, **
4763
}
4864

4965
self._launch_generic_job(
50-
template_path="templates/postgresql.exporter.job.yaml",
66+
template_path="templates/postgresql.exporter.job.yaml",
5167
replacements=replacements
5268
)
69+
70+
71+
def _resolve_container_env_secrets(self, raw_env: list) -> list:
72+
"""Decode any secretKeyRef references in container env list into concrete values."""
73+
resolved_env = []
74+
75+
for item in raw_env:
76+
if not isinstance(item, dict):
77+
continue
78+
79+
name = item.get("name")
80+
value = item.get("value")
81+
value_from = item.get("valueFrom") or item.get("value_from")
82+
83+
if value_from and isinstance(value_from, dict):
84+
sec_ref = value_from.get("secretKeyRef") or value_from.get("secret_key_ref")
85+
if sec_ref:
86+
sec_name = sec_ref.get("name")
87+
sec_key = sec_ref.get("key")
88+
try:
89+
payload = self.get_secret_payload(sec_name, self.namespace)
90+
if sec_key in payload:
91+
value = payload[sec_key]
92+
resolved_env.append({"name": name, "value": value})
93+
continue
94+
except Exception as e:
95+
print(f" warn: failed to resolve secretKeyRef for variable '{name}' ({e})")
96+
97+
if value is not None:
98+
resolved_env.append({"name": name, "value": value})
99+
100+
return resolved_env
101+
102+
def _get_secret_value_from_file_path(self, workload_type: str, workload_name: str, file_path: str) -> str:
103+
"""Inspects workload volume mounts to resolve which Secret mounted file matches the target path and reads it."""
104+
try:
105+
if workload_type.lower() == "statefulset":
106+
res_obj = self.apps_v1.read_namespaced_stateful_set(name=workload_name, namespace=self.namespace)
107+
else:
108+
res_obj = self.apps_v1.read_namespaced_deployment(name=workload_name, namespace=self.namespace)
109+
110+
pod_spec = res_obj.spec.template.spec
111+
container = pod_spec.containers[0]
112+
113+
# 1. Look for volume mount matching the file path directory
114+
matching_vol_name = None
115+
sub_key = None
116+
117+
for v_mount in container.volume_mounts or []:
118+
if file_path.startswith(v_mount.mount_path):
119+
matching_vol_name = v_mount.name
120+
# Calculate subpath or key filename
121+
sub_key = file_path[len(v_mount.mount_path):].lstrip("/")
122+
break
123+
124+
if matching_vol_name:
125+
for vol in pod_spec.volumes or []:
126+
if vol.name == matching_vol_name and vol.secret:
127+
sec_name = vol.secret.secret_name
128+
payload = self.get_secret_payload(sec_name, self.namespace)
129+
130+
# If secret has exact key or single item
131+
if sub_key in payload:
132+
return payload[sub_key]
133+
elif len(payload) == 1:
134+
return list(payload.values())[0]
135+
136+
except Exception as e:
137+
print(f" warn: failed to resolve secret content from file path '{file_path}': {e}")
138+
139+
return file_path
140+
141+
142+
def _build_strict_postgres_job_env(self, pvc_name: str, target_ip: str, workload_type: str, workload_name: str, master_ns: str = None) -> tuple[list, list]:
143+
"""
144+
Extracts container environment, resolves K8s Secrets, and strictly maps
145+
keys configured in backup-map (prioritizing literal overrides) to standard PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE.
146+
"""
147+
# Read postgresql configuration dict directly using master_ns
148+
pg_map_config = {}
149+
try:
150+
saved_map = bpm.get_backup_map(self, master_ns=master_ns)
151+
for ns in saved_map.get("namespaces", []):
152+
if ns.get("name") == self.namespace:
153+
for claim in ns.get("claims", []):
154+
if claim.get("name") == pvc_name:
155+
pg_map_config = claim.get("postgresql", {})
156+
break
157+
except Exception as e:
158+
print(f" warn: failed to read postgresql config from backup-map ({e})")
159+
160+
# Security check: disallow plain-text 'password' key in backup-map
161+
if "password" in pg_map_config:
162+
raise ValueError(f"Security Error: Plain-text 'password' key is forbidden in backup-map for PVC '{pvc_name}'. Use 'password-env' instead.")
163+
164+
# Read original container spec from target workload
165+
original_env_raw = []
166+
original_env_from_raw = []
167+
168+
if workload_type.lower() == "statefulset":
169+
res_obj = self.apps_v1.read_namespaced_stateful_set(name=workload_name, namespace=self.namespace)
170+
else:
171+
res_obj = self.apps_v1.read_namespaced_deployment(name=workload_name, namespace=self.namespace)
172+
173+
c_spec = res_obj.spec.template.spec.containers[0]
174+
if c_spec.env:
175+
original_env_raw = self.core_v1.api_client.sanitize_for_serialization(c_spec.env)
176+
if c_spec.env_from:
177+
original_env_from_raw = self.core_v1.api_client.sanitize_for_serialization(c_spec.env_from)
178+
179+
resolved_env = self._resolve_container_env_secrets(original_env_raw)
180+
env_dict = {e["name"]: e["value"] for e in resolved_env if "name" in e and "value" in e}
181+
182+
# 1. HOST: 'host' (literal) takes priority over 'host-env', fallback to target_ip
183+
if "host" in pg_map_config and pg_map_config["host"]:
184+
host_val = str(pg_map_config["host"])
185+
elif "host-env" in pg_map_config and pg_map_config["host-env"]:
186+
env_var_name = pg_map_config["host-env"]
187+
if env_var_name not in env_dict:
188+
raise ValueError(f"PostgreSQL host-env variable '{env_var_name}' not found in workload environment for PVC '{pvc_name}'.")
189+
host_val = env_dict[env_var_name]
190+
else:
191+
host_val = target_ip
192+
193+
resolved_env.append({"name": "PGHOST", "value": host_val})
194+
195+
# 2. PORT: 'port' (literal) takes priority over 'port-env', fallback to 5432
196+
if "port" in pg_map_config and pg_map_config["port"]:
197+
port_val = str(pg_map_config["port"])
198+
elif "port-env" in pg_map_config and pg_map_config["port-env"]:
199+
env_var_name = pg_map_config["port-env"]
200+
if env_var_name not in env_dict:
201+
raise ValueError(f"PostgreSQL port-env variable '{env_var_name}' not found in workload environment for PVC '{pvc_name}'.")
202+
port_val = str(env_dict[env_var_name])
203+
else:
204+
port_val = "5432"
205+
206+
resolved_env.append({"name": "PGPORT", "value": port_val})
207+
208+
# 3. USER: 'user' (literal) takes priority over 'user-env'
209+
if "user" in pg_map_config and pg_map_config["user"]:
210+
user_val = str(pg_map_config["user"])
211+
elif "user-env" in pg_map_config and pg_map_config["user-env"]:
212+
env_var_name = pg_map_config["user-env"]
213+
if env_var_name not in env_dict:
214+
raise ValueError(f"PostgreSQL user-env variable '{env_var_name}' not found in workload environment for PVC '{pvc_name}'.")
215+
user_val = env_dict[env_var_name]
216+
else:
217+
raise ValueError(f"PostgreSQL 'user' or 'user-env' configuration missing in backup-map for PVC '{pvc_name}'.")
218+
219+
resolved_env.append({"name": "PGUSER", "value": user_val})
220+
221+
# 4. PASSWORD: Only 'password-env' is permitted for security
222+
pass_env_var = pg_map_config.get("password-env")
223+
if not pass_env_var:
224+
raise ValueError(f"PostgreSQL 'password-env' configuration missing in backup-map for PVC '{pvc_name}'.")
225+
226+
if pass_env_var not in env_dict:
227+
raise ValueError(f"PostgreSQL password-env variable '{pass_env_var}' not found in workload environment for PVC '{pvc_name}'.")
228+
229+
pass_val = env_dict[pass_env_var]
230+
231+
# If pass_val is a file path (starts with /), resolve the actual secret content
232+
if isinstance(pass_val, str) and pass_val.startswith("/"):
233+
pass_val = self._get_secret_value_from_file_path(workload_type, workload_name, pass_val)
234+
235+
resolved_env.append({"name": "PGPASSWORD", "value": pass_val})
236+
237+
# 5. DATABASE (Optional): 'db' (literal) takes priority over 'db-env'
238+
if "db" in pg_map_config and pg_map_config["db"]:
239+
db_val = str(pg_map_config["db"])
240+
resolved_env.append({"name": "PGDATABASE", "value": db_val})
241+
elif "db-env" in pg_map_config and pg_map_config["db-env"]:
242+
env_var_name = pg_map_config["db-env"]
243+
if env_var_name in env_dict:
244+
db_val = env_dict[env_var_name]
245+
resolved_env.append({"name": "PGDATABASE", "value": db_val})
246+
247+
return resolved_env, original_env_from_raw

0 commit comments

Comments
 (0)