44
55from enum import Enum , unique
66import json
7+ import logging
8+ import os
79from pathlib import Path
810import subprocess
911import sys
12+ import time
1013from typing import Any , Dict , List , Optional
1114
1215from maint_utils import run_cmd
1316
17+ # A `kubectl cp` streams a tar over the exec channel and can stall intermittently,
18+ # which would otherwise hang forever. Bound each copy with a timeout so a stalled
19+ # stream gets killed, and retry a few times so a transient stall is recovered.
20+ # Clamp to a sane floor so a misconfigured env var can't skip the copy entirely.
21+ CP_ATTEMPTS = max (1 , int (os .getenv ("kubectl_cp_attempts" , "3" )))
22+ CP_TIMEOUT_S = max (10.0 , float (os .getenv ("kubectl_cp_timeout_s" , "300" )))
23+ # A fast-failing copy can exhaust all attempts within the same second, giving a
24+ # transient failure no time to clear; pause between attempts so the retries span time.
25+ CP_RETRY_DELAY = 3.0
26+
1427
1528class CombineApp :
1629 """Run commands on the Combine services."""
@@ -39,6 +52,7 @@ def exec(
3952 * ,
4053 exec_opts : Optional [List [str ]] = None ,
4154 check_results : bool = True ,
55+ timeout : Optional [float ] = None ,
4256 ) -> subprocess .CompletedProcess [str ]:
4357 """
4458 Run a kubectl 'exec' command in a Combine Kubernetes cluster.
@@ -52,6 +66,8 @@ def exec(
5266 command, for example, to specify a working directory or a
5367 specific user to run the command.
5468 check_results: Indicate if subprocess should not check for failure.
69+ timeout: If set, kill the command and raise subprocess.TimeoutExpired
70+ when it runs longer than this many seconds.
5571 Returns a subprocess.CompletedProcess.
5672 """
5773 exec_opts = exec_opts or []
@@ -65,11 +81,69 @@ def exec(
6581 + [pod_id , "--" ]
6682 + cmd ,
6783 check_results = check_results ,
84+ timeout = timeout ,
6885 )
6986
70- def kubectl (self , cmd : List [str ]) -> subprocess .CompletedProcess [str ]:
71- """Run kubectl command adding the configuration file and namespace."""
72- return run_cmd (["kubectl" ] + self .kubectl_opts + cmd )
87+ def kubectl (
88+ self , cmd : List [str ], * , check_results : bool = True , timeout : Optional [float ] = None
89+ ) -> subprocess .CompletedProcess [str ]:
90+ """Run kubectl command adding the configuration file and namespace.
91+
92+ Args:
93+ cmd: The kubectl subcommand and its arguments.
94+ check_results: Indicate if subprocess should not check for failure.
95+ timeout: If set, kill the command and raise subprocess.TimeoutExpired
96+ when it runs longer than this many seconds.
97+ """
98+ return run_cmd (
99+ ["kubectl" ] + self .kubectl_opts + cmd ,
100+ check_results = check_results ,
101+ timeout = timeout ,
102+ )
103+
104+ def cp_with_retry (
105+ self ,
106+ cp_args : List [str ],
107+ * ,
108+ label : str ,
109+ timeout : float = CP_TIMEOUT_S ,
110+ attempts : int = CP_ATTEMPTS ,
111+ ) -> None :
112+ """Run a `kubectl cp`, bounding it with a timeout and retrying transient stalls.
113+
114+ `kubectl cp` streams a tar over the exec channel and can stall intermittently
115+ with no output. Each attempt is killed after `timeout` seconds and the copy is
116+ tried up to `attempts` times, pausing briefly between attempts. If every
117+ attempt fails, the failing copy is logged and the process exits non-zero so
118+ the failure surfaces instead of hanging silently.
119+
120+ Args:
121+ cp_args: Arguments to `kubectl cp` (source and destination, plus any flags).
122+ label: Human-readable description of what is being copied, for logging.
123+ timeout: Per-attempt timeout in seconds.
124+ attempts: Total number of attempts before giving up.
125+ """
126+ for attempt in range (1 , attempts + 1 ):
127+ try :
128+ proc = self .kubectl (["cp" ] + cp_args , check_results = False , timeout = timeout )
129+ except subprocess .TimeoutExpired :
130+ logging .warning (
131+ f"Copy of { label } timed out after { timeout :g} s "
132+ f"(attempt { attempt } /{ attempts } )."
133+ )
134+ else :
135+ if proc .returncode == 0 :
136+ logging .debug (f"stderr:\n { proc .stderr .strip ()} " )
137+ logging .debug (f"stdout:\n { proc .stdout .strip ()} " )
138+ return
139+ logging .warning (
140+ f"Copy of { label } failed with return code { proc .returncode } "
141+ f"(attempt { attempt } /{ attempts } ).\n { proc .stderr .strip ()} "
142+ )
143+ if attempt < attempts :
144+ time .sleep (CP_RETRY_DELAY )
145+ logging .error (f"Failed to copy { label } after { attempts } attempts; aborting." )
146+ sys .exit (1 )
73147
74148 def get_pod_id (self , service : CombineApp .Component , * , instance : int = 0 ) -> str :
75149 """Look up the Kubernetes pod id for the specified service."""
0 commit comments