5
5
import re
6
6
import socket
7
7
import time
8
- from itertools import count
9
8
10
- import CloudFlare
11
9
import heroku3
12
10
import sentry_sdk
11
+ from cloudflare import Cloudflare
13
12
from dotenv import load_dotenv
14
13
from heroku3 .models .app import App
15
14
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
+
16
22
SUCCESS_ACM_STATUS = {
17
23
"cert issued" ,
18
24
"pending" , # Assume this is ok. It'll be picked up on next iteration if it's not
23
29
]
24
30
25
31
26
- def get_cloudflare_list ( api , * args , params = None ) :
32
+ class FakeDomain :
27
33
"""
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.
29
35
"""
30
36
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
37
38
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
41
41
42
42
43
43
def enable_acm (app ):
44
- logging .info ("Enabling ACM for %s" , app .name )
45
44
app ._h ._http_resource (
46
45
method = "POST" , resource = ("apps" , app .id , "acm" )
47
46
).raise_for_status ()
@@ -76,7 +75,9 @@ def main():
76
75
if sentry_dsn := os .environ .get ("SENTRY_DSN" ):
77
76
sentry_sdk .init (sentry_dsn )
78
77
79
- cf = CloudFlare .CloudFlare (raw = True )
78
+ logger .setLevel (getattr (logging , os .getenv ("LOG_LEVEL" , "INFO" )))
79
+
80
+ cf = Cloudflare ()
80
81
81
82
heroku = heroku3 .from_key (os .getenv ("HEROKU_API_KEY" ))
82
83
@@ -86,22 +87,22 @@ def main():
86
87
os .getenv ("HEROKU_TEAMS" ).split ("," ) if "HEROKU_TEAMS" in os .environ else None
87
88
)
88
89
90
+ dry_run = os .getenv ("DRY_RUN" , "false" ).lower () == "true"
91
+
89
92
if interval :
90
93
while True :
91
- do_create (cf , heroku , matcher , heroku_teams )
94
+ do_create (cf , heroku , matcher , heroku_teams , dry_run )
92
95
time .sleep (interval )
93
96
else :
94
- do_create (cf , heroku , matcher , heroku_teams )
97
+ do_create (cf , heroku , matcher , heroku_teams , dry_run )
95
98
96
99
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 " ])
99
102
100
103
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" )
105
106
}
106
107
107
108
heroku_apps = list (
@@ -110,24 +111,31 @@ def do_create(cf, heroku, matcher, heroku_teams):
110
111
else get_apps_for_teams (heroku , heroku_teams )
111
112
)
112
113
114
+ known_records = set ()
115
+
116
+ logger .info ("Checking %d apps" , len (heroku_apps ))
117
+
113
118
for app in heroku_apps :
114
119
if matcher .match (app .name ) is None :
115
120
continue
116
121
117
- app_domain = f"{ app .name } .{ cf_zone [ ' name' ] } "
122
+ app_domain = f"{ app .name } .{ cf_zone . name } "
118
123
app_domains = {domain .hostname : domain for domain in app .domains ()}
119
124
120
125
existing_record = all_records .get (app_domain )
121
126
122
127
# Add the domain to Heroku if it doesn't know about it
123
128
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
127
135
128
136
# 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 )
131
139
app .remove_domain (app_domain )
132
140
new_heroku_domain = app .add_domain (app_domain , sni_endpoint = None )
133
141
app_domains [new_heroku_domain .hostname ] = new_heroku_domain
@@ -140,31 +148,45 @@ def do_create(cf, heroku, matcher, heroku_teams):
140
148
}
141
149
142
150
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
152
158
)
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 )
153
169
154
170
# Enable ACM if not already, so certs can be issued
155
171
has_acm = any (d .acm_status for d in app_domains .values ())
156
172
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 )
158
178
159
179
# Delete heroku records which don't exist anymore
160
180
# This intentionally doesn't contain records we just created, so the records propagate
161
181
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 )
165
187
):
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 )
168
190
169
191
170
192
if __name__ == "__main__" :
0 commit comments