22
33import time
44from contextlib import suppress
5- from typing import Any
5+ from datetime import UTC , datetime , timedelta
6+ from typing import Any , override
67
78from acme .errors import ValidationError
89from cryptography .hazmat .primitives .asymmetric .rsa import RSAPrivateKey
1516from pulumi import Input , Output , ResourceOptions
1617from pulumi .dynamic import CreateResult , DiffResult , Resource
1718from simple_acme_dns import ACMEClient
19+ from simple_acme_dns .errors import InvalidKeyType
1820
1921from data_safe_haven .exceptions import DataSafeHavenAzureError , DataSafeHavenSSLError
2022from data_safe_haven .external import AzureSdk
@@ -43,8 +45,8 @@ def __init__(
4345
4446
4547class SSLCertificateProvider (DshResourceProvider ):
48+ @override
4649 def create (self , props : dict [str , Any ]) -> CreateResult :
47- """Create new SSL certificate."""
4850 outs = dict (** props )
4951 try :
5052 client = ACMEClient (
@@ -60,8 +62,8 @@ def create(self, props: dict[str, Any]) -> CreateResult:
6062 private_key_bytes = client .generate_private_key (key_type = "rsa2048" )
6163 client .generate_csr ()
6264 # Request DNS verification tokens and add them to the DNS record
63- azure_sdk = AzureSdk (props ["subscription_name" ], disable_logging = True )
6465 verification_tokens = client .request_verification_tokens ().items ()
66+ azure_sdk = AzureSdk (props ["subscription_name" ], disable_logging = True )
6567 for record_name , record_values in verification_tokens :
6668 record_set = azure_sdk .ensure_dns_txt_record (
6769 record_name = record_name .replace (f".{ props ['domain_name' ]} " , "" ),
@@ -96,7 +98,7 @@ def create(self, props: dict[str, Any]) -> CreateResult:
9698 private_key = load_pem_private_key (private_key_bytes , None )
9799 if not isinstance (private_key , RSAPrivateKey ):
98100 msg = f"Private key is of type { type (private_key )} not RSAPrivateKey."
99- raise TypeError (msg )
101+ raise DataSafeHavenSSLError (msg )
100102 all_certs = [
101103 load_pem_x509_certificate (data )
102104 for data in certificate_bytes .split (b"\n \n " )
@@ -118,8 +120,16 @@ def create(self, props: dict[str, Any]) -> CreateResult:
118120 certificate_contents = pfx_bytes ,
119121 key_vault_name = props ["key_vault_name" ],
120122 )
121- outs ["secret_id" ] = kvcert .secret_id
122- except Exception as exc :
123+ # Failures here will raise an exception that will be caught below
124+ outs ["expiry_date" ] = kvcert .properties .expires_on .isoformat ()
125+ outs ["secret_id" ] = "/" .join (kvcert .secret_id .split ("/" )[:- 1 ])
126+ except (
127+ AttributeError ,
128+ DataSafeHavenAzureError ,
129+ IndexError ,
130+ InvalidKeyType ,
131+ StopIteration ,
132+ ) as exc :
123133 cert_name = f"[green]{ props ['certificate_secret_name' ]} [/]"
124134 domain_name = f"[green]{ props ['domain_name' ]} [/]"
125135 msg = f"Failed to create SSL certificate { cert_name } for { domain_name } ."
@@ -129,8 +139,8 @@ def create(self, props: dict[str, Any]) -> CreateResult:
129139 outs = outs ,
130140 )
131141
142+ @override
132143 def delete (self , id_ : str , props : dict [str , Any ]) -> None :
133- """Delete an SSL certificate."""
134144 # Use `id` as a no-op to avoid ARG002 while maintaining function signature
135145 id (id_ )
136146 try :
@@ -146,43 +156,51 @@ def delete(self, id_: str, props: dict[str, Any]) -> None:
146156 certificate_name = props ["certificate_secret_name" ],
147157 key_vault_name = props ["key_vault_name" ],
148158 )
149- except Exception as exc :
159+ except DataSafeHavenAzureError as exc :
150160 cert_name = f"[green]{ props ['certificate_secret_name' ]} [/]"
151161 domain_name = f"[green]{ props ['domain_name' ]} [/]"
152162 msg = f"Failed to delete SSL certificate { cert_name } for { domain_name } ."
153163 raise DataSafeHavenSSLError (msg ) from exc
154164
165+ @override
155166 def diff (
156167 self ,
157168 id_ : str ,
158169 old_props : dict [str , Any ],
159170 new_props : dict [str , Any ],
160171 ) -> DiffResult :
161- """Calculate diff between old and new state"""
162172 # Use `id` as a no-op to avoid ARG002 while maintaining function signature
163173 id (id_ )
164- return self .partial_diff (old_props , new_props , [])
174+ partial = self .partial_diff (old_props , new_props , [])
175+ expiry_date = datetime .fromisoformat (
176+ old_props .get ("expiry_date" , "0001-01-01T00:00:00+00:00" )
177+ )
178+ needs_renewal = datetime .now (UTC ) + timedelta (days = 30 ) > expiry_date
179+ return DiffResult (
180+ changes = partial .changes or needs_renewal ,
181+ replaces = partial .replaces ,
182+ stables = partial .stables ,
183+ delete_before_replace = True ,
184+ )
165185
186+ @override
166187 def refresh (self , props : dict [str , Any ]) -> dict [str , Any ]:
167- try :
168- outs = dict (** props )
169- with suppress (DataSafeHavenAzureError , KeyError ):
170- azure_sdk = AzureSdk (outs ["subscription_name" ], disable_logging = True )
171- certificate = azure_sdk .get_keyvault_certificate (
172- outs ["certificate_secret_name" ], outs ["key_vault_name" ]
173- )
174- if certificate .secret_id :
175- outs ["secret_id" ] = certificate .secret_id
176- return outs
177- except Exception as exc :
178- cert_name = f"[green]{ props ['certificate_secret_name' ]} [/]"
179- domain_name = f"[green]{ props ['domain_name' ]} [/]"
180- msg = f"Failed to refresh SSL certificate { cert_name } for { domain_name } ."
181- raise DataSafeHavenSSLError (msg ) from exc
188+ outs = dict (** props )
189+ with suppress (DataSafeHavenAzureError , KeyError ):
190+ azure_sdk = AzureSdk (outs ["subscription_name" ], disable_logging = True )
191+ kvcert = azure_sdk .get_keyvault_certificate (
192+ outs ["certificate_secret_name" ], outs ["key_vault_name" ]
193+ )
194+ if kvcert .secret_id :
195+ outs ["secret_id" ] = kvcert .secret_id
196+ if kvcert .properties and kvcert .properties .expires_on :
197+ outs ["expiry_date" ] = kvcert .properties .expires_on .isoformat ()
198+ return outs
182199
183200
184201class SSLCertificate (Resource ):
185202 _resource_type_name = "dsh:common:SSLCertificate" # set resource type
203+ expiry_date : Output [str ]
186204 secret_id : Output [str ]
187205
188206 def __init__ (
@@ -194,6 +212,6 @@ def __init__(
194212 super ().__init__ (
195213 SSLCertificateProvider (),
196214 name ,
197- {"secret_id" : None , ** vars (props )},
215+ {"expiry_date" : None , " secret_id" : None , ** vars (props )},
198216 opts ,
199217 )
0 commit comments