@@ -34,6 +34,9 @@ def _unit_match_pattern(self):
3434 def _facade (self ):
3535 return client .ApplicationFacade .from_connection (self .connection )
3636
37+ def _facade_version (self ):
38+ return client .ApplicationFacade .best_facade_version (self .connection )
39+
3740 def on_unit_add (self , callable_ ):
3841 """Add a "unit added" observer to this entity, which will be called
3942 whenever a unit is added to this application.
@@ -233,16 +236,115 @@ async def destroy(self):
233236 return await app_facade .Destroy (application = self .name )
234237 remove = destroy
235238
236- async def expose (self ):
237- """Make this application publicly available over the network.
239+ async def expose (self , exposed_endpoints = None ):
240+ """Make a subset of the application endpoints or the entire application
241+ available over the network.
242+
243+ If the exposed_endpoints argument is not provided, all opened port
244+ ranges for the application will become reachable from 0.0.0.0/0.
245+
246+ On juju 2.9 and onwards, the exposed_endpoints argument may be used
247+ to specify a list of spaces and or CIDRs that should be able to
248+ reach the port ranges opened for a particular subnet. The
249+ exposed_endpoints parameter is a map where keys are endpoint names
250+ or the empty string ("") which works as a wildcard for all endpoints
251+ and values are ExposedEndpoint instances.
238252
253+ When targeting an older juju controller, the exposed_endpoints param
254+ is not supported and an error will be raised if it is provided.
239255 """
240256 app_facade = self ._facade ()
257+ facade_version = self ._facade_version ()
258+
259+ if exposed_endpoints is not None :
260+ if not isinstance (exposed_endpoints , dict ):
261+ raise ValueError ("endpoints must be a dictionary with ExposedEndpoint values" )
262+
263+ # The bundle changes code will pass in raw dicts with the exposed
264+ # endpoint data. We need to convert those into ExposedEndpoints
265+ for k , v in exposed_endpoints .items ():
266+ if not isinstance (v , ExposedEndpoint ):
267+ exposed_endpoints [k ] = ExposedEndpoint .from_dict (v )
268+
269+ # Check if the specified exposed_endpoints would cause security
270+ # issues when applied to a pre 2.9 controller.
271+ has_more_than_one_endpoints = len (exposed_endpoints ) > 1
272+ has_non_wildcard_endpoint = (
273+ len (exposed_endpoints ) > 0 and "" not in exposed_endpoints
274+ )
275+ has_wildcard_endpoint_with_spaces_or_non_wildcard_cidrs = (
276+ "" in exposed_endpoints and (
277+ exposed_endpoints ["" ].includes_non_wildcard_cidrs () or
278+ exposed_endpoints ["" ].includes_spaces ()
279+ )
280+ )
241281
242- log .debug (
243- 'Exposing %s' , self .name )
282+ is_security_risk = (
283+ facade_version < 13 and
284+ (
285+ has_more_than_one_endpoints or
286+ has_non_wildcard_endpoint or
287+ has_wildcard_endpoint_with_spaces_or_non_wildcard_cidrs
288+ )
289+ )
290+
291+ if is_security_risk :
292+ raise JujuError ("controller does not support granular expose parameters; applying this change would make all open application ports accessible from 0.0.0.0/0" )
293+
294+ for endpoint , expose_details in exposed_endpoints .items ():
295+ access_from = "from CIDRs 0.0.0.0/0 and ::/0"
296+ if isinstance (expose_details , ExposedEndpoint ):
297+ access_from = str (expose_details )
298+
299+ if endpoint == "" :
300+ log .debug ("expose all endpoints of %s and allow access %s" , self .name , access_from )
301+ else :
302+ log .debug ("override expose settings for endpoint %s of %s and %s" , endpoint , self .name , access_from )
303+
304+ # Map ExposedEndpoint entries to a dict we can pass to the facade.
305+ exposed_endpoints = {
306+ k : v .to_dict () for k , v in exposed_endpoints .items ()
307+ }
308+ else :
309+ log .debug ("expose all endpoints of %s and allow access from CIDRs 0.0.0.0/0 and ::/0" , self .name )
310+
311+ if facade_version < 13 :
312+ return await app_facade .Expose (application = self .name )
313+
314+ return await app_facade .Expose (application = self .name ,
315+ exposed_endpoints = exposed_endpoints )
316+
317+ async def unexpose (self , exposed_endpoints = None ):
318+ """Prevent a subset of the application endpoints or the entire
319+ application from being reached over the network.
320+
321+ If the exposed_endpoints argument is not provided, the entire
322+ application will be unexposed.
323+
324+ On juju 2.9 and onwards, the exposed_endpoints argument may be used
325+ to specify a list of endpoint names whose port ranges should be
326+ unexposed.
327+
328+ When targeting an older juju controller, the exposed_endpoints param
329+ is not supported and an error will be raised if it is provided.
330+ """
331+ app_facade = self ._facade ()
332+ facade_version = self ._facade_version ()
333+
334+ # Check if an endpoint list is provided
335+ if exposed_endpoints is not None and len (exposed_endpoints ) > 0 :
336+ if facade_version < 13 :
337+ raise JujuError ("controller does not support granular expose parameters; applying this change would unexpose the application" )
338+
339+ log .debug ("Unexposing endpoints %s of %s" ,
340+ "," .join (exposed_endpoints ), self .name )
341+ return await app_facade .Unexpose (application = self .name ,
342+ exposed_endpoints = exposed_endpoints )
343+
344+ # Just expose the entire application
345+ log .debug ("Unexposing %s" , self .name )
346+ return await app_facade .Unexpose (application = self .name )
244347
245- return await app_facade .Expose (application = self .name )
246348
247349 async def get_config (self ):
248350 """Return the configuration settings dict for this application.
@@ -438,17 +540,6 @@ def set_plan(self, plan_name):
438540 """
439541 raise NotImplementedError ()
440542
441- async def unexpose (self ):
442- """Remove public availability over the network for this application.
443-
444- """
445- app_facade = self ._facade ()
446-
447- log .debug (
448- 'Unexposing %s' , self .name )
449-
450- return await app_facade .Unexpose (application = self .name )
451-
452543 def update_allocation (self , allocation ):
453544 """Update existing allocation for this application.
454545
@@ -585,3 +676,70 @@ async def get_metrics(self):
585676
586677 """
587678 return await self .model .get_metrics (self .tag )
679+
680+
681+ class ExposedEndpoint :
682+ """ExposedEndpoint stores the list of CIDRs and space names which should be
683+ allowed access to the port ranges that the application has opened for a
684+ particular endpoint. Both lists are optional; if empty, the opened port
685+ ranges will be reachable from any source IP address."""
686+
687+ def __init__ (self , to_spaces = None , to_cidrs = None ):
688+ if to_spaces is not None and not isinstance (to_spaces , list ):
689+ raise ValueError ("to_spaces must be a list of space names or None" )
690+ if to_cidrs is not None and not isinstance (to_cidrs , list ):
691+ raise ValueError ("to_cidrs must be a list of CIDRs or None" )
692+
693+ self .to_cidrs = to_cidrs
694+ self .to_spaces = to_spaces
695+
696+ def includes_spaces (self ):
697+ return self .to_spaces is not None and len (self .to_spaces ) > 0
698+
699+ def includes_non_wildcard_cidrs (self ):
700+ to_cidrs = (self .to_cidrs or [])
701+ non_wildcard_cidrs = filter (lambda x : x == "0.0.0.0/0" or x == "::/0" ,
702+ to_cidrs )
703+ return len (list (non_wildcard_cidrs )) > 0
704+
705+ @classmethod
706+ def from_dict (cls , data ):
707+ d = (data or {})
708+ if not isinstance (d , dict ):
709+ raise ValueError ("expected a dictionary with fields: expose-to-spaces and expose-to-cidrs" )
710+
711+ to_spaces = None
712+ if "expose-to-spaces" in d and isinstance (d ["expose-to-spaces" ], list ):
713+ to_spaces = d ["expose-to-spaces" ]
714+ to_cidrs = None
715+ if "expose-to-cidrs" in d and isinstance (d ["expose-to-cidrs" ], list ):
716+ to_cidrs = d ["expose-to-cidrs" ]
717+
718+ return cls (to_spaces = to_spaces , to_cidrs = to_cidrs )
719+
720+ def to_dict (self ):
721+ d = {}
722+ if self .to_cidrs is not None :
723+ d ["expose-to-cidrs" ] = self .to_cidrs
724+ if self .to_spaces is not None :
725+ d ["expose-to-spaces" ] = self .to_spaces
726+ return d
727+
728+ def __str__ (self ):
729+ descr = ""
730+ if self .to_spaces is not None and len (self .to_spaces ) > 0 :
731+ if len (self .to_spaces ) == 1 :
732+ descr = "from space {}" .format (self .to_spaces [0 ])
733+ elif len (self .to_spaces ) > 1 :
734+ descr = "from spaces {}" .format ("," .join (self .to_spaces ))
735+
736+ if self .to_cidrs is not None and len (self .to_cidrs ) > 0 :
737+ descr = descr + " and "
738+
739+ if self .to_cidrs is not None :
740+ if len (self .to_cidrs ) == 1 :
741+ descr = descr + "from CIDR {}" .format (self .to_cidrs [0 ])
742+ elif len (self .to_cidrs ) > 1 :
743+ descr = descr + "from CIDRs {}" .format ("," .join (self .to_cidrs ))
744+
745+ return descr
0 commit comments