Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(consolerole): Add support for namespaced token used for interactive console access to containers #148

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
15 changes: 15 additions & 0 deletions charts/controller/templates/console-clusterrole.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{{- if (.Values.global.use_rbac) -}}
{{- if (.Capabilities.APIVersions.Has (include "rbacAPIVersion" .)) -}}
kind: ClusterRole
apiVersion: {{ template "rbacAPIVersion" . }}
metadata:
name: deis:deis-console
labels:
app: deis-console
heritage: deis
rules:
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["get", "create", "list"]
{{- end -}}
{{- end -}}
6 changes: 6 additions & 0 deletions charts/controller/templates/controller-clusterrole.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["get","create","list"]
- apiGroups: [""]
resources: ["resourcequotas"]
verbs: ["get", "create"]
Expand All @@ -50,6 +53,9 @@ rules:
- apiGroups: ["extensions", "autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "create", "update", "delete"]
- apiGroups: ["","rbac.authorization.k8s.io"]
resources: ["roles","rolebindings","serviceaccounts"]
verbs: ["get","create"]
{{ if .Values.global.experimental_native_ingress }}
- apiGroups: ["extensions"]
resources: ["ingresses"]
Expand Down
6 changes: 6 additions & 0 deletions charts/controller/templates/controller-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ spec:
value: "{{ .Values.global.gunicorn_workers }}"
- name: "CONN_MAX_AGE"
value: "{{ .Values.global.conn_max_age }}"
- name: "CONTAINER_CONSOLE_ENABLED"
value: "{{ .Values.container_console.enabled }}"
- name: "CONTAINER_CONSOLE_WEBSOCKET_TIMEOUT"
value: "{{ .Values.container_console.enabled }}"
- name: "K8S_API_ENDPOINT"
value: "{{ .Values.container_console.websocket_timeout }}"
- name: "SLUGRUNNER_IMAGE_NAME"
valueFrom:
configMapKeyRef:
Expand Down
5 changes: 5 additions & 0 deletions charts/controller/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ k8s_api_verify_tls: "true"
# This will be the hostname that is used to build endpoints such as "deis.$HOSTNAME"
platform_domain: ""

container_console:
enabled: "true"
k8s_api_endpoint: https://127.0.0.1:443
websocket_timeout: 500

global:
# Set the storage backend
#
Expand Down
12 changes: 12 additions & 0 deletions rootfs/api/models/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ def create(self, *args, **kwargs): # noqa

raise ServiceUnavailable('Kubernetes resources could not be created') from e

try:
# Create the console resources
self.log(
'creating Console Role for namespace {}'.format(namespace),
level=logging.DEBUG
)
self._scheduler.consolerole.create(namespace)
except KubeException as e:
raise ServiceUnavailable(
'Could not create Console Role in Namespace {}'
.format(namespace)) from e

try:
# In order to create an ingress, we must first have a namespace.
if settings.EXPERIMENTAL_NATIVE_INGRESS:
Expand Down
6 changes: 6 additions & 0 deletions rootfs/api/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,9 @@
AUTH_LDAP_MIRROR_GROUPS = True
AUTH_LDAP_FIND_GROUP_PERMS = True
AUTH_LDAP_CACHE_GROUPS = False

CONTAINER_CONSOLE_ENABLED = bool(
strtobool(os.environ.get('CONTAINER_CONSOLE_ENABLED', 'true'))
)
CONTAINER_CONSOLE_WEBSOCKET_TIMEOUT = os.environ.get('CONTAINER_CONSOLE_WEBSOCKET_TIMEOUT', 30)
K8S_API_ENDPOINT = os.environ.get('K8S_API_ENDPOINT', 'https://127.0.0.1:443')
3 changes: 3 additions & 0 deletions rootfs/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
views.AppViewSet.as_view({'get': 'retrieve', 'post': 'update', 'delete': 'destroy'})),
url(r'^apps/?$',
views.AppViewSet.as_view({'get': 'list', 'post': 'create'})),
# application console token
url(r"^apps/(?P<id>{})/console-token".format(settings.APP_URL_REGEX),
views.AppViewSet.as_view({'get': 'console_token'})),
# key
url(r'^keys/(?P<id>.+)/?$',
views.KeyViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),
Expand Down
61 changes: 61 additions & 0 deletions rootfs/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from api import authentication, models, permissions, serializers, viewsets
from api.models import AlreadyExists, ServiceUnavailable, DeisException, UnprocessableEntity

from json import loads as json_loads
import logging

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -51,6 +52,14 @@ def get(self, request):
return HttpResponse("OK")
head = get

class ResponseWithCallback(Response):
def __init__(self, data, then_callback, **kwargs):
super().__init__(data, **kwargs)
self.then_callback = then_callback

def close(self):
super().close()
self.then_callback()

class UserRegistrationViewSet(GenericViewSet,
mixins.CreateModelMixin):
Expand Down Expand Up @@ -258,6 +267,58 @@ def update(self, request, **kwargs):
app.save()
return Response(status=status.HTTP_200_OK)

def console_token(self, request, **kwargs):
if settings.CONTAINER_CONSOLE_ENABLED is not True:
return Response(
{
"error": True,
"msg": "console feature not enabled on this cluster"
}
)
try:
app = get_object_or_404(models.App, id=self.kwargs["id"])
except Http404:
return Response(
{
"error": True,
"msg": "Application '{}' not found"
.format(self.kwargs["id"])
}
)
app.log(
"User {} requested console access to {}"
.format(self.request.user, self.kwargs["id"])
)
self.check_object_permissions(self.request, app)
try:
parsed_secrets = json_loads(
app._scheduler.secret.get(self.kwargs["id"]).content
)["items"]
except ValueError:
return Response(
{
"error": True,
"msg": "Could not retrieve json that includes token"
}
)
try:
token = [
x for x in parsed_secrets
if x['metadata']['name'].startswith("deis-console-")
][0]['data']['token']
except (KeyError, IndexError):
return Response({"error": True, "msg": "Could not retrieve token"})

rsp = {
'apiEndpoint': settings.K8S_API_ENDPOINT,
'websocketTimeout' : int(settings.CONTAINER_CONSOLE_WEBSOCKET_TIMEOUT),
'token': token, "error": False
}
def execute_after():
app._scheduler.consolerole.refresh_service_account_token(self.kwargs["id"])

return ResponseWithCallback(rsp, execute_after, status=status.HTTP_200_OK)


class BuildViewSet(ReleasableViewSet):
"""A viewset for interacting with Build objects."""
Expand Down
114 changes: 114 additions & 0 deletions rootfs/scheduler/resources/consolerole.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from scheduler.exceptions import KubeHTTPException
from scheduler.resources import Resource
from datetime import datetime, timedelta
import json

class Consolerole(Resource):
short_name = 'consolerole'

def _service_account_manifest(self, namespace):
manifest = {
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": {
"name": "deis-console-{}".format(namespace),
"namespace": "{}".format(namespace)
},
"secrets:": [{
"name": "deis-console-{}".format(namespace)
}],
}

return manifest

def _role_binding_manifest(self, namespace):
manifest = {
"kind": "RoleBinding",
"apiVersion": "rbac.authorization.k8s.io/v1",
"metadata": {
"name": "deis-console-{}".format(namespace),
"labels": {
'heritage': 'deis'
}
},
"roleRef": {
"apiGroup": "rbac.authorization.k8s.io",
"kind": "ClusterRole",
"name": "deis:deis-console"
},
"subjects": [{
"kind": "ServiceAccount",
"name": "deis-console-{}".format(namespace),
"namespace": "{}".format(namespace),
}]

}

return manifest

def _create_service_account(self, namespace):
url = self.api("/namespaces/{}/serviceaccounts", namespace)
manifest = self._service_account_manifest(namespace)
response = self.http_post(url, json=manifest)
if response.status_code == 409:
return response
if not response.status_code == 201:
raise KubeHTTPException(
response, "create ServiceAccount {}".format(namespace)
)

return response

def _delete_secret(self, namespace, secret_name):
url = self.api("/namespaces/{}/secrets/{}", namespace, secret_name)
response = self.http_delete(url)
if not response.status_code == 200:
raise KubeHTTPException(
response, "Delete secret: {} in namespace {}".format(secret_name, namespace)
)

def _get_namespace_secrets(self, namespace):
url = self.api("/namespaces/{}/secrets", namespace)
response = self.http_get(url)
if not response.status_code == 200:
raise KubeHTTPException(
response, "Get secrets in namespace {}".format(namespace)
)
return response.content

def _get_service_account_secret(self, secrets_json):
secrets = json.loads(secrets_json)
secret = [
x for x in secrets['items']
if x['metadata']['name'].startswith("deis-console-")
]
if secret:
return secret[0]
return None

def refresh_service_account_token(self, namespace):
secrets_json = self._get_namespace_secrets(namespace)
secret = self._get_service_account_secret(secrets_json)
self._delete_secret(namespace, secret['metadata']['name'])

def _service_account_token_exists(self, namespace):
secrets_json = self._get_namespace_secrets(namespace)
secret = self._get_service_account_secret(secrets_json)
return secret != None

def create(self, namespace):
self._create_service_account(namespace)
url = (
"/apis/rbac.authorization.k8s.io/v1"
"/namespaces/{}/rolebindings"
).format(namespace)
manifest = self._role_binding_manifest(namespace)
response = self.http_post(url, json=manifest)
if response.status_code == 409:
return response
if not response.status_code == 201:
raise KubeHTTPException(
response, "create RoleBinding for {}".format(namespace)
)

return response