GitOps Patterns with Python — Deep Dive
Image tag updater
The most common Python component in a GitOps pipeline is the image updater. After CI builds and pushes a new container image, a Python script updates the image tag in the config repository, creating the Git commit that triggers deployment:
import subprocess
import tempfile
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class GitOpsImageUpdater:
def __init__(
self,
config_repo: str,
branch: str = "main",
git_user: str = "gitops-bot",
git_email: str = "gitops@example.com",
):
self.config_repo = config_repo
self.branch = branch
self.git_user = git_user
self.git_email = git_email
def update_image_tag(
self,
app_name: str,
new_tag: str,
file_path: str = "apps/{app}/deployment.yaml",
image_pattern: str = "image: {registry}/{app}:",
) -> dict:
"""Clone config repo, update image tag, commit and push."""
with tempfile.TemporaryDirectory() as tmpdir:
# Clone
self._run(
f"git clone --depth 1 -b {self.branch} {self.config_repo} repo",
cwd=tmpdir,
)
repo_dir = Path(tmpdir) / "repo"
# Configure git
self._run(f"git config user.name '{self.git_user}'", cwd=repo_dir)
self._run(f"git config user.email '{self.git_email}'", cwd=repo_dir)
# Find and update the manifest
manifest = repo_dir / file_path.format(app=app_name)
if not manifest.exists():
return {"updated": False, "reason": f"{manifest} not found"}
content = manifest.read_text()
# Replace image tag using line-by-line matching
lines = content.split("\n")
updated = False
registry_prefix = image_pattern.format(
registry="", app=app_name
).rstrip(":")
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("image:") and app_name in stripped:
# Extract everything before the tag
prefix = stripped.rsplit(":", 1)[0]
indent = line[: len(line) - len(line.lstrip())]
lines[i] = f"{indent}{prefix}:{new_tag}"
updated = True
break
if not updated:
return {"updated": False, "reason": "image line not found"}
manifest.write_text("\n".join(lines))
# Commit and push
self._run(f"git add {manifest.name}", cwd=manifest.parent)
commit_msg = f"chore({app_name}): update image tag to {new_tag}"
self._run(f"git commit -m '{commit_msg}'", cwd=repo_dir)
self._run(f"git push origin {self.branch}", cwd=repo_dir)
return {
"updated": True,
"app": app_name,
"tag": new_tag,
"commit_message": commit_msg,
}
def _run(self, cmd: str, cwd=None) -> subprocess.CompletedProcess:
return subprocess.run(
cmd, shell=True, cwd=cwd, check=True,
capture_output=True, text=True,
)
Kubernetes operator with Kopf
For custom resources that ArgoCD and Flux can’t natively reconcile, Python operators fill the gap. Kopf (Kubernetes Operator Pythonic Framework) makes this accessible:
pip install kopf kubernetes
# operator.py — GitOps-aware custom resource operator
import kopf
import kubernetes
import logging
import subprocess
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
@kopf.on.create("example.com", "v1", "gitopsapps")
@kopf.on.update("example.com", "v1", "gitopsapps")
async def reconcile_gitops_app(spec, name, namespace, **kwargs):
"""Reconcile a GitOpsApp custom resource.
The CRD spec contains:
- repoURL: git repository URL
- path: path within repo to manifests
- targetRevision: branch or tag
- syncPolicy: auto or manual
"""
repo_url = spec.get("repoURL")
path = spec.get("path", ".")
revision = spec.get("targetRevision", "main")
logger.info(f"Reconciling {name}: {repo_url}@{revision}/{path}")
with tempfile.TemporaryDirectory() as tmpdir:
# Clone the config repo
subprocess.run(
["git", "clone", "--depth", "1", "-b", revision, repo_url, "repo"],
cwd=tmpdir,
check=True,
capture_output=True,
)
manifest_dir = Path(tmpdir) / "repo" / path
if not manifest_dir.exists():
raise kopf.PermanentError(f"Path {path} not found in repo")
# Apply all YAML files in the path
manifests = list(manifest_dir.glob("*.yaml")) + list(
manifest_dir.glob("*.yml")
)
applied = []
for manifest in sorted(manifests):
result = subprocess.run(
["kubectl", "apply", "-f", str(manifest), "-n", namespace],
capture_output=True,
text=True,
)
if result.returncode == 0:
applied.append(manifest.name)
else:
logger.error(f"Failed to apply {manifest.name}: {result.stderr}")
return {"applied": applied, "revision": revision}
@kopf.timer("example.com", "v1", "gitopsapps", interval=300)
async def drift_check(spec, name, namespace, **kwargs):
"""Periodically check for drift between Git and actual state."""
logger.info(f"Drift check for {name}")
# Re-run reconciliation — Kubernetes apply is idempotent
await reconcile_gitops_app(spec, name, namespace, **kwargs)
Config generation with CDK8s
CDK8s lets you define Kubernetes resources in Python with full type safety and abstraction:
pip install cdk8s cdk8s-plus-27
# main.py — Generate Kubernetes manifests from Python
from cdk8s import App, Chart
from cdk8s_plus_27 import (
Deployment,
Service,
ServiceType,
ContainerProps,
Probe,
ResourceRequirements,
CpuAmount,
MemoryAmount,
HorizontalPodAutoscaler,
)
from constructs import Construct
class WebService(Chart):
def __init__(
self,
scope: Construct,
id: str,
*,
image: str,
replicas: int = 3,
port: int = 8000,
cpu_request: str = "100m",
memory_request: str = "128Mi",
):
super().__init__(scope, id)
deployment = Deployment(
self,
"deployment",
replicas=replicas,
containers=[
ContainerProps(
name="app",
image=image,
port_number=port,
readiness=Probe.from_http_get(
path="/health",
port=port,
),
liveness=Probe.from_http_get(
path="/health",
port=port,
),
resources=ResourceRequirements(
cpu=CpuAmount.milli(int(cpu_request.rstrip("m"))),
memory=MemoryAmount.mebibytes(
int(memory_request.rstrip("Mi"))
),
),
)
],
)
service = Service(
self,
"service",
selector=deployment,
service_type=ServiceType.CLUSTER_IP,
ports=[{"port": 80, "target_port": port}],
)
HorizontalPodAutoscaler(
self,
"hpa",
target=deployment,
min_replicas=replicas,
max_replicas=replicas * 3,
metrics=[
{"type": "cpu", "target": {"type": "utilization", "average_utilization": 70}},
],
)
# Generate manifests for different environments
app = App()
WebService(
app,
"payment-service-prod",
image="registry.example.com/payment:v1.2.3",
replicas=3,
cpu_request="500m",
memory_request="512Mi",
)
WebService(
app,
"payment-service-staging",
image="registry.example.com/payment:v1.3.0-rc1",
replicas=1,
cpu_request="100m",
memory_request="128Mi",
)
app.synth() # Generates YAML files in dist/
Commit the generated dist/ directory to the config repo. ArgoCD or Flux picks up the changes.
Drift detection and alerting
A standalone drift detector that compares Git state with cluster state:
from kubernetes import client, config
import subprocess
import tempfile
import json
import logging
from pathlib import Path
from deepdiff import DeepDiff
logger = logging.getLogger(__name__)
class DriftDetector:
def __init__(self, config_repo: str, branch: str = "main"):
self.config_repo = config_repo
self.branch = branch
config.load_incluster_config()
self.apps = client.AppsV1Api()
def detect_drift(self, namespace: str, app_path: str) -> list[dict]:
"""Compare desired state in Git with actual state in cluster."""
drifts = []
with tempfile.TemporaryDirectory() as tmpdir:
subprocess.run(
["git", "clone", "--depth", "1", "-b", self.branch,
self.config_repo, "repo"],
cwd=tmpdir, check=True, capture_output=True,
)
manifest_dir = Path(tmpdir) / "repo" / app_path
for manifest_file in manifest_dir.glob("*.yaml"):
import yaml
with open(manifest_file) as f:
desired = yaml.safe_load(f)
if not desired or desired.get("kind") != "Deployment":
continue
name = desired["metadata"]["name"]
try:
actual = self.apps.read_namespaced_deployment(
name, namespace
)
actual_dict = actual.to_dict()
# Compare key fields
desired_image = (
desired["spec"]["template"]["spec"]["containers"][0]["image"]
)
actual_image = actual_dict["spec"]["template"]["spec"][
"containers"
][0]["image"]
desired_replicas = desired["spec"].get("replicas", 1)
actual_replicas = actual_dict["spec"].get("replicas", 1)
if desired_image != actual_image:
drifts.append({
"resource": name,
"field": "image",
"desired": desired_image,
"actual": actual_image,
})
if desired_replicas != actual_replicas:
drifts.append({
"resource": name,
"field": "replicas",
"desired": desired_replicas,
"actual": actual_replicas,
})
except client.ApiException as e:
if e.status == 404:
drifts.append({
"resource": name,
"field": "existence",
"desired": "present",
"actual": "missing",
})
return drifts
GitOps workflow comparison
| Pattern | Tool | Python role | Best for |
|---|---|---|---|
| Image updater | Custom script | Core — updates Git commits | Any GitOps setup |
| Config generation | CDK8s, Kapitan | Core — generates manifests | Complex multi-env configs |
| Custom operators | Kopf | Core — reconciles CRDs | Non-K8s resources |
| Drift detection | Custom script | Core — compares state | Compliance, auditing |
| PR-based promotion | GitHub Actions + Python | Glue — creates PRs for env promotion | Multi-stage environments |
Security considerations
GitOps shifts the security boundary. Since Git is the source of truth, protecting the config repository becomes critical:
- Branch protection — require PR reviews for config changes
- Signed commits — verify that commits come from authorized users
- Sealed Secrets — encrypt secrets before storing in Git (using Bitnami Sealed Secrets or SOPS)
- RBAC — the GitOps agent should have minimal cluster permissions
- Audit logging — Git’s built-in history provides an immutable audit trail
Python scripts that update the config repo should use deploy keys with write access to only that repository, not broad personal access tokens.
The one thing to remember: Python is the glue language of GitOps — it generates configs, updates image tags, builds custom operators, and detects drift. The pattern works because Git provides an immutable audit trail and Kubernetes provides a declarative API that makes reconciliation natural.
See Also
- Python Blue Green Deployments How Python helps teams switch between two identical server environments so updates never cause downtime
- Python Canary Releases Why teams send new code to just a few users first — and how Python manages the gradual rollout
- Python Chaos Engineering Why engineers deliberately break their own systems using Python — and how it prevents real disasters
- Python Compliance As Code How Python turns security rules and regulations into automated checks that run every time code changes
- Python Feature Branch Deployments How teams give every code branch its own live preview website using Python automation