Skip to content

Commit ef50436

Browse files
committed
feat(controller): add pod exec api
1 parent 8424079 commit ef50436

10 files changed

Lines changed: 162 additions & 15 deletions

File tree

charts/controller/templates/controller-clusterrole.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ rules:
3131
- apiGroups: [""]
3232
resources: ["pods/log"]
3333
verbs: ["get"]
34+
- apiGroups: [""]
35+
resources: ["pods/exec"]
36+
verbs: ["create"]
3437
- apiGroups: [""]
3538
resources: ["pods"]
3639
verbs: ["get", "list", "create", "delete"]

rootfs/api/asgi.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@
1010
import os
1111

1212
from django.core.asgi import get_asgi_application
13+
from channels.routing import ProtocolTypeRouter, URLRouter
14+
from channels.security.websocket import AllowedHostsOriginValidator
15+
1316

1417
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'api.settings.production')
18+
http = get_asgi_application()
19+
20+
from .routing import websocket_urlpatterns # noqa
21+
from .middleware import ChannelOAuthMiddleware # noqa
22+
websocket = AllowedHostsOriginValidator(ChannelOAuthMiddleware(URLRouter(
23+
websocket_urlpatterns
24+
)))
1525

16-
application = get_asgi_application()
26+
application = ProtocolTypeRouter({"http": http, "websocket": websocket})

rootfs/api/authentication.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.conf import settings
33
from django.contrib.auth.models import AnonymousUser
44
from django.core.cache import cache
5-
from django.utils.translation import gettext_lazy as _
5+
from django.utils.translation import gettext_lazy
66
from rest_framework import authentication
77
from rest_framework.authentication import TokenAuthentication, \
88
get_authorization_header
@@ -31,20 +31,22 @@ def authenticate(self, request):
3131
return None
3232

3333
if len(auth) == 1:
34-
msg = _('Invalid token header. No credentials provided.')
34+
msg = gettext_lazy('Invalid token header. No credentials provided.')
3535
raise exceptions.AuthenticationFailed(msg)
3636
elif len(auth) > 2:
37-
msg = _('Invalid token header. Token string should not contain spaces.') # noqa
37+
msg = gettext_lazy(
38+
'Invalid token header. Token string should not contain spaces.')
3839
raise exceptions.AuthenticationFailed(msg)
3940

4041
try:
4142
token = auth[1].decode()
4243
except UnicodeError:
43-
msg = _('Invalid token header. Token string should not contain invalid characters.') # noqa
44+
msg = gettext_lazy(
45+
'Invalid token header. Token string should not contain invalid characters.')
4446
raise exceptions.AuthenticationFailed(msg)
4547
return cache.get_or_set(
46-
token, lambda: self._get_user(token), settings.OAUTH_CACHE_USER_TIME), None # noqa
47-
return super(DryccAuthentication, self).authenticate(request) # noqa
48+
token, lambda: self._get_user(token), settings.OAUTH_CACHE_USER_TIME), None
49+
return super(DryccAuthentication, self).authenticate(request)
4850

4951
@staticmethod
5052
def _get_user(key):
@@ -57,4 +59,4 @@ def _get_user(key):
5759
return user
5860
except Exception as e:
5961
logger.info(e)
60-
raise exceptions.AuthenticationFailed(_('Verify token fail.'))
62+
raise exceptions.AuthenticationFailed(gettext_lazy('Verify token fail.'))

rootfs/api/backend.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ class DryccOAuth(BaseOAuth2):
5656

5757
def get_user_details(self, response):
5858
"""Return user details from GitHub account"""
59-
print(response)
6059
return {
6160
'username': response.get('username'),
6261
'email': response.get('email') or '',

rootfs/api/consumers.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import json
2+
import asyncio
3+
import collections
4+
from django.conf import settings
5+
6+
from asgiref.sync import sync_to_async
7+
8+
from kubernetes.client import Configuration
9+
from kubernetes.client.api import core_v1_api
10+
from kubernetes.stream import stream
11+
12+
from channels.db import database_sync_to_async
13+
from channels.exceptions import DenyConnection
14+
from channels.generic.websocket import AsyncWebsocketConsumer
15+
16+
from .models.app import App
17+
from .permissions import has_app_permission
18+
19+
20+
Request = collections.namedtuple("Request", ["user", "method"])
21+
22+
23+
class AppPodExecConsumer(AsyncWebsocketConsumer):
24+
25+
@property
26+
def kubernetes(self):
27+
with open('/var/run/secrets/kubernetes.io/serviceaccount/token') as token_file:
28+
token = token_file.read()
29+
config = Configuration(host=settings.SCHEDULER_URL)
30+
config.api_key = {"authorization": "Bearer " + token}
31+
config.verify_ssl = settings.K8S_API_VERIFY_TLS
32+
if config.verify_ssl:
33+
config.ssl_ca_cert = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
34+
Configuration.set_default(config)
35+
return core_v1_api.CoreV1Api()
36+
37+
async def check(self):
38+
self.id = self.scope["url_route"]["kwargs"]["id"]
39+
self.pod_id = self.scope["url_route"]["kwargs"]["pod_id"]
40+
if self.scope["user"] is None:
41+
return False, "user not login"
42+
request = Request(self.scope["user"], "POST")
43+
app = await database_sync_to_async(App.objects.get)(id=self.id)
44+
return await database_sync_to_async(has_app_permission)(request, app)
45+
46+
async def connect(self):
47+
self.stream = None
48+
self.conneted = True
49+
is_ok, message = await self.check()
50+
if is_ok:
51+
await self.accept()
52+
else:
53+
raise DenyConnection(message)
54+
55+
async def send(self, data):
56+
if data is None:
57+
return
58+
elif isinstance(data, bytes):
59+
await super().send(bytes_data=data)
60+
elif isinstance(data, str):
61+
await super().send(text_data=data)
62+
63+
async def task(self):
64+
while self.stream.is_open() and self.conneted:
65+
self.stream.update(timeout=9)
66+
if await sync_to_async(self.stream.peek_stdout)():
67+
data = self.stream.read_stdout()
68+
elif await sync_to_async(self.stream.peek_stderr)():
69+
data = self.stream.peek_stderr()
70+
else:
71+
data = None
72+
await self.send(data)
73+
74+
async def disconnect(self, close_code):
75+
if self.stream:
76+
self.stream.close()
77+
self.conneted = False
78+
79+
async def receive(self, text_data=None, bytes_data=None):
80+
if self.stream is None and text_data is not None:
81+
args = (self.kubernetes.connect_get_namespaced_pod_exec, self.pod_id, self.id)
82+
kwargs = json.loads(text_data)
83+
kwargs.update({"stderr": True, "stdout": True})
84+
if kwargs["stdin"]:
85+
kwargs.update({"_preload_content": False})
86+
self.stream = stream(*args, **kwargs)
87+
asyncio.create_task(self.task())
88+
else:
89+
await self.send(stream(*args, **kwargs))
90+
await self.close(code=1000)
91+
elif self.stream is not None:
92+
data = text_data if text_data else bytes_data
93+
await sync_to_async(self.stream.write_stdin)(data)
94+
else:
95+
raise ValueError("This operation is not supported!")

rootfs/api/management/commands/healthchecks.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,5 @@ def handle(self, *args, **options):
1212
django.db.connection.cursor()
1313
print("Database is alive!")
1414
except Exception as e:
15-
print("There was a problem connecting to the database")
16-
print(str(e))
15+
print("There was a problem connecting to the database", e)
1716
sys.exit(1)

rootfs/api/middleware.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
44
See https://docs.djangoproject.com/en/1.11/topics/http/middleware/
55
"""
6-
6+
import collections
77
from api import __version__
88

9+
from channels.middleware import BaseMiddleware
10+
from channels.db import database_sync_to_async
11+
from api.authentication import DryccAuthentication
12+
913

1014
class APIVersionMiddleware(object):
1115
"""
@@ -26,3 +30,27 @@ def __call__(self, request):
2630
response['DRYCC_API_VERSION'] = version
2731
response['DRYCC_PLATFORM_VERSION'] = __version__
2832
return response
33+
34+
35+
class ChannelOAuthMiddleware(BaseMiddleware):
36+
"""
37+
Middleware which populates scope["user"] from a auth2 token.
38+
"""
39+
Request = collections.namedtuple('Request', ["META"])
40+
41+
async def get_user(self, scope):
42+
headers = {}
43+
for header in scope["headers"]:
44+
if header[0] in (b"user-agent", b"authorization"):
45+
key = "HTTP_%s" % header[0].decode().replace("-", "_").upper()
46+
headers[key] = header[1].decode()
47+
if len(headers) != 2:
48+
return None
49+
request = self.Request(headers)
50+
user, _ = await database_sync_to_async(DryccAuthentication().authenticate)(request)
51+
return user
52+
53+
async def __call__(self, scope, receive, send):
54+
scope = dict(scope)
55+
scope["user"] = await self.get_user(scope)
56+
return await super().__call__(scope, receive, send)

rootfs/api/routing.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# chat/routing.py
2+
from django.urls import re_path
3+
4+
from . import consumers
5+
6+
websocket_urlpatterns = [
7+
re_path(
8+
r'^v2/apps/(?P<id>.*)/pods/(?P<pod_id>.*)/exec/?$',
9+
consumers.AppPodExecConsumer.as_asgi())
10+
]

rootfs/drycc/gunicorn/config.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22
from os.path import dirname, realpath
3-
from multiprocessing import cpu_count
43

54
import faulthandler
65
faulthandler.enable()
@@ -13,7 +12,7 @@
1312
else:
1413
bind = '0.0.0.0:8000'
1514

16-
workers = int(os.environ.get('GUNICORN_WORKERS', cpu_count() * 4 + 1))
15+
workers = int(os.environ.get('GUNICORN_WORKERS', 2))
1716
worker_class = "uvicorn.workers.UvicornWorker"
1817
pythonpath = dirname(dirname(dirname(realpath(__file__))))
1918
timeout = 1200

rootfs/requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# Drycc controller requirements
22
backoff==1.11.1
33
django==4.1
4+
channels==3.0.5
45
django-cors-headers==3.7.0
56
django-guardian==2.4.0
67
djangorestframework==3.12.4
78
docker==5.0.0
89
gunicorn==20.1.0
9-
uvicorn==0.18.2
10+
uvicorn[standard]==0.18.2
1011
asgiref==3.5.2
1112
idna==3.2
1213
jmespath==0.10.0
@@ -28,3 +29,4 @@ dj-database-url==0.5.0
2829
social-auth-app-django==5.0.0
2930
python-jose==3.3.0
3031
Authlib==0.15.4
32+
kubernetes==24.2.0

0 commit comments

Comments
 (0)