1+ import yaml
12from src .kubernetes .base import KubernetesBase
3+ import src .utils .backup_map as bpm
24
35
46class 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