Granny
One CLI for the cloud chores you'd otherwise do across six tabs.
granny is a pragmatic, multi-provider DevOps toolkit with a strong bias
toward European cloud infrastructure. It wraps the parts of provider APIs
you actually use day-to-day — Bunny pull zones, Cloudflare DNS, Hetzner S3,
Scaleway functions, Mailjet sender setup, INWX zones, AWS Lambda inventory
— behind a single command:
granny dns add api.example.com --type A --value 203.0.113.4 --provider hetzner
granny cdn purge 12345
granny storage bunny upload my-zone ./dist
granny credentials status
What it is
- Pragmatic, not exhaustive. Granny implements the parts of each provider API the maintainers needed in production. It will not cover every endpoint of every service — and it will not pretend to. Add what you need; the contributor guide explains how.
- Multi-cloud, with a European tilt. First-class support for Bunny, Cloudflare, Hetzner, deSEC, ClouDNS, INWX, Scaleway, and Mailjet alongside AWS S3 / Lambda / WorkMail. The cloud world doesn't end at AWS, and granny doesn't pretend it does.
- Secrets done right. Every credential goes through one resolver
chain — environment variable first, optional Vaultwarden vault second,
never hardcoded.
.envand.deploy.envare auto-loaded; vault support is a one-line activation when you want it. - Library or CLI. Every CLI subcommand is a thin shim over a small
Python module. Import
granny.dns.cloudflare,granny.cdn.bunny,granny.cloudflare.d1directly when you need to script something the CLI doesn't expose yet. - Small, composable, no plugin system. The whole package is one
pip installaway. No daemon, no service, no opinionated framework. Drop it into your CI image and call it a day.
Install
pip install granny-devops
# or
uv add granny-devops
Optional extras enable additional providers:
pip install "granny-devops[gcp]" # GCP for granny analyze (gpus|credits|costs)
pip install "granny-devops[azure]" # Azure for granny analyze (gpus|credits|costs)
pip install "granny-devops[cdn]" # Cloudflare, Hetzner DNS
Tagged releases land on pypi.org and the public GitLab PyPI registry in parallel. If PyPI is propagating slowly, fall back to the registry:
pip install --extra-index-url https://gitlab.com/api/v4/projects/81189862/packages/pypi/simple granny-devops
For Vaultwarden (Locke) credential resolution, install separately:
pip install "locke @ git+https://gitlab.com/martin-wieser/locke.git#subdirectory=python"
Configure
Granny reads secrets from environment variables, falling back to a Vaultwarden vault when Locke is installed. Copy the example and fill in what you need:
cp .env.example .env
Common keys:
| Provider | Variables |
|---|---|
| Bunny | BUNNY_API_KEY (+ BUNNY_API_KEY_<CUSTOMER> for multi-account) |
| Airtable | AIRTABLE_API_KEY |
| Cloudflare | CLOUDFLARE_API_TOKEN |
| Hetzner | HETZNER_S3_ACCESS_KEY, HETZNER_S3_SECRET_KEY, HETZNER_DNS_API_TOKEN |
| Scaleway | SCW_ACCESS_KEY, SCW_SECRET_KEY, SCW_DEFAULT_PROJECT_ID |
| Knative FaaS API | OBACHAN_API_KEY, OBACHAN_API_BASE_URL |
| Obachan OpenBao | OBACHAN_BAO_ADDR, OBACHAN_BAO_TOKEN |
| Mailjet | MAILJET_API_KEY, MAILJET_SECRET_KEY |
| deSEC | DESEC_API_TOKEN |
| ClouDNS | CLOUDNS_AUTH_ID/_PASSWORD (or _SUB_AUTH_ID/_SUB_AUTH_USER) |
| INWX | INWX_USERNAME, INWX_PASSWORD, INWX_SHARED_SECRET (only with 2FA) |
| Docker Hub | DOCKER_HUB_USER, DOCKER_HUB_TOKEN |
| Elasticsearch / Kibana | ELASTICSEARCH_URL, ELASTICSEARCH_API_KEY or ELASTICSEARCH_USERNAME + ELASTICSEARCH_PASSWORD |
Set only the ones you need. Use granny credentials status to verify
what's configured at any time.
Use
granny --help # see every command group
granny <group> --help # drill into one
# Provision mail/calendar OAuth clients to a dashboard env and Obachan OpenBao
granny oauth provision-productivity --env-file ../dashboard/.deploy.env \
--obachan-env staging --obachan-app dashboard \
--google-client-json client_secret.json \
--azure-client-id "$AZURE_CLIENT_ID" --azure-client-secret "$AZURE_CLIENT_SECRET"
The CLI is organized by capability, not by provider. You pick the provider per command:
# DNS — same command, any of seven providers
granny dns list example.com --provider cloudflare
granny dns add www.example.com --type CNAME --value example.com --provider bunny
granny dns nameservers example.com --provider inwx
# CDN — Bunny pull zones
granny cdn list-zones
granny cdn purge 12345
granny cdn ssl www.example.com --dns01
# Object storage — three providers, one verb set
granny storage bunny create my-assets --region DE
granny storage hetzner create my-bucket --region fsn1 --public
granny storage aws create my-website --website
# AWS inventory (read-only)
granny analyze vpcs --json-output
granny analyze lambdas --region us-east-1 eu-west-1
# AWS Bedrock -- foundation models (read-only; on-demand access auto-enables on first invoke)
granny aws bedrock list-models --profile lularge --region eu-central-1 --by-provider Anthropic
granny aws bedrock check-access --model-id anthropic.claude-sonnet-4-20250514-v1:0 --profile lularge --region eu-central-1
# Cross-cloud GPU / credit / cost inventory (AWS + GCP + Azure)
granny analyze gpus # running GPUs everywhere
granny analyze gpus --filter h100,h200 # find Hopper clusters
granny analyze gpus --include-reserved # also capacity blocks / RIs
granny analyze gpus --provider aws --profile prod --profile dev
granny analyze credits # available balances
granny analyze costs # MTD + month-end forecast
# AWS Capacity Blocks for ML -- discover available H100/H200/A100 blocks
granny analyze capacity-blocks --instance-type p5.48xlarge --hours 24
granny analyze capacity-blocks --instance-type p5e.48xlarge --count 2 --hours 168
# Kubernetes service architecture inventory via kubectl
# Maps Services to Ingress hosts, EndpointSlices/Endpoints, and owning workloads.
# Refuses writable Kubernetes identities by default; use a read-only kubeconfig.
granny analyze k8s-services --context prod-cluster --namespace platform
granny analyze k8s-services --context prod-cluster --json-output
granny analyze k8s-services --context prod-cluster --mermaid > architecture.mmd
# Cloudflare account resources (Workers, D1, R2, KV)
granny cloudflare d1 create my-app
granny cloudflare r2 create my-app-media
granny cloudflare site provision my-app --secret-from-vault MAILJET_API_KEY
# Airtable endpoint maintenance
granny airtable bases
granny airtable tables --base app123
granny airtable find-text --base app123 --table Config --contains old.example.com
granny airtable replace-text --base app123 --table Config \
--field Endpoint --old old.example.com --new new.example.com # dry run
granny airtable replace-text --base app123 --table Config \
--field Endpoint --old old.example.com --new new.example.com --apply --confirm
# Multi-arch Docker builds with deterministic tags
granny docker build-base --image myapp-base --hash-file requirements.txt
# Scaleway FaaS
granny serverless deploy my-fn --source-dir ./dist --namespace my-app
granny serverless delete-function my-fn --namespace my-app --yes
# Knative FaaS control API (`granny faas` is an alias)
granny knative list
granny knative deploy my-fn --image registry.example.com/my-fn:latest \
--env LOG_LEVEL=info --min-scale 0 --max-scale 5
granny knative scale my-fn --max-scale 10
granny knative logs my-fn --tail 100
granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
granny knative remove my-fn --yes
# Mailjet + WorkMail
granny email mailjet setup-dns example.com
granny email workmail create-user example.com --email user@example.com
# One-shot infrastructure provisioning (dispatches granny/create/*.py)
granny create s3-website example.com --help
granny create scaleway-container --name my-app --port 3000
granny create mailjet-dns example.com
granny create letsencrypt-cert \
--domain '*.example.com' --domain example.com \
--dns inwx --zone example.com \
--email ops@example.com --out-dir ./certs # real LE cert via ACME dns-01, any granny DNS provider
# Authentik admin (provider + application + group plumbing)
granny authentik provision-oauth-app my-app \
--name "My App" \
--redirect-uri https://app.example.com/auth/callback \
--launch-url https://app.example.com \
--group my-app-admins
granny authentik list providers
granny authentik rotate-secret my-oauth-provider
granny authentik delete-app my-app --name "My App" # name-confirmed application delete
granny authentik add-user-to-group user@example.com # defaults to dash_admins
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
# Zitadel admin (self-hosted OIDC server — same shape as authentik)
# ZITADEL_DOMAIN (e.g. https://id.example.com) — plain config, NOT a secret;
# set it via env or .env/.deploy.env (granny auto-loads those). Not vaulted.
# Plus one machine credential:
# ZITADEL_API_TOKEN (a PAT, used as a bearer), OR
# ZITADEL_SA_KEY / ZITADEL_SA_KEY_FILE (service-account key JSON; JWT-profile auth)
# Secret vault keys (with the [vault] extra): granny/infra/zitadel-api-token, -sa-key
# Mint a PAT reproducibly: granny zitadel create-pat iam-admin --expiration 2027-06-27T00:00:00Z
granny zitadel create-project platform
granny zitadel create-oidc-app platform my-app \
--redirect-uri https://app.example.com/oidc/callback
granny zitadel set-redirect-uris --project platform --app my-app \
--redirect-uri https://app.example.com/oidc/callback \
--redirect-uri https://app.example.com/oidc/callback2 # merges by default; --replace to set exactly
granny zitadel rotate-secret platform my-app
granny zitadel create-user alice --email alice@example.com --password 's3cret'
granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
granny zitadel grant-user-role platform alice admin # ...granted to users
granny zitadel enforce-mfa # forceMfa + passkey + TOTP
granny zitadel list users # also: projects | apps --project p | roles --project p
# Elasticsearch / Kibana users
granny elk add-user user@example.com \
--email user@example.com \
--full-name "Example User" \
--role kibana_admin \
--generate-password
# Azure -- subscriptions, ARM deployments, OpenAI, App Service, VM sizes
granny azure account whoami # signed-in identity
granny azure account list # subscriptions
granny azure deployment list --subscription <id> --all-groups
granny azure group create --subscription <id> --name <rg> --location swedencentral
granny azure openai accounts --subscription <id>
granny azure openai account create --subscription <id> --resource-group <rg> \
--name <acct> --location swedencentral # create an Azure OpenAI account
granny azure openai keys --subscription <id> --resource-group <rg> --account <acct>
granny azure openai deployments --subscription <id> --kind OpenAI --with-usage # + created_at & last-used (Azure Monitor)
granny azure openai deployment create --subscription <id> --resource-group <rg> \
--account <acct> --name gpt-5.4 --model gpt-5.4 --model-version 2026-03-05 \
--sku GlobalStandard --capacity 50 # create a model deployment
granny azure webapp list --subscription <id>
granny azure vm-sizes list --subscription <id> --location polandcentral
granny azure vm list --subscription <id> # running VMs + power state
granny azure vm deallocate --subscription <id> --resource-group <rg> --name <vm>
granny azure vm delete --subscription <id> --resource-group <rg> --name <vm> --dry-run
granny azure role list --subscription <id> --principal-id <oid>
granny azure role assign --subscription <id> --role-name Reader --dry-run
# Search-engine indexing (IndexNow + Google Indexing API)
granny indexing generate-key # mint an IndexNow key
granny indexing submit https://example.com/new-page # notify every engine
granny indexing indexnow https://example.com/p1 https://example.com/p2
granny indexing google https://example.com/job-posting --action URL_UPDATED
Capability matrix
| Capability | Providers |
|---|---|
| DNS | Cloudflare, Bunny, Hetzner, deSEC, ClouDNS, INWX, manual |
| CDN | Bunny |
| Edge scripting | Bunny |
| Object storage | AWS S3, Bunny Storage, Hetzner S3 |
| Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
| Workers / KV / D1 / R2 | Cloudflare |
| Record search and endpoint maintenance | Airtable |
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
| AWS inventory | VPCs, Lambdas |
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
| SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
| SSO / IdP | Authentik (provider, application, group, and user operations) |
| Observability admin | Elasticsearch / Kibana native-user management |
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
| Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
| Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
As a library
Every CLI command is a thin wrapper. Import the underlying module when the CLI doesn't have what you need:
from granny.dns.factory import get_provider
from granny.cloudflare.d1 import D1Client
from granny.credentials import get_secret, load_secrets_into_env
# Multi-provider DNS
dns = get_provider("inwx")
zone = dns.get_zone_id("example.com")
dns.upsert_record(zone, "_acme-challenge", "TXT", "abc-token", ttl=300)
# Cloudflare D1 directly
db = D1Client().create_database("my-app", primary_location_hint="weur")
# Bulk-load registered secrets into os.environ for downstream tools
load_secrets_into_env()
Project layout
granny/
cli/ Click command groups (granny <group> <verb>)
airtable/ Airtable record search and endpoint maintenance
analyze/ Cross-cloud inventory (AWS, GCP, Azure)
authentik/ Authentik admin
azure/ Azure account, ARM deployments, Cognitive, App Service, VMs, RBAC roles, quota
cdn/ Bunny CDN
cloudflare/ Cloudflare Workers / D1 / R2 / KV
create/ Standalone setup scripts (granny create <name>)
credentials/ Env + vault secret resolution
dns/ Provider-agnostic DNS CRUD
docker/ Multi-arch image builds
edge/ Bunny Edge Scripting
elk/ Elasticsearch / Kibana security user management
email/ Mailjet, WorkMail, SES forwarding
indexing/ IndexNow + Google Indexing API
knative/ Knative FaaS control API client
oauth/ Google/Microsoft productivity OAuth provisioning
serverless/ Scaleway FaaS
storage/ Object storage (AWS / Bunny / Hetzner)
Where to look next
CHANGELOG.md— release history and notable changes.Project_Guidelines.md— contributor toolchain, conventions, extension checklists, release flow.AGENTS.md— instructions for AI coding agents working in the repository (Claude Code readsCLAUDE.mdwhich imports this)..env.example— full list of supported environment variables.granny --helpandgranny <group> --help— authoritative command docs, always in sync with the installed version.
License
MIT — see LICENSE. Built and maintained by Martin Wieser.
Release files for granny-devops 0.20.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| granny_devops-0.20.0.tar.gz | 303.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| granny_devops-0.20.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 671.1 kB
Release files / granny_devops-0.20.0.tar.gz
| Download URL | granny_devops-0.20.0.tar.gz |
|---|---|
| Size | 303.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3bc801d9cfd4a284bb3497e19d82670c994109116746ef48f2975a26df91f181
|
|
BLAKE2b-256 checksum How to use checksums |
fb8c71a91d581ccbfc5b9caa661ce39c82ee78f6dd6ba406e382edb364c118ae
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.15
|
Release files / granny_devops-0.20.0-py3-none-any.whl
| Download URL | granny_devops-0.20.0-py3-none-any.whl |
|---|---|
| Size | 367.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4739bfa67badea44ee0376d48599844c25b53c88766fb09b3ab60c830cac5885
|
|
BLAKE2b-256 checksum How to use checksums |
8c85cc1c97bed838653eb51cb27350ff08ce71263effb5e74adbcefd9527a457
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.15
|