Files
anchorhost-backend/kubernetes_service.py
T
wfalkowski c177d7981d
Build and publish Docker image / build-and-publish (push) Successful in 2m45s
Refactor backend and add registry build workflow
2026-10-20 16:44:49 +00:00

991 lines
42 KiB
Python

"""Kubernetes client initialization and cluster operations."""
from datetime import datetime
from kubernetes import client, config
from kubernetes.client.rest import ApiException
from kubernetes.stream import stream
import os
import random
from runtime_config import (
logger,
)
import time
from typing import Dict, List
def initialize_kubernetes():
"""Initialize Kubernetes client with robust configuration"""
try:
config.load_incluster_config()
logger.info("✓ Loaded in-cluster Kubernetes config")
return True
except config.ConfigException:
try:
config.load_kube_config()
logger.info("✓ Loaded Kubernetes config from kubeconfig file")
return True
except config.ConfigException as e:
logger.warning(f"⚠ Could not configure kubernetes client: {e}")
logger.warning("⚠ Continuing in development mode without Kubernetes")
return False
k8s_connected = initialize_kubernetes()
if k8s_connected:
v1 = client.CoreV1Api()
apps_v1 = client.AppsV1Api()
networking_v1 = client.NetworkingV1Api()
custom_objects_api = client.CustomObjectsApi()
else:
v1 = None
apps_v1 = None
networking_v1 = None
custom_objects_api = None
class KubernetesManager:
def __init__(self):
self.connected = k8s_connected
self.last_error = None
def test_connection(self):
if not self.connected:
return False
try:
namespaces = v1.list_namespace()
self.last_error = None
namespace_names = [ns.metadata.name for ns in namespaces.items]
logger.info(f"✓ Kubernetes connection successful! Available namespaces: {namespace_names}")
return True
except ApiException as e:
self.last_error = e
logger.error(f"✗ Kubernetes connection failed: {e.reason}")
return False
def create_namespace(self, namespace_name: str, labels: Dict = None) -> bool:
# Kubernetes namespaces MUST be lowercase
namespace_name = namespace_name.lower()
if not self.connected:
logger.info(f"⚠ Kubernetes not connected, skipping namespace creation for {namespace_name}")
return True
try:
v1.read_namespace(name=namespace_name)
logger.info(f"Namespace {namespace_name} already exists")
return True
except ApiException as e:
if e.status == 404:
namespace_body = client.V1Namespace(
metadata=client.V1ObjectMeta(
name=namespace_name,
labels=labels or {
'created-by': 'anchorhost',
'user': namespace_name.replace('user-', '')
}
)
)
try:
v1.create_namespace(body=namespace_body)
logger.info(f"✓ Created namespace {namespace_name}")
return True
except ApiException as create_error:
logger.error(f"✗ Failed to create namespace {namespace_name}: {create_error.reason}")
return False
else:
logger.error(f"✗ Error checking namespace {namespace_name}: {e.reason}")
return False
def create_pvc(self, namespace: str, pvc_name: str, storage_size: str, storage_class: str = 'longhorn') -> bool:
"""Create a Persistent Volume Claim"""
if not self.connected:
logger.info(f"⚠ Kubernetes not connected, skipping PVC creation for {pvc_name}")
return True
try:
# Check if PVC already exists
try:
existing_pvc = v1.read_namespaced_persistent_volume_claim(name=pvc_name, namespace=namespace)
logger.info(f"PVC {pvc_name} already exists")
return True
except ApiException as e:
if e.status != 404:
logger.error(f"✗ Error checking PVC {pvc_name}: {e.reason}")
return False
# PVC doesn't exist, create it
pvc_body = client.V1PersistentVolumeClaim(
metadata=client.V1ObjectMeta(name=pvc_name),
spec=client.V1PersistentVolumeClaimSpec(
access_modes=["ReadWriteOnce"],
resources=client.V1ResourceRequirements(
requests={"storage": storage_size}
),
storage_class_name=storage_class
)
)
v1.create_namespaced_persistent_volume_claim(namespace=namespace, body=pvc_body)
logger.info(f"✓ Created PVC {pvc_name} with {storage_size} storage")
return True
except ApiException as e:
logger.error(f"✗ Failed to create PVC {pvc_name}: {e.reason}")
if e.status == 400:
logger.error(f" Bad Request details: {e.body}")
return False
def get_vm_status(self, namespace: str, vm_name: str) -> Dict:
"""Get VirtualMachine status with detailed information"""
if not self.connected:
return {'phase': 'Running', 'ready': True, 'conditions': []}
try:
# Get VirtualMachine
vm = custom_objects_api.get_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=vm_name
)
# Get VirtualMachineInstance if it exists
try:
vmi = custom_objects_api.get_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachineinstances",
name=vm_name
)
status = vmi.get('status', {})
phase = status.get('phase', 'Unknown')
conditions = status.get('conditions', [])
# Check if VM is ready
ready_condition = next((c for c in conditions if c.get('type') == 'Ready'), None)
ready = ready_condition.get('status', 'False') == 'True' if ready_condition else False
# Also get runStrategy from VM spec
vm_spec = vm.get('spec', {})
run_strategy = vm_spec.get('runStrategy', 'Always')
return {
'phase': phase,
'ready': ready,
'conditions': conditions,
'interfaces': status.get('interfaces', []),
'nodeName': status.get('nodeName'),
'runStrategy': run_strategy
}
except ApiException as e:
if e.status == 404:
# VMI doesn't exist yet, check VM status and runStrategy
vm_spec = vm.get('spec', {})
run_strategy = vm_spec.get('runStrategy', 'Always')
vm_status = vm.get('status', {})
ready = vm_status.get('ready', False)
# If runStrategy is 'Halted', the VM is stopped
if run_strategy == 'Halted':
return {
'phase': 'Stopped',
'ready': False,
'conditions': vm_status.get('conditions', []),
'runStrategy': run_strategy
}
return {
'phase': 'Pending' if not ready else 'Scheduling',
'ready': ready,
'conditions': vm_status.get('conditions', []),
'runStrategy': run_strategy
}
raise e
except Exception as e:
logger.error(f"Error getting VM status for {vm_name}: {e}")
return {'phase': 'Unknown', 'ready': False, 'conditions': [], 'error': str(e)}
def get_service_ports(self, namespace: str, service_name: str) -> List[Dict]:
"""Get service ports with node port information"""
if not self.connected:
return []
try:
service = v1.read_namespaced_service(name=service_name, namespace=namespace)
ports = []
for port in service.spec.ports:
ports.append({
'name': port.name,
'protocol': port.protocol,
'port': port.port,
'target_port': port.target_port,
'node_port': port.node_port
})
return ports
except Exception as e:
logger.error(f"Error getting service ports for {service_name}: {e}")
return []
def wait_for_vm_ready(self, namespace: str, vm_name: str, timeout: int = 600) -> bool:
"""Wait for VM to be ready with timeout"""
if not self.connected:
return True
logger.info(f"⏳ Waiting for VM {vm_name} to be ready (timeout: {timeout}s)")
start_time = time.time()
while time.time() - start_time < timeout:
try:
status = self.get_vm_status(namespace, vm_name)
if status.get('ready') and status.get('phase') == 'Running':
logger.info(f"✅ VM {vm_name} is ready and running")
return True
# Log progress every 30 seconds
if int(time.time() - start_time) % 30 == 0:
logger.info(f"⏳ VM {vm_name} status: {status.get('phase')}, ready: {status.get('ready')}")
time.sleep(5)
except Exception as e:
logger.error(f"Error checking VM status: {e}")
time.sleep(10)
logger.warning(f"⚠ VM {vm_name} not ready within {timeout} seconds")
return False
def get_pod_logs(self, namespace: str, pod_name: str, tail_lines: int = 100, container_name: str = None) -> str:
"""Get logs from a pod"""
if not self.connected:
return f"Mock logs for pod {pod_name} in namespace {namespace}"
try:
logs = v1.read_namespaced_pod_log(
name=pod_name,
namespace=namespace,
container=container_name,
tail_lines=tail_lines
)
return logs
except ApiException as e:
return f"Error fetching logs: {e.reason}"
def attach_and_send_command(self, namespace: str, pod_name: str, command: str, container_name: str = None) -> str:
"""Attach to pod and send command to stdin using RCON or direct stdin injection"""
if not self.connected:
return f"Mock: Command '{command}' sent to game server"
try:
# For game servers, we need to use the appropriate method to send commands
# Most game servers use RCON or a control file/pipe
# Method 1: Try to execute directly with rcon-cli if available
try:
resp = stream(
v1.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=["sh", "-c", f"rcon-cli {command} 2>&1 || echo 'Command executed: {command}'"],
container=container_name,
stderr=True,
stdin=False,
stdout=True,
tty=False
)
logger.info(f"RCON command result: {resp}")
return resp
except Exception as rcon_error:
logger.info(f"RCON not available, trying alternative method: {rcon_error}")
# Method 2: For Minecraft specifically, write to console input file if it exists
try:
resp = stream(
v1.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=["sh", "-c", f"echo '{command}' >> /data/console.input 2>&1 || echo 'Command queued: {command}'"],
container=container_name,
stderr=True,
stdin=False,
stdout=True,
tty=False
)
logger.info(f"Console file write result: {resp}")
return resp
except Exception as file_error:
logger.info(f"Console file method failed: {file_error}")
# Method 3: Try to find and write to the main process stdin using tmux/screen if available
try:
resp = stream(
v1.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=["sh", "-c", f"tmux send-keys -t 0 '{command}' Enter 2>&1 || screen -S minecraft -X stuff '{command}^M' 2>&1 || echo 'Command: {command}'"],
container=container_name,
stderr=True,
stdin=False,
stdout=True,
tty=False
)
logger.info(f"Terminal multiplexer result: {resp}")
return resp
except Exception as tmux_error:
logger.warning(f"Terminal multiplexer method failed: {tmux_error}")
# Fallback: Just return success message
return f"Command sent to game server: {command}\nNote: Command delivery confirmation not available. Check server logs."
except Exception as e:
logger.error(f"Error sending command to pod: {e}")
return f"Error: {str(e)}"
def get_pod_metrics(self, namespace: str, pod_name: str, server_ram_gb: int = 1, server_cpu_cores: int = 1) -> Dict:
"""Get CPU and memory usage for a pod"""
# Fallback values when k8s is not connected or metrics unavailable
def get_fallback_metrics():
cpu_millicores = random.randint(50, 300)
memory_mebibytes = random.randint(128, 1024)
max_cpu_millicores = server_cpu_cores * 1000
cpu_percentage = min((cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0
memory_percentage = min((memory_mebibytes / (server_ram_gb * 1024)) * 100, 100)
return {
'cpu': f'{cpu_millicores}m',
'memory': f'{memory_mebibytes}Mi',
'cpu_percentage': round(cpu_percentage, 1),
'memory_percentage': round(memory_percentage, 1),
'timestamp': datetime.now().isoformat()
}
if not self.connected:
return get_fallback_metrics()
try:
api = client.CustomObjectsApi()
metrics = api.get_namespaced_custom_object(
group="metrics.k8s.io",
version="v1beta1",
namespace=namespace,
plural="pods",
name=pod_name
)
containers = metrics.get('containers', [])
if not containers:
return get_fallback_metrics()
total_cpu_millicores = 0
total_memory_mebibytes = 0
max_cpu_millicores = server_cpu_cores * 1000
# Accumulate CPU and memory across all containers
for container in containers:
usage = container.get('usage', {})
# Parse CPU - Kubernetes returns CPU in various formats: "123456789n" (nanoseconds), "123m" (millicores), "1" (cores)
if 'cpu' in usage:
cpu_str = str(usage['cpu']).strip()
cpu_millicores = 0
if cpu_str.endswith('n'):
# Nanoseconds: convert to millicores (1 millicore = 1/1000 core = 1000000 nanoseconds)
try:
cpu_nanoseconds = int(cpu_str[:-1])
cpu_millicores = int(cpu_nanoseconds / 1000000)
except (ValueError, IndexError):
pass
elif cpu_str.endswith('m'):
# Already in millicores
try:
cpu_millicores = int(cpu_str[:-1])
except (ValueError, IndexError):
pass
elif cpu_str.endswith('u'):
# Microcores: convert to millicores (1 millicore = 1000 microcores)
try:
cpu_microcores = int(cpu_str[:-1])
cpu_millicores = int(cpu_microcores / 1000)
except (ValueError, IndexError):
pass
else:
# Try to parse as cores or raw number
try:
cpu_cores = float(cpu_str)
cpu_millicores = int(cpu_cores * 1000)
except ValueError:
pass
total_cpu_millicores += cpu_millicores
# Parse memory - Kubernetes returns memory as "123Ki", "123Mi", "123Gi", or bytes
if 'memory' in usage:
memory_str = str(usage['memory']).strip()
memory_mebibytes = 0
if memory_str.endswith('Ki'):
try:
memory_kib = int(memory_str[:-2])
memory_mebibytes = memory_kib / 1024
except (ValueError, IndexError):
pass
elif memory_str.endswith('Mi'):
try:
memory_mebibytes = int(memory_str[:-2])
except (ValueError, IndexError):
pass
elif memory_str.endswith('Gi'):
try:
memory_gib = float(memory_str[:-2])
memory_mebibytes = memory_gib * 1024
except (ValueError, IndexError):
pass
else:
# Try to parse as bytes
try:
memory_bytes = int(memory_str)
memory_mebibytes = memory_bytes / (1024 * 1024)
except ValueError:
pass
total_memory_mebibytes += memory_mebibytes
# If we couldn't parse any metrics, use fallback
if total_cpu_millicores == 0 and total_memory_mebibytes == 0:
return get_fallback_metrics()
cpu_percentage = min((total_cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0
memory_percentage = min((total_memory_mebibytes / (server_ram_gb * 1024)) * 100, 100) if server_ram_gb > 0 else 0
return {
'cpu': f'{int(total_cpu_millicores)}m',
'memory': f'{int(total_memory_mebibytes)}Mi',
'cpu_percentage': round(cpu_percentage, 1),
'memory_percentage': round(memory_percentage, 1),
'timestamp': datetime.now().isoformat()
}
except Exception as e:
logger.warning(f"Failed to get pod metrics for {pod_name}: {e}")
return get_fallback_metrics()
def get_pods_for_server(self, namespace: str, server_id: str) -> List[str]:
"""Get all pods for a server"""
if not self.connected:
return [f"{server_id}-pod"]
try:
selectors = [
f"kubevirt.io/domain={server_id}",
f"app={server_id}",
f"server-id={server_id}"
]
for selector in selectors:
try:
pods = v1.list_namespaced_pod(
namespace=namespace,
label_selector=selector
)
if pods.items:
return [pod.metadata.name for pod in pods.items]
except Exception:
continue
pods = v1.list_namespaced_pod(namespace=namespace)
return [pod.metadata.name for pod in pods.items if server_id in pod.metadata.name]
except Exception as e:
logger.error(f"Error getting pods for server {server_id}: {e}")
return []
def exec_command(self, namespace: str, pod_name: str, command: List[str], container_name: str = None) -> str:
"""Execute command in pod"""
if not self.connected:
if command and 'pwd' in command:
return "/data\n"
return f"Mock command execution: {' '.join(command)}\n"
try:
resp = stream(
v1.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=command,
container=container_name,
stderr=True,
stdin=False,
stdout=True,
tty=False
)
return resp
except Exception as e:
return f"Error executing command: {str(e)}"
def exec_command_with_stdin(self, namespace: str, pod_name: str, command: List[str], stdin_data: str, container_name: str = None) -> str:
"""Execute command in pod with stdin input - used for large file uploads to avoid command-line limits"""
if not self.connected:
return f"Mock command execution with stdin: {' '.join(command)}\n"
try:
resp = stream(
v1.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=command,
container=container_name,
stderr=True,
stdin=True,
stdout=True,
tty=False,
_preload_content=False
)
# Write stdin data in chunks to avoid memory issues
chunk_size = 8192 # 8KB chunks
data_bytes = stdin_data.encode('utf-8') if isinstance(stdin_data, str) else stdin_data
for i in range(0, len(data_bytes), chunk_size):
chunk = data_bytes[i:i + chunk_size]
resp.write_stdin(chunk)
# Close stdin by closing the stream or sending EOF if supported
# WSClient doesn't have close_stdin(), so we use close() or just wait
# if the command is expected to finish (like head -c)
try:
# Some versions might support sending EOF character
resp.write_stdin('\x04')
except:
pass
# Read response
output = ""
max_wait_time = 300 # 5 minutes max wait for large files
start_time = time.time()
while resp.is_open() and (time.time() - start_time) < max_wait_time:
resp.update(timeout=1)
if resp.peek_stdout():
output += resp.read_stdout(timeout=1)
if resp.peek_stderr():
error = resp.read_stderr(timeout=1)
if error:
output += error
resp.close()
if (time.time() - start_time) >= max_wait_time:
logger.warning(f"Command with stdin timed out after {max_wait_time}s")
return output
except Exception as e:
logger.error(f"Error executing command with stdin: {e}", exc_info=True)
return f"Error executing command: {str(e)}"
def list_files(self, namespace: str, pod_name: str, path: str = "/", container_name: str = None) -> List[Dict]:
"""List files and directories in a path - IMPROVED VERSION"""
if not self.connected:
# Return mock data for development
mock_files = [
{"name": "eula.txt", "type": "file", "size": 1024, "modified": "2024-01-01T00:00:00Z"},
{"name": "server.properties", "type": "file", "size": 2048, "modified": "2024-01-01T00:00:00Z"},
{"name": "logs", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"},
{"name": "world", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"}
]
return mock_files
try:
# Use ls command for reliable file listing
cmd = ["ls", "-la", path]
result = self.exec_command(namespace, pod_name, cmd, container_name=container_name)
logger.info(f"LS result for {path}: {result}")
files = []
lines = result.strip().split('\n')
for line in lines:
# Skip the "total X" line and empty lines
if line.startswith('total ') or not line.strip():
continue
try:
parts = line.split()
if len(parts) < 9:
continue
permissions = parts[0]
name = ' '.join(parts[8:])
if name in ['.', '..']:
continue
file_type = "directory" if permissions.startswith('d') else "file"
# Get file size from ls output (field 5)
size = 0
try:
size = int(parts[4])
except (ValueError, IndexError):
pass
# Parse modification time (fields 5-8)
try:
# Format: "Jan 01 12:00" or "Jan 01 2023"
date_parts = parts[5:8]
if len(date_parts) == 3:
month, day, time_or_year = date_parts
if ':' in time_or_year:
# Current year, use current year
year = datetime.now().year
modified_str = f"{month} {day} {year} {time_or_year}"
modified = datetime.strptime(modified_str, "%b %d %Y %H:%M")
else:
# Different year
year = time_or_year
modified_str = f"{month} {day} {year}"
modified = datetime.strptime(modified_str, "%b %d %Y")
else:
modified = datetime.now()
except Exception as e:
logger.warning(f"Error parsing date: {e}")
modified = datetime.now()
files.append({
"name": name,
"type": file_type,
"size": size,
"modified": modified.isoformat()
})
except (ValueError, IndexError) as e:
logger.warning(f"Error parsing file entry: {line}, error: {e}")
continue
return files
except Exception as e:
logger.error(f"Error listing files in {path}: {e}")
return []
def read_file(self, namespace: str, pod_name: str, file_path: str, container_name: str = None) -> str:
"""Read file content from pod"""
if not self.connected:
return "Mock file content for development"
try:
cmd = ["cat", file_path]
result = self.exec_command(namespace, pod_name, cmd, container_name=container_name)
return result
except Exception as e:
logger.error(f"Error reading file {file_path}: {e}")
return ""
def read_file_base64(self, namespace: str, pod_name: str, file_path: str, container_name: str = None) -> str:
"""Read file content from pod as base64 encoded string"""
if not self.connected:
return ""
try:
import base64
cmd = ["base64", "-w", "0", file_path]
result = self.exec_command(namespace, pod_name, cmd, container_name=container_name)
return result.strip()
except Exception as e:
logger.error(f"Error reading file as base64 {file_path}: {e}")
return ""
def write_file(self, namespace: str, pod_name: str, file_path: str, content: str, container_name: str = None) -> bool:
"""Write content to file in pod using base64 for reliability"""
if not self.connected:
return True
try:
import base64
# Encode content to base64
b64_content = base64.b64encode(content.encode('utf-8')).decode('utf-8')
# Use base64 -d to decode on the pod side
# This is much more reliable than heredocs for content with special characters
cmd = ["sh", "-c", f"echo '{b64_content}' | base64 -d > '{file_path}'"]
self.exec_command(namespace, pod_name, cmd, container_name=container_name)
return True
except Exception as e:
logger.error(f"Error writing file {file_path}: {e}")
return False
def create_directory(self, namespace: str, pod_name: str, dir_path: str, container_name: str = None) -> bool:
"""Create directory in pod"""
if not self.connected:
return True
try:
cmd = ["mkdir", "-p", dir_path]
self.exec_command(namespace, pod_name, cmd, container_name=container_name)
return True
except Exception as e:
logger.error(f"Error creating directory {dir_path}: {e}")
return False
def delete_path(self, namespace: str, pod_name: str, path: str, container_name: str = None) -> bool:
"""Delete file or directory in pod"""
if not self.connected:
return True
try:
cmd = ["rm", "-rf", path]
self.exec_command(namespace, pod_name, cmd, container_name=container_name)
return True
except Exception as e:
logger.error(f"Error deleting path {path}: {e}")
return False
def move_path(self, namespace: str, pod_name: str, src_path: str, dest_path: str, container_name: str = None) -> bool:
"""Move file or directory in pod"""
if not self.connected:
return True
try:
cmd = ["mv", src_path, dest_path]
self.exec_command(namespace, pod_name, cmd, container_name=container_name)
return True
except Exception as e:
logger.error(f"Error moving path {src_path} to {dest_path}: {e}")
return False
def copy_path(self, namespace: str, pod_name: str, src_path: str, dest_path: str, container_name: str = None) -> bool:
"""Copy file or directory in pod"""
if not self.connected:
return True
try:
cmd = ["cp", "-r", src_path, dest_path]
self.exec_command(namespace, pod_name, cmd, container_name=container_name)
return True
except Exception as e:
logger.error(f"Error copying path {src_path} to {dest_path}: {e}")
return False
def upload_file(self, namespace: str, pod_name: str, file_path: str, file_content: str, is_base64: bool = True) -> bool:
"""Upload file to pod"""
if not self.connected:
return True
try:
if is_base64:
# Decode base64 content
import base64
content = base64.b64decode(file_content).decode('utf-8', errors='ignore')
else:
content = file_content
return self.write_file(namespace, pod_name, file_path, content)
except Exception as e:
logger.error(f"Error uploading file {file_path}: {e}")
return False
def exec_command_with_file_stdin(self, namespace: str, pod_name: str, command: List[str], file_path: str, base64_encode: bool = True, container_name: str = None) -> str:
"""Execute command in pod with stdin input from a file - streams file in chunks to avoid memory issues
Used for large file uploads to avoid loading entire file into memory"""
if not self.connected:
return f"Mock command execution with file stdin: {' '.join(command)}\n"
try:
resp = stream(
v1.connect_get_namespaced_pod_exec,
pod_name,
namespace,
command=command,
container=container_name,
stderr=True,
stdin=True,
stdout=True,
tty=False,
_preload_content=False
)
# Stream file in chunks to avoid memory issues
# IMPORTANT: chunk_size must be a multiple of 3 for base64 encoding to work correctly across chunks
chunk_size = 48 * 1024 # 48KB chunks (multiple of 3)
base64_chunk_size = 8192 # 8KB chunks for base64 string writing
with open(file_path, 'rb') as f:
if base64_encode:
# Read binary file, encode to base64, and stream to stdin
import base64
encoder = base64.b64encode
while True:
# Read binary chunk
binary_chunk = f.read(chunk_size)
if not binary_chunk:
break
# Encode to base64
base64_chunk = encoder(binary_chunk).decode('utf-8')
# Write base64 string in smaller chunks to stdin
base64_bytes = base64_chunk.encode('utf-8')
for i in range(0, len(base64_bytes), base64_chunk_size):
chunk = base64_bytes[i:i + base64_chunk_size]
resp.write_stdin(chunk)
else:
# Stream binary directly
while True:
chunk = f.read(chunk_size)
if not chunk:
break
resp.write_stdin(chunk)
# Close stdin by sending EOF if supported
try:
resp.write_stdin('\x04')
except:
pass
# Read response
output = ""
max_wait_time = 600 # 10 minutes max wait for very large files
start_time = time.time()
while resp.is_open() and (time.time() - start_time) < max_wait_time:
resp.update(timeout=1)
if resp.peek_stdout():
output += resp.read_stdout(timeout=1)
if resp.peek_stderr():
error = resp.read_stderr(timeout=1)
if error:
output += error
resp.close()
if (time.time() - start_time) >= max_wait_time:
logger.warning(f"Command with file stdin timed out after {max_wait_time}s")
return output
except Exception as e:
logger.error(f"Error executing command with file stdin: {e}", exc_info=True)
return f"Error executing command: {str(e)}"
def write_file_binary(self, namespace: str, pod_name: str, file_path: str, base64_content: str, container_name: str = None) -> bool:
"""Write binary file to pod using base64 decode on pod side (for large files)
Uses stdin to avoid command-line length limits for large files"""
if not self.connected:
return True
try:
# Ensure directory exists
dir_path = '/'.join(file_path.split('/')[:-1]) if '/' in file_path else '.'
if dir_path and dir_path != '.':
self.create_directory(namespace, pod_name, dir_path, container_name=container_name)
escaped_path = file_path.replace("'", "'\\''")
temp_file = f"/tmp/upload_{int(time.time() * 1000000)}.b64"
escaped_temp = temp_file.replace("'", "'\\''")
# Use stdin to write base64 data to avoid command-line length limits
# Using head -c ensures the command terminates after reading the expected size
b64_data = base64_content.encode('utf-8') if isinstance(base64_content, str) else base64_content
content_len = len(b64_data)
write_base64_cmd = ["sh", "-c", f"head -c {content_len} > '{escaped_temp}'"]
write_result = self.exec_command_with_stdin(namespace, pod_name, write_base64_cmd, base64_content, container_name=container_name)
# Check if write succeeded by verifying temp file exists
check_temp_cmd = ["sh", "-c", f"test -f '{escaped_temp}' && echo 'temp_exists' || echo 'temp_missing'"]
temp_check_result = self.exec_command(namespace, pod_name, check_temp_cmd, container_name=container_name)
if 'temp_exists' not in temp_check_result:
logger.error(f"Failed to write temp base64 file: {write_result}")
return False
# Decode base64 file to final location with error checking
decode_cmd = ["sh", "-c", f"base64 -d < '{escaped_temp}' > '{escaped_path}' 2>&1 && rm -f '{escaped_temp}' && echo 'decode_success' || (rm -f '{escaped_temp}' && echo 'decode_failed')"]
decode_result = self.exec_command(namespace, pod_name, decode_cmd, container_name=container_name)
if 'decode_failed' in decode_result:
logger.error(f"Failed to decode base64 file: {decode_result}")
return False
# Verify file was created and has content
check_cmd = ["sh", "-c", f"test -f '{escaped_path}' && test -s '{escaped_path}' && echo 'exists' || echo 'missing'"]
result = self.exec_command(namespace, pod_name, check_cmd, container_name=container_name)
if 'exists' not in result:
logger.error(f"File verification failed: {result}")
return False
return True
except Exception as e:
logger.error(f"Error writing binary file {file_path}: {e}")
import traceback
logger.error(traceback.format_exc())
return False
def write_file_binary_from_disk(self, namespace: str, pod_name: str, file_path: str, local_file_path: str, container_name: str = None) -> bool:
"""Write binary file to pod from a local file on disk - streams file to avoid memory issues
This is the preferred method for large files as it doesn't load the entire file into memory"""
if not self.connected:
return True
try:
# Ensure directory exists
dir_path = '/'.join(file_path.split('/')[:-1]) if '/' in file_path else '.'
if dir_path and dir_path != '.':
self.create_directory(namespace, pod_name, dir_path, container_name=container_name)
escaped_path = file_path.replace("'", "'\\''")
temp_file = f"/tmp/upload_{int(time.time() * 1000000)}.b64"
escaped_temp = temp_file.replace("'", "'\\''")
# Stream file from disk, base64 encode, and write to pod temp file
# Calculate expected base64 size to use head -c for automatic termination
import math
local_size = os.path.getsize(local_file_path)
b64_size = math.ceil(local_size / 3) * 4
write_base64_cmd = ["sh", "-c", f"head -c {b64_size} > '{escaped_temp}'"]
write_result = self.exec_command_with_file_stdin(namespace, pod_name, write_base64_cmd, local_file_path, base64_encode=True, container_name=container_name)
# Check if write succeeded by verifying temp file exists
check_temp_cmd = ["sh", "-c", f"test -f '{escaped_temp}' && echo 'temp_exists' || echo 'temp_missing'"]
temp_check_result = self.exec_command(namespace, pod_name, check_temp_cmd, container_name=container_name)
if 'temp_exists' not in temp_check_result:
logger.error(f"Failed to write temp base64 file: {write_result}")
return False
# Decode base64 file to final location with error checking
decode_cmd = ["sh", "-c", f"base64 -d < '{escaped_temp}' > '{escaped_path}' 2>&1 && rm -f '{escaped_temp}' && echo 'decode_success' || (rm -f '{escaped_temp}' && echo 'decode_failed')"]
decode_result = self.exec_command(namespace, pod_name, decode_cmd, container_name=container_name)
if 'decode_failed' in decode_result:
logger.error(f"Failed to decode base64 file: {decode_result}")
return False
# Verify file was created and has content
check_cmd = ["sh", "-c", f"test -f '{escaped_path}' && test -s '{escaped_path}' && echo 'exists' || echo 'missing'"]
result = self.exec_command(namespace, pod_name, check_cmd, container_name=container_name)
if 'exists' not in result:
logger.error(f"File verification failed: {result}")
return False
return True
except Exception as e:
logger.error(f"Error writing binary file from disk {file_path}: {e}")
import traceback
logger.error(traceback.format_exc())
return False
def download_file(self, namespace: str, pod_name: str, file_path: str, container_name: str = None) -> str:
"""Download file from pod as base64"""
if not self.connected:
return ""
try:
# Use read_file_base64 for binary-safe file reading
# This handles both text and binary files correctly
content = self.read_file_base64(namespace, pod_name, file_path, container_name=container_name)
return content
except Exception as e:
logger.error(f"Error downloading file {file_path}: {e}")
return ""
k8s_manager = KubernetesManager()