From d503a9211370c5fa14b8bda4117884f2df688911 Mon Sep 17 00:00:00 2001 From: Joshua Blake Date: Fri, 15 Jul 2016 08:29:05 +0100 Subject: [PATCH 1/6] Switch to using Avatar models. Can now have multiple Avatars per user (eg: different games). Opens up the way for a changelog on an Avatar. Initial work for #79 and #21. --- players/admin.py | 5 +- players/migrations/0003_auto_20160719_0838.py | 70 +++++++++++++++++++ players/models.py | 20 +++++- players/views.py | 21 +++--- 4 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 players/migrations/0003_auto_20160719_0838.py diff --git a/players/admin.py b/players/admin.py index cd03629b7..305f732d9 100644 --- a/players/admin.py +++ b/players/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin -from models import Player +from models import Avatar, Game -admin.site.register(Player) +admin.site.register(Avatar) +admin.site.register(Game) diff --git a/players/migrations/0003_auto_20160719_0838.py b/players/migrations/0003_auto_20160719_0838.py new file mode 100644 index 000000000..1440b3705 --- /dev/null +++ b/players/migrations/0003_auto_20160719_0838.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings +import players.models + + +def move_data(apps, schema_editor): + Player = apps.get_model("players", "Player") + Avatar = apps.get_model("players", "Avatar") + Game = apps.get_model("players", "Game") + + if Player.objects.count() == 0: + return + main_game = Game(pk=1, name="main") + main_game.save() + + avatars = [Avatar(game=main_game, owner=player.user, code=player.code) + for player in Player.objects.all()] + Avatar.objects.bulk_create(avatars) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('players', '0002_auto_20160601_1914'), + ] + + operations = [ + migrations.CreateModel( + name='Avatar', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('code', models.TextField()), + ('auth_token', models.CharField(default=players.models.generate_auth_token, max_length=24)), + ], + ), + migrations.CreateModel( + name='Game', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=100)), + ('auth_token', models.CharField(default=players.models.generate_auth_token, max_length=24)), + ], + ), + migrations.RemoveField( + model_name='player', + name='user', + ), + migrations.AddField( + model_name='avatar', + name='game', + field=models.ForeignKey(to='players.Game'), + ), + migrations.AddField( + model_name='avatar', + name='owner', + field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='avatar', + unique_together=set([('owner', 'game')]), + ), + migrations.RunPython(move_data), + migrations.DeleteModel( + name='Player', + ), + ] diff --git a/players/models.py b/players/models.py index a27f5cd08..2cf1754da 100644 --- a/players/models.py +++ b/players/models.py @@ -1,7 +1,23 @@ +from base64 import urlsafe_b64encode from django.contrib.auth.models import User from django.db import models +from os import urandom -class Player(models.Model): - user = models.OneToOneField(User) +def generate_auth_token(): + return urlsafe_b64encode(urandom(16)) + + +class Game(models.Model): + name = models.CharField(max_length=100) + auth_token = models.CharField(max_length=24, default=generate_auth_token) + + +class Avatar(models.Model): + owner = models.ForeignKey(User) + game = models.ForeignKey(Game) code = models.TextField() + auth_token = models.CharField(max_length=24, default=generate_auth_token) + + class Meta: + unique_together = ('owner', 'game') diff --git a/players/views.py b/players/views.py index ba9ae5271..0579d3c55 100644 --- a/players/views.py +++ b/players/views.py @@ -7,8 +7,8 @@ import os -from models import Player from . import app_settings +from models import Avatar def _post_code_success_response(message): @@ -26,22 +26,23 @@ def create_response(status, message): @login_required def code(request): try: - player = request.user.player - except Player.DoesNotExist: + avatar = request.user.avatar_set.get(game_id=1) + except Avatar.DoesNotExist: initial_code_file_name = os.path.join( os.path.abspath(os.path.dirname(__file__)), 'avatar_examples/dumb_avatar.py', ) with open(initial_code_file_name) as initial_code_file: initial_code = initial_code_file.read() - player = Player.objects.create(user=request.user, code=initial_code) + avatar = Avatar.objects.create(owner=request.user, code=initial_code, + game_id=1) if request.method == 'POST': - player.code = request.POST['code'] - player.save() + avatar.code = request.POST['code'] + avatar.save() return _post_code_success_response("Your code was saved!") else: - return HttpResponse(player.code) + return HttpResponse(avatar.code) def games(request): @@ -50,9 +51,9 @@ def games(request): 'parameters': [], 'users': [ { - 'id': player.user.pk, - 'code': player.code, - } for player in Player.objects.all() + 'id': avatar.owner_id, + 'code': avatar.code, + } for avatar in Avatar.objects.filter(game_id=1) ] } } From 02192be566d5af57789d28e795eb32f249508254 Mon Sep 17 00:00:00 2001 From: Joshua Blake Date: Tue, 19 Jul 2016 09:11:27 +0100 Subject: [PATCH 2/6] Tidy + PEP8 --- aimmo-game-creator/service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aimmo-game-creator/service.py b/aimmo-game-creator/service.py index 671eb8968..89e9070fd 100755 --- a/aimmo-game-creator/service.py +++ b/aimmo-game-creator/service.py @@ -8,9 +8,11 @@ LOGGER = logging.getLogger(__name__) + def create_game_rc(api, name, environment_variables): environment_variables['SOCKETIO_RESOURCE'] = "game/%s/socket.io" % name - environment_variables['GAME_API_URL'] = 'https://staging-dot-decent-digit-629.appspot.com/aimmo/api/games/' + environment_variables['GAME_API_URL'] =\ + 'https://staging-dot-decent-digit-629.appspot.com/aimmo/api/games/' environment_variables['GAME_NAME'] = name environment_variables['GAME_URL'] = "http://game-%s" % name environment_variables['PYKUBE_KUBERNETES_SERVICE_HOST'] = 'kubernetes' From 178033242c2df5137de524938e5cf5d1a6f78cb3 Mon Sep 17 00:00:00 2001 From: Joshua Blake Date: Tue, 19 Jul 2016 09:43:26 +0100 Subject: [PATCH 3/6] Switch to using Game.id over name --- aimmo-game-creator/service.py | 55 +++++++++++++------------ aimmo-game/service.py | 2 +- aimmo-game/simulation/worker_manager.py | 8 ++-- players/urls.py | 2 +- players/views.py | 4 +- 5 files changed, 37 insertions(+), 34 deletions(-) diff --git a/aimmo-game-creator/service.py b/aimmo-game-creator/service.py index 89e9070fd..ceb34daaa 100755 --- a/aimmo-game-creator/service.py +++ b/aimmo-game-creator/service.py @@ -9,12 +9,12 @@ LOGGER = logging.getLogger(__name__) -def create_game_rc(api, name, environment_variables): - environment_variables['SOCKETIO_RESOURCE'] = "game/%s/socket.io" % name +def create_game_rc(api, id, environment_variables): + environment_variables['SOCKETIO_RESOURCE'] = "game/%s/socket.io" % id environment_variables['GAME_API_URL'] =\ - 'https://staging-dot-decent-digit-629.appspot.com/aimmo/api/games/' - environment_variables['GAME_NAME'] = name - environment_variables['GAME_URL'] = "http://game-%s" % name + 'https://staging-dot-decent-digit-629.appspot.com/aimmo/api/games/%s' % id + environment_variables['GAME_ID'] = id + environment_variables['GAME_URL'] = "http://game-%s" % id environment_variables['PYKUBE_KUBERNETES_SERVICE_HOST'] = 'kubernetes' rc = ReplicationController( api, @@ -22,24 +22,24 @@ def create_game_rc(api, name, environment_variables): 'kind': 'ReplicationController', 'apiVersion': 'v1', 'metadata': { - 'name': "game-%s" % name, + 'name': "game-%s" % id, 'namespace': 'default', 'labels': { 'app': 'aimmo-game', - 'game': name, + 'game_id': id, }, }, 'spec': { 'replicas': 1, 'selector': { 'app': 'aimmo-game', - 'game': name, + 'game': id, }, 'template': { 'metadata': { 'labels': { 'app': 'aimmo-game', - 'game': name, + 'id': id, }, }, 'spec': { @@ -78,23 +78,23 @@ def create_game_rc(api, name, environment_variables): rc.create() -def create_game_service(api, name, _config): +def create_game_service(api, id, _config): service = Service( api, { 'kind': 'Service', 'apiVersion': 'v1', 'metadata': { - 'name': "game-%s" % name, + 'name': "game-%s" % id, 'labels': { 'app': 'aimmo-game', - 'game': name, + 'game': id, }, }, 'spec': { 'selector': { 'app': 'aimmo-game', - 'game': name, + 'game': id, }, 'ports': [ { @@ -112,9 +112,11 @@ def create_game_service(api, name, _config): def get_games(): return { - 'main': { - 'VAR1': 'VAL1', - } + 1: + { + 'name': 'main', + 'VAR1': 'VAL1', + }, } @@ -123,16 +125,17 @@ def maintain_games(api, games): (ReplicationController, create_game_rc), (Service, create_game_service), ): - current_game_names = set() + current_game_ids = set() for game in object_type.objects(api).filter(selector={'app': 'aimmo-game'}): - game_name = game.obj['metadata']['labels']['game'] - current_game_names.add(game_name) - if game_name not in games: - LOGGER.info("Deleting game %s", game_name) + game_id = game.obj['metadata']['labels']['game_id'] + if game_id in games: + current_game_ids.add(game_id) + else: + LOGGER.info("Deleting game %s", games[game_id]['name']) game.delete() for game_id, game_config in games.items(): - if game_id not in current_game_names: - LOGGER.info("Creating game %s", game_id) + if game_id not in current_game_ids: + LOGGER.info("Creating game %s", games[game_id]['name']) creation_callback(api, game_id, game_config) ingress = Ingress( @@ -150,12 +153,12 @@ def maintain_games(api, games): 'http': { 'paths': [ { - 'path': "/game/%s/*" % name, + 'path': "/game/%s/*" % id, 'backend': { - 'serviceName': "game-%s" % name, + 'serviceName': "game-%s" % id, 'servicePort': 80, }, - } for name in games.keys() + } for id in games.keys() ], }, }, diff --git a/aimmo-game/service.py b/aimmo-game/service.py index 89e476194..de072a63f 100755 --- a/aimmo-game/service.py +++ b/aimmo-game/service.py @@ -113,7 +113,7 @@ def run_game(): game_state = GameState(my_map, player_manager) turn_manager = TurnManager(game_state=game_state, end_turn_callback=send_world_update) WorkerManagerClass = WORKER_MANAGERS[os.environ.get('WORKER_MANAGER', 'local')] - worker_manager = WorkerManagerClass(game_state=game_state, users_url=os.environ.get('GAME_API_URL', 'http://localhost:8000/players/api/games/')) + worker_manager = WorkerManagerClass(game_state=game_state, users_url=os.environ.get('GAME_API_URL', 'http://localhost:8000/players/api/games/1'), port=port) worker_manager.start() turn_manager.start() diff --git a/aimmo-game/simulation/worker_manager.py b/aimmo-game/simulation/worker_manager.py index 14e6900bd..0c165fcac 100644 --- a/aimmo-game/simulation/worker_manager.py +++ b/aimmo-game/simulation/worker_manager.py @@ -193,7 +193,7 @@ class KubernetesWorkerManager(WorkerManager): def __init__(self, *args, **kwargs): self.api = HTTPClient(KubeConfig.from_service_account()) - self.game_name = os.environ['GAME_NAME'] + self.game_id = os.environ['GAME_ID'] self.game_url = os.environ['GAME_URL'] super(KubernetesWorkerManager, self).__init__(*args, **kwargs) @@ -204,10 +204,10 @@ def create_worker(self, player_id): 'kind': 'Pod', 'apiVersion': 'v1', 'metadata': { - 'generateName': "aimmo-%s-worker-%s-" % (self.game_name, player_id), + 'generateName': "aimmo-%s-worker-%s-" % (self.game_id, player_id), 'labels': { 'app': 'aimmo-game-worker', - 'game': self.game_name, + 'game': self.game_id, 'player': str(player_id), }, }, @@ -248,7 +248,7 @@ def create_worker(self, player_id): def remove_worker(self, player_id): for pod in Pod.objects(self.api).filter(selector={ 'app': 'aimmo-game-worker', - 'game': self.game_name, + 'game': self.game_id, 'player': str(player_id), }): pod.delete() diff --git a/players/urls.py b/players/urls.py index a5eb05a45..f5ddc842b 100644 --- a/players/urls.py +++ b/players/urls.py @@ -13,7 +13,7 @@ url(r'^statistics/$', staff_member_required(TemplateView.as_view(template_name='players/statistics.html')), name='aimmo/statistics'), url(r'^api/code/$', staff_member_required(views.code), name='aimmo/code'), - url(r'^api/games/$', views.games, name='aimmo/games'), + url(r'^api/games/(?P[0-9]+)$', views.get_game, name='aimmo/game_details'), url(r'^jsreverse/$', 'django_js_reverse.views.urls_js', name='aimmo/js_reverse'), # TODO: Pull request to make django_js_reverse.urls ] diff --git a/players/views.py b/players/views.py index 0579d3c55..ea9dc599c 100644 --- a/players/views.py +++ b/players/views.py @@ -45,7 +45,7 @@ def code(request): return HttpResponse(avatar.code) -def games(request): +def get_game(request, id): response = { 'main': { 'parameters': [], @@ -53,7 +53,7 @@ def games(request): { 'id': avatar.owner_id, 'code': avatar.code, - } for avatar in Avatar.objects.filter(game_id=1) + } for avatar in Avatar.objects.filter(game_id=id) ] } } From 70932fa4630f55b5e2428f36f9b026fb4764e62c Mon Sep 17 00:00:00 2001 From: Joshua Blake Date: Tue, 19 Jul 2016 10:17:15 +0100 Subject: [PATCH 4/6] Support multiple games. Launch all workers in the backend, managed by aimmo-game-creator. Display all games to users in the frontend. --- aimmo-game-creator/service.py | 180 +--------- aimmo-game-creator/worker_manager.py | 318 ++++++++++++++++++ aimmo-game/service.py | 4 +- aimmo-game/simulation/worker_manager.py | 7 +- players/app_settings.py | 3 +- players/static/js/program.js | 51 ++- players/templates/players/base.html | 2 +- .../templates/players/base_game_chooser.html | 10 + players/templates/players/program.html | 4 +- players/templates/players/watch.html | 2 +- players/urls.py | 12 +- players/views.py | 34 +- run | 2 +- 13 files changed, 417 insertions(+), 212 deletions(-) create mode 100644 aimmo-game-creator/worker_manager.py create mode 100644 players/templates/players/base_game_chooser.html diff --git a/aimmo-game-creator/service.py b/aimmo-game-creator/service.py index ceb34daaa..5bfbc3283 100755 --- a/aimmo-game-creator/service.py +++ b/aimmo-game-creator/service.py @@ -1,185 +1,17 @@ #!/usr/bin/env python - +from worker_manager import WORKER_MANAGERS import logging -from pykube import HTTPClient -from pykube import KubeConfig -from pykube import Ingress, ReplicationController, Service -import time +import os LOGGER = logging.getLogger(__name__) -def create_game_rc(api, id, environment_variables): - environment_variables['SOCKETIO_RESOURCE'] = "game/%s/socket.io" % id - environment_variables['GAME_API_URL'] =\ - 'https://staging-dot-decent-digit-629.appspot.com/aimmo/api/games/%s' % id - environment_variables['GAME_ID'] = id - environment_variables['GAME_URL'] = "http://game-%s" % id - environment_variables['PYKUBE_KUBERNETES_SERVICE_HOST'] = 'kubernetes' - rc = ReplicationController( - api, - { - 'kind': 'ReplicationController', - 'apiVersion': 'v1', - 'metadata': { - 'name': "game-%s" % id, - 'namespace': 'default', - 'labels': { - 'app': 'aimmo-game', - 'game_id': id, - }, - }, - 'spec': { - 'replicas': 1, - 'selector': { - 'app': 'aimmo-game', - 'game': id, - }, - 'template': { - 'metadata': { - 'labels': { - 'app': 'aimmo-game', - 'id': id, - }, - }, - 'spec': { - 'containers': [ - { - 'env': [ - { - 'name': env_name, - 'value': env_value, - } for env_name, env_value in environment_variables.items() - ], - 'image': 'ocadotechnology/aimmo-game:latest', - 'ports': [ - { - 'containerPort': 5000, - }, - ], - 'name': 'aimmo-game', - 'resources': { - 'limits': { - 'cpu': '1000m', - 'memory': '128Mi', - }, - 'requests': { - 'cpu': '100m', - 'memory': '64Mi', - }, - }, - }, - ], - }, - }, - }, - }, - ) - rc.create() - - -def create_game_service(api, id, _config): - service = Service( - api, - { - 'kind': 'Service', - 'apiVersion': 'v1', - 'metadata': { - 'name': "game-%s" % id, - 'labels': { - 'app': 'aimmo-game', - 'game': id, - }, - }, - 'spec': { - 'selector': { - 'app': 'aimmo-game', - 'game': id, - }, - 'ports': [ - { - 'protocol': 'TCP', - 'port': 80, - 'targetPort': 5000, - }, - ], - 'type': 'NodePort', - }, - }, - ) - service.create() - - -def get_games(): - return { - 1: - { - 'name': 'main', - 'VAR1': 'VAL1', - }, - } - - -def maintain_games(api, games): - for object_type, creation_callback in ( - (ReplicationController, create_game_rc), - (Service, create_game_service), - ): - current_game_ids = set() - for game in object_type.objects(api).filter(selector={'app': 'aimmo-game'}): - game_id = game.obj['metadata']['labels']['game_id'] - if game_id in games: - current_game_ids.add(game_id) - else: - LOGGER.info("Deleting game %s", games[game_id]['name']) - game.delete() - for game_id, game_config in games.items(): - if game_id not in current_game_ids: - LOGGER.info("Creating game %s", games[game_id]['name']) - creation_callback(api, game_id, game_config) - - ingress = Ingress( - api, - { - 'apiVersion': 'extensions/v1beta1', - 'kind': 'Ingress', - 'metadata': { - 'name': 'game', - }, - 'spec': { - 'rules': [ - { - 'host': 'staging.aimmo.codeforlife.education', - 'http': { - 'paths': [ - { - 'path': "/game/%s/*" % id, - 'backend': { - 'serviceName': "game-%s" % id, - 'servicePort': 80, - }, - } for id in games.keys() - ], - }, - }, - ], - }, - }, - ) - if ingress.exists(): - ingress.update() - else: - ingress.create() - - def main(): logging.basicConfig(level=logging.DEBUG) - api = HTTPClient(KubeConfig.from_service_account()) - while True: - games = get_games() - maintain_games(api, games) - time.sleep(5) - + WorkerManagerClass = WORKER_MANAGERS[os.environ.get('WORKER_MANAGER', 'local')] + worker_manager = WorkerManagerClass(os.environ.get('GAMES_API_URL', + 'http://localhost:8000/players/api/games/')) + worker_manager.run() if __name__ == '__main__': main() diff --git a/aimmo-game-creator/worker_manager.py b/aimmo-game-creator/worker_manager.py new file mode 100644 index 000000000..ba301649b --- /dev/null +++ b/aimmo-game-creator/worker_manager.py @@ -0,0 +1,318 @@ +from abc import ABCMeta, abstractmethod +from eventlet.greenpool import GreenPool +from eventlet.semaphore import Semaphore +import logging +import pykube +import os +import requests +import subprocess +import time + + +LOGGER = logging.getLogger(__name__) + + +class _WorkerManagerData(object): + """ + This class is thread safe + """ + + def __init__(self): + self._games = set() + self._lock = Semaphore() + + def _remove_game(self, game_id): + assert self._lock.locked + self._games.remove(game_id) + + def _add_game(self, game_id): + assert self._lock.locked + self._games.add(game_id) + + def remove_unknown_games(self, known_games): + with self._lock: + unknown_games = self._games - frozenset(known_games) + for u in unknown_games: + self._remove_game(u) + return unknown_games + + def get_games(self): + with self._lock: + for g in self._games: + yield g + + def add_new_games(self, all_games): + with self._lock: + new_games = frozenset(all_games) - self._games + for n in new_games: + self._add_game(n) + return new_games + + +class WorkerManager(object): + """ + Methods of this class must be thread safe unless explicitly stated. + """ + __metaclass__ = ABCMeta + daemon = True + + def __init__(self, games_url): + """ + + :param thread_pool: + """ + self._data = _WorkerManagerData() + self.games_url = games_url + self._pool = GreenPool(size=3) + super(WorkerManager, self).__init__() + + def get_persistent_state(self, player_id): + """Get the persistent state for a worker.""" + + return None + + @abstractmethod + def create_worker(self, player_id): + """Create a worker.""" + + raise NotImplemented + + @abstractmethod + def remove_worker(self, player_id): + """Remove a worker for the given player.""" + + raise NotImplemented + + # TODO handle failure + def spawn(self, game_id, game_data): + # Kill worker + LOGGER.info("Removing worker for game %s" % game_data['name']) + self.remove_worker(game_id) + + # Spawn worker + LOGGER.info("Spawning worker for game %s" % game_data['name']) + self.create_worker(game_id, game_data) + + def _parallel_map(self, func, *iterable_args): + list(self._pool.imap(func, *iterable_args)) + + def run(self): + while True: + try: + LOGGER.info("Waking up") + games = requests.get(self.games_url).json() + except (requests.RequestException, ValueError) as err: + LOGGER.error("Failed to obtain game data : %s", err) + else: + games_to_add = {id: games[id] + for id in self._data.add_new_games(games.keys())} + LOGGER.debug("Need to add games: %s" % games_to_add) + + # Add missing games + self._parallel_map(self.spawn, games_to_add.keys(), games_to_add.values()) + + # Delete extra games + known_games = set(games.keys()) + removed_user_ids = self._data.remove_unknown_games(known_games) + LOGGER.debug("Removing users: %s" % removed_user_ids) + self._parallel_map(self.remove_worker, removed_user_ids) + + LOGGER.info("Sleeping") + time.sleep(10) + + +class LocalWorkerManager(WorkerManager): + """Relies on them already being created already.""" + + host = '127.0.0.1' + worker_directory = os.path.join( + os.path.dirname(__file__), + '../aimmo-game/', + ) + worker_path = os.path.join(worker_directory, 'service.py') + + def __init__(self, *args, **kwargs): + self.workers = {} + super(LocalWorkerManager, self).__init__(*args, **kwargs) + + def create_worker(self, game_id, game_data): + assert(game_id not in self.workers) + port = str(6001 + int(game_id) * 1000) + process_args = [ + 'python', + self.worker_path, + self.host, + port, + ] + env = os.environ.copy() + env['GAME_API_URL'] = 'http://localhost:8000/players/api/games/%s' % game_id + self.workers[game_id] = subprocess.Popen(process_args, cwd=self.worker_directory, env=env) + worker_url = 'http://%s:%s' % ( + self.host, + port, + ) + LOGGER.info("Worker started for game %s, listening at %s", game_id, worker_url) + + def remove_worker(self, game_id): + if game_id in self.workers: + self.workers[game_id].kill() + del self.workers[game_id] + + +class KubernetesWorkerManager(WorkerManager): + '''Kubernetes worker manager.''' + + def __init__(self, *args, **kwargs): + self._api = pykube.HTTPClient(pykube.KubeConfig.from_service_account()) + super(KubernetesWorkerManager, self).__init__(*args, **kwargs) + + def _create_game_rc(self, id, environment_variables): + environment_variables['SOCKETIO_RESOURCE'] = "game/%s/socket.io" % id + environment_variables['GAME_API_URL'] =\ + 'https://staging-dot-decent-digit-629.appspot.com/aimmo/api/games/%s' % id + environment_variables['GAME_ID'] = id + environment_variables['GAME_URL'] = "http://game-%s" % id + environment_variables['PYKUBE_KUBERNETES_SERVICE_HOST'] = 'kubernetes' + rc = pykube.ReplicationController( + self._api, + { + 'kind': 'ReplicationController', + 'apiVersion': 'v1', + 'metadata': { + 'name': "game-%s" % id, + 'namespace': 'default', + 'labels': { + 'app': 'aimmo-game', + 'game': id, + }, + }, + 'spec': { + 'replicas': 1, + 'selector': { + 'app': 'aimmo-game', + 'game': id, + }, + 'template': { + 'metadata': { + 'labels': { + 'app': 'aimmo-game', + 'id': id, + }, + }, + 'spec': { + 'containers': [ + { + 'env': [ + { + 'name': env_name, + 'value': env_value, + } for env_name, env_value in environment_variables.items() + ], + 'image': 'ocadotechnology/aimmo-game:latest', + 'ports': [ + { + 'containerPort': 5000, + }, + ], + 'name': 'aimmo-game', + 'resources': { + 'limits': { + 'cpu': '1000m', + 'memory': '128Mi', + }, + 'requests': { + 'cpu': '100m', + 'memory': '64Mi', + }, + }, + }, + ], + }, + }, + }, + }, + ) + rc.create() + + def _create_game_service(self, id, _config): + service = pykube.Service( + self._api, + { + 'kind': 'Service', + 'apiVersion': 'v1', + 'metadata': { + 'name': "game-%s" % id, + 'labels': { + 'app': 'aimmo-game', + 'game': id, + }, + }, + 'spec': { + 'selector': { + 'app': 'aimmo-game', + 'game': id, + }, + 'ports': [ + { + 'protocol': 'TCP', + 'port': 80, + 'targetPort': 5000, + }, + ], + 'type': 'NodePort', + }, + }, + ) + service.create() + + def _update_ingress(self): + ingress = pykube.Ingress( + self._api, + { + 'apiVersion': 'extensions/v1beta1', + 'kind': 'Ingress', + 'metadata': { + 'name': 'game', + }, + 'spec': { + 'rules': [{ + 'host': 'staging.aimmo.codeforlife.education', + 'http': { + 'paths': [ + { + 'path': "/game/%s/*" % id, + 'backend': { + 'serviceName': "game-%s" % id, + 'servicePort': 80, + }, + } for id in self._data.get_games() + ], + }, + }], + }, + }, + ) + if ingress.exists(): + ingress.update() + else: + ingress.create() + + def remove_worker(self, game_id): + for object_type in (pykube.ReplicationController, pykube.Service): + for game in object_type.objects(self._api).\ + filter(selector={'app': 'aimmo-game', + 'game': game_id}): + game.delete() + self._update_ingress() + + def create_worker(self, id, data): + self._create_game_service(id, data) + self._create_game_rc(id, data) + self._update_ingress() + LOGGER.info("Worker started for %s", id) + + +WORKER_MANAGERS = { + 'local': LocalWorkerManager, + 'kubernetes': KubernetesWorkerManager, +} diff --git a/aimmo-game/service.py b/aimmo-game/service.py index de072a63f..4d9e159a2 100755 --- a/aimmo-game/service.py +++ b/aimmo-game/service.py @@ -104,7 +104,7 @@ def player_data(player_id): }) -def run_game(): +def run_game(port): global worker_manager print("Running game...") @@ -122,7 +122,7 @@ def run_game(): logging.basicConfig(level=logging.DEBUG) socketio.init_app(app, resource=os.environ.get('SOCKETIO_RESOURCE', 'socket.io')) - run_game() + run_game(int(sys.argv[2])) socketio.run( app, debug=False, diff --git a/aimmo-game/simulation/worker_manager.py b/aimmo-game/simulation/worker_manager.py index 0c165fcac..0483df4d8 100644 --- a/aimmo-game/simulation/worker_manager.py +++ b/aimmo-game/simulation/worker_manager.py @@ -68,7 +68,7 @@ class WorkerManager(threading.Thread): """ daemon = True - def __init__(self, game_state, users_url): + def __init__(self, game_state, users_url, port=5000): """ :param thread_pool: @@ -76,6 +76,7 @@ def __init__(self, game_state, users_url): self._data = _WorkerManagerData(game_state, {}) self.users_url = users_url self._pool = GreenPool(size=3) + self.port = port super(WorkerManager, self).__init__() def get_code(self, player_id): @@ -159,8 +160,8 @@ class LocalWorkerManager(WorkerManager): def __init__(self, *args, **kwargs): self.workers = {} - self.next_port = 1989 super(LocalWorkerManager, self).__init__(*args, **kwargs) + self.next_port = self.port def create_worker(self, player_id): assert(player_id not in self.workers) @@ -172,7 +173,7 @@ def create_worker(self, player_id): str(self.next_port), ] env = os.environ.copy() - env['DATA_URL'] = "http://127.0.0.1:5000/player/%d" % player_id + env['DATA_URL'] = "http://127.0.0.1:%d/player/%d" % (self.port, player_id) self.workers[player_id] = subprocess.Popen(process_args, cwd=self.worker_directory, env=env) worker_url = 'http://%s:%d' % ( self.host, diff --git a/players/app_settings.py b/players/app_settings.py index 67b9abea2..646557b38 100644 --- a/players/app_settings.py +++ b/players/app_settings.py @@ -1,9 +1,8 @@ from django.conf import settings -from django.utils.module_loading import import_string #: URL function for locating the game server, takes one parameter `game` GAME_SERVER_LOCATION_FUNCTION = getattr( settings, 'AIMMO_GAME_SERVER_LOCATION_FUNCTION', - lambda _: ('http://localhost:5000', '/socket.io'), + lambda id: ('http://localhost:%d' % (6001 + int(id) * 1000), '/socket.io'), ) diff --git a/players/static/js/program.js b/players/static/js/program.js index ee3434fbf..ffd146f3e 100644 --- a/players/static/js/program.js +++ b/players/static/js/program.js @@ -35,8 +35,30 @@ $( document ).ready(function() { } }; + loadCode(); + $('#saveBtn').click(function(event) { + event.preventDefault(); + $.ajax({ + url: Urls['aimmo/code'](id=getActiveGame()), + type: 'POST', + dataType: 'json', + data: {code: editor.getValue(), csrfmiddlewaretoken: $('#saveForm input[name=csrfmiddlewaretoken]').val()}, + success: function(data) { + $('#alerts').hide(); + checkStatus(data); + }, + error: function(jqXHR, textStatus, errorThrown) { + showAlert('An error has occurred whilst saving: ' + errorThrown, DANGER_CLASS); + } + }); + }); + +}); + +function loadCode() { + var editor = ace.edit("editor"); $.ajax({ - url: Urls['aimmo/code'](), + url: Urls['aimmo/code'](id=getActiveGame()), type: 'GET', dataType: 'text', success: function(data) { @@ -49,21 +71,20 @@ $( document ).ready(function() { editor.selection.moveCursorFileStart(); } }); +} - $('#saveBtn').click(function(event) { +function getActiveGame() { + return $('#active-code').data('pk'); +} + + +$( document ).ready(function() { + $('#games li').click(function(event) { event.preventDefault(); - $.ajax({ - url: Urls['aimmo/code'](), - type: 'POST', - dataType: 'json', - data: {code: editor.getValue(), csrfmiddlewaretoken: $('#saveForm input[name=csrfmiddlewaretoken]').val()}, - success: function(data) { - $('#alerts').hide(); - checkStatus(data); - }, - error: function(jqXHR, textStatus, errorThrown) { - showAlert('An error has occurred whilst saving: ' + errorThrown, DANGER_CLASS); - } - }); + $('#games .active').removeClass('active'); + $('#active-code').removeAttr('id'); + $(this).addClass('active'); + $(this).attr('id', 'active-code'); + loadCode(); }); }); diff --git a/players/templates/players/base.html b/players/templates/players/base.html index 6d861b062..38bf45c68 100644 --- a/players/templates/players/base.html +++ b/players/templates/players/base.html @@ -43,8 +43,8 @@ + {% block extra_nav %}{% endblock %} -
{% block content %} {% endblock %} diff --git a/players/templates/players/base_game_chooser.html b/players/templates/players/base_game_chooser.html new file mode 100644 index 000000000..46e7ae557 --- /dev/null +++ b/players/templates/players/base_game_chooser.html @@ -0,0 +1,10 @@ +{% extends 'players/base.html' %} +{% block extra_nav %} + +{% endblock %} diff --git a/players/templates/players/program.html b/players/templates/players/program.html index 7d81c7b4c..42dcd03b8 100644 --- a/players/templates/players/program.html +++ b/players/templates/players/program.html @@ -1,4 +1,4 @@ -{% extends 'players/base.html' %} +{% extends 'players/base_game_chooser.html' %} {% block nav-program %}
  • Program (current)
  • {% endblock %} @@ -13,7 +13,7 @@

    Program

    -
    + {% csrf_token %}
    diff --git a/players/templates/players/watch.html b/players/templates/players/watch.html index b8ac61149..fc67fbf27 100644 --- a/players/templates/players/watch.html +++ b/players/templates/players/watch.html @@ -1,4 +1,4 @@ -{% extends 'players/base.html' %} +{% extends 'players/base_game_chooser.html' %} {% block nav-watch %}
  • Watch
  • {% endblock %} diff --git a/players/urls.py b/players/urls.py index f5ddc842b..d21539349 100644 --- a/players/urls.py +++ b/players/urls.py @@ -1,5 +1,5 @@ from django.conf.urls import url -from django.views.generic import TemplateView +from django.views.generic import RedirectView, TemplateView from django.contrib.auth.decorators import login_required from django.contrib.admin.views.decorators import staff_member_required @@ -8,12 +8,14 @@ urlpatterns = [ url(r'^$', staff_member_required(TemplateView.as_view(template_name='players/home.html')), name='aimmo/home'), - url(r'^program/$', staff_member_required(login_required(TemplateView.as_view(template_name='players/program.html'))), name='aimmo/program'), - url(r'^watch/$', staff_member_required(views.WatchView.as_view()), name='aimmo/watch'), + url(r'^program/$', staff_member_required(login_required(views.ProgramView.as_view())), name='aimmo/program'), + url(r'^watch/$', RedirectView.as_view(url='1'), name='aimmo/watch'), + url(r'^watch/(?P[0-9]+)/$', staff_member_required(views.WatchView.as_view()), name='aimmo/watch'), url(r'^statistics/$', staff_member_required(TemplateView.as_view(template_name='players/statistics.html')), name='aimmo/statistics'), - url(r'^api/code/$', staff_member_required(views.code), name='aimmo/code'), - url(r'^api/games/(?P[0-9]+)$', views.get_game, name='aimmo/game_details'), + url(r'^api/code/(?P[0-9]+)/$', staff_member_required(views.code), name='aimmo/code'), + url(r'^api/games/$', views.list_games, name='aimmo/games'), + url(r'^api/games/(?P[0-9]+)/$', views.get_game, name='aimmo/game_details'), url(r'^jsreverse/$', 'django_js_reverse.views.urls_js', name='aimmo/js_reverse'), # TODO: Pull request to make django_js_reverse.urls ] diff --git a/players/views.py b/players/views.py index ea9dc599c..e56f1736d 100644 --- a/players/views.py +++ b/players/views.py @@ -8,7 +8,7 @@ import os from . import app_settings -from models import Avatar +from models import Avatar, Game def _post_code_success_response(message): @@ -24,9 +24,9 @@ def create_response(status, message): @login_required -def code(request): +def code(request, id): try: - avatar = request.user.avatar_set.get(game_id=1) + avatar = request.user.avatar_set.get(game_id=id) except Avatar.DoesNotExist: initial_code_file_name = os.path.join( os.path.abspath(os.path.dirname(__file__)), @@ -35,7 +35,7 @@ def code(request): with open(initial_code_file_name) as initial_code_file: initial_code = initial_code_file.read() avatar = Avatar.objects.create(owner=request.user, code=initial_code, - game_id=1) + game_id=id) if request.method == 'POST': avatar.code = request.POST['code'] avatar.save() @@ -45,13 +45,23 @@ def code(request): return HttpResponse(avatar.code) +def list_games(request): + response = { + game.pk: + { + 'name': game.name, + } for game in Game.objects.all() + } + return JsonResponse(response) + + def get_game(request, id): response = { 'main': { 'parameters': [], 'users': [ { - 'id': avatar.owner_id, + 'id': avatar.pk, 'code': avatar.code, } for avatar in Avatar.objects.filter(game_id=id) ] @@ -60,10 +70,22 @@ def get_game(request, id): return JsonResponse(response) +class ProgramView(TemplateView): + template_name = 'players/program.html' + + def get_context_data(self, **kwargs): + context = super(ProgramView, self).get_context_data(**kwargs) + context['games'] = Game.objects.all() + context['active_game'] = 1 + return context + + class WatchView(TemplateView): template_name = 'players/watch.html' def get_context_data(self, **kwargs): context = super(WatchView, self).get_context_data(**kwargs) - context['game_url_base'], context['game_url_path'] = app_settings.GAME_SERVER_LOCATION_FUNCTION('main') + context['game_url_base'], context['game_url_path'] = app_settings.GAME_SERVER_LOCATION_FUNCTION(kwargs['id']) + context['games'] = Game.objects.all() + context['active_game'] = int(kwargs['id']) return context diff --git a/run b/run index 739547ff8..0ed1da5b6 100755 --- a/run +++ b/run @@ -9,6 +9,6 @@ pip install -e . ./example_project/manage.py collectstatic --noinput ./example_project/manage.py runserver "$@" & sleep 2 -./aimmo-game/service.py 127.0.0.1 5000 & +./aimmo-game-creator/service.py & wait From fa2895e0109b8ccb61b73e3b10c477890346c502 Mon Sep 17 00:00:00 2001 From: Joshua Blake Date: Wed, 20 Jul 2016 19:05:57 +0100 Subject: [PATCH 5/6] Add link to create a new game --- players/templates/players/base.html | 1 + players/urls.py | 1 + players/views.py | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/players/templates/players/base.html b/players/templates/players/base.html index 38bf45c68..4d4cdb3da 100644 --- a/players/templates/players/base.html +++ b/players/templates/players/base.html @@ -40,6 +40,7 @@ {% block nav-program %}
  • Program (current)
  • {% endblock %} {% block nav-watch %}
  • Watch
  • {% endblock %} {% block nav-statistics %}
  • Statistics
  • {% endblock %} +
  • Add a new game
  • diff --git a/players/urls.py b/players/urls.py index d21539349..155921d10 100644 --- a/players/urls.py +++ b/players/urls.py @@ -18,4 +18,5 @@ url(r'^api/games/(?P[0-9]+)/$', views.get_game, name='aimmo/game_details'), url(r'^jsreverse/$', 'django_js_reverse.views.urls_js', name='aimmo/js_reverse'), # TODO: Pull request to make django_js_reverse.urls + url(r'^games/new/$', views.add_game, name='aimmo/new_game'), ] diff --git a/players/views.py b/players/views.py index e56f1736d..5bde51070 100644 --- a/players/views.py +++ b/players/views.py @@ -3,6 +3,7 @@ from django.http import JsonResponse from django.contrib.auth.decorators import login_required from django.http import HttpResponse +from django.shortcuts import redirect from django.views.generic import TemplateView import os @@ -89,3 +90,10 @@ def get_context_data(self, **kwargs): context['games'] = Game.objects.all() context['active_game'] = int(kwargs['id']) return context + + +def add_game(request): + name = "Game %d" % (Game.objects.count() + 1) + game = Game(name=name) + game.save() + return redirect('aimmo/program') From a8e22a48b204bfbe3b5ea092c140a28388aef04b Mon Sep 17 00:00:00 2001 From: Joshua Blake Date: Wed, 27 Jul 2016 18:55:54 +0100 Subject: [PATCH 6/6] Update run.py --- run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run.py b/run.py index ea0d45fb2..3c652b5c4 100644 --- a/run.py +++ b/run.py @@ -8,7 +8,7 @@ _SCRIPT_LOCATION = os.path.abspath(os.path.dirname(__file__)) _MANAGE_PY = os.path.join(_SCRIPT_LOCATION, 'example_project', 'manage.py') -_SERVICE_PY = os.path.join(_SCRIPT_LOCATION, 'aimmo-game', 'service.py') +_SERVICE_PY = os.path.join(_SCRIPT_LOCATION, 'aimmo-game-creator', 'service.py') if __name__ == '__main__': @@ -70,7 +70,7 @@ def main(): server = run_command_async(['python', _MANAGE_PY, 'runserver'] + server_args) time.sleep(2) - game = run_command_async(['python', _SERVICE_PY, '127.0.0.1', '5000']) + game = run_command_async(['python', _SERVICE_PY]) server.wait() game.wait()