pulumi-django-azure 1.0.9__tar.gz → 1.0.15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pulumi-django-azure might be problematic. Click here for more details.

@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pulumi-django-azure
3
- Version: 1.0.9
3
+ Version: 1.0.15
4
4
  Summary: Simply deployment of Django on Azure with Pulumi
5
5
  Author-email: Maarten Ureel <maarten@youreal.eu>
6
6
  License: MIT License
@@ -50,6 +50,19 @@ To have a proper and secure environment, we need these components:
50
50
  * Azure Key Vault per application
51
51
  * Webapp running pgAdmin
52
52
 
53
+ ## Project requirements
54
+ Your Django project should contain a folder `cicd` with these files:
55
+ * pre_build.sh: commands to be executed before building the application, for example NPM install, CSS build commands,...
56
+ * post_build.sh: commands to be executed after building the application, e.g. cleaning up.
57
+ Note that this runs in the identity of the build container, so you should not run database or storage manipulations here.
58
+ * startup.sh: commands to run the actual application. I recommend to put at least:
59
+ ```bash
60
+ python manage.py migrate
61
+ python manage.py collectstatic --noinput
62
+ python manage.py purge_cdn
63
+ gunicorn --timeout 600 --workers $((($NUM_CORES*2)+1)) --chdir $APP_PATH yourapplication.wsgi --access-logfile '-' --error-logfile '-'
64
+ ```
65
+ Be sure to change `yourapplication` in the above. To see the `purge_cdn` management command we use here, see below.
53
66
  ## Installation
54
67
  This package is published on PyPi, so you can just add pulumi-django-azure to your requirements file.
55
68
 
@@ -244,6 +257,82 @@ Be sure to configure the SSH key that Azure will use on GitLab side. You can obt
244
257
 
245
258
  This would then trigger a redeploy everytime you make a commit to your live branch.
246
259
 
260
+ ## CDN Purging
261
+ We added a management command to Django to purge the CDN cache, and added that to the startup script. Our version is here:
262
+ ```python
263
+ import os
264
+
265
+ from azure.mgmt.cdn import CdnManagementClient
266
+ from azure.mgmt.cdn.models import PurgeParameters
267
+ from django.core.management.base import BaseCommand
268
+
269
+ from core.azure_helper import AZURE_CREDENTIAL, get_subscription_id
270
+
271
+
272
+ class Command(BaseCommand):
273
+ help = "Purges the CDN endpoint"
274
+
275
+ def add_arguments(self, parser):
276
+ parser.add_argument(
277
+ "--wait",
278
+ action="store_true",
279
+ help="Wait for the purge operation to complete",
280
+ )
281
+
282
+ def handle(self, *args, **options):
283
+ # Read environment variables
284
+ resource_group = os.getenv("WEBSITE_RESOURCE_GROUP")
285
+ profile_name = os.getenv("CDN_PROFILE")
286
+ endpoint_name = os.getenv("CDN_ENDPOINT")
287
+ content_paths = ["/*"]
288
+
289
+ # Ensure all required environment variables are set
290
+ if not all([resource_group, profile_name, endpoint_name]):
291
+ self.stderr.write(self.style.ERROR("Missing required environment variables."))
292
+ return
293
+
294
+ # Authenticate with Azure
295
+ cdn_client = CdnManagementClient(AZURE_CREDENTIAL, get_subscription_id())
296
+
297
+ try:
298
+ # Purge the CDN endpoint
299
+ purge_operation = cdn_client.endpoints.begin_purge_content(
300
+ resource_group_name=resource_group,
301
+ profile_name=profile_name,
302
+ endpoint_name=endpoint_name,
303
+ content_file_paths=PurgeParameters(content_paths=content_paths),
304
+ )
305
+
306
+ # Check if the --wait argument was provided
307
+ if options["wait"]:
308
+ purge_operation.result() # Wait for the operation to complete
309
+ self.stdout.write(self.style.SUCCESS("CDN endpoint purge operation completed successfully."))
310
+ else:
311
+ self.stdout.write(self.style.SUCCESS("CDN endpoint purge operation started successfully."))
312
+
313
+ except Exception as e:
314
+ self.stderr.write(self.style.ERROR(f"Error executing CDN endpoint purge command: {e}"))
315
+ ```
316
+
317
+ And our azure_helper:
318
+ ```python
319
+ from azure.identity import DefaultAzureCredential
320
+ from azure.mgmt.resource import SubscriptionClient
321
+
322
+ # Azure credentials
323
+ AZURE_CREDENTIAL = DefaultAzureCredential()
324
+
325
+
326
+ def get_db_password() -> str:
327
+ return AZURE_CREDENTIAL.get_token("https://ossrdbms-aad.database.windows.net/.default").token
328
+
329
+
330
+ def get_subscription_id() -> str:
331
+ subscription_client = SubscriptionClient(AZURE_CREDENTIAL)
332
+ subscriptions = list(subscription_client.subscriptions.list())
333
+ return subscriptions[0].subscription_id
334
+ ```
335
+
247
336
  ## Change requests
248
337
  I created this for internal use but since it took me a while to puzzle all the things together I decided to share it.
249
338
  Therefore this project is not super generic, but tailored to my needs. I am however open to pull or change requests to improve this project or to make it more usable for others.
@@ -11,6 +11,19 @@ To have a proper and secure environment, we need these components:
11
11
  * Azure Key Vault per application
12
12
  * Webapp running pgAdmin
13
13
 
14
+ ## Project requirements
15
+ Your Django project should contain a folder `cicd` with these files:
16
+ * pre_build.sh: commands to be executed before building the application, for example NPM install, CSS build commands,...
17
+ * post_build.sh: commands to be executed after building the application, e.g. cleaning up.
18
+ Note that this runs in the identity of the build container, so you should not run database or storage manipulations here.
19
+ * startup.sh: commands to run the actual application. I recommend to put at least:
20
+ ```bash
21
+ python manage.py migrate
22
+ python manage.py collectstatic --noinput
23
+ python manage.py purge_cdn
24
+ gunicorn --timeout 600 --workers $((($NUM_CORES*2)+1)) --chdir $APP_PATH yourapplication.wsgi --access-logfile '-' --error-logfile '-'
25
+ ```
26
+ Be sure to change `yourapplication` in the above. To see the `purge_cdn` management command we use here, see below.
14
27
  ## Installation
15
28
  This package is published on PyPi, so you can just add pulumi-django-azure to your requirements file.
16
29
 
@@ -205,6 +218,82 @@ Be sure to configure the SSH key that Azure will use on GitLab side. You can obt
205
218
 
206
219
  This would then trigger a redeploy everytime you make a commit to your live branch.
207
220
 
221
+ ## CDN Purging
222
+ We added a management command to Django to purge the CDN cache, and added that to the startup script. Our version is here:
223
+ ```python
224
+ import os
225
+
226
+ from azure.mgmt.cdn import CdnManagementClient
227
+ from azure.mgmt.cdn.models import PurgeParameters
228
+ from django.core.management.base import BaseCommand
229
+
230
+ from core.azure_helper import AZURE_CREDENTIAL, get_subscription_id
231
+
232
+
233
+ class Command(BaseCommand):
234
+ help = "Purges the CDN endpoint"
235
+
236
+ def add_arguments(self, parser):
237
+ parser.add_argument(
238
+ "--wait",
239
+ action="store_true",
240
+ help="Wait for the purge operation to complete",
241
+ )
242
+
243
+ def handle(self, *args, **options):
244
+ # Read environment variables
245
+ resource_group = os.getenv("WEBSITE_RESOURCE_GROUP")
246
+ profile_name = os.getenv("CDN_PROFILE")
247
+ endpoint_name = os.getenv("CDN_ENDPOINT")
248
+ content_paths = ["/*"]
249
+
250
+ # Ensure all required environment variables are set
251
+ if not all([resource_group, profile_name, endpoint_name]):
252
+ self.stderr.write(self.style.ERROR("Missing required environment variables."))
253
+ return
254
+
255
+ # Authenticate with Azure
256
+ cdn_client = CdnManagementClient(AZURE_CREDENTIAL, get_subscription_id())
257
+
258
+ try:
259
+ # Purge the CDN endpoint
260
+ purge_operation = cdn_client.endpoints.begin_purge_content(
261
+ resource_group_name=resource_group,
262
+ profile_name=profile_name,
263
+ endpoint_name=endpoint_name,
264
+ content_file_paths=PurgeParameters(content_paths=content_paths),
265
+ )
266
+
267
+ # Check if the --wait argument was provided
268
+ if options["wait"]:
269
+ purge_operation.result() # Wait for the operation to complete
270
+ self.stdout.write(self.style.SUCCESS("CDN endpoint purge operation completed successfully."))
271
+ else:
272
+ self.stdout.write(self.style.SUCCESS("CDN endpoint purge operation started successfully."))
273
+
274
+ except Exception as e:
275
+ self.stderr.write(self.style.ERROR(f"Error executing CDN endpoint purge command: {e}"))
276
+ ```
277
+
278
+ And our azure_helper:
279
+ ```python
280
+ from azure.identity import DefaultAzureCredential
281
+ from azure.mgmt.resource import SubscriptionClient
282
+
283
+ # Azure credentials
284
+ AZURE_CREDENTIAL = DefaultAzureCredential()
285
+
286
+
287
+ def get_db_password() -> str:
288
+ return AZURE_CREDENTIAL.get_token("https://ossrdbms-aad.database.windows.net/.default").token
289
+
290
+
291
+ def get_subscription_id() -> str:
292
+ subscription_client = SubscriptionClient(AZURE_CREDENTIAL)
293
+ subscriptions = list(subscription_client.subscriptions.list())
294
+ return subscriptions[0].subscription_id
295
+ ```
296
+
208
297
  ## Change requests
209
298
  I created this for internal use but since it took me a while to puzzle all the things together I decided to share it.
210
299
  Therefore this project is not super generic, but tailored to my needs. I am however open to pull or change requests to improve this project or to make it more usable for others.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pulumi-django-azure"
7
- version = "1.0.9"
7
+ version = "1.0.15"
8
8
  description = "Simply deployment of Django on Azure with Pulumi"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Maarten Ureel", email = "maarten@youreal.eu" }]
@@ -27,19 +27,36 @@ Homepage = "https://gitlab.com/MaartenUreel/pulumi-django-azure"
27
27
 
28
28
  [tool.poetry]
29
29
  name = "pulumi-django-azure"
30
- version = "1.0.9"
30
+ version = "1.0.15"
31
31
  description = "Simply deployment of Django on Azure with Pulumi"
32
32
  authors = ["Maarten Ureel <maarten@youreal.eu>"]
33
33
 
34
34
  [tool.poetry.dependencies]
35
35
  python = "^3.11"
36
- pulumi-azure-native = "^2.24.0"
37
- pulumi = "^3.99.0"
38
- pulumi-random = "^4.15.0"
36
+ pulumi-azure-native = "^2.64.2"
37
+ pulumi = "^3.135.0"
38
+ pulumi-random = "^4.16.6"
39
39
 
40
40
  [tool.poetry.group.dev.dependencies]
41
- twine = "^4.0.2"
42
- build = "^1.0.3"
41
+ twine = "^5.1.1"
42
+ build = "^1.2.2"
43
+ ruff = "^0.4.9"
43
44
 
44
- [tool.isort]
45
- profile = "black"
45
+ [tool.ruff]
46
+ line-length = 140
47
+
48
+ [tool.ruff.lint]
49
+ select = [
50
+ # pycodestyle
51
+ "E",
52
+ # Pyflakes
53
+ "F",
54
+ # pyupgrade
55
+ "UP",
56
+ # flake8-bugbear
57
+ "B",
58
+ # flake8-simplify
59
+ "SIM",
60
+ # isort
61
+ "I",
62
+ ]
@@ -0,0 +1 @@
1
+ from .django_deployment import DjangoDeployment, HostDefinition # noqa: F401
@@ -1,10 +1,56 @@
1
- from typing import Optional, Sequence
1
+ from collections.abc import Sequence
2
+ from typing import Optional, Union
2
3
 
3
4
  import pulumi
4
5
  import pulumi_azure_native as azure
5
6
  import pulumi_random
6
7
 
7
8
 
9
+ class HostDefinition:
10
+ """
11
+ A definition for a custom host name, optionally with a DNS zone.
12
+
13
+ :param host: The host name. If a zone is given, this is the relative host name.
14
+ :param zone: The DNS zone (optional).
15
+ :param identifier: An identifier for this host definition (optional).
16
+ """
17
+
18
+ def __init__(self, host: str, zone: Optional[azure.network.Zone] = None, identifier: Optional[str] = None):
19
+ self.host = host
20
+ self.zone = zone
21
+ self._identifier = identifier
22
+
23
+ @property
24
+ def identifier(self) -> str:
25
+ """
26
+ The identifier for this host definition.
27
+
28
+ :return: The identifier
29
+ """
30
+ if not self._identifier:
31
+ if self.zone:
32
+ raise ValueError(f"An identifier is required for the HostDefinition with host '{self.host}' ensure uniqueness.")
33
+ else:
34
+ # Use the host name as the identifier
35
+ return self.host.replace(".", "-")
36
+ else:
37
+ return self._identifier
38
+
39
+ @property
40
+ def full_host(self) -> pulumi.Output[str]:
41
+ """
42
+ The full host name, including the zone.
43
+
44
+ :return: The full host name
45
+ """
46
+ if not self.zone:
47
+ return pulumi.Output.concat(self.host)
48
+ elif self.host == "@":
49
+ return self.zone.name
50
+ else:
51
+ return pulumi.Output.concat(self.host, ".", self.zone.name)
52
+
53
+
8
54
  class DjangoDeployment(pulumi.ComponentResource):
9
55
  HEALTH_CHECK_PATH = "/health-check"
10
56
 
@@ -19,7 +65,10 @@ class DjangoDeployment(pulumi.ComponentResource):
19
65
  appservice_ip_prefix: str,
20
66
  app_service_sku: azure.web.SkuDescriptionArgs,
21
67
  storage_account_name: str,
22
- cdn_host: Optional[str],
68
+ storage_allowed_origins: Optional[Sequence[str]] = None,
69
+ pgadmin_access_ip: Optional[Sequence[str]] = None,
70
+ pgadmin_dns_zone: Optional[azure.network.Zone] = None,
71
+ cdn_host: Optional[HostDefinition] = None,
23
72
  opts=None,
24
73
  ):
25
74
  """
@@ -34,7 +83,10 @@ class DjangoDeployment(pulumi.ComponentResource):
34
83
  :param appservice_ip_prefix: The IP prefix for the app service subnet.
35
84
  :param app_service_sku: The SKU for the app service plan.
36
85
  :param storage_account_name: The name of the storage account. Should be unique across Azure.
37
- :param cdn_host: A custom CDN host name (optional)
86
+ :param storage_allowed_origins: The origins (hosts) to allow access through CORS policy. You can specify '*' to allow all.
87
+ :param pgadmin_access_ip: The IP addresses to allow access to pgAdmin. If empty, all IP addresses are allowed.
88
+ :param pgadmin_dns_zone: The Azure DNS zone to a pgadmin DNS record in. (optional)
89
+ :param cdn_host: A custom CDN host name. (optional)
38
90
  :param opts: The resource options
39
91
  """
40
92
 
@@ -49,7 +101,7 @@ class DjangoDeployment(pulumi.ComponentResource):
49
101
  self._vnet = vnet
50
102
 
51
103
  # Storage resources
52
- self._create_storage(account_name=storage_account_name)
104
+ self._create_storage(account_name=storage_account_name, allowed_origins=storage_allowed_origins)
53
105
  self._cdn_host = self._create_cdn(custom_host=cdn_host)
54
106
 
55
107
  # PostgreSQL resources
@@ -67,9 +119,9 @@ class DjangoDeployment(pulumi.ComponentResource):
67
119
  self._app_service_plan = self._create_app_service_plan(sku=app_service_sku)
68
120
 
69
121
  # Create a pgAdmin app
70
- self._create_pgadmin_app()
122
+ self._create_pgadmin_app(access_ip=pgadmin_access_ip, dns_zone=pgadmin_dns_zone)
71
123
 
72
- def _create_storage(self, account_name: str):
124
+ def _create_storage(self, account_name: str, allowed_origins: Optional[Sequence[str]] = None):
73
125
  # Create blob storage
74
126
  self._storage_account = azure.storage.StorageAccount(
75
127
  f"sa-{self._name}",
@@ -85,7 +137,26 @@ class DjangoDeployment(pulumi.ComponentResource):
85
137
  enable_https_traffic_only=True,
86
138
  )
87
139
 
88
- def _create_cdn(self, custom_host: Optional[str]) -> pulumi.Output[str]:
140
+ if allowed_origins:
141
+ azure.storage.BlobServiceProperties(
142
+ f"sa-{self._name}-blob-properties",
143
+ resource_group_name=self._rg,
144
+ account_name=self._storage_account.name,
145
+ blob_services_name="default",
146
+ cors=azure.storage.CorsRulesArgs(
147
+ cors_rules=[
148
+ azure.storage.CorsRuleArgs(
149
+ allowed_headers=["*"],
150
+ allowed_methods=["GET", "OPTIONS", "HEAD"],
151
+ allowed_origins=allowed_origins,
152
+ exposed_headers=["Access-Control-Allow-Origin"],
153
+ max_age_in_seconds=86400,
154
+ )
155
+ ]
156
+ ),
157
+ )
158
+
159
+ def _create_cdn(self, custom_host: Optional[HostDefinition]) -> pulumi.Output[str]:
89
160
  """
90
161
  Create a CDN endpoint. If a host name is given, it will be used as the custom domain.
91
162
  Otherwise, the default CDN host name will be returned.
@@ -95,7 +166,7 @@ class DjangoDeployment(pulumi.ComponentResource):
95
166
  """
96
167
 
97
168
  # Put CDN in front
98
- cdn = azure.cdn.Profile(
169
+ self._cdn_profile = azure.cdn.Profile(
99
170
  f"cdn-{self._name}",
100
171
  resource_group_name=self._rg,
101
172
  location="global",
@@ -125,25 +196,51 @@ class DjangoDeployment(pulumi.ComponentResource):
125
196
  ],
126
197
  is_http_allowed=False,
127
198
  is_https_allowed=True,
128
- profile_name=cdn.name,
199
+ profile_name=self._cdn_profile.name,
129
200
  origin_host_header=endpoint_origin,
130
201
  origins=[azure.cdn.DeepCreatedOriginArgs(name="origin-storage", host_name=endpoint_origin)],
131
- query_string_caching_behavior=azure.cdn.QueryStringCachingBehavior.IGNORE_QUERY_STRING,
202
+ query_string_caching_behavior=azure.cdn.QueryStringCachingBehavior.USE_QUERY_STRING,
132
203
  )
133
204
 
134
205
  pulumi.export("cdn_cname", self._cdn_endpoint.host_name)
135
206
 
136
207
  # Add custom domain if given
137
208
  if custom_host:
138
- azure.cdn.CustomDomain(
139
- f"cdn-custom-domain-{self._name}",
140
- resource_group_name=self._rg,
141
- profile_name=cdn.name,
142
- endpoint_name=self._cdn_endpoint.name,
143
- host_name=custom_host,
144
- )
209
+ if custom_host.zone:
210
+ # Create a DNS record for the custom host in the given zone
211
+ rs = azure.network.RecordSet(
212
+ f"cdn-cname-{self._name}",
213
+ resource_group_name=self._rg,
214
+ zone_name=custom_host.zone.name,
215
+ relative_record_set_name=custom_host.host,
216
+ record_type="CNAME",
217
+ ttl=3600,
218
+ target_resource=azure.network.SubResourceArgs(
219
+ id=self._cdn_endpoint.id,
220
+ ),
221
+ )
145
222
 
146
- return custom_host
223
+ azure.cdn.CustomDomain(
224
+ f"cdn-custom-domain-{self._name}",
225
+ resource_group_name=self._rg,
226
+ profile_name=self._cdn_profile.name,
227
+ endpoint_name=self._cdn_endpoint.name,
228
+ host_name=custom_host.full_host,
229
+ opts=pulumi.ResourceOptions(depends_on=rs),
230
+ )
231
+
232
+ return custom_host.full_host
233
+ else:
234
+ # Add custom hostname without a zone
235
+ azure.cdn.CustomDomain(
236
+ f"cdn-custom-domain-{self._name}",
237
+ resource_group_name=self._rg,
238
+ profile_name=self._cdn_profile.name,
239
+ endpoint_name=self._cdn_endpoint.name,
240
+ host_name=custom_host.host,
241
+ )
242
+
243
+ return custom_host.host
147
244
  else:
148
245
  # Return the default CDN host name
149
246
  return self._cdn_endpoint.host_name
@@ -200,7 +297,11 @@ class DjangoDeployment(pulumi.ComponentResource):
200
297
  pulumi.export("pgsql_host", self._pgsql.fully_qualified_domain_name)
201
298
 
202
299
  def _create_subnet(
203
- self, name, prefix, delegation_service: Optional[str] = None, service_endpoints: Sequence[str] = []
300
+ self,
301
+ name,
302
+ prefix,
303
+ delegation_service: Optional[str] = None,
304
+ service_endpoints: Sequence[str] = [],
204
305
  ) -> azure.network.Subnet:
205
306
  """
206
307
  Generic method to create a subnet with a delegation.
@@ -239,7 +340,22 @@ class DjangoDeployment(pulumi.ComponentResource):
239
340
  sku=sku,
240
341
  )
241
342
 
242
- def _create_pgadmin_app(self):
343
+ def _create_pgadmin_app(self, access_ip: Optional[Sequence[str]] = None, dns_zone: Optional[azure.network.Zone] = None):
344
+ # Determine the IP restrictions
345
+ ip_restrictions = []
346
+ default_restriction = azure.web.DefaultAction.ALLOW
347
+ if access_ip:
348
+ default_restriction = azure.web.DefaultAction.DENY
349
+
350
+ for ip in access_ip:
351
+ ip_restrictions.append(
352
+ azure.web.IpSecurityRestrictionArgs(
353
+ action="Allow",
354
+ ip_address=ip,
355
+ priority=300,
356
+ )
357
+ )
358
+
243
359
  # The app itself
244
360
  app = azure.web.WebApp(
245
361
  f"app-pgadmin-{self._name}",
@@ -255,7 +371,10 @@ class DjangoDeployment(pulumi.ComponentResource):
255
371
  linux_fx_version="DOCKER|dpage/pgadmin4",
256
372
  health_check_path="/misc/ping",
257
373
  app_settings=[
258
- azure.web.NameValuePairArgs(name="DOCKER_REGISTRY_SERVER_URL", value="https://index.docker.io/v1"),
374
+ azure.web.NameValuePairArgs(
375
+ name="DOCKER_REGISTRY_SERVER_URL",
376
+ value="https://index.docker.io/v1",
377
+ ),
259
378
  azure.web.NameValuePairArgs(name="DOCKER_ENABLE_CI", value="true"),
260
379
  # azure.web.NameValuePairArgs(name="WEBSITE_HTTPLOGGING_RETENTION_DAYS", value="7"),
261
380
  # pgAdmin settings
@@ -263,6 +382,9 @@ class DjangoDeployment(pulumi.ComponentResource):
263
382
  azure.web.NameValuePairArgs(name="PGADMIN_DEFAULT_EMAIL", value="dbadmin@dbadmin.net"),
264
383
  azure.web.NameValuePairArgs(name="PGADMIN_DEFAULT_PASSWORD", value="dbadmin"),
265
384
  ],
385
+ # IP restrictions
386
+ ip_security_restrictions_default_action=default_restriction,
387
+ ip_security_restrictions=ip_restrictions,
266
388
  ),
267
389
  )
268
390
 
@@ -274,6 +396,50 @@ class DjangoDeployment(pulumi.ComponentResource):
274
396
  share_name="pgadmin",
275
397
  )
276
398
 
399
+ if dns_zone:
400
+ # Create a DNS record for the pgAdmin app
401
+ cname = azure.network.RecordSet(
402
+ f"dns-cname-pgadmin-{self._name}",
403
+ resource_group_name=self._rg,
404
+ zone_name=dns_zone.name,
405
+ relative_record_set_name="pgadmin",
406
+ record_type="CNAME",
407
+ ttl=3600,
408
+ cname_record=azure.network.CnameRecordArgs(
409
+ cname=app.default_host_name,
410
+ ),
411
+ )
412
+
413
+ # For the certificate validation to work
414
+ txt_validation = azure.network.RecordSet(
415
+ f"dns-txt-pgadmin-{self._name}",
416
+ resource_group_name=self._rg,
417
+ zone_name=dns_zone.name,
418
+ relative_record_set_name="asuid.pgadmin",
419
+ record_type="TXT",
420
+ ttl=3600,
421
+ txt_records=[
422
+ azure.network.TxtRecordArgs(
423
+ value=[app.custom_domain_verification_id],
424
+ )
425
+ ],
426
+ )
427
+
428
+ # Add custom hostname
429
+ self._add_webapp_host(
430
+ app=app,
431
+ host=dns_zone.name.apply(lambda name: f"pgadmin.{name}"),
432
+ suffix=self._name,
433
+ depends_on=[cname, txt_validation],
434
+ identifier="pgadmin",
435
+ )
436
+
437
+ # Export the custom hostname
438
+ pulumi.export("pgadmin_url", dns_zone.name.apply(lambda name: f"https://pgadmin.{name}"))
439
+ else:
440
+ # Export the default hostname
441
+ pulumi.export("pgadmin_url", app.default_host_name.apply(lambda host: f"https://{host}"))
442
+
277
443
  # Mount the storage container
278
444
  azure.web.WebAppAzureStorageAccounts(
279
445
  f"app-pgadmin-mount-{self._name}",
@@ -290,9 +456,24 @@ class DjangoDeployment(pulumi.ComponentResource):
290
456
  },
291
457
  )
292
458
 
293
- pulumi.export("pgadmin_url", app.default_host_name.apply(lambda host: f"https://{host}"))
459
+ def _get_existing_web_app_host_name_binding(self, resource_group_name: str, app_name: str, host_name: str):
460
+ try:
461
+ return azure.web.get_web_app_host_name_binding(
462
+ resource_group_name=resource_group_name,
463
+ name=app_name,
464
+ host_name=host_name,
465
+ )
466
+ except Exception:
467
+ return None
294
468
 
295
- def _add_webapp_host(self, app: azure.web.WebApp, host: str, suffix: str):
469
+ def _add_webapp_host(
470
+ self,
471
+ app: azure.web.WebApp,
472
+ host: Union[str, pulumi.Input[str]],
473
+ suffix: str,
474
+ identifier: str,
475
+ depends_on: Optional[Sequence[pulumi.Resource]] = None,
476
+ ):
296
477
  """
297
478
  Because of a circular dependency, we need to create the certificate and the binding in two steps.
298
479
  First we create a binding without a certificate,
@@ -305,48 +486,93 @@ class DjangoDeployment(pulumi.ComponentResource):
305
486
  :param app: The web app
306
487
  :param host: The host name
307
488
  :param suffix: A suffix to make the resource name unique
489
+ :param depend_on: The resource to depend on (optional)
308
490
  """
309
491
 
310
- safe_host = host.replace(".", "-")
492
+ if not depends_on:
493
+ depends_on = []
311
494
 
312
- try:
313
- # Retrieve the existing binding - this will throw an exception if it doesn't exist
314
- azure.web.get_web_app_host_name_binding(
315
- resource_group_name=app.resource_group,
316
- name=app.name,
317
- host_name=host,
495
+ # Retrieve the existing binding (None if it doesn't exist)
496
+ existing_binding = pulumi.Output.all(app.resource_group, app.name, host).apply(
497
+ lambda args: self._get_existing_web_app_host_name_binding(
498
+ resource_group_name=args[0],
499
+ app_name=args[1],
500
+ host_name=args[2],
318
501
  )
502
+ )
319
503
 
320
- # Create a managed certificate
321
- # This will work because the binding exists actually
322
- certificate = azure.web.Certificate(
323
- f"cert-{suffix}-{safe_host}",
324
- resource_group_name=self._rg,
325
- server_farm_id=app.server_farm_id,
326
- canonical_name=host,
327
- host_names=[host],
328
- )
504
+ # Create an inline function that we will invoke through the Output.apply lambda
505
+ def _create_binding_with_cert(existing_binding):
506
+ if existing_binding:
507
+ # Create a managed certificate
508
+ # This will work because the binding exists actually
509
+ certificate = azure.web.Certificate(
510
+ f"cert-{suffix}-{identifier}",
511
+ resource_group_name=self._rg,
512
+ server_farm_id=app.server_farm_id,
513
+ canonical_name=host,
514
+ host_names=[host],
515
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
516
+ )
329
517
 
330
- # Create a new binding, replacing the old one,
331
- # with the certificate
332
- azure.web.WebAppHostNameBinding(
333
- f"host-binding-{suffix}-{safe_host}",
334
- resource_group_name=self._rg,
335
- name=app.name,
336
- host_name=host,
337
- ssl_state=azure.web.SslState.SNI_ENABLED,
338
- thumbprint=certificate.thumbprint,
339
- )
340
- except Exception:
341
- # Create a binding without a certificate
342
- azure.web.WebAppHostNameBinding(
343
- f"host-binding-{suffix}-{safe_host}",
518
+ # Create a new binding, replacing the old one,
519
+ # with the certificate
520
+ azure.web.WebAppHostNameBinding(
521
+ f"host-binding-{suffix}-{identifier}",
522
+ resource_group_name=self._rg,
523
+ name=app.name,
524
+ host_name=host,
525
+ ssl_state=azure.web.SslState.SNI_ENABLED,
526
+ thumbprint=certificate.thumbprint,
527
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
528
+ )
529
+
530
+ else:
531
+ # Create a binding without a certificate
532
+ azure.web.WebAppHostNameBinding(
533
+ f"host-binding-{suffix}-{identifier}",
534
+ resource_group_name=self._rg,
535
+ name=app.name,
536
+ host_name=host,
537
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
538
+ )
539
+
540
+ existing_binding.apply(_create_binding_with_cert)
541
+
542
+ def _create_comms_dns_records(self, suffix, host: HostDefinition, records: dict) -> list[azure.network.RecordSet]:
543
+ created_records = []
544
+
545
+ # Domain validation and SPF record (one TXT record with multiple values)
546
+ r = azure.network.RecordSet(
547
+ f"dns-comms-{suffix}-{host.identifier}-domain",
548
+ resource_group_name=self._rg,
549
+ zone_name=host.zone.name,
550
+ relative_record_set_name="@",
551
+ record_type="TXT",
552
+ ttl=3600,
553
+ txt_records=[
554
+ azure.network.TxtRecordArgs(value=[records["domain"]["value"]]),
555
+ azure.network.TxtRecordArgs(value=[records["s_pf"]["value"]]),
556
+ ],
557
+ )
558
+ created_records.append(r)
559
+
560
+ # DKIM records (two CNAME records)
561
+ for record in ("d_kim", "d_kim2"):
562
+ r = azure.network.RecordSet(
563
+ f"dns-comms-{suffix}-{host.identifier}-{record}",
344
564
  resource_group_name=self._rg,
345
- name=app.name,
346
- host_name=host,
565
+ zone_name=host.zone.name,
566
+ relative_record_set_name=records[record]["name"],
567
+ record_type="CNAME",
568
+ ttl=records[record]["ttl"],
569
+ cname_record=azure.network.CnameRecordArgs(cname=records[record]["value"]),
347
570
  )
571
+ created_records.append(r)
348
572
 
349
- def _add_webapp_comms(self, data_location: str, domains: list[str], suffix: str) -> azure.communication.CommunicationService:
573
+ return created_records
574
+
575
+ def _add_webapp_comms(self, data_location: str, domains: list[HostDefinition], suffix: str) -> azure.communication.CommunicationService:
350
576
  email_service = azure.communication.EmailService(
351
577
  f"comms-email-{suffix}",
352
578
  resource_group_name=self._rg,
@@ -355,30 +581,37 @@ class DjangoDeployment(pulumi.ComponentResource):
355
581
  )
356
582
 
357
583
  domain_resources = []
358
- if domains:
359
- # Add our own custom domains
360
- for domain in domains:
361
- safe_host = domain.replace(".", "-")
362
- d = azure.communication.Domain(
363
- f"comms-email-domain-{suffix}-{safe_host}",
364
- resource_group_name=self._rg,
365
- location="global",
366
- domain_management=azure.communication.DomainManagement.CUSTOMER_MANAGED,
367
- domain_name=domain,
368
- email_service_name=email_service.name,
369
- )
370
- domain_resources.append(d.id.apply(lambda n: n))
371
- else:
372
- # Add an Azure managed domain
584
+ comm_dependencies = []
585
+
586
+ # Add our own custom domains
587
+ for domain in domains:
373
588
  d = azure.communication.Domain(
374
- f"comms-email-domain-{suffix}-azure",
589
+ f"comms-email-domain-{suffix}-{domain.identifier}",
375
590
  resource_group_name=self._rg,
376
591
  location="global",
377
- domain_management=azure.communication.DomainManagement.AZURE_MANAGED,
378
- domain_name="AzureManagedDomain",
592
+ domain_management=azure.communication.DomainManagement.CUSTOMER_MANAGED,
593
+ domain_name=domain.full_host,
379
594
  email_service_name=email_service.name,
380
595
  )
381
- domain_resources.append(d.id.apply(lambda n: n))
596
+
597
+ if domain.zone:
598
+ # Create DNS records in the managed zone
599
+ comm_dependencies = pulumi.Output.all(suffix, domain, d.verification_records).apply(
600
+ lambda args: self._create_comms_dns_records(suffix=args[0], host=args[1], records=args[2])
601
+ )
602
+
603
+ domain_resources.append(d.id)
604
+
605
+ # Add an Azure managed domain
606
+ d = azure.communication.Domain(
607
+ f"comms-email-domain-{suffix}-azure",
608
+ resource_group_name=self._rg,
609
+ location="global",
610
+ domain_management=azure.communication.DomainManagement.AZURE_MANAGED,
611
+ domain_name="AzureManagedDomain",
612
+ email_service_name=email_service.name,
613
+ )
614
+ domain_resources.append(d.id)
382
615
 
383
616
  # Create Communication Services and link the domains
384
617
  comm_service = azure.communication.CommunicationService(
@@ -387,16 +620,25 @@ class DjangoDeployment(pulumi.ComponentResource):
387
620
  location="global",
388
621
  data_location=data_location,
389
622
  linked_domains=domain_resources,
623
+ opts=pulumi.ResourceOptions(depends_on=comm_dependencies),
390
624
  )
391
625
 
392
626
  return comm_service
393
627
 
394
628
  def _add_webapp_vault(self, administrators: list[str], suffix: str) -> azure.keyvault.Vault:
395
- # Create a keyvault
629
+ # Create a keyvault with a random suffix to make the name unique
630
+ random_suffix = pulumi_random.RandomString(
631
+ f"vault-suffix-{suffix}",
632
+ # Total length is 24, so deduct the length of the suffix
633
+ length=(24 - 7 - len(suffix)),
634
+ special=False,
635
+ upper=False,
636
+ )
637
+
396
638
  vault = azure.keyvault.Vault(
397
639
  f"vault-{suffix}",
398
640
  resource_group_name=self._rg,
399
- vault_name=f"vault-{suffix}",
641
+ vault_name=random_suffix.result.apply(lambda r: f"vault-{suffix}-{r}"),
400
642
  properties=azure.keyvault.VaultPropertiesArgs(
401
643
  tenant_id=self._tenant_id,
402
644
  sku=azure.keyvault.SkuArgs(
@@ -407,27 +649,35 @@ class DjangoDeployment(pulumi.ComponentResource):
407
649
  ),
408
650
  )
409
651
 
410
- # Find the Key Vault Administrator role
411
- administrator_role = vault.id.apply(
412
- lambda scope: azure.authorization.get_role_definition(
413
- role_definition_id="00482a5a-887f-4fb3-b363-3b7fe8e74483",
414
- scope=scope,
415
- )
416
- )
417
-
418
652
  # Add vault administrators
419
- for a in administrators:
420
- azure.authorization.RoleAssignment(
421
- f"ra-{suffix}-vault-admin-{a}",
422
- principal_id=a,
423
- principal_type=azure.authorization.PrincipalType.USER,
424
- role_definition_id=administrator_role.id,
425
- scope=vault.id,
653
+ if administrators:
654
+ # Find the Key Vault Administrator role
655
+ administrator_role = vault.id.apply(
656
+ lambda scope: azure.authorization.get_role_definition(
657
+ role_definition_id="00482a5a-887f-4fb3-b363-3b7fe8e74483",
658
+ scope=scope,
659
+ )
426
660
  )
427
661
 
662
+ # Actual administrator roles
663
+ for a in administrators:
664
+ azure.authorization.RoleAssignment(
665
+ f"ra-{suffix}-vault-admin-{a}",
666
+ principal_id=a,
667
+ principal_type=azure.authorization.PrincipalType.USER,
668
+ role_definition_id=administrator_role.id,
669
+ scope=vault.id,
670
+ )
671
+
428
672
  return vault
429
673
 
430
- def _add_webapp_secret(self, vault: azure.keyvault.Vault, secret_name: str, config_secret_name: str, suffix: str):
674
+ def _add_webapp_secret(
675
+ self,
676
+ vault: azure.keyvault.Vault,
677
+ secret_name: str,
678
+ config_secret_name: str,
679
+ suffix: str,
680
+ ):
431
681
  secret = self._config.require_secret(config_secret_name)
432
682
 
433
683
  # Normalize the secret name
@@ -487,13 +737,13 @@ class DjangoDeployment(pulumi.ComponentResource):
487
737
  db_name: str,
488
738
  repository_url: str,
489
739
  repository_branch: str,
490
- website_hosts: list[str],
740
+ website_hosts: list[HostDefinition],
491
741
  django_settings_module: str,
492
- environment_variables: dict[str, str] = {},
493
- secrets: dict[str, str] = {},
742
+ environment_variables: Optional[dict[str, str]] = None,
743
+ secrets: Optional[dict[str, str]] = None,
494
744
  comms_data_location: Optional[str] = None,
495
- comms_domains: Optional[list[str]] = [],
496
- vault_administrators: Optional[list[str]] = [],
745
+ comms_domains: Optional[list[HostDefinition]] = None,
746
+ vault_administrators: Optional[list[str]] = None,
497
747
  ) -> azure.web.WebApp:
498
748
  """
499
749
  Create a Django website with it's own database and storage containers.
@@ -541,7 +791,11 @@ class DjangoDeployment(pulumi.ComponentResource):
541
791
 
542
792
  # Communication Services (optional)
543
793
  if comms_data_location:
794
+ if not comms_domains:
795
+ comms_domains = []
796
+
544
797
  comms = self._add_webapp_comms(comms_data_location, comms_domains, f"{name}-{self._name}")
798
+
545
799
  # Add the service endpoint as environment variable
546
800
  environment_variables["AZURE_COMMUNICATION_SERVICE_ENDPOINT"] = comms.host_name.apply(lambda host: f"https://{host}")
547
801
  else:
@@ -567,6 +821,8 @@ class DjangoDeployment(pulumi.ComponentResource):
567
821
  for key, value in environment_variables.items()
568
822
  ]
569
823
 
824
+ allowed_hosts = pulumi.Output.concat(*[pulumi.Output.concat(host.full_host, ",") for host in website_hosts])
825
+
570
826
  app = azure.web.WebApp(
571
827
  f"app-{name}-{self._name}",
572
828
  resource_group_name=self._rg,
@@ -577,6 +833,7 @@ class DjangoDeployment(pulumi.ComponentResource):
577
833
  ),
578
834
  https_only=True,
579
835
  site_config=azure.web.SiteConfigArgs(
836
+ app_command_line="cicd/startup.sh",
580
837
  always_on=True,
581
838
  health_check_path=self.HEALTH_CHECK_PATH,
582
839
  ftps_state=azure.web.FtpsState.DISABLED,
@@ -594,15 +851,23 @@ class DjangoDeployment(pulumi.ComponentResource):
594
851
  # azure.web.NameValuePairArgs(name="DEBUG", value="true"),
595
852
  azure.web.NameValuePairArgs(name="DJANGO_SETTINGS_MODULE", value=django_settings_module),
596
853
  azure.web.NameValuePairArgs(name="DJANGO_SECRET_KEY", value=secret_key.result),
597
- azure.web.NameValuePairArgs(name="DJANGO_ALLOWED_HOSTS", value=",".join(website_hosts)),
854
+ azure.web.NameValuePairArgs(name="DJANGO_ALLOWED_HOSTS", value=allowed_hosts),
598
855
  # Vault settings
599
856
  azure.web.NameValuePairArgs(name="AZURE_KEY_VAULT", value=vault.name),
600
857
  # Storage settings
601
- azure.web.NameValuePairArgs(name="AZURE_STORAGE_ACCOUNT_NAME", value=self._storage_account.name),
602
- azure.web.NameValuePairArgs(name="AZURE_STORAGE_CONTAINER_STATICFILES", value=static_container.name),
858
+ azure.web.NameValuePairArgs(
859
+ name="AZURE_STORAGE_ACCOUNT_NAME",
860
+ value=self._storage_account.name,
861
+ ),
862
+ azure.web.NameValuePairArgs(
863
+ name="AZURE_STORAGE_CONTAINER_STATICFILES",
864
+ value=static_container.name,
865
+ ),
603
866
  azure.web.NameValuePairArgs(name="AZURE_STORAGE_CONTAINER_MEDIA", value=media_container.name),
604
867
  # CDN
605
868
  azure.web.NameValuePairArgs(name="CDN_HOST", value=self._cdn_host),
869
+ azure.web.NameValuePairArgs(name="CDN_PROFILE", value=self._cdn_profile.name),
870
+ azure.web.NameValuePairArgs(name="CDN_ENDPOINT", value=self._cdn_endpoint.name),
606
871
  # Database settings
607
872
  azure.web.NameValuePairArgs(name="DB_HOST", value=self._pgsql.fully_qualified_domain_name),
608
873
  azure.web.NameValuePairArgs(name="DB_NAME", value=db.name),
@@ -620,9 +885,85 @@ class DjangoDeployment(pulumi.ComponentResource):
620
885
  # We need this to verify custom domains
621
886
  pulumi.export(f"{name}_site_domain_verification_id", app.custom_domain_verification_id)
622
887
  pulumi.export(f"{name}_site_domain_cname", app.default_host_name)
888
+ virtual_ip = app.outbound_ip_addresses.apply(lambda addresses: addresses.split(",")[-1])
889
+ pulumi.export(f"{name}_site_virtual_ip", virtual_ip)
890
+
891
+ # Get the URL of the publish profile.
892
+ # Use app.identity here too to ensure the app is actually created before getting credentials.
893
+ credentials = pulumi.Output.all(self._rg, app.name, app.identity).apply(
894
+ lambda args: azure.web.list_web_app_publishing_credentials(
895
+ resource_group_name=args[0],
896
+ name=args[1],
897
+ )
898
+ )
899
+
900
+ pulumi.export(f"{name}_deploy_url", pulumi.Output.concat(credentials.scm_uri, "/deploy"))
623
901
 
624
902
  for host in website_hosts:
625
- self._add_webapp_host(app=app, host=host, suffix=f"{name}-{self._name}")
903
+ dependencies = []
904
+
905
+ if host.zone:
906
+ # Create a DNS record in the zone
907
+
908
+ if host.host == "@":
909
+ # Create a A record for the virtual IP address
910
+ a = azure.network.RecordSet(
911
+ f"dns-a-{name}-{self._name}-{host.identifier}",
912
+ resource_group_name=self._rg,
913
+ zone_name=host.zone.name,
914
+ relative_record_set_name=host.host,
915
+ record_type="A",
916
+ ttl=3600,
917
+ a_records=[
918
+ azure.network.ARecordArgs(
919
+ ipv4_address=virtual_ip,
920
+ )
921
+ ],
922
+ )
923
+
924
+ dependencies.append(a)
925
+ else:
926
+ # Create a CNAME record for the custom hostname
927
+ cname = azure.network.RecordSet(
928
+ f"dns-cname-{name}-{self._name}-{host.identifier}",
929
+ resource_group_name=self._rg,
930
+ zone_name=host.zone.name,
931
+ relative_record_set_name=host.host,
932
+ record_type="CNAME",
933
+ ttl=3600,
934
+ cname_record=azure.network.CnameRecordArgs(
935
+ cname=app.default_host_name,
936
+ ),
937
+ )
938
+ dependencies.append(cname)
939
+
940
+ # For the certificate validation to work
941
+ relative_record_set_name = "asuid" if host.host == "@" else pulumi.Output.concat("asuid.", host.host)
942
+
943
+ txt_validation = azure.network.RecordSet(
944
+ f"dns-txt-{name}-{self._name}-{host.identifier}",
945
+ resource_group_name=self._rg,
946
+ zone_name=host.zone.name,
947
+ relative_record_set_name=relative_record_set_name,
948
+ record_type="TXT",
949
+ ttl=3600,
950
+ txt_records=[
951
+ azure.network.TxtRecordArgs(
952
+ value=[app.custom_domain_verification_id],
953
+ )
954
+ ],
955
+ )
956
+
957
+ dependencies.append(txt_validation)
958
+
959
+ # Add the host with optional dependencies
960
+ self._add_webapp_host(
961
+ app=app,
962
+ host=host.full_host,
963
+ suffix=f"{name}-{self._name}",
964
+ identifier=host.identifier,
965
+ depends_on=dependencies,
966
+ )
626
967
 
627
968
  # To enable deployment from GitLab
628
969
  azure.web.WebAppSourceControl(
@@ -638,7 +979,8 @@ class DjangoDeployment(pulumi.ComponentResource):
638
979
 
639
980
  # Where we can retrieve the SSH key
640
981
  pulumi.export(
641
- f"{name}_deploy_ssh_key_url", app.name.apply(lambda name: f"https://{name}.scm.azurewebsites.net/api/sshkey?ensurePublicKey=1")
982
+ f"{name}_deploy_ssh_key_url",
983
+ app.name.apply(lambda name: f"https://{name}.scm.azurewebsites.net/api/sshkey?ensurePublicKey=1"),
642
984
  )
643
985
 
644
986
  # Find the role for Key Vault Secrets User
@@ -694,28 +1036,29 @@ class DjangoDeployment(pulumi.ComponentResource):
694
1036
  scope=comms.id,
695
1037
  )
696
1038
 
697
- # Create a CORS rules for this website
698
- if website_hosts:
699
- origins = [f"https://{host}" for host in website_hosts]
700
- else:
701
- origins = ["*"]
1039
+ # Grant the app to purge the CDN endpoint
1040
+ cdn_role = self._cdn_endpoint.id.apply(
1041
+ lambda scope: azure.authorization.get_role_definition(
1042
+ role_definition_id="/426e0c7f-0c7e-4658-b36f-ff54d6c29b45",
1043
+ scope=scope,
1044
+ )
1045
+ )
702
1046
 
703
- azure.storage.BlobServiceProperties(
704
- f"sa-{name}-blob-properties",
705
- resource_group_name=self._rg,
706
- account_name=self._storage_account.name,
707
- blob_services_name="default",
708
- cors=azure.storage.CorsRulesArgs(
709
- cors_rules=[
710
- azure.storage.CorsRuleArgs(
711
- allowed_headers=["*"],
712
- allowed_methods=["GET", "OPTIONS", "HEAD"],
713
- allowed_origins=origins,
714
- exposed_headers=["Access-Control-Allow-Origin"],
715
- max_age_in_seconds=86400,
716
- )
717
- ]
718
- ),
1047
+ azure.authorization.RoleAssignment(
1048
+ f"ra-{name}-cdn",
1049
+ principal_id=principal_id,
1050
+ principal_type=azure.authorization.PrincipalType.SERVICE_PRINCIPAL,
1051
+ role_definition_id=cdn_role.id,
1052
+ scope=self._cdn_endpoint.id,
719
1053
  )
720
1054
 
721
1055
  return app
1056
+
1057
+ def _strip_off_dns_zone_name(self, host: str, zone: azure.network.Zone) -> pulumi.Output[str]:
1058
+ """
1059
+ Strip off the DNS zone name from the host name.
1060
+
1061
+ :param host: The host name
1062
+ :return: The host name without the DNS zone
1063
+ """
1064
+ return zone.name.apply(lambda name: host[: -len(name) - 1])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pulumi-django-azure
3
- Version: 1.0.9
3
+ Version: 1.0.15
4
4
  Summary: Simply deployment of Django on Azure with Pulumi
5
5
  Author-email: Maarten Ureel <maarten@youreal.eu>
6
6
  License: MIT License
@@ -50,6 +50,19 @@ To have a proper and secure environment, we need these components:
50
50
  * Azure Key Vault per application
51
51
  * Webapp running pgAdmin
52
52
 
53
+ ## Project requirements
54
+ Your Django project should contain a folder `cicd` with these files:
55
+ * pre_build.sh: commands to be executed before building the application, for example NPM install, CSS build commands,...
56
+ * post_build.sh: commands to be executed after building the application, e.g. cleaning up.
57
+ Note that this runs in the identity of the build container, so you should not run database or storage manipulations here.
58
+ * startup.sh: commands to run the actual application. I recommend to put at least:
59
+ ```bash
60
+ python manage.py migrate
61
+ python manage.py collectstatic --noinput
62
+ python manage.py purge_cdn
63
+ gunicorn --timeout 600 --workers $((($NUM_CORES*2)+1)) --chdir $APP_PATH yourapplication.wsgi --access-logfile '-' --error-logfile '-'
64
+ ```
65
+ Be sure to change `yourapplication` in the above. To see the `purge_cdn` management command we use here, see below.
53
66
  ## Installation
54
67
  This package is published on PyPi, so you can just add pulumi-django-azure to your requirements file.
55
68
 
@@ -244,6 +257,82 @@ Be sure to configure the SSH key that Azure will use on GitLab side. You can obt
244
257
 
245
258
  This would then trigger a redeploy everytime you make a commit to your live branch.
246
259
 
260
+ ## CDN Purging
261
+ We added a management command to Django to purge the CDN cache, and added that to the startup script. Our version is here:
262
+ ```python
263
+ import os
264
+
265
+ from azure.mgmt.cdn import CdnManagementClient
266
+ from azure.mgmt.cdn.models import PurgeParameters
267
+ from django.core.management.base import BaseCommand
268
+
269
+ from core.azure_helper import AZURE_CREDENTIAL, get_subscription_id
270
+
271
+
272
+ class Command(BaseCommand):
273
+ help = "Purges the CDN endpoint"
274
+
275
+ def add_arguments(self, parser):
276
+ parser.add_argument(
277
+ "--wait",
278
+ action="store_true",
279
+ help="Wait for the purge operation to complete",
280
+ )
281
+
282
+ def handle(self, *args, **options):
283
+ # Read environment variables
284
+ resource_group = os.getenv("WEBSITE_RESOURCE_GROUP")
285
+ profile_name = os.getenv("CDN_PROFILE")
286
+ endpoint_name = os.getenv("CDN_ENDPOINT")
287
+ content_paths = ["/*"]
288
+
289
+ # Ensure all required environment variables are set
290
+ if not all([resource_group, profile_name, endpoint_name]):
291
+ self.stderr.write(self.style.ERROR("Missing required environment variables."))
292
+ return
293
+
294
+ # Authenticate with Azure
295
+ cdn_client = CdnManagementClient(AZURE_CREDENTIAL, get_subscription_id())
296
+
297
+ try:
298
+ # Purge the CDN endpoint
299
+ purge_operation = cdn_client.endpoints.begin_purge_content(
300
+ resource_group_name=resource_group,
301
+ profile_name=profile_name,
302
+ endpoint_name=endpoint_name,
303
+ content_file_paths=PurgeParameters(content_paths=content_paths),
304
+ )
305
+
306
+ # Check if the --wait argument was provided
307
+ if options["wait"]:
308
+ purge_operation.result() # Wait for the operation to complete
309
+ self.stdout.write(self.style.SUCCESS("CDN endpoint purge operation completed successfully."))
310
+ else:
311
+ self.stdout.write(self.style.SUCCESS("CDN endpoint purge operation started successfully."))
312
+
313
+ except Exception as e:
314
+ self.stderr.write(self.style.ERROR(f"Error executing CDN endpoint purge command: {e}"))
315
+ ```
316
+
317
+ And our azure_helper:
318
+ ```python
319
+ from azure.identity import DefaultAzureCredential
320
+ from azure.mgmt.resource import SubscriptionClient
321
+
322
+ # Azure credentials
323
+ AZURE_CREDENTIAL = DefaultAzureCredential()
324
+
325
+
326
+ def get_db_password() -> str:
327
+ return AZURE_CREDENTIAL.get_token("https://ossrdbms-aad.database.windows.net/.default").token
328
+
329
+
330
+ def get_subscription_id() -> str:
331
+ subscription_client = SubscriptionClient(AZURE_CREDENTIAL)
332
+ subscriptions = list(subscription_client.subscriptions.list())
333
+ return subscriptions[0].subscription_id
334
+ ```
335
+
247
336
  ## Change requests
248
337
  I created this for internal use but since it took me a while to puzzle all the things together I decided to share it.
249
338
  Therefore this project is not super generic, but tailored to my needs. I am however open to pull or change requests to improve this project or to make it more usable for others.
@@ -1 +0,0 @@
1
- from .django_deployment import DjangoDeployment # noqa: F401