Python Scripting for DevOps
Python scripting is a cornerstone of DevOps automation, enabling teams to automate repetitive tasks, orchestrate deployments, and manage infrastructure. This post covers practical Python scripting patterns and techniques specifically designed for DevOps workflows, from simple automation scripts to complex orchestration tools.
Script Structure Best Practices
Shebang and Executable Scripts
#!/usr/bin/env python3
"""
Server deployment automation script.
This script automates the deployment of applications to servers.
"""
import sys
import argparse
from pathlib import Path
def main():
"""Main entry point for the script."""
parser = argparse.ArgumentParser(description="Deploy application to servers")
parser.add_argument("--environment", required=True, choices=["dev", "staging", "prod"])
parser.add_argument("--version", required=True, help="Version to deploy")
parser.add_argument("--dry-run", action="store_true", help="Simulate deployment")
args = parser.parse_args()
print(f"Deploying version {args.version} to {args.environment}")
if args.dry_run:
print("DRY RUN - No actual changes made")
return 0
# Deployment logic here
return 0
if __name__ == "__main__":
sys.exit(main())
Make the script executable:
chmod +x deploy.py
./deploy.py --environment dev --version 1.2.3
Configuration Management
import os
import json
import yaml
from pathlib import Path
from typing import Dict, Any
class Config:
"""Configuration manager for scripts."""
def __init__(self, config_file: str = None):
self.config = {}
# Load from environment
self.load_from_env()
# Load from file
if config_file:
self.load_from_file(config_file)
def load_from_env(self):
"""Load configuration from environment variables."""
self.config["api_url"] = os.getenv("API_URL", "http://localhost:8080")
self.config["timeout"] = int(os.getenv("TIMEOUT", "30"))
self.config["debug"] = os.getenv("DEBUG", "false").lower() == "true"
def load_from_file(self, filepath: str):
"""Load configuration from YAML or JSON file."""
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {filepath}")
with open(path, "r") as f:
if path.suffix == ".yaml" or path.suffix == ".yml":
file_config = yaml.safe_load(f)
elif path.suffix == ".json":
file_config = json.load(f)
else:
raise ValueError(f"Unsupported config format: {path.suffix}")
# Merge with existing config (file takes precedence)
self.config.update(file_config)
def get(self, key: str, default: Any = None) -> Any:
"""Get configuration value."""
return self.config.get(key, default)
# Usage
config = Config("config.yaml")
api_url = config.get("api_url")
graph LR
A[Script Start] --> B[Load Environment Variables]
B --> C[Load Config File]
C --> D[Merge Configuration]
D --> E[Use Config in Script]
Running System Commands
import subprocess
from typing import Tuple
def run_command(command: list, capture_output: bool = True) -> Tuple[int, str, str]:
"""
Run a system command and return exit code, stdout, stderr.
Args:
command: Command as list of strings
capture_output: Whether to capture stdout/stderr
Returns:
Tuple of (exit_code, stdout, stderr)
"""
try:
result = subprocess.run(
command,
capture_output=capture_output,
text=True,
check=False
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return 1, "", str(e)
# Usage
exit_code, stdout, stderr = run_command(["docker", "ps", "-a"])
if exit_code == 0:
print("Command succeeded:")
print(stdout)
else:
print(f"Command failed: {stderr}")
# Run command with shell
def run_shell_command(command: str) -> Tuple[int, str, str]:
"""Run command in shell (use with caution)."""
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True
)
return result.returncode, result.stdout, result.stderr
# Execute multiple commands in pipeline
def run_pipeline(commands: list) -> str:
"""Execute commands in a pipeline."""
processes = []
for i, cmd in enumerate(commands):
stdin = processes[i-1].stdout if i > 0 else None
proc = subprocess.Popen(
cmd,
stdin=stdin,
stdout=subprocess.PIPE,
text=True
)
processes.append(proc)
output, _ = processes[-1].communicate()
return output
# Usage: docker ps | grep nginx
output = run_pipeline([["docker", "ps"], ["grep", "nginx"]])
Logging and Error Handling
import logging
import sys
from datetime import datetime
def setup_logging(log_file: str = None, level: str = "INFO"):
"""Configure logging for the script."""
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
handlers = [logging.StreamHandler(sys.stdout)]
if log_file:
handlers.append(logging.FileHandler(log_file))
logging.basicConfig(
level=getattr(logging, level.upper()),
format=log_format,
handlers=handlers
)
# Usage
setup_logging(log_file="deployment.log", level="INFO")
logger = logging.getLogger(__name__)
logger.info("Starting deployment")
logger.warning("Configuration file not found, using defaults")
logger.error("Failed to connect to server")
# Context manager for operation logging
from contextlib import contextmanager
import time
@contextmanager
def log_operation(operation_name: str):
"""Context manager to log operation start, end, and duration."""
logger.info(f"Starting: {operation_name}")
start_time = time.time()
try:
yield
duration = time.time() - start_time
logger.info(f"Completed: {operation_name} (took {duration:.2f}s)")
except Exception as e:
duration = time.time() - start_time
logger.error(f"Failed: {operation_name} after {duration:.2f}s - {e}")
raise
# Usage
with log_operation("Database backup"):
backup_database()
File and Directory Operations
import shutil
from pathlib import Path
from typing import List
def ensure_directory(path: str):
"""Create directory if it doesn't exist."""
Path(path).mkdir(parents=True, exist_ok=True)
def clean_directory(path: str, pattern: str = "*"):
"""Remove all files matching pattern in directory."""
directory = Path(path)
for item in directory.glob(pattern):
if item.is_file():
item.unlink()
elif item.is_dir():
shutil.rmtree(item)
def copy_files(source: str, dest: str, pattern: str = "*"):
"""Copy files matching pattern from source to dest."""
source_path = Path(source)
dest_path = Path(dest)
ensure_directory(dest)
for file_path in source_path.glob(pattern):
if file_path.is_file():
shutil.copy2(file_path, dest_path / file_path.name)
def find_files(directory: str, pattern: str, recursive: bool = True) -> List[Path]:
"""Find all files matching pattern."""
path = Path(directory)
glob_pattern = f"**/{pattern}" if recursive else pattern
return list(path.glob(glob_pattern))
# Archive operations
def create_tar_archive(source_dir: str, output_file: str):
"""Create tar.gz archive."""
import tarfile
with tarfile.open(output_file, "w:gz") as tar:
tar.add(source_dir, arcname=Path(source_dir).name)
def extract_tar_archive(archive_file: str, dest_dir: str):
"""Extract tar.gz archive."""
import tarfile
with tarfile.open(archive_file, "r:gz") as tar:
tar.extractall(dest_dir)
# Usage
ensure_directory("backups")
create_tar_archive("/var/www/app", "backups/app-backup.tar.gz")
Working with Remote Servers
import paramiko
from typing import Optional
class SSHClient:
"""Simplified SSH client for remote operations."""
def __init__(self, hostname: str, username: str,
password: Optional[str] = None,
key_filename: Optional[str] = None):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if key_filename:
self.client.connect(
hostname,
username=username,
key_filename=key_filename
)
else:
self.client.connect(
hostname,
username=username,
password=password
)
def execute(self, command: str) -> Tuple[int, str, str]:
"""Execute command on remote server."""
stdin, stdout, stderr = self.client.exec_command(command)
exit_code = stdout.channel.recv_exit_status()
return exit_code, stdout.read().decode(), stderr.read().decode()
def upload_file(self, local_path: str, remote_path: str):
"""Upload file to remote server."""
sftp = self.client.open_sftp()
sftp.put(local_path, remote_path)
sftp.close()
def download_file(self, remote_path: str, local_path: str):
"""Download file from remote server."""
sftp = self.client.open_sftp()
sftp.get(remote_path, local_path)
sftp.close()
def close(self):
"""Close SSH connection."""
self.client.close()
# Usage
with SSHClient("server.example.com", "deploy", key_filename="~/.ssh/id_rsa") as ssh:
exit_code, stdout, stderr = ssh.execute("docker ps")
print(stdout)
API Integration
import requests
from typing import Dict, Any, Optional
import time
class APIClient:
"""Reusable API client with retry logic."""
def __init__(self, base_url: str, api_key: Optional[str] = None,
timeout: int = 30, max_retries: int = 3):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.max_retries = max_retries
self.session = requests.Session()
if api_key:
self.session.headers.update({"Authorization": f"Bearer {api_key}"})
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
"""Make HTTP request with retry logic."""
url = f"{self.base_url}/{endpoint.lstrip('/')}"
for attempt in range(self.max_retries):
try:
response = self.session.request(
method,
url,
timeout=self.timeout,
**kwargs
)
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
if attempt == self.max_retries - 1:
raise
wait_time = 2 ** attempt
logger.warning(f"Request failed, retrying in {wait_time}s: {e}")
time.sleep(wait_time)
def get(self, endpoint: str, **kwargs) -> Dict[Any, Any]:
"""GET request."""
response = self._request("GET", endpoint, **kwargs)
return response.json()
def post(self, endpoint: str, data: Dict = None, **kwargs) -> Dict[Any, Any]:
"""POST request."""
response = self._request("POST", endpoint, json=data, **kwargs)
return response.json()
def put(self, endpoint: str, data: Dict = None, **kwargs) -> Dict[Any, Any]:
"""PUT request."""
response = self._request("PUT", endpoint, json=data, **kwargs)
return response.json()
def delete(self, endpoint: str, **kwargs) -> Dict[Any, Any]:
"""DELETE request."""
response = self._request("DELETE", endpoint, **kwargs)
return response.json() if response.text else {}
# Usage
api = APIClient("https://api.example.com", api_key="secret-key")
users = api.get("/users")
user = api.post("/users", data={"name": "Alice", "email": "alice@example.com"})
sequenceDiagram
participant Script
participant APIClient
participant API
Script->>APIClient: get("/users")
APIClient->>API: HTTP GET
alt Success
API->>APIClient: 200 OK
APIClient->>Script: Return data
else Failure
API->>APIClient: Error
APIClient->>APIClient: Retry (exponential backoff)
APIClient->>API: HTTP GET (retry)
end
Docker Operations
import docker
from typing import List, Dict
class DockerManager:
"""Manage Docker containers and images."""
def __init__(self):
self.client = docker.from_env()
def list_containers(self, all: bool = False) -> List[Dict]:
"""List running containers."""
containers = self.client.containers.list(all=all)
return [
{
"id": c.short_id,
"name": c.name,
"status": c.status,
"image": c.image.tags[0] if c.image.tags else "none"
}
for c in containers
]
def run_container(self, image: str, name: str = None,
ports: Dict = None, environment: Dict = None) -> str:
"""Run a new container."""
container = self.client.containers.run(
image,
name=name,
ports=ports,
environment=environment,
detach=True
)
return container.id
def stop_container(self, container_id: str):
"""Stop a running container."""
container = self.client.containers.get(container_id)
container.stop()
def remove_container(self, container_id: str, force: bool = False):
"""Remove a container."""
container = self.client.containers.get(container_id)
container.remove(force=force)
def build_image(self, path: str, tag: str) -> bool:
"""Build Docker image from Dockerfile."""
try:
self.client.images.build(path=path, tag=tag)
return True
except docker.errors.BuildError as e:
logger.error(f"Build failed: {e}")
return False
def pull_image(self, image: str, tag: str = "latest"):
"""Pull image from registry."""
self.client.images.pull(image, tag=tag)
def cleanup_dangling_images(self):
"""Remove dangling images."""
self.client.images.prune(filters={"dangling": True})
# Usage
docker_mgr = DockerManager()
containers = docker_mgr.list_containers(all=True)
for container in containers:
print(f"{container['name']}: {container['status']}")
Practical Example: Deployment Automation Script
#!/usr/bin/env python3
"""
Automated application deployment script.
Handles building, testing, and deploying applications to various environments.
"""
import sys
import argparse
import logging
from pathlib import Path
from typing import Dict
import subprocess
import time
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
class DeploymentManager:
"""Manage application deployment process."""
def __init__(self, environment: str, version: str, dry_run: bool = False):
self.environment = environment
self.version = version
self.dry_run = dry_run
self.config = self.load_config()
def load_config(self) -> Dict:
"""Load environment-specific configuration."""
import yaml
config_file = f"config/{self.environment}.yaml"
with open(config_file, "r") as f:
return yaml.safe_load(f)
def run_command(self, command: list) -> bool:
"""Execute command and return success status."""
logger.info(f"Running: {' '.join(command)}")
if self.dry_run:
logger.info("DRY RUN - Command not executed")
return True
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
logger.error(f"Command failed: {result.stderr}")
return False
logger.info(result.stdout)
return True
def build(self) -> bool:
"""Build application."""
logger.info(f"Building version {self.version}")
commands = [
["docker", "build", "-t", f"myapp:{self.version}", "."],
["docker", "tag", f"myapp:{self.version}",
f"registry.example.com/myapp:{self.version}"]
]
for cmd in commands:
if not self.run_command(cmd):
return False
return True
def test(self) -> bool:
"""Run tests."""
logger.info("Running tests")
command = [
"docker", "run", "--rm", f"myapp:{self.version}",
"pytest", "tests/"
]
return self.run_command(command)
def push(self) -> bool:
"""Push image to registry."""
logger.info("Pushing image to registry")
command = [
"docker", "push",
f"registry.example.com/myapp:{self.version}"
]
return self.run_command(command)
def deploy(self) -> bool:
"""Deploy to environment."""
logger.info(f"Deploying to {self.environment}")
# Update Kubernetes deployment
commands = [
["kubectl", "set", "image", "deployment/myapp",
f"myapp=registry.example.com/myapp:{self.version}",
f"--namespace={self.environment}"],
["kubectl", "rollout", "status", "deployment/myapp",
f"--namespace={self.environment}"]
]
for cmd in commands:
if not self.run_command(cmd):
return False
return True
def rollback(self) -> bool:
"""Rollback deployment."""
logger.warning("Rolling back deployment")
command = [
"kubectl", "rollout", "undo", "deployment/myapp",
f"--namespace={self.environment}"
]
return self.run_command(command)
def execute(self) -> int:
"""Execute full deployment process."""
try:
logger.info(f"Starting deployment to {self.environment}")
logger.info(f"Version: {self.version}")
logger.info(f"Dry run: {self.dry_run}")
if not self.build():
logger.error("Build failed")
return 1
if not self.test():
logger.error("Tests failed")
return 1
if not self.push():
logger.error("Push failed")
return 1
if not self.deploy():
logger.error("Deployment failed, initiating rollback")
self.rollback()
return 1
logger.info("Deployment completed successfully")
return 0
except Exception as e:
logger.error(f"Deployment error: {e}")
return 1
def main():
parser = argparse.ArgumentParser(description="Deploy application")
parser.add_argument(
"--environment",
required=True,
choices=["dev", "staging", "prod"],
help="Target environment"
)
parser.add_argument(
"--version",
required=True,
help="Version to deploy"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate deployment without making changes"
)
args = parser.parse_args()
manager = DeploymentManager(
environment=args.environment,
version=args.version,
dry_run=args.dry_run
)
return manager.execute()
if __name__ == "__main__":
sys.exit(main())
Key Takeaways
- Structure scripts with proper argument parsing, logging, and error handling
- Use configuration files and environment variables for flexibility across environments
- Wrap subprocess calls in reusable functions with proper error handling
- Implement retry logic for network operations to handle transient failures
- Use context managers for resource cleanup and operation logging
- Create reusable classes for common operations (API clients, Docker management)
- Make scripts executable with proper shebang and set execute permissions
- Log operations comprehensively for debugging and audit trails
- Support dry-run mode for safe testing of scripts
- Use type hints to document function parameters and return values
- Organize scripts in modules when complexity grows
- Handle exceptions gracefully and provide meaningful error messages