pulumi-django-azure 1.0.10__py3-none-any.whl → 1.0.14__py3-none-any.whl

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 +1 @@
1
- from .django_deployment import DjangoDeployment # noqa: F401
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,9 @@ 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
+ pgadmin_access_ip: Optional[Sequence[str]] = None,
69
+ pgadmin_dns_zone: Optional[azure.network.Zone] = None,
70
+ cdn_host: Optional[HostDefinition] = None,
23
71
  opts=None,
24
72
  ):
25
73
  """
@@ -34,7 +82,9 @@ class DjangoDeployment(pulumi.ComponentResource):
34
82
  :param appservice_ip_prefix: The IP prefix for the app service subnet.
35
83
  :param app_service_sku: The SKU for the app service plan.
36
84
  :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)
85
+ :param pgadmin_access_ip: The IP addresses to allow access to pgAdmin. If empty, all IP addresses are allowed.
86
+ :param pgadmin_dns_zone: The Azure DNS zone to a pgadmin DNS record in. (optional)
87
+ :param cdn_host: A custom CDN host name. (optional)
38
88
  :param opts: The resource options
39
89
  """
40
90
 
@@ -67,7 +117,7 @@ class DjangoDeployment(pulumi.ComponentResource):
67
117
  self._app_service_plan = self._create_app_service_plan(sku=app_service_sku)
68
118
 
69
119
  # Create a pgAdmin app
70
- self._create_pgadmin_app()
120
+ self._create_pgadmin_app(access_ip=pgadmin_access_ip, dns_zone=pgadmin_dns_zone)
71
121
 
72
122
  def _create_storage(self, account_name: str):
73
123
  # Create blob storage
@@ -85,7 +135,7 @@ class DjangoDeployment(pulumi.ComponentResource):
85
135
  enable_https_traffic_only=True,
86
136
  )
87
137
 
88
- def _create_cdn(self, custom_host: Optional[str]) -> pulumi.Output[str]:
138
+ def _create_cdn(self, custom_host: Optional[HostDefinition]) -> pulumi.Output[str]:
89
139
  """
90
140
  Create a CDN endpoint. If a host name is given, it will be used as the custom domain.
91
141
  Otherwise, the default CDN host name will be returned.
@@ -95,7 +145,7 @@ class DjangoDeployment(pulumi.ComponentResource):
95
145
  """
96
146
 
97
147
  # Put CDN in front
98
- cdn = azure.cdn.Profile(
148
+ self._cdn_profile = azure.cdn.Profile(
99
149
  f"cdn-{self._name}",
100
150
  resource_group_name=self._rg,
101
151
  location="global",
@@ -125,7 +175,7 @@ class DjangoDeployment(pulumi.ComponentResource):
125
175
  ],
126
176
  is_http_allowed=False,
127
177
  is_https_allowed=True,
128
- profile_name=cdn.name,
178
+ profile_name=self._cdn_profile.name,
129
179
  origin_host_header=endpoint_origin,
130
180
  origins=[azure.cdn.DeepCreatedOriginArgs(name="origin-storage", host_name=endpoint_origin)],
131
181
  query_string_caching_behavior=azure.cdn.QueryStringCachingBehavior.IGNORE_QUERY_STRING,
@@ -135,15 +185,41 @@ class DjangoDeployment(pulumi.ComponentResource):
135
185
 
136
186
  # Add custom domain if given
137
187
  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
- )
188
+ if custom_host.zone:
189
+ # Create a DNS record for the custom host in the given zone
190
+ rs = azure.network.RecordSet(
191
+ f"cdn-cname-{self._name}",
192
+ resource_group_name=self._rg,
193
+ zone_name=custom_host.zone.name,
194
+ relative_record_set_name=custom_host.host,
195
+ record_type="CNAME",
196
+ ttl=3600,
197
+ target_resource=azure.network.SubResourceArgs(
198
+ id=self._cdn_endpoint.id,
199
+ ),
200
+ )
201
+
202
+ azure.cdn.CustomDomain(
203
+ f"cdn-custom-domain-{self._name}",
204
+ resource_group_name=self._rg,
205
+ profile_name=self._cdn_profile.name,
206
+ endpoint_name=self._cdn_endpoint.name,
207
+ host_name=custom_host.full_host,
208
+ opts=pulumi.ResourceOptions(depends_on=rs),
209
+ )
145
210
 
146
- return custom_host
211
+ return custom_host.full_host
212
+ else:
213
+ # Add custom hostname without a zone
214
+ azure.cdn.CustomDomain(
215
+ f"cdn-custom-domain-{self._name}",
216
+ resource_group_name=self._rg,
217
+ profile_name=self._cdn_profile.name,
218
+ endpoint_name=self._cdn_endpoint.name,
219
+ host_name=custom_host.host,
220
+ )
221
+
222
+ return custom_host.host
147
223
  else:
148
224
  # Return the default CDN host name
149
225
  return self._cdn_endpoint.host_name
@@ -200,7 +276,11 @@ class DjangoDeployment(pulumi.ComponentResource):
200
276
  pulumi.export("pgsql_host", self._pgsql.fully_qualified_domain_name)
201
277
 
202
278
  def _create_subnet(
203
- self, name, prefix, delegation_service: Optional[str] = None, service_endpoints: Sequence[str] = []
279
+ self,
280
+ name,
281
+ prefix,
282
+ delegation_service: Optional[str] = None,
283
+ service_endpoints: Sequence[str] = [],
204
284
  ) -> azure.network.Subnet:
205
285
  """
206
286
  Generic method to create a subnet with a delegation.
@@ -239,7 +319,22 @@ class DjangoDeployment(pulumi.ComponentResource):
239
319
  sku=sku,
240
320
  )
241
321
 
242
- def _create_pgadmin_app(self):
322
+ def _create_pgadmin_app(self, access_ip: Optional[Sequence[str]] = None, dns_zone: Optional[azure.network.Zone] = None):
323
+ # Determine the IP restrictions
324
+ ip_restrictions = []
325
+ default_restriction = azure.web.DefaultAction.ALLOW
326
+ if access_ip:
327
+ default_restriction = azure.web.DefaultAction.DENY
328
+
329
+ for ip in access_ip:
330
+ ip_restrictions.append(
331
+ azure.web.IpSecurityRestrictionArgs(
332
+ action="Allow",
333
+ ip_address=ip,
334
+ priority=300,
335
+ )
336
+ )
337
+
243
338
  # The app itself
244
339
  app = azure.web.WebApp(
245
340
  f"app-pgadmin-{self._name}",
@@ -255,7 +350,10 @@ class DjangoDeployment(pulumi.ComponentResource):
255
350
  linux_fx_version="DOCKER|dpage/pgadmin4",
256
351
  health_check_path="/misc/ping",
257
352
  app_settings=[
258
- azure.web.NameValuePairArgs(name="DOCKER_REGISTRY_SERVER_URL", value="https://index.docker.io/v1"),
353
+ azure.web.NameValuePairArgs(
354
+ name="DOCKER_REGISTRY_SERVER_URL",
355
+ value="https://index.docker.io/v1",
356
+ ),
259
357
  azure.web.NameValuePairArgs(name="DOCKER_ENABLE_CI", value="true"),
260
358
  # azure.web.NameValuePairArgs(name="WEBSITE_HTTPLOGGING_RETENTION_DAYS", value="7"),
261
359
  # pgAdmin settings
@@ -263,6 +361,9 @@ class DjangoDeployment(pulumi.ComponentResource):
263
361
  azure.web.NameValuePairArgs(name="PGADMIN_DEFAULT_EMAIL", value="dbadmin@dbadmin.net"),
264
362
  azure.web.NameValuePairArgs(name="PGADMIN_DEFAULT_PASSWORD", value="dbadmin"),
265
363
  ],
364
+ # IP restrictions
365
+ ip_security_restrictions_default_action=default_restriction,
366
+ ip_security_restrictions=ip_restrictions,
266
367
  ),
267
368
  )
268
369
 
@@ -274,6 +375,50 @@ class DjangoDeployment(pulumi.ComponentResource):
274
375
  share_name="pgadmin",
275
376
  )
276
377
 
378
+ if dns_zone:
379
+ # Create a DNS record for the pgAdmin app
380
+ cname = azure.network.RecordSet(
381
+ f"dns-cname-pgadmin-{self._name}",
382
+ resource_group_name=self._rg,
383
+ zone_name=dns_zone.name,
384
+ relative_record_set_name="pgadmin",
385
+ record_type="CNAME",
386
+ ttl=3600,
387
+ cname_record=azure.network.CnameRecordArgs(
388
+ cname=app.default_host_name,
389
+ ),
390
+ )
391
+
392
+ # For the certificate validation to work
393
+ txt_validation = azure.network.RecordSet(
394
+ f"dns-txt-pgadmin-{self._name}",
395
+ resource_group_name=self._rg,
396
+ zone_name=dns_zone.name,
397
+ relative_record_set_name="asuid.pgadmin",
398
+ record_type="TXT",
399
+ ttl=3600,
400
+ txt_records=[
401
+ azure.network.TxtRecordArgs(
402
+ value=[app.custom_domain_verification_id],
403
+ )
404
+ ],
405
+ )
406
+
407
+ # Add custom hostname
408
+ self._add_webapp_host(
409
+ app=app,
410
+ host=dns_zone.name.apply(lambda name: f"pgadmin.{name}"),
411
+ suffix=self._name,
412
+ depends_on=[cname, txt_validation],
413
+ identifier="pgadmin",
414
+ )
415
+
416
+ # Export the custom hostname
417
+ pulumi.export("pgadmin_url", dns_zone.name.apply(lambda name: f"https://pgadmin.{name}"))
418
+ else:
419
+ # Export the default hostname
420
+ pulumi.export("pgadmin_url", app.default_host_name.apply(lambda host: f"https://{host}"))
421
+
277
422
  # Mount the storage container
278
423
  azure.web.WebAppAzureStorageAccounts(
279
424
  f"app-pgadmin-mount-{self._name}",
@@ -290,9 +435,24 @@ class DjangoDeployment(pulumi.ComponentResource):
290
435
  },
291
436
  )
292
437
 
293
- pulumi.export("pgadmin_url", app.default_host_name.apply(lambda host: f"https://{host}"))
438
+ def _get_existing_web_app_host_name_binding(self, resource_group_name: str, app_name: str, host_name: str):
439
+ try:
440
+ return azure.web.get_web_app_host_name_binding(
441
+ resource_group_name=resource_group_name,
442
+ name=app_name,
443
+ host_name=host_name,
444
+ )
445
+ except Exception:
446
+ return None
294
447
 
295
- def _add_webapp_host(self, app: azure.web.WebApp, host: str, suffix: str):
448
+ def _add_webapp_host(
449
+ self,
450
+ app: azure.web.WebApp,
451
+ host: Union[str, pulumi.Input[str]],
452
+ suffix: str,
453
+ identifier: str,
454
+ depends_on: Optional[Sequence[pulumi.Resource]] = None,
455
+ ):
296
456
  """
297
457
  Because of a circular dependency, we need to create the certificate and the binding in two steps.
298
458
  First we create a binding without a certificate,
@@ -305,48 +465,93 @@ class DjangoDeployment(pulumi.ComponentResource):
305
465
  :param app: The web app
306
466
  :param host: The host name
307
467
  :param suffix: A suffix to make the resource name unique
468
+ :param depend_on: The resource to depend on (optional)
308
469
  """
309
470
 
310
- safe_host = host.replace(".", "-")
471
+ if not depends_on:
472
+ depends_on = []
311
473
 
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,
474
+ # Retrieve the existing binding (None if it doesn't exist)
475
+ existing_binding = pulumi.Output.all(app.resource_group, app.name, host).apply(
476
+ lambda args: self._get_existing_web_app_host_name_binding(
477
+ resource_group_name=args[0],
478
+ app_name=args[1],
479
+ host_name=args[2],
318
480
  )
481
+ )
319
482
 
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
- )
483
+ # Create an inline function that we will invoke through the Output.apply lambda
484
+ def _create_binding_with_cert(existing_binding):
485
+ if existing_binding:
486
+ # Create a managed certificate
487
+ # This will work because the binding exists actually
488
+ certificate = azure.web.Certificate(
489
+ f"cert-{suffix}-{identifier}",
490
+ resource_group_name=self._rg,
491
+ server_farm_id=app.server_farm_id,
492
+ canonical_name=host,
493
+ host_names=[host],
494
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
495
+ )
329
496
 
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}",
497
+ # Create a new binding, replacing the old one,
498
+ # with the certificate
499
+ azure.web.WebAppHostNameBinding(
500
+ f"host-binding-{suffix}-{identifier}",
501
+ resource_group_name=self._rg,
502
+ name=app.name,
503
+ host_name=host,
504
+ ssl_state=azure.web.SslState.SNI_ENABLED,
505
+ thumbprint=certificate.thumbprint,
506
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
507
+ )
508
+
509
+ else:
510
+ # Create a binding without a certificate
511
+ azure.web.WebAppHostNameBinding(
512
+ f"host-binding-{suffix}-{identifier}",
513
+ resource_group_name=self._rg,
514
+ name=app.name,
515
+ host_name=host,
516
+ opts=pulumi.ResourceOptions(depends_on=depends_on),
517
+ )
518
+
519
+ existing_binding.apply(_create_binding_with_cert)
520
+
521
+ def _create_comms_dns_records(self, suffix, host: HostDefinition, records: dict) -> list[azure.network.RecordSet]:
522
+ created_records = []
523
+
524
+ # Domain validation and SPF record (one TXT record with multiple values)
525
+ r = azure.network.RecordSet(
526
+ f"dns-comms-{suffix}-{host.identifier}-domain",
527
+ resource_group_name=self._rg,
528
+ zone_name=host.zone.name,
529
+ relative_record_set_name="@",
530
+ record_type="TXT",
531
+ ttl=3600,
532
+ txt_records=[
533
+ azure.network.TxtRecordArgs(value=[records["domain"]["value"]]),
534
+ azure.network.TxtRecordArgs(value=[records["s_pf"]["value"]]),
535
+ ],
536
+ )
537
+ created_records.append(r)
538
+
539
+ # DKIM records (two CNAME records)
540
+ for record in ("d_kim", "d_kim2"):
541
+ r = azure.network.RecordSet(
542
+ f"dns-comms-{suffix}-{host.identifier}-{record}",
344
543
  resource_group_name=self._rg,
345
- name=app.name,
346
- host_name=host,
544
+ zone_name=host.zone.name,
545
+ relative_record_set_name=records[record]["name"],
546
+ record_type="CNAME",
547
+ ttl=records[record]["ttl"],
548
+ cname_record=azure.network.CnameRecordArgs(cname=records[record]["value"]),
347
549
  )
550
+ created_records.append(r)
551
+
552
+ return created_records
348
553
 
349
- def _add_webapp_comms(self, data_location: str, domains: list[str], suffix: str) -> azure.communication.CommunicationService:
554
+ def _add_webapp_comms(self, data_location: str, domains: list[HostDefinition], suffix: str) -> azure.communication.CommunicationService:
350
555
  email_service = azure.communication.EmailService(
351
556
  f"comms-email-{suffix}",
352
557
  resource_group_name=self._rg,
@@ -355,30 +560,37 @@ class DjangoDeployment(pulumi.ComponentResource):
355
560
  )
356
561
 
357
562
  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
563
+ comm_dependencies = []
564
+
565
+ # Add our own custom domains
566
+ for domain in domains:
373
567
  d = azure.communication.Domain(
374
- f"comms-email-domain-{suffix}-azure",
568
+ f"comms-email-domain-{suffix}-{domain.identifier}",
375
569
  resource_group_name=self._rg,
376
570
  location="global",
377
- domain_management=azure.communication.DomainManagement.AZURE_MANAGED,
378
- domain_name="AzureManagedDomain",
571
+ domain_management=azure.communication.DomainManagement.CUSTOMER_MANAGED,
572
+ domain_name=domain.full_host,
379
573
  email_service_name=email_service.name,
380
574
  )
381
- domain_resources.append(d.id.apply(lambda n: n))
575
+
576
+ if domain.zone:
577
+ # Create DNS records in the managed zone
578
+ comm_dependencies = pulumi.Output.all(suffix, domain, d.verification_records).apply(
579
+ lambda args: self._create_comms_dns_records(suffix=args[0], host=args[1], records=args[2])
580
+ )
581
+
582
+ domain_resources.append(d.id)
583
+
584
+ # Add an Azure managed domain
585
+ d = azure.communication.Domain(
586
+ f"comms-email-domain-{suffix}-azure",
587
+ resource_group_name=self._rg,
588
+ location="global",
589
+ domain_management=azure.communication.DomainManagement.AZURE_MANAGED,
590
+ domain_name="AzureManagedDomain",
591
+ email_service_name=email_service.name,
592
+ )
593
+ domain_resources.append(d.id)
382
594
 
383
595
  # Create Communication Services and link the domains
384
596
  comm_service = azure.communication.CommunicationService(
@@ -387,16 +599,25 @@ class DjangoDeployment(pulumi.ComponentResource):
387
599
  location="global",
388
600
  data_location=data_location,
389
601
  linked_domains=domain_resources,
602
+ opts=pulumi.ResourceOptions(depends_on=comm_dependencies),
390
603
  )
391
604
 
392
605
  return comm_service
393
606
 
394
607
  def _add_webapp_vault(self, administrators: list[str], suffix: str) -> azure.keyvault.Vault:
395
- # Create a keyvault
608
+ # Create a keyvault with a random suffix to make the name unique
609
+ random_suffix = pulumi_random.RandomString(
610
+ f"vault-suffix-{suffix}",
611
+ # Total length is 24, so deduct the length of the suffix
612
+ length=(24 - 7 - len(suffix)),
613
+ special=False,
614
+ upper=False,
615
+ )
616
+
396
617
  vault = azure.keyvault.Vault(
397
618
  f"vault-{suffix}",
398
619
  resource_group_name=self._rg,
399
- vault_name=f"vault-{suffix}",
620
+ vault_name=random_suffix.result.apply(lambda r: f"vault-{suffix}-{r}"),
400
621
  properties=azure.keyvault.VaultPropertiesArgs(
401
622
  tenant_id=self._tenant_id,
402
623
  sku=azure.keyvault.SkuArgs(
@@ -407,27 +628,35 @@ class DjangoDeployment(pulumi.ComponentResource):
407
628
  ),
408
629
  )
409
630
 
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
631
  # 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,
632
+ if administrators:
633
+ # Find the Key Vault Administrator role
634
+ administrator_role = vault.id.apply(
635
+ lambda scope: azure.authorization.get_role_definition(
636
+ role_definition_id="00482a5a-887f-4fb3-b363-3b7fe8e74483",
637
+ scope=scope,
638
+ )
426
639
  )
427
640
 
641
+ # Actual administrator roles
642
+ for a in administrators:
643
+ azure.authorization.RoleAssignment(
644
+ f"ra-{suffix}-vault-admin-{a}",
645
+ principal_id=a,
646
+ principal_type=azure.authorization.PrincipalType.USER,
647
+ role_definition_id=administrator_role.id,
648
+ scope=vault.id,
649
+ )
650
+
428
651
  return vault
429
652
 
430
- def _add_webapp_secret(self, vault: azure.keyvault.Vault, secret_name: str, config_secret_name: str, suffix: str):
653
+ def _add_webapp_secret(
654
+ self,
655
+ vault: azure.keyvault.Vault,
656
+ secret_name: str,
657
+ config_secret_name: str,
658
+ suffix: str,
659
+ ):
431
660
  secret = self._config.require_secret(config_secret_name)
432
661
 
433
662
  # Normalize the secret name
@@ -487,13 +716,13 @@ class DjangoDeployment(pulumi.ComponentResource):
487
716
  db_name: str,
488
717
  repository_url: str,
489
718
  repository_branch: str,
490
- website_hosts: list[str],
719
+ website_hosts: list[HostDefinition],
491
720
  django_settings_module: str,
492
- environment_variables: dict[str, str] = {},
493
- secrets: dict[str, str] = {},
721
+ environment_variables: Optional[dict[str, str]] = None,
722
+ secrets: Optional[dict[str, str]] = None,
494
723
  comms_data_location: Optional[str] = None,
495
- comms_domains: Optional[list[str]] = [],
496
- vault_administrators: Optional[list[str]] = [],
724
+ comms_domains: Optional[list[HostDefinition]] = None,
725
+ vault_administrators: Optional[list[str]] = None,
497
726
  ) -> azure.web.WebApp:
498
727
  """
499
728
  Create a Django website with it's own database and storage containers.
@@ -541,7 +770,11 @@ class DjangoDeployment(pulumi.ComponentResource):
541
770
 
542
771
  # Communication Services (optional)
543
772
  if comms_data_location:
773
+ if not comms_domains:
774
+ comms_domains = []
775
+
544
776
  comms = self._add_webapp_comms(comms_data_location, comms_domains, f"{name}-{self._name}")
777
+
545
778
  # Add the service endpoint as environment variable
546
779
  environment_variables["AZURE_COMMUNICATION_SERVICE_ENDPOINT"] = comms.host_name.apply(lambda host: f"https://{host}")
547
780
  else:
@@ -567,6 +800,8 @@ class DjangoDeployment(pulumi.ComponentResource):
567
800
  for key, value in environment_variables.items()
568
801
  ]
569
802
 
803
+ allowed_hosts = pulumi.Output.concat(*[pulumi.Output.concat(host.full_host, ",") for host in website_hosts])
804
+
570
805
  app = azure.web.WebApp(
571
806
  f"app-{name}-{self._name}",
572
807
  resource_group_name=self._rg,
@@ -595,15 +830,23 @@ class DjangoDeployment(pulumi.ComponentResource):
595
830
  # azure.web.NameValuePairArgs(name="DEBUG", value="true"),
596
831
  azure.web.NameValuePairArgs(name="DJANGO_SETTINGS_MODULE", value=django_settings_module),
597
832
  azure.web.NameValuePairArgs(name="DJANGO_SECRET_KEY", value=secret_key.result),
598
- azure.web.NameValuePairArgs(name="DJANGO_ALLOWED_HOSTS", value=",".join(website_hosts)),
833
+ azure.web.NameValuePairArgs(name="DJANGO_ALLOWED_HOSTS", value=allowed_hosts),
599
834
  # Vault settings
600
835
  azure.web.NameValuePairArgs(name="AZURE_KEY_VAULT", value=vault.name),
601
836
  # 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),
837
+ azure.web.NameValuePairArgs(
838
+ name="AZURE_STORAGE_ACCOUNT_NAME",
839
+ value=self._storage_account.name,
840
+ ),
841
+ azure.web.NameValuePairArgs(
842
+ name="AZURE_STORAGE_CONTAINER_STATICFILES",
843
+ value=static_container.name,
844
+ ),
604
845
  azure.web.NameValuePairArgs(name="AZURE_STORAGE_CONTAINER_MEDIA", value=media_container.name),
605
846
  # CDN
606
847
  azure.web.NameValuePairArgs(name="CDN_HOST", value=self._cdn_host),
848
+ azure.web.NameValuePairArgs(name="CDN_PROFILE", value=self._cdn_profile.name),
849
+ azure.web.NameValuePairArgs(name="CDN_ENDPOINT", value=self._cdn_endpoint.name),
607
850
  # Database settings
608
851
  azure.web.NameValuePairArgs(name="DB_HOST", value=self._pgsql.fully_qualified_domain_name),
609
852
  azure.web.NameValuePairArgs(name="DB_NAME", value=db.name),
@@ -621,9 +864,85 @@ class DjangoDeployment(pulumi.ComponentResource):
621
864
  # We need this to verify custom domains
622
865
  pulumi.export(f"{name}_site_domain_verification_id", app.custom_domain_verification_id)
623
866
  pulumi.export(f"{name}_site_domain_cname", app.default_host_name)
867
+ virtual_ip = app.outbound_ip_addresses.apply(lambda addresses: addresses.split(",")[-1])
868
+ pulumi.export(f"{name}_site_virtual_ip", virtual_ip)
869
+
870
+ # Get the URL of the publish profile.
871
+ # Use app.identity here too to ensure the app is actually created before getting credentials.
872
+ credentials = pulumi.Output.all(self._rg, app.name, app.identity).apply(
873
+ lambda args: azure.web.list_web_app_publishing_credentials(
874
+ resource_group_name=args[0],
875
+ name=args[1],
876
+ )
877
+ )
878
+
879
+ pulumi.export(f"{name}_deploy_url", pulumi.Output.concat(credentials.scm_uri, "/deploy"))
624
880
 
625
881
  for host in website_hosts:
626
- self._add_webapp_host(app=app, host=host, suffix=f"{name}-{self._name}")
882
+ dependencies = []
883
+
884
+ if host.zone:
885
+ # Create a DNS record in the zone
886
+
887
+ if host.host == "@":
888
+ # Create a A record for the virtual IP address
889
+ a = azure.network.RecordSet(
890
+ f"dns-a-{name}-{self._name}-{host.identifier}",
891
+ resource_group_name=self._rg,
892
+ zone_name=host.zone.name,
893
+ relative_record_set_name=host.host,
894
+ record_type="A",
895
+ ttl=3600,
896
+ a_records=[
897
+ azure.network.ARecordArgs(
898
+ ipv4_address=virtual_ip,
899
+ )
900
+ ],
901
+ )
902
+
903
+ dependencies.append(a)
904
+ else:
905
+ # Create a CNAME record for the custom hostname
906
+ cname = azure.network.RecordSet(
907
+ f"dns-cname-{name}-{self._name}-{host.identifier}",
908
+ resource_group_name=self._rg,
909
+ zone_name=host.zone.name,
910
+ relative_record_set_name=host.host,
911
+ record_type="CNAME",
912
+ ttl=3600,
913
+ cname_record=azure.network.CnameRecordArgs(
914
+ cname=app.default_host_name,
915
+ ),
916
+ )
917
+ dependencies.append(cname)
918
+
919
+ # For the certificate validation to work
920
+ relative_record_set_name = "asuid" if host.host == "@" else pulumi.Output.concat("asuid.", host.host)
921
+
922
+ txt_validation = azure.network.RecordSet(
923
+ f"dns-txt-{name}-{self._name}-{host.identifier}",
924
+ resource_group_name=self._rg,
925
+ zone_name=host.zone.name,
926
+ relative_record_set_name=relative_record_set_name,
927
+ record_type="TXT",
928
+ ttl=3600,
929
+ txt_records=[
930
+ azure.network.TxtRecordArgs(
931
+ value=[app.custom_domain_verification_id],
932
+ )
933
+ ],
934
+ )
935
+
936
+ dependencies.append(txt_validation)
937
+
938
+ # Add the host with optional dependencies
939
+ self._add_webapp_host(
940
+ app=app,
941
+ host=host.full_host,
942
+ suffix=f"{name}-{self._name}",
943
+ identifier=host.identifier,
944
+ depends_on=dependencies,
945
+ )
627
946
 
628
947
  # To enable deployment from GitLab
629
948
  azure.web.WebAppSourceControl(
@@ -639,7 +958,8 @@ class DjangoDeployment(pulumi.ComponentResource):
639
958
 
640
959
  # Where we can retrieve the SSH key
641
960
  pulumi.export(
642
- f"{name}_deploy_ssh_key_url", app.name.apply(lambda name: f"https://{name}.scm.azurewebsites.net/api/sshkey?ensurePublicKey=1")
961
+ f"{name}_deploy_ssh_key_url",
962
+ app.name.apply(lambda name: f"https://{name}.scm.azurewebsites.net/api/sshkey?ensurePublicKey=1"),
643
963
  )
644
964
 
645
965
  # Find the role for Key Vault Secrets User
@@ -695,11 +1015,24 @@ class DjangoDeployment(pulumi.ComponentResource):
695
1015
  scope=comms.id,
696
1016
  )
697
1017
 
1018
+ # Grant the app to purge the CDN endpoint
1019
+ cdn_role = self._cdn_endpoint.id.apply(
1020
+ lambda scope: azure.authorization.get_role_definition(
1021
+ role_definition_id="/426e0c7f-0c7e-4658-b36f-ff54d6c29b45",
1022
+ scope=scope,
1023
+ )
1024
+ )
1025
+
1026
+ azure.authorization.RoleAssignment(
1027
+ f"ra-{name}-cdn",
1028
+ principal_id=principal_id,
1029
+ principal_type=azure.authorization.PrincipalType.SERVICE_PRINCIPAL,
1030
+ role_definition_id=cdn_role.id,
1031
+ scope=self._cdn_endpoint.id,
1032
+ )
1033
+
698
1034
  # Create a CORS rules for this website
699
- if website_hosts:
700
- origins = [f"https://{host}" for host in website_hosts]
701
- else:
702
- origins = ["*"]
1035
+ origins = [pulumi.Output.concat("https://", host.full_host) for host in website_hosts] if website_hosts else ["*"]
703
1036
 
704
1037
  azure.storage.BlobServiceProperties(
705
1038
  f"sa-{name}-blob-properties",
@@ -720,3 +1053,12 @@ class DjangoDeployment(pulumi.ComponentResource):
720
1053
  )
721
1054
 
722
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.10
3
+ Version: 1.0.14
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.99.0
37
+ Requires-Dist: pulumi-azure-native>=2.24.0
38
+ Requires-Dist: pulumi-random>=4.14.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,7 @@
1
+ pulumi_django_azure/__init__.py,sha256=5RY9reSVNw-HULrOXfhcq3cyPne-94ojFmeV1m6kIVg,79
2
+ pulumi_django_azure/django_deployment.py,sha256=BWw8GVSsjPLIu2ud09KJGr4D-G7VuJNnJey4nBTCEHI,44199
3
+ pulumi_django_azure-1.0.14.dist-info/LICENSE,sha256=NX2LN3U319Zaac8b7ZgfNOco_nTBbN531X_M_13niSg,1087
4
+ pulumi_django_azure-1.0.14.dist-info/METADATA,sha256=4po_NMDQbKi7lopg17jjZ1LrLM4Ojoy7yiF7NdIibAs,13954
5
+ pulumi_django_azure-1.0.14.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
6
+ pulumi_django_azure-1.0.14.dist-info/top_level.txt,sha256=MNPRJhq-_G8EMCHRkjdcb_xrqzOkmKogXUGV7Ysz3g0,20
7
+ pulumi_django_azure-1.0.14.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: setuptools (75.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,7 +0,0 @@
1
- pulumi_django_azure/__init__.py,sha256=tXTvPfr8-Nll5cjMyY9yj_0z_PQ0XAcxihzHRCES-hU,63
2
- pulumi_django_azure/django_deployment.py,sha256=SgMLoQ2Jwm8anADzrEjVhtfJVAJrYJYFoaOL9bDZngE,30275
3
- pulumi_django_azure-1.0.10.dist-info/LICENSE,sha256=NX2LN3U319Zaac8b7ZgfNOco_nTBbN531X_M_13niSg,1087
4
- pulumi_django_azure-1.0.10.dist-info/METADATA,sha256=rAAqdkW9F0I0OWn7XQA2fHKIpqxmJDQWx39TmH0FEnc,11083
5
- pulumi_django_azure-1.0.10.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
6
- pulumi_django_azure-1.0.10.dist-info/top_level.txt,sha256=MNPRJhq-_G8EMCHRkjdcb_xrqzOkmKogXUGV7Ysz3g0,20
7
- pulumi_django_azure-1.0.10.dist-info/RECORD,,