Skip to content

Commit 466d820

Browse files
committed
Support exposed_endpoints param in Application.expose/unexpose methods
1 parent 8d62bc9 commit 466d820

4 files changed

Lines changed: 405 additions & 21 deletions

File tree

juju/application.py

Lines changed: 174 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

juju/bundle.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -742,7 +742,10 @@ def __str__(self):
742742

743743

744744
class ExposeChange(ChangeInfo):
745-
_toPy = {'application': 'application'}
745+
_toPy = {
746+
'application': 'application',
747+
'exposed-endpoints': 'exposed_endpoints',
748+
}
746749
"""ExposeChange holds a change for exposing an application.
747750
748751
:change_id: id of the change that will be used to identify the current
@@ -755,6 +758,10 @@ class ExposeChange(ChangeInfo):
755758
Params holds the following values:
756759
:application: placeholder name of the application that must be
757760
exposed.
761+
:exposed_endpoints: a an optional dictionary where keys are endpoint
762+
names and values are dicts that specify the space names and CIDRs
763+
that should be able to access the port ranges that the application
764+
has opened for each endpoint.
758765
"""
759766
def __init__(self, change_id, requires, params=None):
760767
super(ExposeChange, self).__init__(change_id, requires)
@@ -781,7 +788,7 @@ async def run(self, context):
781788
"""
782789
application = context.resolve(self.application)
783790
log.info('Exposing %s', application)
784-
return await context.model.applications[application].expose()
791+
return await context.model.applications[application].expose(self.exposed_endpoints)
785792

786793
def __str__(self):
787794
return "expose {application}".format(application=self.application)

0 commit comments

Comments
 (0)