Module 10: Python for DevOps & Cloud

M10: DevOps/Cloud Specialty — Python for Corporate Professionals | OTLMS
M10 · DevOps/Cloud Specialty DevOps/Cloud Track Python for Corporate Professionals

Talking to APIs, Containers, and the Cloud

Modern infrastructure is managed through APIs, not manual clicks — cloud providers, monitoring tools, container platforms, and CI/CD systems all expose REST APIs that Python can call directly. This module covers the requests library for HTTP calls, the conventions behind cloud SDKs like AWS’s boto3, basic container interaction, and the core idea behind Infrastructure as Code — building directly on the automation skills from M8.

5 topics ~90 min read Specialty level DevOps/Cloud Engineers
📖
Concept — APIs, Cloud SDKs, Containers, and IaC
How Python scripts control infrastructure through code instead of dashboards

The defining shift in modern DevOps work is treating infrastructure as something you script and version-control, not something you click through in a web console. Every topic in this module supports that shift — calling APIs, automating cloud resources, and codifying infrastructure definitions.

Topic 1

HTTP and REST APIs — The requests Library

A REST API exposes operations over HTTP using a small set of verbs: GET to retrieve data, POST to create something, PUT/PATCH to update, DELETE to remove. Python’s requests library (the de facto standard, though not built-in — pip install requests) makes calling these as simple as a function call.

Request (you send)
GET /api/v1/servers/web-01
Host: monitoring.internal
Authorization: Bearer abc123
Response (you receive)
200 OK
{“name”: “web-01”,
 “cpu”: 45, “status”: “up”}

Every response carries a status code telling you whether the request succeeded and, if not, roughly why. Checking this code is not optional — assuming every API call succeeds is how scripts silently process garbage data or crash three steps downstream from the actual failure.

200 OK
Request succeeded, response body has the data
201 Created
A new resource was successfully created (common after POST)
401 Unauthorized
Missing or invalid authentication credentials
404 Not Found
The requested resource doesn’t exist
429 Too Many Requests
Rate limit exceeded — back off and retry later
500 Server Error
Something failed on the server’s side — often safe to retry
Topic 2

Authentication — API Keys and Bearer Tokens

Almost every real API requires authentication. The two most common patterns: an API key sent as a header or query parameter, or a Bearer token sent in the Authorization header. Either way, credentials must never be hard-coded directly in your script — exactly the same principle from M5’s config files and M8’s automation scripts: read secrets from environment variables or a secrets manager, never from a literal string in source code.

⚠️ A hard-coded API key in a script that later gets committed to version control (even a private repo) is a leaked credential the moment it’s pushed — git history keeps it forever unless deliberately purged. Always use os.environ.get(“API_KEY”) and keep actual keys out of any file that gets committed.
Topic 3

Retries and Backoff — Handling Unreliable Networks

Networks fail. APIs time out, rate-limit you, or have brief outages. A production script calling external APIs needs a retry strategy — but retrying immediately and repeatedly can make things worse (hammering an already-struggling service). The standard pattern is exponential backoff: wait a little longer after each failed attempt.

attempt 1
fails
wait 1s
attempt 2
fails
wait 2s
attempt 3
✓ succeeds

This directly extends the retry pattern from M6’s error handling — the difference here is the wait time grows (often doubling) with each attempt, giving a struggling service room to recover rather than being retried at a constant, possibly overwhelming rate.

Topic 4

Cloud SDK Conventions — The boto3 Pattern

Cloud providers offer Python SDKs that wrap their APIs into convenient method calls — AWS’s boto3, Azure’s azure-sdk, Google Cloud’s google-cloud libraries. They all follow a similar shape: create a client for a specific service, then call methods on it that map to API operations, returning Python dictionaries.

The pattern is consistent enough that learning one cloud SDK makes the others feel familiar: client = boto3.client(“ec2”), then client.describe_instances() — a method call that, behind the scenes, makes an authenticated HTTP request to AWS and parses the response into a Python dict for you. You rarely touch requests directly when a dedicated SDK exists for the service you’re working with.

Topic 5

Infrastructure as Code — The Underlying Idea

Infrastructure as Code (IaC) means defining servers, networks, and cloud resources in version-controlled files instead of manual console clicks — the same discipline you already apply to application code, applied to infrastructure itself. Tools like Terraform and AWS CloudFormation use declarative configuration files; Python often plays a supporting role, calling cloud SDKs directly for tasks IaC tools don’t cover, or generating/validating IaC configuration programmatically.

Manual (“ClickOps”)
Log into a console, click through menus to create a server. Not repeatable, not reviewable, easy to forget a step or do it differently next time.
Infrastructure as Code
Define the server in a file, commit it to version control, apply it with a tool. Repeatable, reviewable via pull request, identical every time it runs.
Where Python fits: Python is rarely the IaC tool itself, but it’s the glue around it — validating config files before they’re applied, calling cloud SDKs for one-off tasks IaC doesn’t model well, building custom automation that triggers IaC tools, and processing the output/state those tools produce.
✏️
Syntax Reference
requests, retries, and cloud SDK patterns you’ll use constantly

requests — GET, POST, headers, and status checking

Python
import requests
import os

# Basic GET request
response = requests.get("https://api.github.com/users/python", timeout=10)
response.status_code      # 200
response.json()            # parses JSON response body into a Python dict
response.text              # raw response body as a string
response.headers           # response headers as a dict-like object

# Always check for success before trusting the response
if response.status_code == 200:
    data = response.json()
else:
    print(f"Request failed: {response.status_code}")

# raise_for_status() — raises an exception automatically on 4xx/5xx
try:
    response = requests.get("https://api.example.com/data", timeout=10)
    response.raise_for_status()
    data = response.json()
except requests.exceptions.HTTPError as e:
    print(f"HTTP error: {e}")
except requests.exceptions.Timeout:
    print("Request timed out")
except requests.exceptions.ConnectionError:
    print("Could not connect — network or DNS issue")

# Authenticated request with a Bearer token (read from environment, never hard-coded)
api_token = os.environ.get("API_TOKEN")
headers = {"Authorization": f"Bearer {api_token}"}
response = requests.get("https://api.example.com/servers", headers=headers, timeout=10)

# POST request with a JSON body
payload = {"name": "new-server", "environment": "staging"}
response = requests.post("https://api.example.com/servers", json=payload, headers=headers, timeout=10)
# json=payload automatically serialises the dict and sets Content-Type: application/json

# Query parameters (the part after ? in a URL)
params = {"environment": "production", "limit": 10}
response = requests.get("https://api.example.com/servers", params=params, timeout=10)
# sends: https://api.example.com/servers?environment=production&limit=10

Retry with exponential backoff

Python
import requests
import time

def get_with_retry(url, max_attempts=4, base_delay=1):
    """
    GET a URL with exponential backoff on failure.
    Wait times: 1s, 2s, 4s, 8s... doubling each attempt.
    """
    for attempt in range(1, max_attempts + 1):
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            return response.json()
        except (requests.exceptions.RequestException) as e:
            if attempt == max_attempts:
                raise                          # out of retries — let the caller handle it
            wait_time = base_delay * (2 ** (attempt - 1))   # 1, 2, 4, 8...
            print(f"Attempt {attempt} failed ({e}), retrying in {wait_time}s...")
            time.sleep(wait_time)

# Usage
data = get_with_retry("https://api.example.com/health")

Cloud SDK pattern (boto3 example — AWS)

Python — requires: pip install boto3
import boto3

# Create a client for a specific AWS service
ec2 = boto3.client("ec2", region_name="ap-south-1")

# Method calls map to API operations, returning Python dicts
response = ec2.describe_instances()

for reservation in response["Reservations"]:
    for instance in reservation["Instances"]:
        print(f"{instance['InstanceId']}: {instance['State']['Name']}")

# Starting/stopping resources — same client-and-method pattern everywhere
ec2.start_instances(InstanceIds=["i-0abc123def456"])
ec2.stop_instances(InstanceIds=["i-0abc123def456"])

# S3 (object storage) — same client pattern, different service
s3 = boto3.client("s3")
s3.upload_file("backup.zip", "my-backup-bucket", "backups/backup.zip")
objects = s3.list_objects_v2(Bucket="my-backup-bucket")
for obj in objects.get("Contents", []):
    print(f"{obj['Key']}: {obj['Size']} bytes")

# Credentials are read automatically from environment variables,
# ~/.aws/credentials, or an IAM role — never hard-code them in the script

Basic container interaction (Docker SDK)

Python — requires: pip install docker
import docker

client = docker.from_env()       # connects to the local Docker daemon

# List running containers
for container in client.containers.list():
    print(f"{container.name}: {container.status}")

# Start a new container
container = client.containers.run(
    "nginx:latest", detach=True, name="web-test", ports={"80/tcp": 8080}
)

# Inspect, get logs, stop, remove
container.reload()                # refresh container's status info
logs = container.logs(tail=50).decode("utf-8")
container.stop()
container.remove()
💡
Examples — Real Infrastructure Automation
Five programs covering API calls, retries, cloud resources, and IaC validation

These examples favour patterns over exact provider details — the same shape applies whether you’re calling AWS, Azure, GCP, or an internal company API.

Example 1 — Status-aware API health checker
DevOpsAll Roles

Checks the health of several internal API endpoints, correctly distinguishing between “service is down” (connection error), “service is slow” (timeout), and “service responded but reports unhealthy” (a 200 with an unhealthy status in the body) — three meaningfully different failure modes that a naive script would lump together.

Python
import requests
from datetime import datetime

def check_endpoint(name, url, timeout=5):
    """
    Check one health endpoint.
    Returns a dict describing the outcome — never raises.
    """
    try:
        response = requests.get(url, timeout=timeout)
    except requests.exceptions.Timeout:
        return {"name": name, "status": "TIMEOUT", "detail": f"No response within {timeout}s"}
    except requests.exceptions.ConnectionError:
        return {"name": name, "status": "DOWN", "detail": "Connection refused or DNS failure"}

    if response.status_code != 200:
        return {"name": name, "status": "ERROR", "detail": f"HTTP {response.status_code}"}

    try:
        body = response.json()
    except ValueError:
        return {"name": name, "status": "ERROR", "detail": "Response was not valid JSON"}

    if body.get("status") != "healthy":
        return {"name": name, "status": "UNHEALTHY", "detail": body.get("status", "unknown")}

    return {"name": name, "status": "OK", "detail": "healthy"}

# ── Run against several endpoints ──
endpoints = [
    ("auth-service",    "https://auth.internal/health"),
    ("payments-service", "https://payments.internal/health"),
    ("search-service",   "https://search.internal/health"),
]

print(f"Health check — {datetime.now().strftime('%H:%M:%S')}\n")
for name, url in endpoints:
    result = check_endpoint(name, url)
    icon = "✓" if result["status"] == "OK" else "✗"
    print(f"  {icon} {result['name']:20} {result['status']:10} {result['detail']}")
Output (simulated network conditions)
Health check — 09:41:05

  ✓ auth-service         OK          healthy
  ✗ payments-service     TIMEOUT     No response within 5s
  ✗ search-service       UNHEALTHY  degraded
Example 2 — Paginated API fetcher
DevOps

Many APIs return data in pages rather than all at once. This function follows pagination automatically until all results are collected — a pattern needed constantly when pulling complete data sets from any real-world API.

Python
import requests

def fetch_all_pages(base_url, headers=None, page_size=50):
    """
    Fetch every page of results from a paginated API.

    Assumes the API responds with: {"results": [...], "next_page": int or None}
    """
    all_results = []
    page = 1

    while True:
        response = requests.get(
            base_url,
            params={"page": page, "page_size": page_size},
            headers=headers,
            timeout=10
        )
        response.raise_for_status()
        body = response.json()

        all_results.extend(body["results"])

        if body.get("next_page") is None:
            break                      # no more pages — stop
        page = body["next_page"]

    return all_results

# Simulated usage (against a real paginated endpoint):
# servers = fetch_all_pages("https://api.example.com/servers")
# print(f"Fetched {len(servers)} servers across all pages")

# ── Demonstration with a fake in-memory "API" to show the logic ──
def simulate_paginated_response(page, page_size):
    all_servers = [f"server-{i:03d}" for i in range(1, 127)]   # 126 total servers
    start = (page - 1) * page_size
    chunk = all_servers[start:start + page_size]
    next_page = page + 1 if start + page_size < len(all_servers) else None
    return {"results": chunk, "next_page": next_page}

collected, page = [], 1
while True:
    body = simulate_paginated_response(page, page_size=50)
    collected.extend(body["results"])
    print(f"Page {page}: got {len(body['results'])} items (total so far: {len(collected)})")
    if body["next_page"] is None:
        break
    page = body["next_page"]

print(f"\nTotal servers fetched: {len(collected)}")
Output
Page 1: got 50 items (total so far: 50)
Page 2: got 50 items (total so far: 100)
Page 3: got 26 items (total so far: 126)

Total servers fetched: 126
Example 3 — Cloud resource auditor (boto3 pattern)
DevOps

Scans cloud compute instances for cost-control issues — specifically, instances running outside business hours that should probably be stopped. Written against the boto3 client pattern; the logic transfers directly to Azure or GCP SDKs with different method names.

Python — requires boto3 + AWS credentials configured
import boto3
from datetime import datetime

def find_untagged_instances(region="ap-south-1"):
    """
    Return running EC2 instances that are missing a required 'Owner' tag —
    a common cost-governance check in real cloud environments.
    """
    ec2 = boto3.client("ec2", region_name=region)
    response = ec2.describe_instances(
        Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
    )

    untagged = []
    for reservation in response["Reservations"]:
        for instance in reservation["Instances"]:
            tags = {t["Key"]: t["Value"] for t in instance.get("Tags", [])}
            if "Owner" not in tags:
                untagged.append({
                    "id":         instance["InstanceId"],
                    "type":       instance["InstanceType"],
                    "launch_time": instance["LaunchTime"],
                })
    return untagged

# ── In a real AWS account ──
# untagged = find_untagged_instances()
# for inst in untagged:
#     print(f"⚠ {inst['id']} ({inst['type']}) — missing Owner tag, launched {inst['launch_time']}")

# ── Demonstration with simulated response data (same shape as the real API) ──
simulated_response = {
    "Reservations": [
        {"Instances": [
            {"InstanceId": "i-0a1b2c3d", "InstanceType": "t3.large",
             "LaunchTime": datetime(2026, 5, 2), "Tags": [{"Key": "Owner", "Value": "team-platform"}]},
            {"InstanceId": "i-0e5f6g7h", "InstanceType": "m5.xlarge",
             "LaunchTime": datetime(2026, 3, 14), "Tags": []},
        ]}
    ]
}

untagged = []
for reservation in simulated_response["Reservations"]:
    for instance in reservation["Instances"]:
        tags = {t["Key"]: t["Value"] for t in instance.get("Tags", [])}
        if "Owner" not in tags:
            untagged.append(instance)

print(f"Found {len(untagged)} untagged running instance(s):")
for inst in untagged:
    print(f"  ⚠ {inst['InstanceId']} ({inst['InstanceType']}) — launched {inst['LaunchTime'].date()}")
Output
Found 1 untagged running instance(s):
  ⚠ i-0e5f6g7h (m5.xlarge) — launched 2026-03-14
Example 4 — Resilient API client with retry and timeout
DevOpsAutomation

A complete, reusable API client function with timeout protection, exponential backoff, and proper distinction between retryable errors (timeouts, 5xx, 429) and non-retryable ones (4xx client errors like 404, which retrying won’t fix).

Python
import requests
import time

class APIError(Exception):
    """Raised when an API call fails permanently after all retries."""
    pass

RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}

def call_api(url, max_attempts=4, timeout=8):
    """
    Call an API with retries on transient failures only.
    Client errors (4xx other than 429) fail immediately — retrying won't help.
    """
    for attempt in range(1, max_attempts + 1):
        try:
            response = requests.get(url, timeout=timeout)
        except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
            if attempt == max_attempts:
                raise APIError(f"Failed after {max_attempts} attempts (network): {e}") from e
            wait = 2 ** (attempt - 1)
            print(f"  Attempt {attempt}: network error, retrying in {wait}s...")
            time.sleep(wait)
            continue

        if response.status_code == 200:
            return response.json()

        if response.status_code in RETRYABLE_STATUS_CODES:
            if attempt == max_attempts:
                raise APIError(f"Failed after {max_attempts} attempts: HTTP {response.status_code}")
            wait = 2 ** (attempt - 1)
            print(f"  Attempt {attempt}: HTTP {response.status_code} (retryable), retrying in {wait}s...")
            time.sleep(wait)
        else:
            # Non-retryable client error (e.g. 404) — fail immediately, no point retrying
            raise APIError(f"Non-retryable error: HTTP {response.status_code}")

# Usage:
# try:
#     data = call_api("https://api.example.com/servers/web-01")
# except APIError as e:
#     print(f"Could not retrieve server info: {e}")
print("Function defined — ready to call against a real endpoint.")
Output
Function defined — ready to call against a real endpoint.
Example 5 — Terraform plan validator (IaC support script)
DevOpsAutomation

A Python script that reads a Terraform plan exported as JSON and flags risky changes — specifically, any resource deletion — before a human approves the apply step. This is the kind of “glue” script described in Topic 5: Python supporting an IaC tool rather than replacing it.

Python
import json

def analyse_terraform_plan(plan_json):
    """
    Analyse a Terraform plan (as parsed JSON) and flag risky changes.

    Returns a dict with counts and a list of resources being destroyed.
    """
    summary = {"create": 0, "update": 0, "delete": 0, "no-op": 0}
    deletions = []

    for change in plan_json.get("resource_changes", []):
        actions = change["change"]["actions"]

        if "delete" in actions:
            summary["delete"] += 1
            deletions.append(change["address"])
        elif "create" in actions:
            summary["create"] += 1
        elif "update" in actions:
            summary["update"] += 1
        else:
            summary["no-op"] += 1

    return {"summary": summary, "deletions": deletions}

# ── Simulated Terraform plan output (terraform show -json plan.tfplan) ──
sample_plan = {
    "resource_changes": [
        {"address": "aws_instance.web_01",       "change": {"actions": ["create"]}},
        {"address": "aws_instance.db_01",        "change": {"actions": ["update"]}},
        {"address": "aws_security_group.legacy", "change": {"actions": ["delete"]}},
        {"address": "aws_s3_bucket.archive_2023", "change": {"actions": ["delete"]}},
        {"address": "aws_instance.cache_01",     "change": {"actions": ["no-op"]}},
    ]
}

result = analyse_terraform_plan(sample_plan)

print("Plan summary:")
for action, count in result["summary"].items():
    print(f"  {action:8}: {count}")

if result["deletions"]:
    print(f"\n⚠ WARNING: {len(result['deletions'])} resource(s) will be DESTROYED:")
    for addr in result["deletions"]:
        print(f"  - {addr}")
    print("\nManual review required before applying this plan.")
Output
Plan summary:
  create  : 1
  update  : 1
  delete  : 2
  no-op   : 1

⚠ WARNING: 2 resource(s) will be DESTROYED:
  – aws_security_group.legacy
  – aws_s3_bucket.archive_2023

Manual review required before applying this plan.
🏋️
Practice Exercises
Four exercises covering requests, retries, and JSON-based infrastructure analysis

Exercises 1 and 2 use real public APIs that need no authentication, so you can run them immediately. pip install requests first if you haven’t already.

1
Basic GET with status checking. Call https://api.github.com/repos/python/cpython with requests.get(). Check the status code; if 200, print the repository’s star count and description from the JSON response (keys: stargazers_count, description). If not 200, print an error message showing the status code. Add a 5-second timeout.
response = requests.get(url, timeout=5), then if response.status_code == 200: data = response.json(); print(data[“stargazers_count”]). GitHub’s public API doesn’t require authentication for basic read requests like this one.
2
Handle a 404 gracefully. Call a deliberately wrong URL, like https://api.github.com/repos/python/this-repo-does-not-exist-12345. Confirm you get a 404, and write code that prints a friendly, specific message (“Repository not found”) rather than letting a raw exception or confusing JSON error leak to the user. Then write a second function that takes any repo owner/name as parameters and reuses this logic generically.
if response.status_code == 404: print(“Repository not found”); return None. For the generic function: def get_repo_info(owner, repo): url = f”https://api.github.com/repos/{owner}/{repo}”; … — reuse the same status-checking logic inside it.
3
Simulate exponential backoff. Write a function simulate_retry(success_on_attempt) that pretends to call an API — it “fails” on every attempt before success_on_attempt and “succeeds” on that attempt. Print the attempt number and simulated wait time (1s, 2s, 4s…) for each failure, and confirm the function returns successfully once it reaches the success attempt. Test with success_on_attempt=3 and success_on_attempt=1 (should succeed immediately, no waiting).
You don’t need requests for this — it’s pure logic practice. Loop with for attempt in range(1, max_attempts+1); if attempt == success_on_attempt, print success and return; otherwise print the failure and computed wait time 2 ** (attempt – 1), but don’t actually call time.sleep() in this exercise unless you want to wait for real.
4
Analyse a JSON infrastructure inventory. Create a JSON file (or Python dict) representing a list of cloud server resources, each with id, type, monthly_cost, and tags (a dict — some missing an “Owner” key, echoing Example 3). Write a function that returns: total monthly cost across all resources, a list of untagged resource IDs, and total monthly cost specifically from untagged resources (the “wasted spend” figure most cost audits care about).
Loop once, building three values together: total += r[“monthly_cost”]; if “Owner” not in r[“tags”]: untagged_ids.append(r[“id”]); untagged_cost += r[“monthly_cost”]. Return all three as a dict or tuple.
📋
Assignment — M10
A multi-service health monitor with retries and a JSON report — estimated 75–90 minutes

📋 API Health Dashboard CLI

Build a script called health_dashboard.py that checks multiple API endpoints (use a mix of real public APIs — GitHub’s API, a JSON placeholder API, etc. — and at least one deliberately broken URL) and produces a structured health report, combining argparse from M8 with the API patterns from this module.

  1. Configuration — read a list of endpoints to check from a JSON config file: [{“name”: “github”, “url”: “https://api.github.com”}, …] with at least 4 entries, including one bad URL that will genuinely fail.
  2. Argument parsing–config (path to the JSON file, required), –timeout (default 5), –retries (default 3), –output (path for the JSON report, default health_report.json).
  3. check_with_retry(name, url, timeout, max_attempts) — checks one endpoint with exponential backoff on timeouts/connection errors, but fails immediately (no retry) on a 404 — echoing Example 4’s distinction between retryable and non-retryable failures. Returns a result dict with name, status (“OK”/”DOWN”/”ERROR”), response_time_ms, and attempts_used.
  4. Run all checks — check every configured endpoint, printing progress to the terminal as each completes.
  5. Write the JSON report — to the –output path: a timestamp, overall summary (total/healthy/down counts), and the full per-endpoint results list.
  6. Exit code — echoing M8’s discipline: exit 0 if every endpoint is healthy, exit 1 if any endpoint is down or erroring, so this script could be wired into a monitoring system’s alert pipeline.
Run your script and confirm: the deliberately broken URL is correctly retried (if it’s a timeout-style failure) or fails fast (if it’s a 404), the exit code reflects overall health correctly, and the JSON report is well-formed and readable. Share your config file, script, a sample report, and the exit code from a real run in the comments.
🧠
Quiz — Check Your Understanding
5 questions · instant feedback · retake as many times as you need

Several of these test the judgment behind retry logic — knowing when retrying helps and when it’s pointless or harmful is more important than the syntax itself.

1. An API call returns HTTP status 404. Should your script automatically retry this request?
2. Why does exponential backoff increase the wait time between retry attempts (1s, 2s, 4s, 8s…) rather than retrying immediately every time?
3. Why should an API key or cloud credential never be written directly as a literal string in a Python script?
4. What is the common pattern shared by virtually every cloud SDK (boto3, Azure SDK, Google Cloud client libraries)?
5. What is the core idea behind “Infrastructure as Code,” and where does Python typically fit relative to dedicated IaC tools like Terraform?
You can now call APIs safely, work with cloud SDK conventions, and write supporting automation around Infrastructure as Code.
M12: Capstone Project — apply everything from your specialty track to a complete end-to-end project.
Continue →
Scroll to Top