From d2429f78832ff2b4523fce9edfb6563ab55f211b Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Tue, 5 Dec 2023 16:52:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20virtual=20app=20(#?= =?UTF-8?q?12199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 支持 virtual app * perf: 增加 virtual host * perf: 新增 virtual app 上传接口 * perf: 更名为 app provider * perf: 优化代码 --------- Co-authored-by: Eric --- apps/authentication/api/connection_token.py | 13 ++- .../authentication/models/connection_token.py | 11 +- .../serializers/connect_token_secret.py | 10 +- apps/jumpserver/conf.py | 1 + .../rewriting/storage/permissions.py | 1 + apps/jumpserver/settings/custom.py | 2 + apps/settings/serializers/public.py | 1 + apps/terminal/api/__init__.py | 5 +- apps/terminal/api/virtualapp/__init__.py | 3 + apps/terminal/api/virtualapp/provider.py | 63 +++++++++++ apps/terminal/api/virtualapp/relation.py | 64 +++++++++++ apps/terminal/api/virtualapp/virtualapp.py | 77 +++++++++++++ apps/terminal/connect_methods.py | 28 +++++ apps/terminal/const.py | 1 + apps/terminal/migrations/0068_virtualapp.py | 93 ++++++++++++++++ apps/terminal/models/__init__.py | 1 + apps/terminal/models/virtualapp/__init__.py | 2 + apps/terminal/models/virtualapp/provider.py | 28 +++++ apps/terminal/models/virtualapp/virtualapp.py | 103 ++++++++++++++++++ apps/terminal/serializers/__init__.py | 2 + apps/terminal/serializers/virtualapp.py | 41 +++++++ .../serializers/virtualapp_provider.py | 31 ++++++ apps/terminal/signal_handlers/__init__.py | 1 + apps/terminal/signal_handlers/virtualapp.py | 24 ++++ apps/terminal/urls/api_urls.py | 4 + 25 files changed, 605 insertions(+), 5 deletions(-) create mode 100644 apps/terminal/api/virtualapp/__init__.py create mode 100644 apps/terminal/api/virtualapp/provider.py create mode 100644 apps/terminal/api/virtualapp/relation.py create mode 100644 apps/terminal/api/virtualapp/virtualapp.py create mode 100644 apps/terminal/migrations/0068_virtualapp.py create mode 100644 apps/terminal/models/virtualapp/__init__.py create mode 100644 apps/terminal/models/virtualapp/provider.py create mode 100644 apps/terminal/models/virtualapp/virtualapp.py create mode 100644 apps/terminal/serializers/virtualapp.py create mode 100644 apps/terminal/serializers/virtualapp_provider.py create mode 100644 apps/terminal/signal_handlers/virtualapp.py diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 954fb6074..2158a25f1 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -33,7 +33,7 @@ from ..models import ConnectionToken, date_expired_default from ..serializers import ( ConnectionTokenSerializer, ConnectionTokenSecretSerializer, SuperConnectionTokenSerializer, ConnectTokenAppletOptionSerializer, - ConnectionTokenReusableSerializer, + ConnectionTokenReusableSerializer, ConnectTokenVirtualAppOptionSerializer ) __all__ = ['ConnectionTokenViewSet', 'SuperConnectionTokenViewSet'] @@ -464,6 +464,7 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet): 'get_secret_detail': 'authentication.view_superconnectiontokensecret', 'get_applet_info': 'authentication.view_superconnectiontoken', 'release_applet_account': 'authentication.view_superconnectiontoken', + 'get_virtual_app_info': 'authentication.view_superconnectiontoken', } def get_queryset(self): @@ -529,6 +530,16 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet): serializer = ConnectTokenAppletOptionSerializer(data) return Response(serializer.data) + @action(methods=['POST'], detail=False, url_path='virtual-app-option') + def get_virtual_app_info(self, *args, **kwargs): + token_id = self.request.data.get('id') + token = get_object_or_404(ConnectionToken, pk=token_id) + if token.is_expired: + return Response({'error': 'Token expired'}, status=status.HTTP_400_BAD_REQUEST) + data = token.get_virtual_app_option() + serializer = ConnectTokenVirtualAppOptionSerializer(data) + return Response(serializer.data) + @action(methods=['DELETE', 'POST'], detail=False, url_path='applet-account/release') def release_applet_account(self, *args, **kwargs): account_id = self.request.data.get('id') diff --git a/apps/authentication/models/connection_token.py b/apps/authentication/models/connection_token.py index 48c8e8e16..6cd5648db 100644 --- a/apps/authentication/models/connection_token.py +++ b/apps/authentication/models/connection_token.py @@ -18,7 +18,7 @@ from common.utils import lazyproperty, pretty_string, bulk_get from common.utils.timezone import as_current_tz from orgs.mixins.models import JMSOrgBaseModel from orgs.utils import tmp_to_org -from terminal.models import Applet +from terminal.models import Applet, VirtualApp def date_expired_default(): @@ -177,6 +177,15 @@ class ConnectionToken(JMSOrgBaseModel): } return options + def get_virtual_app_option(self): + method = self.connect_method_object + if not method or method.get('type') != 'virtual_app' or method.get('disabled', False): + return None + virtual_app = VirtualApp.objects.filter(name=method.get('value')).first() + if not virtual_app: + return None + return virtual_app + def get_applet_option(self): method = self.connect_method_object if not method or method.get('type') != 'applet' or method.get('disabled', False): diff --git a/apps/authentication/serializers/connect_token_secret.py b/apps/authentication/serializers/connect_token_secret.py index 6685316bd..3eea79e4e 100644 --- a/apps/authentication/serializers/connect_token_secret.py +++ b/apps/authentication/serializers/connect_token_secret.py @@ -15,7 +15,8 @@ from users.models import User from ..models import ConnectionToken __all__ = [ - 'ConnectionTokenSecretSerializer', 'ConnectTokenAppletOptionSerializer' + 'ConnectionTokenSecretSerializer', 'ConnectTokenAppletOptionSerializer', + 'ConnectTokenVirtualAppOptionSerializer', ] @@ -161,3 +162,10 @@ class ConnectTokenAppletOptionSerializer(serializers.Serializer): account = _ConnectionTokenAccountSerializer(read_only=True) gateway = _ConnectionTokenGatewaySerializer(read_only=True) remote_app_option = serializers.JSONField(read_only=True) + + +class ConnectTokenVirtualAppOptionSerializer(serializers.Serializer): + name = serializers.CharField(label=_('Name')) + image_name = serializers.CharField(label=_('Image name')) + image_port = serializers.IntegerField(label=_('Image port')) + image_protocol = serializers.CharField(label=_('Image protocol')) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 45d21aeab..7492f5c4d 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -598,6 +598,7 @@ class Config(dict): 'GPT_BASE_URL': '', 'GPT_PROXY': '', 'GPT_MODEL': 'gpt-3.5-turbo', + 'VIRTUAL_APP_ENABLED': False, } old_config_map = { diff --git a/apps/jumpserver/rewriting/storage/permissions.py b/apps/jumpserver/rewriting/storage/permissions.py index 7af0adece..511545865 100644 --- a/apps/jumpserver/rewriting/storage/permissions.py +++ b/apps/jumpserver/rewriting/storage/permissions.py @@ -5,6 +5,7 @@ path_perms_map = { 'settings': '*', 'replay': 'default', 'applets': 'terminal.view_applet', + 'virtual_apps': 'terminal.view_virtualapp', 'playbooks': 'ops.view_playbook' } diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index fa83fa9fd..d9eb41332 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -219,3 +219,5 @@ GPT_API_KEY = CONFIG.GPT_API_KEY GPT_BASE_URL = CONFIG.GPT_BASE_URL GPT_PROXY = CONFIG.GPT_PROXY GPT_MODEL = CONFIG.GPT_MODEL + +VIRTUAL_APP_ENABLED = CONFIG.VIRTUAL_APP_ENABLED diff --git a/apps/settings/serializers/public.py b/apps/settings/serializers/public.py index 1b5c78609..21dc2c2f8 100644 --- a/apps/settings/serializers/public.py +++ b/apps/settings/serializers/public.py @@ -53,6 +53,7 @@ class PrivateSettingSerializer(PublicSettingSerializer): CONNECTION_TOKEN_REUSABLE = serializers.BooleanField() CACHE_LOGIN_PASSWORD_ENABLED = serializers.BooleanField() VAULT_ENABLED = serializers.BooleanField() + VIRTUAL_APP_ENABLED = serializers.BooleanField() class ServerInfoSerializer(serializers.Serializer): diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py index c4a60efb6..b6afedd51 100644 --- a/apps/terminal/api/__init__.py +++ b/apps/terminal/api/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # -from .session import * -from .component import * from .applet import * +from .component import * from .db_listen_port import * +from .session import * +from .virtualapp import * diff --git a/apps/terminal/api/virtualapp/__init__.py b/apps/terminal/api/virtualapp/__init__.py new file mode 100644 index 000000000..0c67de93f --- /dev/null +++ b/apps/terminal/api/virtualapp/__init__.py @@ -0,0 +1,3 @@ +from .provider import * +from .relation import * +from .virtualapp import * diff --git a/apps/terminal/api/virtualapp/provider.py b/apps/terminal/api/virtualapp/provider.py new file mode 100644 index 000000000..fbdf11ecc --- /dev/null +++ b/apps/terminal/api/virtualapp/provider.py @@ -0,0 +1,63 @@ +from django.core.cache import cache +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response + +from common.api import JMSBulkModelViewSet +from common.permissions import IsServiceAccount +from orgs.utils import tmp_to_builtin_org +from terminal.models import AppProvider +from terminal.serializers import ( + AppProviderSerializer, AppProviderContainerSerializer +) + +__all__ = ['AppProviderViewSet', ] + + +class AppProviderViewSet(JMSBulkModelViewSet): + serializer_class = AppProviderSerializer + queryset = AppProvider.objects.all() + search_fields = ['name', 'hostname', ] + rbac_perms = { + 'containers': 'terminal.view_appprovider', + 'status': 'terminal.view_appprovider', + } + + cache_status_key_prefix = 'virtual_host_{}_status' + + def dispatch(self, request, *args, **kwargs): + with tmp_to_builtin_org(system=1): + return super().dispatch(request, *args, **kwargs) + + def get_permissions(self): + if self.action == 'create': + return [IsServiceAccount()] + return super().get_permissions() + + def perform_create(self, serializer): + request_terminal = getattr(self.request.user, 'terminal', None) + if not request_terminal: + raise ValidationError('Request user has no terminal') + data = dict() + data['terminal'] = request_terminal + data['id'] = self.request.user.id + serializer.save(**data) + + @action(detail=True, methods=['get'], serializer_class=AppProviderContainerSerializer) + def containers(self, request, *args, **kwargs): + instance = self.get_object() + key = self.cache_status_key_prefix.format(instance.id) + data = cache.get(key) + if not data: + data = [] + return self.get_paginated_response_from_queryset(data) + + @action(detail=True, methods=['post'], serializer_class=AppProviderContainerSerializer) + def status(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + key = self.cache_status_key_prefix.format(instance.id) + cache.set(key, validated_data, 60 * 3) + return Response({'msg': 'ok'}) diff --git a/apps/terminal/api/virtualapp/relation.py b/apps/terminal/api/virtualapp/relation.py new file mode 100644 index 000000000..9c15a0e0d --- /dev/null +++ b/apps/terminal/api/virtualapp/relation.py @@ -0,0 +1,64 @@ +from typing import Callable + +from django.conf import settings +from django.shortcuts import get_object_or_404 +from rest_framework.request import Request + +from common.api import JMSModelViewSet +from common.permissions import IsServiceAccount +from common.utils import is_uuid +from rbac.permissions import RBACPermission +from terminal.models import AppProvider +from terminal.serializers import ( + VirtualAppPublicationSerializer +) + + +class ProviderMixin: + request: Request + permission_denied: Callable + kwargs: dict + rbac_perms = ( + ('list', 'terminal.view_appprovider'), + ('retrieve', 'terminal.view_appprovider'), + ) + + def get_permissions(self): + if self.kwargs.get('host') and settings.DEBUG: + return [RBACPermission()] + else: + return [IsServiceAccount()] + + def self_provider(self): + try: + return self.request.user.terminal.app_provider + except AttributeError: + raise self.permission_denied(self.request, 'User has no app provider') + + def pk_provider(self): + return get_object_or_404(AppProvider, id=self.kwargs.get('provider')) + + @property + def provider(self): + if self.kwargs.get('provider'): + host = self.pk_provider() + else: + host = self.self_provider() + return host + + +class AppProviderAppViewSet(ProviderMixin, JMSModelViewSet): + provider: AppProvider + serializer_class = VirtualAppPublicationSerializer + filterset_fields = ['provider__name', 'app__name', 'status'] + + def get_object(self): + pk = self.kwargs.get('pk') + if not is_uuid(pk): + return self.provider.publications.get(app__name=pk) + else: + return self.provider.publications.get(id=pk) + + def get_queryset(self): + queryset = self.provider.publications.all() + return queryset diff --git a/apps/terminal/api/virtualapp/virtualapp.py b/apps/terminal/api/virtualapp/virtualapp.py new file mode 100644 index 000000000..07b92a356 --- /dev/null +++ b/apps/terminal/api/virtualapp/virtualapp.py @@ -0,0 +1,77 @@ +import os.path +import shutil +import zipfile +from typing import Callable + +from django.core.files.storage import default_storage +from django.utils._os import safe_join +from django.utils.translation import gettext as _ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ValidationError + +from common.api import JMSBulkModelViewSet +from common.serializers import FileSerializer +from terminal import serializers +from terminal.models import VirtualAppPublication, VirtualApp + +__all__ = ['VirtualAppViewSet', 'VirtualAppPublicationViewSet'] + + +class UploadMixin: + get_serializer: Callable + request: Request + get_object: Callable + + def extract_zip_pkg(self): + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + file = serializer.validated_data['file'] + save_to = 'virtual_apps/{}'.format(file.name + '.tmp.zip') + if default_storage.exists(save_to): + default_storage.delete(save_to) + rel_path = default_storage.save(save_to, file) + path = default_storage.path(rel_path) + extract_to = default_storage.path('virtual_apps/{}.tmp'.format(file.name)) + if os.path.exists(extract_to): + shutil.rmtree(extract_to) + try: + with zipfile.ZipFile(path) as zp: + if zp.testzip() is not None: + raise ValidationError({'error': _('Invalid zip file')}) + zp.extractall(extract_to) + except RuntimeError as e: + raise ValidationError({'error': _('Invalid zip file') + ': {}'.format(e)}) + tmp_dir = safe_join(extract_to, file.name.replace('.zip', '')) + return tmp_dir + + @action(detail=False, methods=['post'], serializer_class=FileSerializer) + def upload(self, request, *args, **kwargs): + tmp_dir = self.extract_zip_pkg() + manifest = VirtualApp.validate_pkg(tmp_dir) + name = manifest['name'] + instance = VirtualApp.objects.filter(name=name).first() + if instance: + return Response({'error': 'virtual app already exists: {}'.format(name)}, status=400) + + app, serializer = VirtualApp.install_from_dir(tmp_dir) + return Response(serializer.data, status=201) + + +class VirtualAppViewSet(UploadMixin, JMSBulkModelViewSet): + queryset = VirtualApp.objects.all() + serializer_class = serializers.VirtualAppSerializer + filterset_fields = ['name', 'image_name', 'is_active'] + search_fields = ['name', ] + rbac_perms = { + 'upload': 'terminal.add_virtualapp', + } + + +class VirtualAppPublicationViewSet(viewsets.ModelViewSet): + queryset = VirtualAppPublication.objects.all() + serializer_class = serializers.VirtualAppPublicationSerializer + filterset_fields = ['app__name', 'provider__name', 'status'] + search_fields = ['app__name', 'provider__name', ] diff --git a/apps/terminal/connect_methods.py b/apps/terminal/connect_methods.py index 9c7ee989a..27d67e1af 100644 --- a/apps/terminal/connect_methods.py +++ b/apps/terminal/connect_methods.py @@ -113,6 +113,26 @@ class AppletMethod: return methods +class VirtualAppMethod: + + @classmethod + def get_methods(cls): + from .models import VirtualApp + methods = defaultdict(list) + if not getattr(settings, 'VIRTUAL_APP_ENABLED'): + return methods + virtual_apps = VirtualApp.objects.filter(is_active=True) + for virtual_app in virtual_apps: + for protocol in virtual_app.protocols: + methods[protocol].append({ + 'value': virtual_app.name, + 'label': virtual_app.name, + 'type': 'virtual_app', + 'disabled': not virtual_app.is_active, + }) + return methods + + class ConnectMethodUtil: _all_methods = {} @@ -243,6 +263,7 @@ class ConnectMethodUtil: methods = defaultdict(list) spec_web_methods = WebMethod.get_spec_methods() applet_methods = AppletMethod.get_methods() + virtual_app_methods = VirtualAppMethod.get_methods() native_methods = NativeClient.get_methods(os=os) for component, component_protocol in cls.components().items(): @@ -295,5 +316,12 @@ class ConnectMethodUtil: method['component'] = TerminalType.tinker.value methods[asset_protocol].extend(applet_methods) + # 虚拟应用方式,这个只有 panda 提供,并且协议可能是自定义的 + for protocol, virtual_app_methods in virtual_app_methods.items(): + for method in virtual_app_methods: + method['listen'] = Protocol.http + method['component'] = TerminalType.panda.value + methods[protocol].extend(virtual_app_methods) + cls._all_methods[os] = methods return methods diff --git a/apps/terminal/const.py b/apps/terminal/const.py index a545a0a3a..1c07f36b7 100644 --- a/apps/terminal/const.py +++ b/apps/terminal/const.py @@ -66,6 +66,7 @@ class TerminalType(TextChoices): video_worker = 'video_worker', 'Video Worker' chen = 'chen', 'Chen' kael = 'kael', 'Kael' + panda = 'panda', 'Panda' @classmethod def types(cls): diff --git a/apps/terminal/migrations/0068_virtualapp.py b/apps/terminal/migrations/0068_virtualapp.py new file mode 100644 index 000000000..957514480 --- /dev/null +++ b/apps/terminal/migrations/0068_virtualapp.py @@ -0,0 +1,93 @@ +# Generated by Django 4.1.10 on 2023-12-05 07:02 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0067_alter_replaystorage_type'), + ] + + operations = [ + migrations.CreateModel( + name='AppProvider', + fields=[ + ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, unique=True, verbose_name='Name')), + ('hostname', models.CharField(max_length=128, verbose_name='Hostname')), + ], + options={ + 'ordering': ('-date_created',), + }, + ), + migrations.CreateModel( + name='VirtualApp', + fields=[ + ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.SlugField(max_length=128, unique=True, verbose_name='Name')), + ('display_name', models.CharField(max_length=128, verbose_name='Display name')), + ('version', models.CharField(max_length=16, verbose_name='Version')), + ('author', models.CharField(max_length=128, verbose_name='Author')), + ('is_active', models.BooleanField(default=True, verbose_name='Is active')), + ('protocols', models.JSONField(default=list, verbose_name='Protocol')), + ('image_name', models.CharField(max_length=128, verbose_name='Image name')), + ('image_protocol', models.CharField(default='vnc', max_length=16, verbose_name='Image protocol')), + ('image_port', models.IntegerField(default=5900, verbose_name='Image port')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), + ('tags', models.JSONField(default=list, verbose_name='Tags')), + ], + options={ + 'verbose_name': 'Virtual app', + }, + ), + migrations.AlterField( + model_name='terminal', + name='type', + field=models.CharField(choices=[('koko', 'KoKo'), ('guacamole', 'Guacamole'), ('omnidb', 'OmniDB'), ('xrdp', 'Xrdp'), ('lion', 'Lion'), ('core', 'Core'), ('celery', 'Celery'), ('magnus', 'Magnus'), ('razor', 'Razor'), ('tinker', 'Tinker'), ('video_worker', 'Video Worker'), ('chen', 'Chen'), ('kael', 'Kael'), ('panda', 'Panda')], default='koko', max_length=64, verbose_name='type'), + ), + migrations.CreateModel( + name='VirtualAppPublication', + fields=[ + ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('status', models.CharField(default='pending', max_length=16, verbose_name='Status')), + ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='publications', to='terminal.virtualapp', verbose_name='Virtual App')), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='publications', to='terminal.appprovider', verbose_name='App Provider')), + ], + options={ + 'verbose_name': 'Virtual app publication', + 'unique_together': {('provider', 'app')}, + }, + ), + migrations.AddField( + model_name='virtualapp', + name='providers', + field=models.ManyToManyField(through='terminal.VirtualAppPublication', to='terminal.appprovider', verbose_name='Providers'), + ), + migrations.AddField( + model_name='appprovider', + name='apps', + field=models.ManyToManyField(through='terminal.VirtualAppPublication', to='terminal.virtualapp', verbose_name='VirtualApp'), + ), + migrations.AddField( + model_name='appprovider', + name='terminal', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='app_provider', to='terminal.terminal', verbose_name='Terminal'), + ), + ] diff --git a/apps/terminal/models/__init__.py b/apps/terminal/models/__init__.py index 268727394..56fbd8cd9 100644 --- a/apps/terminal/models/__init__.py +++ b/apps/terminal/models/__init__.py @@ -1,3 +1,4 @@ from .session import * from .component import * from .applet import * +from .virtualapp import * diff --git a/apps/terminal/models/virtualapp/__init__.py b/apps/terminal/models/virtualapp/__init__.py new file mode 100644 index 000000000..f784f5c73 --- /dev/null +++ b/apps/terminal/models/virtualapp/__init__.py @@ -0,0 +1,2 @@ +from .provider import * +from .virtualapp import * diff --git a/apps/terminal/models/virtualapp/provider.py b/apps/terminal/models/virtualapp/provider.py new file mode 100644 index 000000000..90a8df2ef --- /dev/null +++ b/apps/terminal/models/virtualapp/provider.py @@ -0,0 +1,28 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from common.db.models import JMSBaseModel + +__all__ = ['AppProvider', ] + + +class AppProvider(JMSBaseModel): + name = models.CharField(max_length=128, verbose_name=_('Name'), unique=True) + hostname = models.CharField(max_length=128, verbose_name=_('Hostname')) + terminal = models.OneToOneField( + 'terminal.Terminal', on_delete=models.CASCADE, null=True, blank=True, + related_name='app_provider', verbose_name=_('Terminal') + ) + apps = models.ManyToManyField( + 'VirtualApp', verbose_name=_('VirtualApp'), + through='VirtualAppPublication', through_fields=('provider', 'app'), + ) + + class Meta: + ordering = ('-date_created',) + + @property + def load(self): + if not self.terminal: + return 'offline' + return self.terminal.load diff --git a/apps/terminal/models/virtualapp/virtualapp.py b/apps/terminal/models/virtualapp/virtualapp.py new file mode 100644 index 000000000..6035c76c4 --- /dev/null +++ b/apps/terminal/models/virtualapp/virtualapp.py @@ -0,0 +1,103 @@ +import os +import shutil + +from django.conf import settings +from django.core.files.storage import default_storage +from django.db import models +from django.utils._os import safe_join +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import ValidationError + +from common.db.models import JMSBaseModel +from common.utils import lazyproperty +from common.utils.yml import yaml_load_with_i18n + +__all__ = ['VirtualApp', 'VirtualAppPublication'] + + +class VirtualApp(JMSBaseModel): + name = models.SlugField(max_length=128, verbose_name=_('Name'), unique=True) + display_name = models.CharField(max_length=128, verbose_name=_('Display name')) + version = models.CharField(max_length=16, verbose_name=_('Version')) + author = models.CharField(max_length=128, verbose_name=_('Author')) + is_active = models.BooleanField(default=True, verbose_name=_('Is active')) + protocols = models.JSONField(default=list, verbose_name=_('Protocol')) + image_name = models.CharField(max_length=128, verbose_name=_('Image name')) + image_protocol = models.CharField(max_length=16, default='vnc', verbose_name=_('Image protocol')) + image_port = models.IntegerField(default=5900, verbose_name=_('Image port')) + comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) + tags = models.JSONField(default=list, verbose_name=_('Tags')) + providers = models.ManyToManyField( + through_fields=('app', 'provider',), through='VirtualAppPublication', + to='AppProvider', verbose_name=_('Providers') + ) + + class Meta: + verbose_name = _('Virtual app') + + def __str__(self): + return self.name + + @property + def path(self): + return default_storage.path('virtual_apps/{}'.format(self.name)) + + @lazyproperty + def readme(self): + readme_file = os.path.join(self.path, 'README.md') + if os.path.isfile(readme_file): + with open(readme_file, 'r') as f: + return f.read() + return '' + + @property + def icon(self): + path = os.path.join(self.path, 'icon.png') + if not os.path.exists(path): + return None + return os.path.join(settings.MEDIA_URL, 'virtual_apps', self.name, 'icon.png') + + @staticmethod + def validate_pkg(d): + files = ['manifest.yml', 'icon.png', ] + for name in files: + path = safe_join(d, name) + if not os.path.exists(path): + raise ValidationError({'error': _('Applet pkg not valid, Missing file {}').format(name)}) + + with open(safe_join(d, 'manifest.yml'), encoding='utf8') as f: + manifest = yaml_load_with_i18n(f) + + if not manifest.get('name', ''): + raise ValidationError({'error': 'Missing name in manifest.yml'}) + return manifest + + @classmethod + def install_from_dir(cls, path): + from terminal.serializers import VirtualAppSerializer + manifest = cls.validate_pkg(path) + name = manifest['name'] + instance = cls.objects.filter(name=name).first() + serializer = VirtualAppSerializer(instance=instance, data=manifest) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + + pkg_path = default_storage.path('virtual_apps/{}'.format(name)) + if os.path.exists(pkg_path): + shutil.rmtree(pkg_path) + shutil.copytree(path, pkg_path) + return instance, serializer + + +class VirtualAppPublication(JMSBaseModel): + provider = models.ForeignKey( + 'AppProvider', on_delete=models.CASCADE, related_name='publications', verbose_name=_('App Provider') + ) + app = models.ForeignKey( + 'VirtualApp', on_delete=models.CASCADE, related_name='publications', verbose_name=_('Virtual App') + ) + status = models.CharField(max_length=16, default='pending', verbose_name=_('Status')) + + class Meta: + verbose_name = _('Virtual app publication') + unique_together = ('provider', 'app') diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py index dc23362f9..ba97ae16d 100644 --- a/apps/terminal/serializers/__init__.py +++ b/apps/terminal/serializers/__init__.py @@ -9,3 +9,5 @@ from .sharing import * from .storage import * from .task import * from .terminal import * +from .virtualapp import * +from .virtualapp_provider import * diff --git a/apps/terminal/serializers/virtualapp.py b/apps/terminal/serializers/virtualapp.py new file mode 100644 index 000000000..f55226eaf --- /dev/null +++ b/apps/terminal/serializers/virtualapp.py @@ -0,0 +1,41 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from common.const.choices import Status +from common.serializers.fields import ObjectRelatedField, LabeledChoiceField +from terminal.const import PublishStatus +from ..models import VirtualApp, VirtualAppPublication, AppProvider + +__all__ = [ + 'VirtualAppSerializer', 'VirtualAppPublicationSerializer' +] + + +class VirtualAppSerializer(serializers.ModelSerializer): + icon = serializers.ReadOnlyField(label=_("Icon")) + image_protocol = serializers.CharField(max_length=16, default='vnc') + image_port = serializers.IntegerField(default=5900) + + class Meta: + model = VirtualApp + fields_mini = ['id', 'display_name', 'name', 'image_name', 'is_active'] + read_only_fields = [ + 'icon', 'readme', 'date_created', 'date_updated', + ] + fields = fields_mini + [ + 'version', 'author', 'image_protocol', 'image_port', + 'protocols', 'tags', 'comment', + ] + read_only_fields + + +class VirtualAppPublicationSerializer(serializers.ModelSerializer): + app = ObjectRelatedField(attrs=('id', 'name', 'image_name',), label=_("Virtual App"), + queryset=VirtualApp.objects.all()) + provider = ObjectRelatedField(queryset=AppProvider.objects.all(), label=_("App Provider")) + status = LabeledChoiceField(choices=PublishStatus.choices, label=_("Status"), default=Status.pending) + + class Meta: + model = VirtualAppPublication + fields_mini = ['id', 'provider', 'app'] + read_only_fields = ['date_created', 'date_updated'] + fields = fields_mini + ['status', 'comment'] + read_only_fields diff --git a/apps/terminal/serializers/virtualapp_provider.py b/apps/terminal/serializers/virtualapp_provider.py new file mode 100644 index 000000000..bfce5e081 --- /dev/null +++ b/apps/terminal/serializers/virtualapp_provider.py @@ -0,0 +1,31 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from common.serializers.fields import LabeledChoiceField +from terminal import const +from ..models import AppProvider + +__all__ = ['AppProviderSerializer', 'AppProviderContainerSerializer', ] + + +class AppProviderSerializer(serializers.ModelSerializer): + load = LabeledChoiceField( + read_only=True, label=_('Load status'), choices=const.ComponentLoad.choices, + ) + + class Meta: + model = AppProvider + field_mini = ['id', 'name', 'hostname'] + read_only_fields = [ + 'date_created', 'date_updated', + ] + fields = field_mini + ['load', 'terminal'] + read_only_fields + + +class AppProviderContainerSerializer(serializers.Serializer): + container_id = serializers.CharField(label=_('Container ID')) + container_image = serializers.CharField(label=_('Container Image')) + container_name = serializers.CharField(label=_('Container Name')) + container_status = serializers.CharField(label=_('Container Status')) + container_ports = serializers.ListField(child=serializers.CharField(), label=_('Container Ports')) + diff --git a/apps/terminal/signal_handlers/__init__.py b/apps/terminal/signal_handlers/__init__.py index bd61c885c..08a6ac407 100644 --- a/apps/terminal/signal_handlers/__init__.py +++ b/apps/terminal/signal_handlers/__init__.py @@ -3,3 +3,4 @@ from .db_port import * from .session import * from .session_sharing import * from .terminal import * +from .virtualapp import * diff --git a/apps/terminal/signal_handlers/virtualapp.py b/apps/terminal/signal_handlers/virtualapp.py new file mode 100644 index 000000000..37c4ed1a1 --- /dev/null +++ b/apps/terminal/signal_handlers/virtualapp.py @@ -0,0 +1,24 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from common.decorators import on_transaction_commit +from ..models import AppProvider, VirtualApp + + +@receiver(post_save, sender=AppProvider) +@on_transaction_commit +def on_virtual_host_create(sender, instance, created=False, **kwargs): + if not created: + return + apps = VirtualApp.objects.all() + instance.apps.set(apps) + + +@receiver(post_save, sender=VirtualApp) +def on_virtual_app_create(sender, instance, created=False, **kwargs): + if not created: + return + providers = AppProvider.objects.all() + if len(providers) == 0: + return + instance.providers.set(providers) diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index 3c38bb934..258e2f0d1 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -30,6 +30,10 @@ router.register(r'applet-hosts', api.AppletHostViewSet, 'applet-host') router.register(r'applet-publications', api.AppletPublicationViewSet, 'applet-publication') router.register(r'applet-host-deployments', api.AppletHostDeploymentViewSet, 'applet-host-deployment') router.register(r'db-listen-ports', api.DBListenPortViewSet, 'db-listen-ports') +router.register(r'virtual-apps', api.VirtualAppViewSet, 'virtual-app') +router.register(r'app-providers', api.AppProviderViewSet, 'app-provider') +router.register(r'app-providers/((?P[^/.]+)/)?apps', api.AppProviderAppViewSet, 'app-provider-app') +router.register(r'virtual-app-publications', api.VirtualAppPublicationViewSet, 'virtual-app-publication') urlpatterns = [ path('my-sessions/', api.MySessionAPIView.as_view(), name='my-session'),