Skip to content

Commit 054abbd

Browse files
committed
Update dependencies and a small refactor
This required a refactor of the Cloudflare API, which is now much nicer.
1 parent 555fd87 commit 054abbd

File tree

8 files changed

+85
-64
lines changed

8 files changed

+85
-64
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ jobs:
1515
- name: Set up Python
1616
uses: actions/setup-python@v2
1717
with:
18-
python-version: "3.11"
18+
python-version: "3.13"
1919

2020
- uses: actions/cache@v2
2121
with:
2222
path: ~/.cache/pip
23-
key: pip-3.11-${{ hashFiles('dev-requirements.txt') }}-${{ hashFiles('requirements.txt') }}
23+
key: pip-3.13-${{ hashFiles('dev-requirements.txt') }}-${{ hashFiles('requirements.txt') }}
2424

2525
- name: Install Dependencies
2626
run: pip install -r dev-requirements.txt

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.11-slim
1+
FROM python:3.13-slim
22

33
RUN apt-get update && apt-get install -y webhook && apt-get clean
44

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ Install the dependencies listed in `requirements.txt`, ideally into a virtual en
1717

1818
Set some environment variables:
1919

20-
- `CF_API_KEY`: A Cloudflare API key, with access to the zone you wish to edit. "DNS Edit" is required for those zones.
20+
- `CLOUDFLARE_API_TOKEN`: A Cloudflare API token, with access to the zone you wish to edit. "DNS Edit" is required for those zones.
2121
- `HEROKU_API_KEY`: API key from Heroku
22-
- `CF_ZONE_ID`: The Cloudflare zone id of the domain to automatically create
22+
- `CLOUDFLARE_ZONE_ID`: The Cloudflare zone id of the domain to automatically create
2323

2424
Optionally:
2525

2626
- `APP_NAME`: A regex of app names to act on. Any not matching this will be skipped.
2727
- `HEROKU_TEAMS`: A comma separated list of Heroku teams to operate on. By default will use all apps the account has access to.
2828
- `ALLOWED_CNAME_TARGETS`: A comma-separated list of regexes which match CNAMEs. If these CNAMEs are found in place of the correct Heroku CNAME, they won't be overridden.
29+
- `LOG_LEVEL`: Log level to use (default "WARNING").
30+
- `DRY_RUN`: Whether to perform actions, or just say they happened (Either `true` or `false` (default)).
2931

3032
These can also be set in a `.env` file.
3133

dev-requirements.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
-r requirements.txt
22

3-
black
4-
isort
5-
flake8
3+
ruff==0.9.2

main.py

Lines changed: 66 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@
55
import re
66
import socket
77
import time
8-
from itertools import count
98

10-
import CloudFlare
119
import heroku3
1210
import sentry_sdk
11+
from cloudflare import Cloudflare
1312
from dotenv import load_dotenv
1413
from heroku3.models.app import App
1514

15+
logger = logging.getLogger("heroku-cloudflare-app-domain")
16+
logging_handler = logging.StreamHandler()
17+
logging_handler.setFormatter(
18+
logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s")
19+
)
20+
logger.addHandler(logging_handler)
21+
1622
SUCCESS_ACM_STATUS = {
1723
"cert issued",
1824
"pending", # Assume this is ok. It'll be picked up on next iteration if it's not
@@ -23,25 +29,18 @@
2329
]
2430

2531

26-
def get_cloudflare_list(api, *args, params=None):
32+
class FakeDomain:
2733
"""
28-
Hack around Cloudflare's API to get all results in a nice way
34+
A fake Domain object to stand in for Heroku's domain.
2935
"""
3036

31-
for page_num in count(start=1):
32-
raw_results = api.get(
33-
*args, params={"page": page_num, "per_page": 50, **(params or {})}
34-
)
35-
36-
yield from raw_results["result"]
37+
acm_status = True
3738

38-
total_pages = raw_results["result_info"]["total_pages"]
39-
if page_num == total_pages:
40-
break
39+
def __init__(self, hostname):
40+
self.domain = self.hostname = hostname
4141

4242

4343
def enable_acm(app):
44-
logging.info("Enabling ACM for %s", app.name)
4544
app._h._http_resource(
4645
method="POST", resource=("apps", app.id, "acm")
4746
).raise_for_status()
@@ -76,7 +75,9 @@ def main():
7675
if sentry_dsn := os.environ.get("SENTRY_DSN"):
7776
sentry_sdk.init(sentry_dsn)
7877

79-
cf = CloudFlare.CloudFlare(raw=True)
78+
logger.setLevel(getattr(logging, os.getenv("LOG_LEVEL", "INFO")))
79+
80+
cf = Cloudflare()
8081

8182
heroku = heroku3.from_key(os.getenv("HEROKU_API_KEY"))
8283

@@ -86,22 +87,22 @@ def main():
8687
os.getenv("HEROKU_TEAMS").split(",") if "HEROKU_TEAMS" in os.environ else None
8788
)
8889

90+
dry_run = os.getenv("DRY_RUN", "false").lower() == "true"
91+
8992
if interval:
9093
while True:
91-
do_create(cf, heroku, matcher, heroku_teams)
94+
do_create(cf, heroku, matcher, heroku_teams, dry_run)
9295
time.sleep(interval)
9396
else:
94-
do_create(cf, heroku, matcher, heroku_teams)
97+
do_create(cf, heroku, matcher, heroku_teams, dry_run)
9598

9699

97-
def do_create(cf, heroku, matcher, heroku_teams):
98-
cf_zone = cf.zones.get(os.environ["CF_ZONE_ID"])["result"]
100+
def do_create(cf: Cloudflare, heroku, matcher, heroku_teams, dry_run):
101+
cf_zone = cf.zones.get(zone_id=os.environ["CLOUDFLARE_ZONE_ID"])
99102

100103
all_records = {
101-
record["name"]: record
102-
for record in get_cloudflare_list(
103-
cf.zones.dns_records, cf_zone["id"], params={"type": "CNAME"}
104-
)
104+
record.name: record
105+
for record in cf.dns.records.list(zone_id=cf_zone.id, type="CNAME")
105106
}
106107

107108
heroku_apps = list(
@@ -110,24 +111,31 @@ def do_create(cf, heroku, matcher, heroku_teams):
110111
else get_apps_for_teams(heroku, heroku_teams)
111112
)
112113

114+
known_records = set()
115+
116+
logger.info("Checking %d apps", len(heroku_apps))
117+
113118
for app in heroku_apps:
114119
if matcher.match(app.name) is None:
115120
continue
116121

117-
app_domain = f"{app.name}.{cf_zone['name']}"
122+
app_domain = f"{app.name}.{cf_zone.name}"
118123
app_domains = {domain.hostname: domain for domain in app.domains()}
119124

120125
existing_record = all_records.get(app_domain)
121126

122127
# Add the domain to Heroku if it doesn't know about it
123128
if app_domain not in app_domains:
124-
logging.info("%s: domain not set in Heroku", app.name)
125-
new_heroku_domain = app.add_domain(app_domain, sni_endpoint=None)
126-
app_domains[new_heroku_domain.hostname] = new_heroku_domain
129+
logger.info("%s: domain not set in Heroku", app.name)
130+
if dry_run:
131+
app_domains[app_domain] = FakeDomain("example.herokudns.com")
132+
else:
133+
new_heroku_domain = app.add_domain(app_domain, sni_endpoint=None)
134+
app_domains[new_heroku_domain.hostname] = new_heroku_domain
127135

128136
# This saves refreshing for the whole app, which can be noisy
129-
if app_domains[app_domain].acm_status not in SUCCESS_ACM_STATUS:
130-
logging.debug("%s: cycling domain to refresh ACM", app.name)
137+
if not dry_run and app_domains[app_domain].acm_status not in SUCCESS_ACM_STATUS:
138+
logger.debug("%s: cycling domain to refresh ACM", app.name)
131139
app.remove_domain(app_domain)
132140
new_heroku_domain = app.add_domain(app_domain, sni_endpoint=None)
133141
app_domains[new_heroku_domain.hostname] = new_heroku_domain
@@ -140,31 +148,45 @@ def do_create(cf, heroku, matcher, heroku_teams):
140148
}
141149

142150
if existing_record is None:
143-
logging.info("%s: domain not set", app.name)
144-
cf.zones.dns_records.post(cf_zone["id"], data=cf_record_data)
145-
elif existing_record["content"] != cname:
146-
if is_allowed_cname_target(existing_record["content"]):
147-
logging.info("%s: record is different, but an allowed value", app.name)
148-
else:
149-
logging.warning("%s: incorrect record value", app.name)
150-
cf.zones.dns_records.patch(
151-
cf_zone["id"], existing_record["id"], data=cf_record_data
151+
logger.info("%s: domain not set", app.name)
152+
if not dry_run:
153+
cf.dns.records.create(zone_id=cf_zone.id, **cf_record_data)
154+
elif existing_record.content != cname:
155+
if is_allowed_cname_target(existing_record.content):
156+
logger.warning(
157+
"%s: record is different, but an allowed value", app.name
152158
)
159+
else:
160+
logger.warning("%s: incorrect record value", app.name)
161+
if not dry_run:
162+
cf.dns.records.edit(
163+
zone_id=cf_zone.id,
164+
dns_record_id=existing_record.id,
165+
**cf_record_data,
166+
)
167+
else:
168+
logger.debug("%s: No action needed", app.name)
153169

154170
# Enable ACM if not already, so certs can be issued
155171
has_acm = any(d.acm_status for d in app_domains.values())
156172
if not has_acm:
157-
enable_acm(app)
173+
logger.info("Enabling ACM for %s", app.name)
174+
if not dry_run:
175+
enable_acm(app)
176+
177+
known_records.add(app_domain)
158178

159179
# Delete heroku records which don't exist anymore
160180
# This intentionally doesn't contain records we just created, so the records propagate
161181
for existing_record in all_records.values():
162-
existing_value = existing_record["content"]
163-
if existing_value.endswith("herokudns.com") and not record_exists(
164-
existing_value
182+
existing_value = existing_record.content
183+
if (
184+
existing_record.name not in known_records
185+
and existing_value.endswith("herokudns.com")
186+
and not record_exists(existing_value)
165187
):
166-
logging.warning("%s: stale heroku domain", existing_value)
167-
cf.zones.dns_records.delete(cf_zone["id"], existing_record["id"])
188+
logger.warning("%s: stale heroku domain", existing_value)
189+
cf.dns.records.delete(existing_record.id, zone_id=cf_zone.id)
168190

169191

170192
if __name__ == "__main__":

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[project]
2+
name = "heroku-cloudflare-app-domain"
3+
version = "0.0.0"
4+
requires-python = "~=3.13"
5+
6+
[tool.ruff.lint]
7+
select = ["E", "F", "I", "W", "N", "B", "A", "C4", "T20"]
8+
ignore = ["E501", "DJ008"]

requirements.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
cloudflare==2.14.3
2-
python-dotenv==1.0.0
1+
cloudflare==4.0.0
2+
python-dotenv==1.0.1
33
heroku3==5.2.1
4-
sentry-sdk==1.39.1
4+
sentry-sdk==2.20.0

setup.cfg

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)