Recipes¶

End-to-end scripts for the workflows people actually wire up. Copy, tweak, deploy.


Recipes below stitch fetch_patches, export, analyze, and PatcherAPIClient into complete programs you’d actually point at production.

Sending Summaries via Slack DM¶

Say you wanted to message your CISO weekly with patch reports. The following recipe posts directly to a Slack user (not a channel) using a Slack Bot Token and Block Kit formatting.

slack_summary.py¶
import asyncio
import os

import httpx
from patcher import PatcherClient

SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
CISO_USER_ID = os.environ["CISO_SLACK_USER_ID"]  # e.g. "U01ABCD2EFG"


async def main() -> None:
    async with PatcherClient(
        client_id=os.environ["JAMF_CLIENT_ID"],
        client_secret=os.environ["JAMF_CLIENT_SECRET"],
        server=os.environ["JAMF_URL"],
    ) as client:
        titles = await client.fetch_patches()

    healthy = sum(1 for t in titles if t.completion_percent >= 90)
    at_risk = [t for t in titles if t.completion_percent < 70]
    at_risk.sort(key=lambda t: t.completion_percent)

    blocks = [
        {"type": "header", "text": {"type": "plain_text", "text": "Daily Patch Summary"}},
        {
            "type": "section",
            "fields": [
                {"type": "mrkdwn", "text": f"*Titles tracked*\n{len(titles)}"},
                {"type": "mrkdwn", "text": f"*≥90% deployed*\n{healthy}"},
                {"type": "mrkdwn", "text": f"*Below 70%*\n{len(at_risk)}"},
            ],
        },
    ]

    if at_risk:
        worst = "\n".join(
            f"• *{t.title}* — {t.completion_percent:.0f}% "
            f"({t.hosts_patched}/{t.total_hosts})"
            for t in at_risk[:5]
        )
        blocks.append({
            "type": "section",
            "text": {"type": "mrkdwn", "text": f"*Worst 5*\n{worst}"},
        })

    httpx.post(
        "https://slack.com/api/chat.postMessage",
        headers={"Authorization": f"Bearer {SLACK_BOT_TOKEN}"},
        json={"channel": CISO_USER_ID, "blocks": blocks},
    ).raise_for_status()


if __name__ == "__main__":
    asyncio.run(main())

Script Requirements¶

Environment Variables

JAMF_CLIENT_ID, JAMF_CLIENT_SECRET, JAMF_URL, SLACK_BOT_TOKEN, CISO_SLACK_USER_ID

Slack OAuth Scopes

The bot needs the chat:write scope and must be installed to the workspace. To DM a user, pass their user ID (U…) as the channel, Slack will open the IM conversation automatically.

Tip

Channel-wide alerting (a #patch-state channel rather than a person’s DMs) is the same recipe, swap CISO_SLACK_USER_ID for a channel ID (C…) or name (#patch-state). For a one-line incident bridge, post a Block Kit actions block with a button linking to the HTML report URL.

Logging Tool Export (Datadog, Scanner, Generic HTTP Intake)¶

Stream patch state to a logging platform as structured log events. The example below targets Datadog’s HTTP intake, but any other HTTP-intake log platform accepts the same shape if you swap the URL and auth header.

logging_exporter.py¶
import asyncio
import os
from datetime import datetime, timezone

import httpx
from patcher import PatcherClient

DD_API_KEY = os.environ["DATADOG_API_KEY"]
DD_INTAKE = "https://http-intake.logs.datadoghq.com/api/v2/logs"
SERVICE = "patcher"


async def main() -> None:
    async with PatcherClient(
        client_id=os.environ["JAMF_CLIENT_ID"],
        client_secret=os.environ["JAMF_CLIENT_SECRET"],
        server=os.environ["JAMF_URL"],
    ) as client:
        titles = await client.fetch_patches()

    now = datetime.now(timezone.utc).isoformat()
    events = [
        {
            "ddsource": SERVICE,
            "service": SERVICE,
            "ddtags": "env:prod,team:macadmin",
            "hostname": "patcher",
            "timestamp": now,
            "message": f"{t.title} at {t.completion_percent:.1f}%",
            "patch_title": t.title,
            "completion_percent": t.completion_percent,
            "hosts_patched": t.hosts_patched,
            "missing_patch": t.missing_patch,
            "total_hosts": t.total_hosts,
            "latest_version": t.latest_version,
        }
        for t in titles
    ]

    httpx.post(
        DD_INTAKE,
        headers={"DD-API-KEY": DD_API_KEY, "Content-Type": "application/json"},
        json=events,
        timeout=30.0,
    ).raise_for_status()
    print(f"Shipped {len(events)} events")


if __name__ == "__main__":
    asyncio.run(main())

Script Requirements¶

Environment Variables

DATADOG_API_KEY, JAMF_CLIENT_ID, JAMF_CLIENT_SECRET, JAMF_URL

Intake Endpoint

A Datadog account with Logs HTTP intake enabled, or any other platform that accepts JSON log events. Swap DD_INTAKE and the auth header to retarget.

Patch state becomes queryable as structured logs (service:patcher AND completion_percent:<50), graphable as facets, and alertable via the platform’s native monitor rules. The event shape itself is generic JSON.

Important

Datadog’s HTTP intake has a 5 MB body cap and 1 000 events per batch. A typical Patcher run is well under both ceilings, but batch if you’re tracking thousands of titles. The same caveat applies to most log-intake APIs.

Generate an Installomator Label for a Cask-Only Title¶

The Patcher catalog stitches Homebrew Cask, Installomator, AutoPkg, JAI, and MAS into one view. For apps that exist in Cask but have no Installomator label, the catalog’s generate-label endpoint projects Cask’s metadata into a label-shaped object. You can pipe that into Installomator’s valuesfromarguments mode to install the app without writing a real label first.

generate_label.py¶
"""Print a runnable Installomator command for any catalog slug.

Usage: python generate_label.py <slug>
Example: python generate_label.py figma
"""
import asyncio
import shlex
import sys

from patcher import PatcherAPIClient

REQUIRED = ("name", "type", "downloadURL", "expectedTeamID")


async def main(slug: str) -> int:
    async with PatcherAPIClient() as api:
        label = await api.generate_label(slug)

    if label is None:
        print(f"Error: no catalog entry for slug '{slug}'", file=sys.stderr)
        return 1

    missing = [k for k in REQUIRED if not label.content.get(k)]
    if missing:
        print(
            f"Error: cannot build valuesfromarguments invocation for '{slug}'; "
            f"missing required field(s): {', '.join(missing)}",
            file=sys.stderr,
        )
        for warning in label.warnings:
            print(f"  warning: {warning}", file=sys.stderr)
        return 2

    args = [f"{key}={shlex.quote(value)}" for key, value in label.content.items()]
    sources = ", ".join(label.sources_used)
    print(f"# Generated from Patcher catalog ({sources})")
    for warning in label.warnings:
        print(f"# warning: {warning}")
    print("./Installomator.sh valuesfromarguments \\\n  " + " \\\n  ".join(args))
    return 0


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python generate_label.py <slug>", file=sys.stderr)
        sys.exit(2)
    sys.exit(asyncio.run(main(sys.argv[1])))
Example invocation and output for a Cask-only title¶
$ python generate_label.py chatgpt-atlas
# Generated from Patcher catalog (homebrew_cask)
./Installomator.sh valuesfromarguments \
  name="ChatGPT Atlas" \
  type=dmg \
  downloadURL=https://persistent.oaistatic.com/... \
  expectedTeamID=2DC432GLL2

The endpoint’s most common warning is missing expectedTeamID. Cask metadata doesn’t include the developer Team ID, so a Cask-only slug will surface a warning if it can’t be inferred. See the Installomator wiki for the full set of variables Installomator accepts and Tutorial 3 for an example of when valuesfromarguments is the right tool.