Python EventBridge Bus, Rule, Target — the schedule that is not a crontab
The bus still fires. The instance can be off.
<!-- hal:authoritative:yaml -->
The bus still fires. The instance can be off.
§I — Frame
Wednesday named a name. list_health_checks spoke Route 53. A SECONDARY record was a different answer. Coin of that day: the failover that is not a replica. The cloud seat was AWS SAP-C02 because the Cert seat was the third SAP visit.
Today the Cert seat is AWS DOP-C02, the third DOP visit. 08-05 opened Domain 1: CodePipeline stages, the buildspec contract, CodeDeploy blue/green. 08-17 opened Domain 2: custom resources, change sets, the signal that must still fire. Those pipelines still have to wake work when an API call lands, or when a clock fires and no instance is listening. The name of that wake is EventBridge. The concrete service an ops tool can ask without redrawing the 08-05 pipeline is the bus, the rule, and the target.
Name the duty. Coin it: the schedule that is not a crontab.
A crontab lives on one host. A line such as 0 12 * * * /usr/local/bin/backup.sh runs if that host is up, if cron is installed, if the line was not commented out last Tuesday. Gift writes the other clock in one clause: "Scheduling tasks in a cron-like manner based on CloudWatch Events triggering Lambda functions" (Python for DevOps, Ch.13 Serverless, p.513). Cron-like. The expression looks like crontab. The owner is a bus. The instance can be stopped. The function still receives JSON.
The Python ops tool talks to boto3.client("events"). It lists event buses. It lists rules on each bus. It lists targets on each rule. It prints ScheduleExpression when a rule has one. It does not put_rule. It does not put_targets. It does not enable_rule. If a scheduled rule has zero targets, the tool says so, because a schedule with no listener is a crontab that nobody hears.
08-26 already taught a census that refuses change_resource_record_sets. This lesson does not reopen that zone. The leftover here is a memorized cron line, or a rule listed against the default bus when the work lives on a custom one.
§II — Foundations: four facts about the bus
Fact one. The bus is the object. A rule sits on a bus.
The SAP clone says it in four lines. Both CloudWatch Events and EventBridge operate using an event bus. Both have a default event bus. CloudWatch Events has only that default bus, explicit, not exposed in the UI. EventBridge can have additional event buses (eventbridge.md, CloudWatch Events and EventBridge). The DOP notes add the successor sentence: EventBridge is CloudWatch Events in its core, then adds third-party and custom sources (devops-engineer-professional.md, EventBridge). The cheatsheet row is one clause: "Event bus, rule-based routing" (aws-core-services.md, Integration).
list_event_buses returns EventBuses[]. Each item has Name, Arn, and Policy when the bus is shared. list_rules takes EventBusName. Omit the argument and you receive the default bus only. A census that never passes EventBusName will print the default rules and call the account empty. The join is yours. A bus with no rules is still a bus. A rule you remember from last night is not a bus.
The CLI Evan types when the join is one bus:
aws events list-event-buses
aws events list-rules --event-bus-name default
aws events describe-rule --name nightly-backup --event-bus-name default
aws events list-targets-by-rule --rule nightly-backup --event-bus-name default
list-event-buses is the set of clocks. list-rules is the set of matches and schedules on one clock. describe-rule is the expression and the state. list-targets-by-rule is who hears it. Four calls. None of them writes a crontab.
Fact two. A rule matches an event, or a rule is a schedule. Those are different verbs.
The clone: "In both systems we create rules matching incoming events, or we have scheduled based rules" (eventbridge.md). The DOP notes: define actions on things that happened, or schedule cron-based events (devops-engineer-professional.md, CloudWatch Events). MonitoringLogging.md names the pair: an event is a change in the AWS environment (EC2 pending to running); a rule matches incoming events and routes them to targets.
A scheduled rule carries ScheduleExpression. The value looks like crontab (cron(0 12 * * ? *)) or like a rate (rate(5 minutes)). Gift's console walk is the same object with a friendlier name: "You can create a CloudWatch timer to call the lambda using the AWS Lambda console" (Ch.15, Using AWS Lambda with CloudWatch Events, p.640). The timer is a rule. The Lambda is a target. The expression is not a file under /etc on the function runtime. The function can have a concurrency of zero. The rule still exists.
An event-pattern rule carries EventPattern and no schedule. It fires when a JSON event matches. CloudTrail integration lets a rule fire on API calls; ReadOnly calls (List*, Get*, Describe*) are not supported (devops-engineer-professional.md). A census that prints Name and ignores ScheduleExpression versus EventPattern will call a match a timer.
Fact three. A target receives JSON. One rule can have many targets. They run in parallel.
MonitoringLogging.md is the sentence that fails the single-listener design: a single rule can route to multiple targets, all processed in parallel; rules are not processed in a particular order; a rule can customize the JSON sent to the target, passing only certain parts or overwriting it with a constant. The DOP notes list the listeners: Lambda, EC2, Kinesis Data Streams, Kinesis Data Firehose, CloudWatch Logs, ECS tasks, Systems Manager Run Command, Systems Manager Automation, Batch, Step Functions, CodePipeline, CodeBuild, Inspector assessment templates, SNS, SQS, built-in EC2 snapshot/reboot/stop/terminate, and the default event bus of another AWS account (devops-engineer-professional.md, Targets).
list_targets_by_rule returns Targets[]. Each item has Id, Arn, and optional Input, InputPath, RoleArn. The join from rule to target is this call. A rule does not embed its targets in describe_rule. A census that stops at list_rules has not asked who hears the clock.
A crontab has one command. A rule has N targets. If you print the expression and skip the target list, you have described a calendar and named no worker.
Fact four. The bus is regional. Cross-account delivery stays in the same region.
Route 53 on 08-26 was global. boto3.client("route53") took no region_name. EventBridge is the other door. boto3.client("events", region_name="us-east-1") lists buses in that region. A wrapper that constructs the events client the way it constructed the Route 53 client will list us-east-1 by default on some boxes and a leftover AWS_DEFAULT_REGION on others. Pass the region. Print the region. A rule in eu-west-1 is invisible to a client aimed at us-east-1.
The DOP notes: can deliver cross-account; must be in the same region (devops-engineer-professional.md). A target ARN in another account is legal when the bus policy allows it. A target ARN in another region is a finding. The census prints Arn and the region parsed from it. It does not rewrite the target.
S3 object events are the cousin that is not this bus. S3 notifications go to SNS, SQS, or Lambda for object-level operations, not bucket-level. They can also integrate with CloudTrail, but only with a trail on that bucket (devops-engineer-professional.md, S3 Events). Do not write a census that treats an S3 notification as an EventBridge rule. Print the service. Leave the substitution to the on-call.
§III — Mechanism: the client, the join, the finding
The client you construct once
import boto3
events = boto3.client("events", region_name="us-east-1")
EventBridge is regional. There is a region_name on this client the way there is on Lambda and not on Route 53. The signed endpoint is events.<region>.amazonaws.com. A wrapper that drops region_name is carrying a habit from Wednesday. Keep the argument. Print it on the first line of the census.
Construct the client once per process. list_event_buses paginates. get_paginator("list_event_buses") is the loop. A while that re-calls list_event_buses and forgets NextToken will print the first page and leave custom buses in the account.
The join you do yourself
Buses do not know which rules they own in the list payload you care about. Rules know the bus name. Targets know the rule. Walk buses first, then rules, then targets.
For each bus, print Name, Arn, and whether Policy is present. Then call list_rules(EventBusName=name). For each rule, print Name, State, EventBusName, ManagedBy, ScheduleExpression, and whether EventPattern is present. Then call list_targets_by_rule(Rule=rule_name, EventBusName=name). Print each target Id and Arn. If ScheduleExpression is set and the target list is empty, that is a finding. If State is DISABLED and someone still calls the rule a live crontab, that is a finding. If you find a rule only because you remembered the name and never listed the bus, that is a finding.
Managed rules (ManagedBy set) belong to a service. Do not treat them as the team's crontab. Print ManagedBy. Leave disable to a different tool.
The finding you refuse to fix
The tool does not call put_rule, put_targets, enable_rule, disable_rule, or put_events. A census that "helpfully" attaches a Lambda you just created is a change window, not a read. 08-17 taught a signal that must still fire: the CloudFormation ResponseURL POST. Today's cousin is the opposite duty. The signal that must still fire is the line that says the scheduled rule has no target. Silence that says "I repaired it" is a lie the next on-call cannot replay.
Exit 2 versus exit 1 follows 08-26. Exit 2 means the client could not run (no credentials, AccessDenied on ListEventBuses). Exit 1 means the client ran and the region is in a bad shape (scheduled rule with zero targets, target ARN in another region, rule listed only on default while a custom bus holds the same name). A supervisor that retries 2 and pages on 1 is reading the right number.
§IV — Worked example: the census
The script takes a region. It lists every bus in that region, then every rule on each bus, then every target on each rule. It prints the join. It never writes.
import sys
import boto3
from botocore.exceptions import ClientError, NoCredentialsError
def page(client, op, **kwargs):
token = None
key = {
"list_event_buses": "EventBuses",
"list_rules": "Rules",
"list_targets_by_rule": "Targets",
}[op]
while True:
args = dict(kwargs)
if token:
args["NextToken"] = token
try:
body = getattr(client, op)(**args)
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code", "unknown")
raise RuntimeError(f"{op}:{code}") from exc
for item in body.get(key) or []:
yield item
token = body.get("NextToken")
if not token:
return
def region_of(arn):
parts = (arn or "").split(":")
if len(parts) >= 4:
return parts[3]
return ""
def main(argv):
if len(argv) != 2:
print("usage: census-eventbridge REGION", file=sys.stderr)
return 2
region = argv[1]
try:
events = boto3.client("events", region_name=region)
except NoCredentialsError:
print("no-credentials", file=sys.stderr)
return 2
worst = 0
try:
buses = list(page(events, "list_event_buses"))
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
print(f"region={region} buses={len(buses)}")
for bus in buses:
name = bus.get("Name") or "default"
print(f"bus name={name} arn={bus.get('Arn')} shared={bool(bus.get('Policy'))}")
try:
rules = list(page(events, "list_rules", EventBusName=name))
except RuntimeError as exc:
print(f"finding list-rules-error bus={name} {exc}", file=sys.stderr)
worst = max(worst, 1)
continue
for rule in rules:
rname = rule.get("Name")
sched = rule.get("ScheduleExpression") or ""
pattern = "yes" if rule.get("EventPattern") else "no"
print(
f"rule bus={name} name={rname} state={rule.get('State')} "
f"managed={rule.get('ManagedBy') or '-'} schedule={sched or '-'} pattern={pattern}"
)
try:
targets = list(page(events, "list_targets_by_rule", Rule=rname, EventBusName=name))
except RuntimeError as exc:
print(f"finding list-targets-error rule={rname} {exc}", file=sys.stderr)
worst = max(worst, 1)
continue
if not targets:
print(f"finding no-targets bus={name} rule={rname} schedule={sched or '-'}", file=sys.stderr)
worst = max(worst, 1)
for target in targets:
arn = target.get("Arn") or ""
treg = region_of(arn)
print(f"target rule={rname} id={target.get('Id')} arn={arn}")
if treg and treg != region:
print(f"finding cross-region-target rule={rname} target-region={treg}", file=sys.stderr)
worst = max(worst, 1)
if sched and not targets:
print(f"finding schedule-without-listener rule={rname} schedule={sched}", file=sys.stderr)
worst = max(worst, 1)
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv))
Four disciplines sit in that script.
The pagination is written out so the next editor sees NextToken on three operations. A forgotten token on list_rules drops the rest of a busy default bus.
list_targets_by_rule is a separate call. describe_rule will not save you. The Bootcamp sentence is that targets process events and rules route to them (MonitoringLogging.md). The ops sentence is that you asked both.
The cross-region finding parses the ARN. It is a hint, not a rewrite. Do not auto-copy the target onto a bus in the printed region.
The script never prints a secret. EventBridge rules are names and expressions. Still refuse repr(exc) on ClientError: the response body can carry request JSON. Print the error Code. 08-26 taught that cousin on Route 53. Same door, different SDK.
§V — Connection to prior lessons
08-26 taught a census that refuses change_resource_record_sets. list_event_buses is to EventBridge what list_health_checks was to Route 53: the list that does not start from last night's handle. The leftover in the bus is a memorized cron line, or a rule you listed only on default.
08-23 taught a census that refuses get_secret. Today's leftover is a schedule expression you treat as a host file because the word "cron" sat next to "crontab" in another chapter. The expression is a rule field. It is not a line in /var/spool/cron.
08-17 taught a signal that must still fire. A Lambda that raised without a PUT left CloudFormation in CREATE_IN_PROGRESS. Today's cousin is a census that "repairs" a missing target and then exits 0. The signal that must still fire is the finding line. The write belongs to a different tool, on a different change ticket.
08-05's Cert lesson took the pipeline. This Ops lesson does not reopen CodePipeline stages or CodeDeploy blue/green. The pipeline is how a commit becomes a task. The bus is how a clock or an API call becomes a target invocation.
§VI — Connection to today's Dev lesson
Today's Dev lesson is asyncio.sleep versus time.sleep. Ramalho's experiment replaces await asyncio.sleep(3) with time.sleep(3) and the spinner never appears (Fluent Python 2ed, Ch.19, printed pp.709-710). time.sleep owns the thread. asyncio.sleep yields to the loop. Other coroutines keep running.
The ops rule is: a scheduled rule is a different owner, not a line on the instance. The language rule is: asyncio.sleep is a different wait, not a copy of time.sleep. One coin. The schedule that is not a crontab is also the sleep that is not a block.
§VII — Closing
Construct the client once, with a region. List the buses. List the rules on each bus. List the targets on each rule. Print scheduled rules with no listener. Print a target whose ARN names another region. Do not write a rule.
The Bootcamp sentence is short: buses hold rules, rules match or schedule, targets receive JSON. Gift's sentence is shorter: cron-like, CloudWatch Events, Lambda. The ops sentence is shorter still. The process that listed them flipped no clock.
Examine the next scheduled rule in the region. If the target exists because someone thought the expression would keep a stopped instance running the job, the coin is already spent.
Related
- Prior arc: the failover that is not a replica (2026-08-26)
- Domain hub: Cross-References/domains/01-Earth-DevOps
- Grounding: Python for DevOps — Ch.13 cron-like CloudWatch Events · EventBridge (Sovereign-Bootcamp SAP clone)