pulumi-django-azure 1.0.11__tar.gz → 1.0.17__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.

@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: pulumi-django-azure
3
- Version: 1.0.11
3
+ Version: 1.0.17
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
@@ -33,9 +33,9 @@ Classifier: Programming Language :: Python :: 3
33
33
  Requires-Python: >=3.9
34
34
  Description-Content-Type: text/markdown
35
35
  License-File: LICENSE
36
- Requires-Dist: pulumi>=3.99.0
37
- Requires-Dist: pulumi-azure-native>=2.24.0
38
- Requires-Dist: pulumi-random>=4.14.0
36
+ Requires-Dist: pulumi>=3.146.0
37
+ Requires-Dist: pulumi-azure-native>=2.82.0
38
+ Requires-Dist: pulumi-random>=4.17.0
39
39
 
40
40
  # Pulumi Django Deployment
41
41
 
@@ -59,9 +59,10 @@ Your Django project should contain a folder `cicd` with these files:
59
59
  ```bash
60
60
  python manage.py migrate
61
61
  python manage.py collectstatic --noinput
62
+ python manage.py purge_cdn
62
63
  gunicorn --timeout 600 --workers $((($NUM_CORES*2)+1)) --chdir $APP_PATH yourapplication.wsgi --access-logfile '-' --error-logfile '-'
63
64
  ```
64
- Be sure to change `yourapplication` in the above.
65
+ Be sure to change `yourapplication` in the above. To see the `purge_cdn` management command we use here, see below.
65
66
  ## Installation
66
67
  This package is published on PyPi, so you can just add pulumi-django-azure to your requirements file.
67
68
 
@@ -256,6 +257,82 @@ Be sure to configure the SSH key that Azure will use on GitLab side. You can obt
256
257
 
257
258
  This would then trigger a redeploy everytime you make a commit to your live branch.
258
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
+
259
336
  ## Change requests
260
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.
261
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.
@@ -20,9 +20,10 @@ Your Django project should contain a folder `cicd` with these files:
20
20
  ```bash
21
21
  python manage.py migrate
22
22
  python manage.py collectstatic --noinput
23
+ python manage.py purge_cdn
23
24
  gunicorn --timeout 600 --workers $((($NUM_CORES*2)+1)) --chdir $APP_PATH yourapplication.wsgi --access-logfile '-' --error-logfile '-'
24
25
  ```
25
- Be sure to change `yourapplication` in the above.
26
+ Be sure to change `yourapplication` in the above. To see the `purge_cdn` management command we use here, see below.
26
27
  ## Installation
27
28
  This package is published on PyPi, so you can just add pulumi-django-azure to your requirements file.
28
29
 
@@ -217,6 +218,82 @@ Be sure to configure the SSH key that Azure will use on GitLab side. You can obt
217
218
 
218
219
  This would then trigger a redeploy everytime you make a commit to your live branch.
219
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
+
220
297
  ## Change requests
221
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.
222
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.11"
7
+ version = "1.0.17"
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" }]
@@ -16,9 +16,9 @@ classifiers = [
16
16
  ]
17
17
  keywords = ["django", "pulumi", "azure"]
18
18
  dependencies = [
19
- "pulumi >= 3.99.0",
20
- "pulumi-azure-native >= 2.24.0",
21
- "pulumi-random >= 4.14.0",
19
+ "pulumi (>=3.146.0)",
20
+ "pulumi-azure-native (>=2.82.0)",
21
+ "pulumi-random (>=4.17.0)",
22
22
  ]
23
23
  requires-python = ">=3.9"
24
24
 
@@ -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.11"
30
+ version = "1.0.17"
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.82.0"
37
+ pulumi = ">=3.146.0"
38
+ pulumi-random = ">=4.17.0"
39
39
 
40
40
  [tool.poetry.group.dev.dependencies]
41
- twine = "^4.0.2"
42
- build = "^1.0.3"
41
+ twine = "^6.0.1"
42
+ build = "^1.2.2.post1"
43
+ ruff = "^0.9.2"
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,59 @@
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
+ def __eq__(self, other):
24
+ return self.host == other.host and self.zone.name == other.zone.name
25
+
26
+ @property
27
+ def identifier(self) -> str:
28
+ """
29
+ The identifier for this host definition.
30
+
31
+ :return: The identifier
32
+ """
33
+ if not self._identifier:
34
+ if self.zone:
35
+ raise ValueError(f"An identifier is required for the HostDefinition with host '{self.host}' ensure uniqueness.")
36
+ else:
37
+ # Use the host name as the identifier
38
+ return self.host.replace(".", "-")
39
+ else:
40
+ return self._identifier
41
+
42
+ @property
43
+ def full_host(self) -> pulumi.Output[str]:
44
+ """
45
+ The full host name, including the zone.
46
+
47
+ :return: The full host name
48
+ """
49
+ if not self.zone:
50
+ return pulumi.Output.concat(self.host)
51
+ elif self.host == "@":
52
+ return self.zone.name
53
+ else:
54
+ return pulumi.Output.concat(self.host, ".", self.zone.name)
55
+
56
+
8
57
  class DjangoDeployment(pulumi.ComponentResource):
9
58
  HEALTH_CHECK_PATH = "/health-check"
10
59
 
@@ -16,10 +65,13 @@ class DjangoDeployment(pulumi.ComponentResource):
16
65
  vnet: azure.network.VirtualNetwork,
17
66
  pgsql_sku: azure.dbforpostgresql.SkuArgs,
18
67
  pgsql_ip_prefix: str,
19
- appservice_ip_prefix: str,
68
+ app_service_ip_prefix: str,
20
69
  app_service_sku: azure.web.SkuDescriptionArgs,
21
70
  storage_account_name: str,
22
- cdn_host: Optional[str],
71
+ storage_allowed_origins: Optional[Sequence[str]] = None,
72
+ pgadmin_access_ip: Optional[Sequence[str]] = None,
73
+ pgadmin_dns_zone: Optional[azure.network.Zone] = None,
74
+ cdn_host: Optional[HostDefinition] = None,
23
75
  opts=None,
24
76
  ):
25
77
  """
@@ -31,10 +83,13 @@ class DjangoDeployment(pulumi.ComponentResource):
31
83
  :param vnet: The virtual network to create the subnets in.
32
84
  :param pgsql_sku: The SKU for the PostgreSQL server.
33
85
  :param pgsql_ip_prefix: The IP prefix for the PostgreSQL subnet.
34
- :param appservice_ip_prefix: The IP prefix for the app service subnet.
86
+ :param app_service_ip_prefix: The IP prefix for the app service subnet.
35
87
  :param app_service_sku: The SKU for the app service plan.
36
88
  :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)
89
+ :param storage_allowed_origins: The origins (hosts) to allow access through CORS policy. You can specify '*' to allow all.
90
+ :param pgadmin_access_ip: The IP addresses to allow access to pgAdmin. If empty, all IP addresses are allowed.
91
+ :param pgadmin_dns_zone: The Azure DNS zone to a pgadmin DNS record in. (optional)
92
+ :param cdn_host: A custom CDN host name. (optional)
38
93
  :param opts: The resource options
39
94
  """
40
95
 
@@ -49,7 +104,7 @@ class DjangoDeployment(pulumi.ComponentResource):
49
104
  self._vnet = vnet
50
105
 
51
106
  # Storage resources
52
- self._create_storage(account_name=storage_account_name)
107
+ self._create_storage(account_name=storage_account_name, allowed_origins=storage_allowed_origins)
53
108
  self._cdn_host = self._create_cdn(custom_host=cdn_host)
54
109
 
55
110
  # PostgreSQL resources
@@ -58,7 +113,7 @@ class DjangoDeployment(pulumi.ComponentResource):
58
113
  # Subnet for the apps
59
114
  self._app_subnet = self._create_subnet(
60
115
  name="app-service",
61
- prefix=appservice_ip_prefix,
116
+ prefix=app_service_ip_prefix,
62
117
  delegation_service="Microsoft.Web/serverFarms",
63
118
  service_endpoints=["Microsoft.Storage"],
64
119
  )
@@ -67,9 +122,9 @@ class DjangoDeployment(pulumi.ComponentResource):
67
122
  self._app_service_plan = self._create_app_service_plan(sku=app_service_sku)
68
123
 
69
124
  # Create a pgAdmin app
70
- self._create_pgadmin_app()
125
+ self._create_pgadmin_app(access_ip=pgadmin_access_ip, dns_zone=pgadmin_dns_zone)
71
126
 
72
- def _create_storage(self, account_name: str):
127
+ def _create_storage(self, account_name: str, allowed_origins: Optional[Sequence[str]] = None):
73
128
  # Create blob storage
74
129
  self._storage_account = azure.storage.StorageAccount(
75
130
  f"sa-{self._name}",
@@ -85,7 +140,26 @@ class DjangoDeployment(pulumi.ComponentResource):
85
140
  enable_https_traffic_only=True,
86
141
  )
87
142
 
88
- def _create_cdn(self, custom_host: Optional[str]) -> pulumi.Output[str]:
143
+ if allowed_origins:
144
+ azure.storage.BlobServiceProperties(
145
+ f"sa-{self._name}-blob-properties",
146
+ resource_group_name=self._rg,
147
+ account_name=self._storage_account.name,
148
+ blob_services_name="default",
149
+ cors=azure.storage.CorsRulesArgs(
150
+ cors_rules=[
151
+ azure.storage.CorsRuleArgs(
152
+ allowed_headers=["*"],
153
+ allowed_methods=["GET", "OPTIONS", "HEAD"],
154
+ allowed_origins=allowed_origins,
155
+ exposed_headers=["Access-Control-Allow-Origin"],
156
+ max_age_in_seconds=86400,
157
+ )
158
+ ]
159
+ ),
160
+ )
161
+
162
+ def _create_cdn(self, custom_host: Optional[HostDefinition]) -> pulumi.Output[str]:
89
163
  """
90
164
  Create a CDN endpoint. If a host name is given, it will be used as the custom domain.
91
165
  Otherwise, the default CDN host name will be returned.
@@ -128,22 +202,48 @@ class DjangoDeployment(pulumi.ComponentResource):
128
202
  profile_name=self._cdn_profile.name,
129
203
  origin_host_header=endpoint_origin,
130
204
  origins=[azure.cdn.DeepCreatedOriginArgs(name="origin-storage", host_name=endpoint_origin)],
131
- query_string_caching_behavior=azure.cdn.QueryStringCachingBehavior.IGNORE_QUERY_STRING,
205
+ query_string_caching_behavior=azure.cdn.QueryStringCachingBehavior.USE_QUERY_STRING,
132
206
  )
133
207
 
134
208
  pulumi.export("cdn_cname", self._cdn_endpoint.host_name)
135
209
 
136
210
  # Add custom domain if given
137
211
  if custom_host:
138
- azure.cdn.CustomDomain(
139
- f"cdn-custom-domain-{self._name}",
140
- resource_group_name=self._rg,
141
- profile_name=self._cdn_profile.name,
142
- endpoint_name=self._cdn_endpoint.name,
143
- host_name=custom_host,
144
- )
212
+ if custom_host.zone:
213
+ # Create a DNS record for the custom host in the given zone
214
+ rs = azure.network.RecordSet(
215
+ f"cdn-cname-{self._name}",
216
+ resource_group_name=self._rg,
217
+ zone_name=custom_host.zone.name,
218
+ relative_record_set_name=custom_host.host,
219
+ record_type="CNAME",
220
+ ttl=3600,
221
+ target_resource=azure.network.SubResourceArgs(
222
+ id=self._cdn_endpoint.id,
223
+ ),
224
+ )
225
+
226
+ azure.cdn.CustomDomain(
227
+ f"cdn-custom-domain-{self._name}",
228
+ resource_group_name=self._rg,
229
+ profile_name=self._cdn_profile.name,
230
+ endpoint_name=self._cdn_endpoint.name,
231
+ host_name=custom_host.full_host,
232
+ opts=pulumi.ResourceOptions(depends_on=rs),
233
+ )
145
234
 
146
- return custom_host
235
+ return custom_host.full_host
236
+ else:
237
+ # Add custom hostname without a zone
238
+ azure.cdn.CustomDomain(
239
+ f"cdn-custom-domain-{self._name}",
240
+ resource_group_name=self._rg,
241
+ profile_name=self._cdn_profile.name,
242
+ endpoint_name=self._cdn_endpoint.name,
243
+ host_name=custom_host.host,
244
+ )
245
+
246
+ return custom_host.host
147
247
  else:
148
248
  # Return the default CDN host name
149
249
  return self._cdn_endpoint.host_name
@@ -200,7 +300,11 @@ class DjangoDeployment(pulumi.ComponentResource):
200
300
  pulumi.export("pgsql_host", self._pgsql.fully_qualified_domain_name)
201
301
 
202
302
  def _create_subnet(
203
- self, name, prefix, delegation_service: Optional[str] = None, service_endpoints: Sequence[str] = []
303
+ self,
304
+ name,
305
+ prefix,
306
+ delegation_service: Optional[str] = None,
307
+ service_endpoints: Sequence[str] = [],
204
308
  ) -> azure.network.Subnet:
205
309
  """
206
310
  Generic method to create a subnet with a delegation.
@@ -239,7 +343,22 @@ class DjangoDeployment(pulumi.ComponentResource):
239
343
  sku=sku,
240
344
  )
241
345
 
242
- def _create_pgadmin_app(self):
346
+ def _create_pgadmin_app(self, access_ip: Optional[Sequence[str]] = None, dns_zone: Optional[azure.network.Zone] = None):
347
+ # Determine the IP restrictions
348
+ ip_restrictions = []
349
+ default_restriction = azure.web.DefaultAction.ALLOW
350
+ if access_ip:
351
+ default_restriction = azure.web.DefaultAction.DENY
352
+
353
+ for ip in access_ip:
354
+ ip_restrictions.append(
355
+ azure.web.IpSecurityRestrictionArgs(
356
+ action="Allow",
357
+ ip_address=ip,
358
+ priority=300,
359
+ )
360
+ )
361
+
243
362
  # The app itself
244
363
  app = azure.web.WebApp(
245
364
  f"app-pgadmin-{self._name}",
@@ -255,7 +374,10 @@ class DjangoDeployment(pulumi.ComponentResource):
255
374
  linux_fx_version="DOCKER|dpage/pgadmin4",
256
375
  health_check_path="/misc/ping",
257
376
  app_settings=[
258
- azure.web.NameValuePairArgs(name="DOCKER_REGISTRY_SERVER_URL", value="https://index.docker.io/v1"),
377
+ azure.web.NameValuePairArgs(
378
+ name="DOCKER_REGISTRY_SERVER_URL",
379
+ value="https://index.docker.io/v1",
380
+ ),
259
381
  azure.web.NameValuePairArgs(name="DOCKER_ENABLE_CI", value="true"),
260
382
  # azure.web.NameValuePairArgs(name="WEBSITE_HTTPLOGGING_RETENTION_DAYS", value="7"),
261
383
  # pgAdmin settings
@@ -263,6 +385,9 @@ class DjangoDeployment(pulumi.ComponentResource):
263
385
  azure.web.NameValuePairArgs(name="PGADMIN_DEFAULT_EMAIL", value="dbadmin@dbadmin.net"),
264
386
  azure.web.NameValuePairArgs(name="PGADMIN_DEFAULT_PASSWORD", value="dbadmin"),
265
387
  ],
388
+ # IP restrictions
389
+ ip_security_restrictions_default_action=default_restriction,
390
+ ip_security_restrictions=ip_restrictions,
266
391
  ),
267
392
  )
268
393
 
@@ -274,6 +399,50 @@ class DjangoDeployment(pulumi.ComponentResource):
274
399
  share_name="pgadmin",
275
400
  )
276
401
 
402
+ if dns_zone:
403
+ # Create a DNS record for the pgAdmin app
404
+ cname = azure.network.RecordSet(
405
+ f"dns-cname-pgadmin-{self._name}",
406
+ resource_group_name=self._rg,
407
+ zone_name=dns_zone.name,
408
+ relative_record_set_name="pgadmin",
409
+ record_type="CNAME",
410
+ ttl=3600,
411
+ cname_record=azure.network.CnameRecordArgs(
412
+ cname=app.default_host_name,
413
+ ),
414
+ )
415
+
416
+ # For the certificate validation to work
417
+ txt_validation = azure.network.RecordSet(
418
+ f"dns-txt-pgadmin-{self._name}",
419
+ resource_group_name=self._rg,
420
+ zone_name=dns_zone.name,
421
+ relative_record_set_name="asuid.pgadmin",
422
+ record_type="TXT",
423
+ ttl=3600,
424
+ txt_records=[
425
+ azure.network.TxtRecordArgs(
426
+ value=[app.custom_domain_verification_id],
427
+ )
428
+ ],
429
+ )
430
+
431
+ # Add custom hostname
432
+ self._add_webapp_host(
433
+ app=app,
434
+ host=dns_zone.name.apply(lambda name: f"pgadmin.{name}"),
435
+ suffix=self._name,
436
+ depends_on=[cname, txt_validation],
437
+ identifier="pgadmin",
438
+ )
439
+
440
+ # Export the custom hostname
441
+ pulumi.export("pgadmin_url", dns_zone.name.apply(lambda name: f"https://pgadmin.{name}"))
442
+ else:
443
+ # Export the default hostname
444
+ pulumi.export("pgadmin_url", app.default_host_name.apply(lambda host: f"https://{host}"))
445
+
277
446
  # Mount the storage container
278
447
  azure.web.WebAppAzureStorageAccounts(
279
448
  f"app-pgadmin-mount-{self._name}",
@@ -290,9 +459,24 @@ class DjangoDeployment(pulumi.ComponentResource):
290
459
  },
291
460
  )
292
461
 
293
- pulumi.export("pgadmin_url", app.default_host_name.apply(lambda host: f"https://{host}"))
462
+ def _get_existing_web_app_host_name_binding(self, resource_group_name: str, app_name: str, host_name: str):
463
+ try:
464
+ return azure.web.get_web_app_host_name_binding(
465
+ resource_group_name=resource_group_name,
466
+ name=app_name,
467
+ host_name=host_name,
468
+ )
469
+ except Exception:
470
+ return None
294
471
 
295
- def _add_webapp_host(self, app: azure.web.WebApp, host: str, suffix: str):
472
+ def _add_webapp_host(
473
+ self,
474
+ app: azure.web.WebApp,
475
+ host: Union[str, pulumi.Input[str]],
476
+ suffix: str,
477
+ identifier: str,
478
+ depends_on: Optional[Sequence[pulumi.Resource]] = None,
479
+ ):
296
480
  """
297
481
  Because of a circular dependency, we need to create the certificate and the binding in two steps.
298
482
  First we create a binding without a certificate,
@@ -305,48 +489,98 @@ class DjangoDeployment(pulumi.ComponentResource):
305
489
  :param app: The web app
306
490
  :param host: The host name
307
491
  :param suffix: A suffix to make the resource name unique
492
+ :param depend_on: The resource to depend on (optional)
308
493
  """
309
494
 
310
- safe_host = host.replace(".", "-")
495
+ if not depends_on:
496
+ depends_on = []
311
497
 
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,
498
+ # Retrieve the existing binding (None if it doesn't exist)
499
+ existing_binding = pulumi.Output.all(app.resource_group, app.name, host).apply(
500
+ lambda args: self._get_existing_web_app_host_name_binding(
501
+ resource_group_name=args[0],
502
+ app_name=args[1],
503
+ host_name=args[2],
318
504
  )
505
+ )
319
506
 
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
- )
507
+ # Create an inline function that we will invoke through the Output.apply lambda
508
+ def _create_binding_with_cert(existing_binding):
509
+ if existing_binding:
510
+ # Create a managed certificate
511
+ # This will work because the binding exists actually
512
+ certificate = azure.web.Certificate(
513
+ f"cert-{suffix}-{identifier}",
514
+ resource_group_name=self._rg,
515
+ server_farm_id=app.server_farm_id,
516
+ canonical_name=host,
517
+ host_names=[host],
518
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
519
+ )
329
520
 
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}",
521
+ # Create a new binding, replacing the old one,
522
+ # with the certificate
523
+ azure.web.WebAppHostNameBinding(
524
+ f"host-binding-{suffix}-{identifier}",
525
+ resource_group_name=self._rg,
526
+ name=app.name,
527
+ host_name=host,
528
+ ssl_state=azure.web.SslState.SNI_ENABLED,
529
+ thumbprint=certificate.thumbprint,
530
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
531
+ )
532
+
533
+ else:
534
+ # Create a binding without a certificate
535
+ azure.web.WebAppHostNameBinding(
536
+ f"host-binding-{suffix}-{identifier}",
537
+ resource_group_name=self._rg,
538
+ name=app.name,
539
+ host_name=host,
540
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
541
+ )
542
+
543
+ existing_binding.apply(_create_binding_with_cert)
544
+
545
+ def _create_comms_dns_records(self, suffix, host: HostDefinition, records: dict) -> list[azure.network.RecordSet]:
546
+ created_records = []
547
+
548
+ # Domain validation and SPF record (one TXT record with multiple values)
549
+ r = azure.network.RecordSet(
550
+ f"dns-comms-{suffix}-{host.identifier}-domain",
551
+ resource_group_name=self._rg,
552
+ zone_name=host.zone.name,
553
+ relative_record_set_name=host.host,
554
+ record_type="TXT",
555
+ ttl=3600,
556
+ txt_records=[
557
+ azure.network.TxtRecordArgs(value=[records["domain"]["value"]]),
558
+ azure.network.TxtRecordArgs(value=[records["s_pf"]["value"]]),
559
+ ],
560
+ )
561
+ created_records.append(r)
562
+
563
+ # DKIM records (two CNAME records)
564
+ for record in ("d_kim", "d_kim2"):
565
+ if host.host == "@":
566
+ relative_record_set_name = records[record]["name"]
567
+ else:
568
+ relative_record_set_name = f"{records[record]['name']}.{host.host}"
569
+
570
+ r = azure.network.RecordSet(
571
+ f"dns-comms-{suffix}-{host.identifier}-{record}",
344
572
  resource_group_name=self._rg,
345
- name=app.name,
346
- host_name=host,
573
+ zone_name=host.zone.name,
574
+ relative_record_set_name=relative_record_set_name,
575
+ record_type="CNAME",
576
+ ttl=records[record]["ttl"],
577
+ cname_record=azure.network.CnameRecordArgs(cname=records[record]["value"]),
347
578
  )
579
+ created_records.append(r)
348
580
 
349
- def _add_webapp_comms(self, data_location: str, domains: list[str], suffix: str) -> azure.communication.CommunicationService:
581
+ return created_records
582
+
583
+ def _add_webapp_comms(self, data_location: str, domains: list[HostDefinition], suffix: str) -> azure.communication.CommunicationService:
350
584
  email_service = azure.communication.EmailService(
351
585
  f"comms-email-{suffix}",
352
586
  resource_group_name=self._rg,
@@ -355,30 +589,37 @@ class DjangoDeployment(pulumi.ComponentResource):
355
589
  )
356
590
 
357
591
  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
592
+ comm_dependencies = []
593
+
594
+ # Add our own custom domains
595
+ for domain in domains:
373
596
  d = azure.communication.Domain(
374
- f"comms-email-domain-{suffix}-azure",
597
+ f"comms-email-domain-{suffix}-{domain.identifier}",
375
598
  resource_group_name=self._rg,
376
599
  location="global",
377
- domain_management=azure.communication.DomainManagement.AZURE_MANAGED,
378
- domain_name="AzureManagedDomain",
600
+ domain_management=azure.communication.DomainManagement.CUSTOMER_MANAGED,
601
+ domain_name=domain.full_host,
379
602
  email_service_name=email_service.name,
380
603
  )
381
- domain_resources.append(d.id.apply(lambda n: n))
604
+
605
+ if domain.zone:
606
+ # Create DNS records in the managed zone
607
+ comm_dependencies = pulumi.Output.all(suffix, domain, d.verification_records).apply(
608
+ lambda args: self._create_comms_dns_records(suffix=args[0], host=args[1], records=args[2])
609
+ )
610
+
611
+ domain_resources.append(d.id)
612
+
613
+ # Add an Azure managed domain
614
+ d = azure.communication.Domain(
615
+ f"comms-email-domain-{suffix}-azure",
616
+ resource_group_name=self._rg,
617
+ location="global",
618
+ domain_management=azure.communication.DomainManagement.AZURE_MANAGED,
619
+ domain_name="AzureManagedDomain",
620
+ email_service_name=email_service.name,
621
+ )
622
+ domain_resources.append(d.id)
382
623
 
383
624
  # Create Communication Services and link the domains
384
625
  comm_service = azure.communication.CommunicationService(
@@ -387,16 +628,25 @@ class DjangoDeployment(pulumi.ComponentResource):
387
628
  location="global",
388
629
  data_location=data_location,
389
630
  linked_domains=domain_resources,
631
+ opts=pulumi.ResourceOptions(depends_on=comm_dependencies),
390
632
  )
391
633
 
392
634
  return comm_service
393
635
 
394
636
  def _add_webapp_vault(self, administrators: list[str], suffix: str) -> azure.keyvault.Vault:
395
- # Create a keyvault
637
+ # Create a keyvault with a random suffix to make the name unique
638
+ random_suffix = pulumi_random.RandomString(
639
+ f"vault-suffix-{suffix}",
640
+ # Total length is 24, so deduct the length of the suffix
641
+ length=(24 - 7 - len(suffix)),
642
+ special=False,
643
+ upper=False,
644
+ )
645
+
396
646
  vault = azure.keyvault.Vault(
397
647
  f"vault-{suffix}",
398
648
  resource_group_name=self._rg,
399
- vault_name=f"vault-{suffix}",
649
+ vault_name=random_suffix.result.apply(lambda r: f"vault-{suffix}-{r}"),
400
650
  properties=azure.keyvault.VaultPropertiesArgs(
401
651
  tenant_id=self._tenant_id,
402
652
  sku=azure.keyvault.SkuArgs(
@@ -407,27 +657,35 @@ class DjangoDeployment(pulumi.ComponentResource):
407
657
  ),
408
658
  )
409
659
 
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
660
  # 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,
661
+ if administrators:
662
+ # Find the Key Vault Administrator role
663
+ administrator_role = vault.id.apply(
664
+ lambda scope: azure.authorization.get_role_definition(
665
+ role_definition_id="00482a5a-887f-4fb3-b363-3b7fe8e74483",
666
+ scope=scope,
667
+ )
426
668
  )
427
669
 
670
+ # Actual administrator roles
671
+ for a in administrators:
672
+ azure.authorization.RoleAssignment(
673
+ f"ra-{suffix}-vault-admin-{a}",
674
+ principal_id=a,
675
+ principal_type=azure.authorization.PrincipalType.USER,
676
+ role_definition_id=administrator_role.id,
677
+ scope=vault.id,
678
+ )
679
+
428
680
  return vault
429
681
 
430
- def _add_webapp_secret(self, vault: azure.keyvault.Vault, secret_name: str, config_secret_name: str, suffix: str):
682
+ def _add_webapp_secret(
683
+ self,
684
+ vault: azure.keyvault.Vault,
685
+ secret_name: str,
686
+ config_secret_name: str,
687
+ suffix: str,
688
+ ):
431
689
  secret = self._config.require_secret(config_secret_name)
432
690
 
433
691
  # Normalize the secret name
@@ -487,13 +745,14 @@ class DjangoDeployment(pulumi.ComponentResource):
487
745
  db_name: str,
488
746
  repository_url: str,
489
747
  repository_branch: str,
490
- website_hosts: list[str],
748
+ website_hosts: list[HostDefinition],
491
749
  django_settings_module: str,
492
- environment_variables: dict[str, str] = {},
493
- secrets: dict[str, str] = {},
750
+ environment_variables: Optional[dict[str, str]] = None,
751
+ secrets: Optional[dict[str, str]] = None,
494
752
  comms_data_location: Optional[str] = None,
495
- comms_domains: Optional[list[str]] = [],
496
- vault_administrators: Optional[list[str]] = [],
753
+ comms_domains: Optional[list[HostDefinition]] = None,
754
+ dedicated_app_service_sku: Optional[azure.web.SkuDescriptionArgs] = None,
755
+ vault_administrators: Optional[list[str]] = None,
497
756
  ) -> azure.web.WebApp:
498
757
  """
499
758
  Create a Django website with it's own database and storage containers.
@@ -510,6 +769,7 @@ class DjangoDeployment(pulumi.ComponentResource):
510
769
  and the name of the secret in the Key Vault.
511
770
  :param comms_data_location: The data location for the Communication Services (optional if you don't need it).
512
771
  :param comms_domains: The list of custom domains for the E-mail Communication Services (optional).
772
+ :param dedicated_app_service_sku: The SKU for the dedicated App Service Plan (optional).
513
773
  :param vault_administrator: The principal ID of the vault administrator (optional).
514
774
  """
515
775
 
@@ -541,7 +801,11 @@ class DjangoDeployment(pulumi.ComponentResource):
541
801
 
542
802
  # Communication Services (optional)
543
803
  if comms_data_location:
804
+ if not comms_domains:
805
+ comms_domains = []
806
+
544
807
  comms = self._add_webapp_comms(comms_data_location, comms_domains, f"{name}-{self._name}")
808
+
545
809
  # Add the service endpoint as environment variable
546
810
  environment_variables["AZURE_COMMUNICATION_SERVICE_ENDPOINT"] = comms.host_name.apply(lambda host: f"https://{host}")
547
811
  else:
@@ -567,10 +831,24 @@ class DjangoDeployment(pulumi.ComponentResource):
567
831
  for key, value in environment_variables.items()
568
832
  ]
569
833
 
834
+ allowed_hosts = pulumi.Output.concat(*[pulumi.Output.concat(host.full_host, ",") for host in website_hosts])
835
+
836
+ # Create a dedicated App Service Plan if requested
837
+ if dedicated_app_service_sku:
838
+ app_service_plan = azure.web.AppServicePlan(
839
+ f"asp-{self._name}-{name}",
840
+ resource_group_name=self._rg,
841
+ kind="Linux",
842
+ reserved=True,
843
+ sku=dedicated_app_service_sku,
844
+ )
845
+ else:
846
+ app_service_plan = self._app_service_plan
847
+
570
848
  app = azure.web.WebApp(
571
849
  f"app-{name}-{self._name}",
572
850
  resource_group_name=self._rg,
573
- server_farm_id=self._app_service_plan.id,
851
+ server_farm_id=app_service_plan.id,
574
852
  virtual_network_subnet_id=self._app_subnet.id,
575
853
  identity=azure.web.ManagedServiceIdentityArgs(
576
854
  type=azure.web.ManagedServiceIdentityType.SYSTEM_ASSIGNED,
@@ -595,12 +873,18 @@ class DjangoDeployment(pulumi.ComponentResource):
595
873
  # azure.web.NameValuePairArgs(name="DEBUG", value="true"),
596
874
  azure.web.NameValuePairArgs(name="DJANGO_SETTINGS_MODULE", value=django_settings_module),
597
875
  azure.web.NameValuePairArgs(name="DJANGO_SECRET_KEY", value=secret_key.result),
598
- azure.web.NameValuePairArgs(name="DJANGO_ALLOWED_HOSTS", value=",".join(website_hosts)),
876
+ azure.web.NameValuePairArgs(name="DJANGO_ALLOWED_HOSTS", value=allowed_hosts),
599
877
  # Vault settings
600
878
  azure.web.NameValuePairArgs(name="AZURE_KEY_VAULT", value=vault.name),
601
879
  # Storage settings
602
- azure.web.NameValuePairArgs(name="AZURE_STORAGE_ACCOUNT_NAME", value=self._storage_account.name),
603
- azure.web.NameValuePairArgs(name="AZURE_STORAGE_CONTAINER_STATICFILES", value=static_container.name),
880
+ azure.web.NameValuePairArgs(
881
+ name="AZURE_STORAGE_ACCOUNT_NAME",
882
+ value=self._storage_account.name,
883
+ ),
884
+ azure.web.NameValuePairArgs(
885
+ name="AZURE_STORAGE_CONTAINER_STATICFILES",
886
+ value=static_container.name,
887
+ ),
604
888
  azure.web.NameValuePairArgs(name="AZURE_STORAGE_CONTAINER_MEDIA", value=media_container.name),
605
889
  # CDN
606
890
  azure.web.NameValuePairArgs(name="CDN_HOST", value=self._cdn_host),
@@ -623,9 +907,69 @@ class DjangoDeployment(pulumi.ComponentResource):
623
907
  # We need this to verify custom domains
624
908
  pulumi.export(f"{name}_site_domain_verification_id", app.custom_domain_verification_id)
625
909
  pulumi.export(f"{name}_site_domain_cname", app.default_host_name)
910
+ virtual_ip = app.outbound_ip_addresses.apply(lambda addresses: addresses.split(",")[-1])
911
+ pulumi.export(f"{name}_site_virtual_ip", virtual_ip)
912
+
913
+ # Get the URL of the publish profile.
914
+ # Use app.identity here too to ensure the app is actually created before getting credentials.
915
+ credentials = pulumi.Output.all(self._rg, app.name, app.identity).apply(
916
+ lambda args: azure.web.list_web_app_publishing_credentials(
917
+ resource_group_name=args[0],
918
+ name=args[1],
919
+ )
920
+ )
921
+
922
+ pulumi.export(f"{name}_deploy_url", pulumi.Output.concat(credentials.scm_uri, "/deploy"))
626
923
 
627
924
  for host in website_hosts:
628
- self._add_webapp_host(app=app, host=host, suffix=f"{name}-{self._name}")
925
+ dependencies = []
926
+
927
+ if host.zone:
928
+ # Create a DNS record in the zone.
929
+ # We always use an A record instead of CNAME to avoid collisions with TXT records.
930
+ a = azure.network.RecordSet(
931
+ f"dns-a-{name}-{self._name}-{host.identifier}",
932
+ resource_group_name=self._rg,
933
+ zone_name=host.zone.name,
934
+ relative_record_set_name=host.host,
935
+ record_type="A",
936
+ ttl=3600,
937
+ a_records=[
938
+ azure.network.ARecordArgs(
939
+ ipv4_address=virtual_ip,
940
+ )
941
+ ],
942
+ )
943
+
944
+ dependencies.append(a)
945
+
946
+ # For the certificate validation to work
947
+ relative_record_set_name = "asuid" if host.host == "@" else pulumi.Output.concat("asuid.", host.host)
948
+
949
+ txt_validation = azure.network.RecordSet(
950
+ f"dns-txt-{name}-{self._name}-{host.identifier}",
951
+ resource_group_name=self._rg,
952
+ zone_name=host.zone.name,
953
+ relative_record_set_name=relative_record_set_name,
954
+ record_type="TXT",
955
+ ttl=3600,
956
+ txt_records=[
957
+ azure.network.TxtRecordArgs(
958
+ value=[app.custom_domain_verification_id],
959
+ )
960
+ ],
961
+ )
962
+
963
+ dependencies.append(txt_validation)
964
+
965
+ # Add the host with optional dependencies
966
+ self._add_webapp_host(
967
+ app=app,
968
+ host=host.full_host,
969
+ suffix=f"{name}-{self._name}",
970
+ identifier=host.identifier,
971
+ depends_on=dependencies,
972
+ )
629
973
 
630
974
  # To enable deployment from GitLab
631
975
  azure.web.WebAppSourceControl(
@@ -641,7 +985,8 @@ class DjangoDeployment(pulumi.ComponentResource):
641
985
 
642
986
  # Where we can retrieve the SSH key
643
987
  pulumi.export(
644
- f"{name}_deploy_ssh_key_url", app.name.apply(lambda name: f"https://{name}.scm.azurewebsites.net/api/sshkey?ensurePublicKey=1")
988
+ f"{name}_deploy_ssh_key_url",
989
+ app.name.apply(lambda name: f"https://{name}.scm.azurewebsites.net/api/sshkey?ensurePublicKey=1"),
645
990
  )
646
991
 
647
992
  # Find the role for Key Vault Secrets User
@@ -713,28 +1058,13 @@ class DjangoDeployment(pulumi.ComponentResource):
713
1058
  scope=self._cdn_endpoint.id,
714
1059
  )
715
1060
 
716
- # Create a CORS rules for this website
717
- if website_hosts:
718
- origins = [f"https://{host}" for host in website_hosts]
719
- else:
720
- origins = ["*"]
1061
+ return app
721
1062
 
722
- azure.storage.BlobServiceProperties(
723
- f"sa-{name}-blob-properties",
724
- resource_group_name=self._rg,
725
- account_name=self._storage_account.name,
726
- blob_services_name="default",
727
- cors=azure.storage.CorsRulesArgs(
728
- cors_rules=[
729
- azure.storage.CorsRuleArgs(
730
- allowed_headers=["*"],
731
- allowed_methods=["GET", "OPTIONS", "HEAD"],
732
- allowed_origins=origins,
733
- exposed_headers=["Access-Control-Allow-Origin"],
734
- max_age_in_seconds=86400,
735
- )
736
- ]
737
- ),
738
- )
1063
+ def _strip_off_dns_zone_name(self, host: str, zone: azure.network.Zone) -> pulumi.Output[str]:
1064
+ """
1065
+ Strip off the DNS zone name from the host name.
739
1066
 
740
- return app
1067
+ :param host: The host name
1068
+ :return: The host name without the DNS zone
1069
+ """
1070
+ return zone.name.apply(lambda name: host[: -len(name) - 1])
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: pulumi-django-azure
3
- Version: 1.0.11
3
+ Version: 1.0.17
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
@@ -33,9 +33,9 @@ Classifier: Programming Language :: Python :: 3
33
33
  Requires-Python: >=3.9
34
34
  Description-Content-Type: text/markdown
35
35
  License-File: LICENSE
36
- Requires-Dist: pulumi>=3.99.0
37
- Requires-Dist: pulumi-azure-native>=2.24.0
38
- Requires-Dist: pulumi-random>=4.14.0
36
+ Requires-Dist: pulumi>=3.146.0
37
+ Requires-Dist: pulumi-azure-native>=2.82.0
38
+ Requires-Dist: pulumi-random>=4.17.0
39
39
 
40
40
  # Pulumi Django Deployment
41
41
 
@@ -59,9 +59,10 @@ Your Django project should contain a folder `cicd` with these files:
59
59
  ```bash
60
60
  python manage.py migrate
61
61
  python manage.py collectstatic --noinput
62
+ python manage.py purge_cdn
62
63
  gunicorn --timeout 600 --workers $((($NUM_CORES*2)+1)) --chdir $APP_PATH yourapplication.wsgi --access-logfile '-' --error-logfile '-'
63
64
  ```
64
- Be sure to change `yourapplication` in the above.
65
+ Be sure to change `yourapplication` in the above. To see the `purge_cdn` management command we use here, see below.
65
66
  ## Installation
66
67
  This package is published on PyPi, so you can just add pulumi-django-azure to your requirements file.
67
68
 
@@ -256,6 +257,82 @@ Be sure to configure the SSH key that Azure will use on GitLab side. You can obt
256
257
 
257
258
  This would then trigger a redeploy everytime you make a commit to your live branch.
258
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
+
259
336
  ## Change requests
260
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.
261
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.
@@ -0,0 +1,3 @@
1
+ pulumi>=3.146.0
2
+ pulumi-azure-native>=2.82.0
3
+ pulumi-random>=4.17.0
@@ -1 +0,0 @@
1
- from .django_deployment import DjangoDeployment # noqa: F401
@@ -1,3 +0,0 @@
1
- pulumi>=3.99.0
2
- pulumi-azure-native>=2.24.0
3
- pulumi-random>=4.14.0