Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions remoteappmanager/docker/configurables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import abc


class Configurable(metaclass=abc.ABCMeta):
"""Base class for Configurables.
They describe startup configuration entry points for images.
Injection of the configurables data is done through a defined
set of environment variables that the image accepts.
"""
#: Unique, controlled tag string that identifies the configurable.
tag = None

@abc.abstractclassmethod
def supported_by(cls, image):
"""Checks if the passed image supports the configurable.
Returns True if so, False otherwise.

Parameters
----------
image: remoteappmanager.docker.Image
The image to check if it supports this configurable

Returns
-------
bool: True if the image supports the configurable.

"""

@abc.abstractclassmethod
def config_dict_to_env(cls, config_dict):
"""
Extracts the relevant data from a dictionary.
Returns a dictionary with the environment to transmit to the image
to set this particular configurable. Values must be strings.

Raises various exceptions if the config_dict is not in the expected
format.

IMPORTANT: the received dictionary is likely coming from
hostile environment. Validate the contents strictly.

Parameters
----------
config_dict: Dict
A dictionary containing data that the Configurable class
understands.

Returns
-------
Dict(Str, Str):
A dictionary of environment variables to pass to the
image at startup.
"""


class Resolution(Configurable):
"""Support for images that allow resolution change of the VNC server."""

tag = "resolution"

@classmethod
def supported_by(cls, image):
return all(x in image.env
for x in ["X11_WIDTH", "X11_HEIGHT", "X11_DEPTH"])

@classmethod
def config_dict_to_env(cls, config_dict):
"""the config dict must contain the following (example)

{
"resolution" : "1024x768"
}

Observe that the key used has nothing to do with the tag.
They are only accidentally the same.
"""
resolution = config_dict["resolution"]
w, h = [int(value) for value in resolution.split("x")]
if w <= 0 or h <= 0:
raise ValueError("invalid width or height")

return {
"X11_WIDTH": str(w),
"X11_HEIGHT": str(h),
"X11_DEPTH": "16"
}


def for_image(image):
"""Returns the configurables that are available for a specific
image.

Parameters
----------
image: remoteappmanager.docker.Image
The image to check.

Returns
-------
List
A list of Configurable classes supported by the given image.

"""
# We lack automatic registration. Add here new configurables
available = [Resolution]

return [conf_class
for conf_class in available
if conf_class.supported_by(image)]
13 changes: 11 additions & 2 deletions remoteappmanager/docker/image.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import string
from traitlets import Unicode, HasTraits, Dict
from traitlets import Unicode, HasTraits, Dict, List

from remoteappmanager.docker.docker_labels import SIMPHONY_NS
from remoteappmanager.docker import configurables

# Characters that are allowed in the environment variables.
_ALLOWED_ENVCHARS = set(string.ascii_lowercase + string.digits + "-")
Expand Down Expand Up @@ -37,6 +38,9 @@ class Image(HasTraits):
# Only keys are used at the moment.
env = Dict()

# A list of configurables that the image supports.
configurables = List()

@classmethod
def from_docker_dict(cls, docker_dict):
"""Converts the dict response from the dockerpy library into an
Expand Down Expand Up @@ -75,6 +79,11 @@ def from_docker_dict(cls, docker_dict):
continue

env = env.upper().replace("-", "_")
self.env[env] = None
# Docker does not allow unexistent values in
# labels, but we should not rely on them anyway,
# as only presence of the env key has a clear
# meaning, hence we force the value to empty.
self.env[env] = ""

self.configurables = configurables.for_image(self)
return self
44 changes: 44 additions & 0 deletions remoteappmanager/docker/tests/test_configurables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import unittest
from remoteappmanager.docker.configurables import Resolution, for_image
from remoteappmanager.docker.image import Image
from remoteappmanager.tests.mocking.virtual.docker_client import \
create_docker_client


class TestConfigurables(unittest.TestCase):
def setUp(self):
docker_client = create_docker_client()
image_dict = docker_client.inspect_image('image_id2')
self.image_2 = Image.from_docker_dict(image_dict)

image_dict = docker_client.inspect_image('image_id1')
self.image_1 = Image.from_docker_dict(image_dict)

def test_resolution(self):
self.assertTrue(Resolution.supported_by(self.image_1))
self.assertFalse(Resolution.supported_by(self.image_2))

def test_for_image(self):
self.assertEqual(for_image(self.image_1), [Resolution])
self.assertEqual(for_image(self.image_2), [])

def test_config_dict_to_env(self):
self.assertEqual(
Resolution.config_dict_to_env({"resolution": "1024x768"}),
{"X11_WIDTH": "1024",
"X11_HEIGHT": "768",
"X11_DEPTH": "16"
}
)

with self.assertRaises(KeyError):
Resolution.config_dict_to_env({})

with self.assertRaises(ValueError):
Resolution.config_dict_to_env({"resolution": "1024"})

with self.assertRaises(ValueError):
Resolution.config_dict_to_env({"resolution": "-10x-10"})

with self.assertRaises(ValueError):
Resolution.config_dict_to_env({"resolution": "0x1024"})
6 changes: 5 additions & 1 deletion remoteappmanager/docker/tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ def test_from_docker_dict_inspect_image(self):
image.ui_name,
image_dict['Config']["Labels"][SIMPHONY_NS.ui_name])
self.assertEqual(image.type, 'vncapp')
self.assertEqual(image.env, {"X11_WIDTH": None})
self.assertEqual(image.env, {
"X11_WIDTH": "",
"X11_DEPTH": "",
"X11_HEIGHT": "",
})

def test_missing_image_type(self):
docker_client = create_docker_client()
Expand Down
3 changes: 2 additions & 1 deletion remoteappmanager/restresources/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def retrieve(self, identifier):
"volume_source": policy.volume_source,
"volume_target": policy.volume_target,
"volume_mode": policy.volume_mode,
}
},
"configurables": [conf.tag for conf in image.configurables]
},
"mapping_id": identifier,
}
Expand Down
50 changes: 45 additions & 5 deletions remoteappmanager/restresources/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ def create(self, representation):
except KeyError:
raise exceptions.BadRequest(message="missing mapping_id")

webapp = self.application
account = self.current_user.account
all_apps = self.application.db.get_apps_for_user(account)
all_apps = webapp.db.get_apps_for_user(account)
container_manager = webapp.container_manager

choice = [(m_id, app, policy)
for m_id, app, policy in all_apps
Expand All @@ -39,12 +41,25 @@ def create(self, representation):

_, app, policy = choice[0]

image = yield container_manager.image(app.image)
if image is None:
raise exceptions.BadRequest(message="unrecognized image")

try:
environment = self._environment_from_configurables(image,
representation)
except Exception:
raise exceptions.BadRequest(message="invalid configurables")

# Everything is fine. Start and wait for the container to come online.
try:
container = yield self._start_container(
self.current_user.name,
app,
policy,
mapping_id)
mapping_id,
environment
)
except Exception as e:
raise exceptions.Unable(message=str(e))

Expand Down Expand Up @@ -175,7 +190,12 @@ def _remove_container_noexcept(self, container):
container.docker_id))

@gen.coroutine
def _start_container(self, user_name, app, policy, mapping_id):
def _start_container(self,
user_name,
app,
policy,
mapping_id,
environment):
"""Start the container. This method is a helper method that
works with low level data and helps in issuing the request to the
data container.
Expand All @@ -191,6 +211,9 @@ def _start_container(self, user_name, app, policy, mapping_id):
policy : ABCApplicationPolicy
The startup policy for the application

environment: Dict
A dictionary of envvars to pass to the container.

Returns
-------
remoteappmanager.docker.container.Container
Expand Down Expand Up @@ -220,8 +243,12 @@ def _start_container(self, user_name, app, policy, mapping_id):
'mode': volume_mode}

try:
f = manager.start_container(user_name, image_name,
mapping_id, volumes)
f = manager.start_container(user_name,
image_name,
mapping_id,
volumes,
environment
)
container = yield gen.with_timeout(
timedelta(
seconds=self.application.file_config.network_timeout
Expand All @@ -245,6 +272,19 @@ def _start_container(self, user_name, app, policy, mapping_id):

return container

def _environment_from_configurables(self, image, representation):
"""Helper routine: extracts the configurables from the
image, matches them to the appropriate configurables
data in the representation, and returns the resulting environment
"""
env = {}

for img_conf in image.configurables:
config_dict = representation["configurables"][img_conf.tag]
env.update(img_conf.config_dict_to_env(config_dict))

return env

@gen.coroutine
def _wait_for_container_ready(self, container):
""" Wait until the container is ready to be connected
Expand Down
27 changes: 16 additions & 11 deletions remoteappmanager/restresources/tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,19 @@ def test_retrieve(self):
self.assertEqual(res.code, httpstatus.OK)
self.assertEqual(escape.json_decode(res.body),
{'container': None,
'image': {'description': '',
'icon_128': '',
'name': 'boo',
'ui_name': 'foo_ui',
'policy': {
"allow_home": True,
"volume_mode": 'ro',
"volume_source": "foo",
"volume_target": "bar",
}},
'image': {
'description': '',
'icon_128': '',
'name': 'boo',
'ui_name': 'foo_ui',
'policy': {
"allow_home": True,
"volume_mode": 'ro',
"volume_source": "foo",
"volume_target": "bar",
},
'configurables': []
},
'mapping_id': 'one'})

self._app.container_manager.containers_from_mapping_id = \
Expand All @@ -121,7 +124,9 @@ def test_retrieve(self):
"volume_mode": 'ro',
"volume_source": "foo",
"volume_target": "bar",
}},
},
'configurables': [],
},
'mapping_id': 'one'})

res = self.fetch("/api/v1/applications/three/")
Expand Down
Loading