From 3fa72aead27edc2b14b2f5bb86cb2ce2d3eb528b Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 6 Aug 2026 21:51:36 +0800 Subject: [PATCH] perf: refactor virutal app same like applet --- .../authentication/models/connection_token.py | 18 +- .../serializers/connect_token_secret.py | 29 +++ apps/terminal/api/virtualapp/provider.py | 53 ++++- apps/terminal/api/virtualapp/virtualapp.py | 37 +++- .../deploy_app_provider/__init__.py | 101 +++++++++ .../deploy_app_provider/playbook.yml | 74 +++++++ .../deploy_app_provider/publish.yml | 9 + .../0012_app_provider_runtime_connection.py | 40 ++++ ...0013_virtual_app_publication_sync_state.py | 25 +++ .../0014_app_provider_deployment.py | 36 +++ apps/terminal/models/virtualapp/provider.py | 93 +++++++- apps/terminal/models/virtualapp/virtualapp.py | 75 +++++++ apps/terminal/serializers/virtualapp.py | 24 +- .../serializers/virtualapp_provider.py | 73 ++++++- apps/terminal/tasks.py | 13 +- apps/terminal/tests.py | 206 +++++++++++++++++- apps/terminal/urls/api_urls.py | 1 + 17 files changed, 887 insertions(+), 20 deletions(-) create mode 100644 apps/terminal/automations/deploy_app_provider/__init__.py create mode 100644 apps/terminal/automations/deploy_app_provider/playbook.yml create mode 100644 apps/terminal/automations/deploy_app_provider/publish.yml create mode 100644 apps/terminal/migrations/0012_app_provider_runtime_connection.py create mode 100644 apps/terminal/migrations/0013_virtual_app_publication_sync_state.py create mode 100644 apps/terminal/migrations/0014_app_provider_deployment.py diff --git a/apps/authentication/models/connection_token.py b/apps/authentication/models/connection_token.py index 6f30d9f01..ff8c71cd5 100644 --- a/apps/authentication/models/connection_token.py +++ b/apps/authentication/models/connection_token.py @@ -243,7 +243,23 @@ class ConnectionToken(JMSOrgBaseModel): virtual_app = VirtualApp.objects.filter(name=method.get('value')).first() if not virtual_app: return None - return virtual_app + provider = virtual_app.select_provider(self.user) + data = { + 'name': virtual_app.name, + 'image_name': virtual_app.image_name, + 'image_port': virtual_app.image_port, + 'image_protocol': virtual_app.image_protocol, + 'provider': provider, + } + + # Keep the legacy globally configured Panda path working until Lion can + # consume the selected provider. Once at least one publication exists, + # however, publication state becomes authoritative. + if provider is None and virtual_app.publications.exists(): + raise JMSException({ + 'error': 'No provider available, please check the virtual app publication and provider status' + }) + return data def get_applet_option(self): method = self.connect_method_object diff --git a/apps/authentication/serializers/connect_token_secret.py b/apps/authentication/serializers/connect_token_secret.py index 016fb161b..5873fd2e6 100644 --- a/apps/authentication/serializers/connect_token_secret.py +++ b/apps/authentication/serializers/connect_token_secret.py @@ -224,3 +224,32 @@ class ConnectTokenVirtualAppOptionSerializer(serializers.Serializer): image_name = serializers.CharField(label=_('Image name')) image_port = serializers.IntegerField(label=_('Image port')) image_protocol = serializers.CharField(label=_('Image protocol')) + provider = serializers.SerializerMethodField(label=_('App Provider')) + + @staticmethod + def get_provider(instance): + provider = instance.get('provider') + if provider is None: + return None + data = { + 'id': str(provider.id), + 'name': provider.name, + 'hostname': provider.hostname, + 'address': provider.address, + 'host_id': str(provider.host_id) if provider.host_id else None, + 'runtime_type': provider.runtime_type, + 'connection_mode': provider.connection_mode, + 'service_url': provider.service_url, + 'load': provider.load, + } + if provider.connection_mode == provider.ConnectionMode.ssh: + data.update({ + 'host': _ConnectionTokenAssetSerializer(provider.host).data, + 'account': _ConnectionTokenAccountSerializer( + provider.select_account() + ).data, + 'gateway': _ConnectionTokenGatewaySerializer( + provider.select_gateway() + ).data if provider.select_gateway() else None, + }) + return data diff --git a/apps/terminal/api/virtualapp/provider.py b/apps/terminal/api/virtualapp/provider.py index 13cab4dfe..fa4592fca 100644 --- a/apps/terminal/api/virtualapp/provider.py +++ b/apps/terminal/api/virtualapp/provider.py @@ -1,17 +1,20 @@ from django.core.cache import cache +from django.db import transaction +from rest_framework import viewsets 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.models import AppProvider, AppProviderDeployment from terminal.serializers import ( - AppProviderSerializer, AppProviderContainerSerializer + AppProviderSerializer, AppProviderContainerSerializer, + AppProviderDeploymentSerializer, ) +from terminal.tasks import run_app_provider_deployment -__all__ = ['AppProviderViewSet', ] +__all__ = ['AppProviderViewSet', 'AppProviderDeploymentViewSet'] class AppProviderViewSet(JMSBulkModelViewSet): @@ -24,26 +27,42 @@ class AppProviderViewSet(JMSBulkModelViewSet): 'status': 'terminal.view_appprovider', } - cache_status_key_prefix = 'virtual_host_{}_status' + cache_status_key_prefix = AppProvider.cache_status_key_prefix 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': + if self.action in ('create', 'startup') and getattr( + self.request.user, 'is_service_account', False + ): 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') + serializer.save(terminal=None) + return data = dict() data['terminal'] = request_terminal data['id'] = self.request.user.id + # Registration is performed by the provider service account. Runtime + # routing and host binding remain administrator-controlled settings. + data['host'] = None + data['runtime_type'] = AppProvider.RuntimeType.docker + data['connection_mode'] = AppProvider.ConnectionMode.direct + data['service_url'] = '' + data['deploy_options'] = {} serializer.save(**data) + @action(detail=True, methods=['post']) + def startup(self, request, *args, **kwargs): + instance = self.get_object() + instance.check_terminal_binding(request) + return Response({'msg': 'ok'}) + @action(detail=True, methods=['get'], serializer_class=AppProviderContainerSerializer) def containers(self, request, *args, **kwargs): instance = self.get_object() @@ -62,3 +81,23 @@ class AppProviderViewSet(JMSBulkModelViewSet): key = self.cache_status_key_prefix.format(instance.id) cache.set(key, validated_data, 60 * 3) return Response({'msg': 'ok'}) + + +class AppProviderDeploymentViewSet(viewsets.ModelViewSet): + serializer_class = AppProviderDeploymentSerializer + queryset = AppProviderDeployment.objects.all() + filterset_fields = ['provider', 'status'] + + @staticmethod + def start_deploy(instance): + run_app_provider_deployment.apply_async( + (instance.id,), task_id=str(instance.id) + ) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + instance.save_task(instance.id) + transaction.on_commit(lambda: self.start_deploy(instance)) + return Response({'task': str(instance.id)}, status=201) diff --git a/apps/terminal/api/virtualapp/virtualapp.py b/apps/terminal/api/virtualapp/virtualapp.py index 42b70001c..a07053051 100644 --- a/apps/terminal/api/virtualapp/virtualapp.py +++ b/apps/terminal/api/virtualapp/virtualapp.py @@ -3,6 +3,7 @@ import shutil from typing import Callable from django.core.files.storage import default_storage +from django.db import transaction from django.utils.translation import gettext as _ from rest_framework import viewsets from rest_framework.decorators import action @@ -13,7 +14,8 @@ 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 +from terminal.models import VirtualAppPublication, VirtualApp, AppProviderDeployment +from terminal.tasks import run_app_provider_deployment from common.utils.zip import safe_extract_zip __all__ = ['VirtualAppViewSet', 'VirtualAppPublicationViewSet'] @@ -81,5 +83,36 @@ class VirtualAppViewSet(UploadMixin, JMSBulkModelViewSet): class VirtualAppPublicationViewSet(viewsets.ModelViewSet): queryset = VirtualAppPublication.objects.all() serializer_class = serializers.VirtualAppPublicationSerializer - filterset_fields = ['app__name', 'provider__name', 'status'] + filterset_fields = ['app', 'app__name', 'provider', 'provider__name', 'status'] search_fields = ['app__name', 'provider__name', ] + + @staticmethod + def start_publish(publication): + deployment = AppProviderDeployment.objects.create( + provider=publication.provider, + publication=publication, + ) + deployment.save_task(deployment.id) + transaction.on_commit( + lambda: run_app_provider_deployment.apply_async( + (deployment.id,), task_id=str(deployment.id) + ) + ) + return deployment + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + publication = serializer.save(status='pending') + deployment = self.start_publish(publication) + data = serializer.data + data['task'] = str(deployment.id) + return Response(data, status=201) + + @action(detail=True, methods=['post']) + def publish(self, request, *args, **kwargs): + publication = self.get_object() + publication.status = 'pending' + publication.save(update_fields=['status', 'date_updated']) + deployment = self.start_publish(publication) + return Response({'task': str(deployment.id)}, status=201) diff --git a/apps/terminal/automations/deploy_app_provider/__init__.py b/apps/terminal/automations/deploy_app_provider/__init__.py new file mode 100644 index 000000000..8374db9d4 --- /dev/null +++ b/apps/terminal/automations/deploy_app_provider/__init__.py @@ -0,0 +1,101 @@ +import datetime +import os +import shutil +import uuid + +import yaml +from django.conf import settings +from django.utils import timezone + +from common.db.utils import safe_db_connection +from common.utils import get_logger +from ops.ansible import JMSInventory, SuperPlaybookRunner + +logger = get_logger(__name__) +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class DeployAppProviderManager: + def __init__(self, deployment): + self.deployment = deployment + self.provider = deployment.provider + self.run_dir = self.get_run_dir() + + @staticmethod + def get_run_dir(): + base = os.path.join(settings.ANSIBLE_DIR, 'app_provider_deploy') + now = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + return os.path.join(base, f'{now}_{uuid.uuid4().hex[:8]}') + + def generate_inventory(self): + inventory = JMSInventory( + [self.provider.host], account_policy='privileged_only' + ) + path = os.path.join(self.run_dir, 'inventory', 'hosts.yml') + inventory.write_to_file(path) + return path + + def generate_playbook(self): + template = 'publish.yml' if self.deployment.publication_id else 'playbook.yml' + with open(os.path.join(CURRENT_DIR, template)) as f: + plays = yaml.safe_load(f) + + options = self.provider.deploy_options + core_host = options.get('CORE_HOST') or settings.SITE_URL or 'http://localhost:8080' + service_url = self.provider.service_url or f'http://{self.provider.host.address}:9001' + variables = { + **options, + 'CORE_HOST': core_host.rstrip('/'), + 'BOOTSTRAP_TOKEN': settings.BOOTSTRAP_TOKEN, + 'PROVIDER_ID': str(self.provider.id), + 'PROVIDER_NAME': self.provider.name, + 'PANDA_HOST_IP': self.provider.host.address, + 'PANDA_IMAGE': options.get('PANDA_IMAGE', 'jumpserver/panda:latest'), + 'PANDA_RANGE_PORTS': options.get('PANDA_RANGE_PORTS', '6900-7900'), + 'IGNORE_VERIFY_CERTS': options.get('IGNORE_VERIFY_CERTS', True), + } + if self.deployment.publication_id: + variables['APP_IMAGE'] = self.deployment.publication.app.image_name + for play in plays: + play['vars'].update(variables) + + if self.provider.service_url != service_url: + self.provider.service_url = service_url + self.provider.save(update_fields=['service_url']) + + path = os.path.join(self.run_dir, 'playbook', 'main.yml') + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + yaml.safe_dump(plays, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + return path + + def run(self): + try: + self.deployment.date_start = timezone.now() + runner = SuperPlaybookRunner( + inventory=self.generate_inventory(), + playbook=self.generate_playbook(), + project_dir=self.run_dir, + safety_mode='playbook_unsafe', + inventory_safety='json_escape', + ) + result = runner.run() + self.deployment.status = result.status + if self.deployment.publication_id: + publication_status = ( + 'pending' if result.status in ('success', 'successful') else 'failed' + ) + self.deployment.publication.status = publication_status + self.deployment.publication.save(update_fields=['status', 'date_updated']) + except Exception as exc: + logger.exception('Deploy app provider failed: %s', exc) + self.deployment.status = 'error' + if self.deployment.publication_id: + self.deployment.publication.status = 'failed' + self.deployment.publication.save(update_fields=['status', 'date_updated']) + finally: + self.deployment.date_finished = timezone.now() + with safe_db_connection(): + self.deployment.save() + if not settings.DEBUG_DEV: + shutil.rmtree(self.run_dir, ignore_errors=True) diff --git a/apps/terminal/automations/deploy_app_provider/playbook.yml b/apps/terminal/automations/deploy_app_provider/playbook.yml new file mode 100644 index 000000000..9e2f47154 --- /dev/null +++ b/apps/terminal/automations/deploy_app_provider/playbook.yml @@ -0,0 +1,74 @@ +--- +- hosts: all + become: true + vars: + CORE_HOST: https://demo.example.com + BOOTSTRAP_TOKEN: PleaseChangeMe + PROVIDER_ID: 00000000-0000-0000-0000-000000000000 + PROVIDER_NAME: panda + PANDA_HOST_IP: 127.0.0.1 + PANDA_IMAGE: jumpserver/panda:latest + PANDA_RANGE_PORTS: 6900-7900 + IGNORE_VERIFY_CERTS: true + + tasks: + - name: Check Docker runtime + ansible.builtin.command: docker version + register: docker_version + changed_when: false + failed_when: false + + - name: Download Docker installer + ansible.builtin.get_url: + url: https://get.docker.com + dest: /tmp/get-docker.sh + mode: '0700' + when: docker_version.rc != 0 + + - name: Install Docker runtime + ansible.builtin.command: sh /tmp/get-docker.sh + when: docker_version.rc != 0 + + - name: Ensure Docker service is running + ansible.builtin.service: + name: docker + state: started + enabled: true + + - name: Ensure Panda data directory exists + ansible.builtin.file: + path: /opt/jumpserver/panda/data + state: directory + mode: '0700' + + - name: Pull Panda image + ansible.builtin.command: docker pull {{ PANDA_IMAGE }} + + - name: Remove previous Panda container + ansible.builtin.command: docker rm -f jms_panda + register: remove_panda + changed_when: remove_panda.rc == 0 + failed_when: false + + - name: Start Panda provider + ansible.builtin.shell: >- + docker run -d --name jms_panda --restart always --network host + -v /var/run/docker.sock:/var/run/docker.sock + -v /opt/jumpserver/panda/data:/opt/panda/data + -e CORE_HOST={{ CORE_HOST | quote }} + -e BOOTSTRAP_TOKEN={{ BOOTSTRAP_TOKEN | quote }} + -e IGNORE_VERIFY_CERTS={{ IGNORE_VERIFY_CERTS | string | lower | quote }} + -e PANDA_PROVIDER_ID={{ PROVIDER_ID | quote }} + -e PANDA_HOST_IP={{ PANDA_HOST_IP | quote }} + -e PANDA_RANGE_PORTS={{ PANDA_RANGE_PORTS | quote }} + {{ PANDA_IMAGE | quote }} + no_log: true + + - name: Wait for Panda health endpoint + ansible.builtin.uri: + url: http://127.0.0.1:9001/panda/health/ + status_code: 200 + register: panda_health + retries: 30 + delay: 2 + until: panda_health.status == 200 diff --git a/apps/terminal/automations/deploy_app_provider/publish.yml b/apps/terminal/automations/deploy_app_provider/publish.yml new file mode 100644 index 000000000..f0ad79a40 --- /dev/null +++ b/apps/terminal/automations/deploy_app_provider/publish.yml @@ -0,0 +1,9 @@ +--- +- hosts: all + become: true + vars: + APP_IMAGE: example/application:latest + + tasks: + - name: Pull virtual application image + ansible.builtin.command: docker pull {{ APP_IMAGE }} diff --git a/apps/terminal/migrations/0012_app_provider_runtime_connection.py b/apps/terminal/migrations/0012_app_provider_runtime_connection.py new file mode 100644 index 000000000..ba56feaef --- /dev/null +++ b/apps/terminal/migrations/0012_app_provider_runtime_connection.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.13 on 2026-08-06 13:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0023_platformpackage_platform_package'), + ('terminal', '0011_endpoint_magnus_port'), + ] + + operations = [ + migrations.AddField( + model_name='appprovider', + name='connection_mode', + field=models.CharField(choices=[('direct', 'Direct'), ('ssh', 'SSH')], default='direct', max_length=16, verbose_name='Connection mode'), + ), + migrations.AddField( + model_name='appprovider', + name='deploy_options', + field=models.JSONField(blank=True, default=dict, verbose_name='Deploy options'), + ), + migrations.AddField( + model_name='appprovider', + name='host', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='app_provider', to='assets.host', verbose_name='Host'), + ), + migrations.AddField( + model_name='appprovider', + name='runtime_type', + field=models.CharField(choices=[('docker', 'Docker'), ('podman', 'Podman')], default='docker', max_length=16, verbose_name='Runtime type'), + ), + migrations.AddField( + model_name='appprovider', + name='service_url', + field=models.URLField(blank=True, default='', max_length=1024, verbose_name='Service URL'), + ), + ] diff --git a/apps/terminal/migrations/0013_virtual_app_publication_sync_state.py b/apps/terminal/migrations/0013_virtual_app_publication_sync_state.py new file mode 100644 index 000000000..0b1ac8dff --- /dev/null +++ b/apps/terminal/migrations/0013_virtual_app_publication_sync_state.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('terminal', '0012_app_provider_runtime_connection'), + ] + + operations = [ + migrations.AddField( + model_name='virtualapppublication', + name='app_version', + field=models.CharField(blank=True, default='', max_length=16, verbose_name='Published version'), + ), + migrations.AddField( + model_name='virtualapppublication', + name='image_digest', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='Image digest'), + ), + migrations.AddField( + model_name='virtualapppublication', + name='date_synced', + field=models.DateTimeField(blank=True, null=True, verbose_name='Date synced'), + ), + ] diff --git a/apps/terminal/migrations/0014_app_provider_deployment.py b/apps/terminal/migrations/0014_app_provider_deployment.py new file mode 100644 index 000000000..cd925f1f0 --- /dev/null +++ b/apps/terminal/migrations/0014_app_provider_deployment.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.13 on 2026-08-06 14:09 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0013_virtual_app_publication_sync_state'), + ] + + operations = [ + migrations.CreateModel( + name='AppProviderDeployment', + 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')), + ('date_start', models.DateTimeField(blank=True, null=True, verbose_name='Date start')), + ('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')), + ('task', models.UUIDField(blank=True, null=True, verbose_name='Task')), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployments', to='terminal.appprovider', verbose_name='App Provider')), + ('publication', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='deployments', to='terminal.virtualapppublication', verbose_name='Virtual app publication')), + ], + options={ + 'verbose_name': 'App Provider deployment', + 'ordering': ('-date_created',), + }, + ), + ] diff --git a/apps/terminal/models/virtualapp/provider.py b/apps/terminal/models/virtualapp/provider.py index 0c53e9620..99cc12959 100644 --- a/apps/terminal/models/virtualapp/provider.py +++ b/apps/terminal/models/virtualapp/provider.py @@ -1,14 +1,41 @@ from django.db import models +from django.core.cache import cache from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import ValidationError from common.db.models import JMSBaseModel -__all__ = ['AppProvider', ] +__all__ = ['AppProvider', 'AppProviderDeployment'] class AppProvider(JMSBaseModel): + class RuntimeType(models.TextChoices): + docker = 'docker', 'Docker' + podman = 'podman', 'Podman' + + class ConnectionMode(models.TextChoices): + direct = 'direct', _('Direct') + ssh = 'ssh', 'SSH' + + cache_status_key_prefix = 'virtual_host_{}_status' name = models.CharField(max_length=128, verbose_name=_('Name'), unique=True) hostname = models.CharField(max_length=128, verbose_name=_('Hostname')) + host = models.OneToOneField( + 'assets.Host', on_delete=models.SET_NULL, null=True, blank=True, + related_name='app_provider', verbose_name=_('Host'), + ) + runtime_type = models.CharField( + max_length=16, choices=RuntimeType.choices, default=RuntimeType.docker, + verbose_name=_('Runtime type'), + ) + connection_mode = models.CharField( + max_length=16, choices=ConnectionMode.choices, default=ConnectionMode.direct, + verbose_name=_('Connection mode'), + ) + service_url = models.URLField( + max_length=1024, blank=True, default='', verbose_name=_('Service URL'), + ) + deploy_options = models.JSONField(default=dict, blank=True, verbose_name=_('Deploy options')) terminal = models.OneToOneField( 'terminal.Terminal', on_delete=models.CASCADE, null=True, blank=True, related_name='app_provider', verbose_name=_('Terminal') @@ -27,3 +54,67 @@ class AppProvider(JMSBaseModel): if not self.terminal: return 'offline' return self.terminal.load + + @property + def container_count(self): + containers = cache.get(self.cache_status_key_prefix.format(self.id), []) + return len(containers) + + @property + def address(self): + return self.host.address if self.host else self.hostname + + def select_gateway(self): + if not self.host or not self.host.zone: + return None + return self.host.zone.select_gateway() + + def check_terminal_binding(self, request): + request_terminal = getattr(request.user, 'terminal', None) + if not request_terminal: + raise ValidationError('Request user has no terminal') + self.terminal = request_terminal + self.save(update_fields=['terminal', 'date_updated']) + + def select_account(self): + if not self.host: + return None + return self.host.accounts.active().order_by( + '-privileged', '-date_updated' + ).first() + + @property + def connection_ready(self): + if self.connection_mode == self.ConnectionMode.direct: + return True + if not self.host: + return False + has_ssh = self.host.protocols.filter(name='ssh').exists() + return has_ssh and self.select_account() is not None + + +class AppProviderDeployment(JMSBaseModel): + provider = models.ForeignKey( + 'AppProvider', on_delete=models.CASCADE, related_name='deployments', + verbose_name=_('App Provider'), + ) + publication = models.ForeignKey( + 'VirtualAppPublication', on_delete=models.CASCADE, null=True, blank=True, + related_name='deployments', verbose_name=_('Virtual app publication'), + ) + status = models.CharField(max_length=16, default='pending', verbose_name=_('Status')) + date_start = models.DateTimeField(null=True, blank=True, verbose_name=_('Date start')) + date_finished = models.DateTimeField(null=True, blank=True, verbose_name=_('Date finished')) + task = models.UUIDField(null=True, blank=True, verbose_name=_('Task')) + + class Meta: + ordering = ('-date_created',) + verbose_name = _('App Provider deployment') + + def start(self): + from terminal.automations.deploy_app_provider import DeployAppProviderManager + DeployAppProviderManager(self).run() + + def save_task(self, task): + self.task = task + self.save(update_fields=['task']) diff --git a/apps/terminal/models/virtualapp/virtualapp.py b/apps/terminal/models/virtualapp/virtualapp.py index ec968b782..ca44a17d5 100644 --- a/apps/terminal/models/virtualapp/virtualapp.py +++ b/apps/terminal/models/virtualapp/virtualapp.py @@ -2,6 +2,7 @@ import os import shutil from django.conf import settings +from django.core.cache import cache from django.core.files.storage import default_storage from django.db import models from django.utils._os import safe_join @@ -11,10 +12,14 @@ from rest_framework.serializers import ValidationError from assets.utils.platform_package import locate_package_root from common.db.models import JMSBaseModel from common.utils import lazyproperty +from common.utils import get_logger from common.utils.yml import yaml_load_with_i18n +from terminal.const import ComponentLoad, PublishStatus __all__ = ['VirtualApp', 'VirtualAppPublication'] +logger = get_logger(__name__) + class VirtualApp(JMSBaseModel): name = models.SlugField(max_length=128, verbose_name=_('Name'), unique=True) @@ -33,6 +38,8 @@ class VirtualApp(JMSBaseModel): to='AppProvider', verbose_name=_('Providers') ) + provider_prefer_key_tpl = 'virtual_app_provider_prefer_{}_{}' + class Meta: verbose_name = _('Virtual app') @@ -93,6 +100,65 @@ class VirtualApp(JMSBaseModel): shutil.copytree(path, pkg_path) return instance, serializer + def filter_available_providers(self): + """Return providers where this app has been published successfully. + + A provider without a bound terminal, or whose terminal reports offline, + must not receive a new virtual application instance. + """ + publications = self.publications.filter( + status=PublishStatus.success + ).select_related('provider__terminal') + providers = [ + publication.provider for publication in publications + if publication.provider.load != ComponentLoad.offline + and publication.provider.connection_ready + ] + if not providers: + logger.info('No available provider for virtual app: %s', self.name) + return providers + + @classmethod + def clear_provider_prefer(cls): + cache.delete_pattern(cls.provider_prefer_key_tpl.format('*', '*')) + + @classmethod + def _select_provider_by_load(cls, providers): + load_priorities = { + ComponentLoad.normal: 0, + ComponentLoad.high: 1, + ComponentLoad.critical: 2, + } + return min( + providers, + key=lambda provider: ( + load_priorities.get(provider.load, 3), + provider.container_count, + str(provider.id), + ), + default=None, + ) + + def select_provider(self, user): + providers = self.filter_available_providers() + if not providers: + return None + + prefer_key = self.provider_prefer_key_tpl.format(self.id, user.id) + preferred_provider_id = cache.get(prefer_key) + preferred_provider = next( + (item for item in providers if str(item.id) == str(preferred_provider_id)), + None, + ) + provider = self._select_provider_by_load(providers) + # Affinity may choose a busier provider, but must never override a + # healthier load class. + if preferred_provider and preferred_provider.load == provider.load: + provider = preferred_provider + elif provider: + cache.set(prefer_key, str(provider.id), timeout=None) + return provider + class VirtualAppPublication(JMSBaseModel): provider = models.ForeignKey( @@ -102,6 +168,15 @@ class VirtualAppPublication(JMSBaseModel): 'VirtualApp', on_delete=models.CASCADE, related_name='publications', verbose_name=_('Virtual app') ) status = models.CharField(max_length=16, default='pending', verbose_name=_('Status')) + app_version = models.CharField( + max_length=16, blank=True, default='', verbose_name=_('Published version') + ) + image_digest = models.CharField( + max_length=255, blank=True, default='', verbose_name=_('Image digest') + ) + date_synced = models.DateTimeField( + null=True, blank=True, verbose_name=_('Date synced') + ) class Meta: verbose_name = _('Virtual app publication') diff --git a/apps/terminal/serializers/virtualapp.py b/apps/terminal/serializers/virtualapp.py index 1c148eceb..0a1b4add7 100644 --- a/apps/terminal/serializers/virtualapp.py +++ b/apps/terminal/serializers/virtualapp.py @@ -1,4 +1,5 @@ from django.utils.translation import gettext_lazy as _ +from django.utils import timezone from rest_framework import serializers from common.const.choices import Status @@ -28,9 +29,19 @@ class VirtualAppSerializer(ManifestI18nMixin, serializers.ModelSerializer): 'protocols', 'tags', 'comment', ] + read_only_fields + def update(self, instance, validated_data): + image_changed = any( + field in validated_data and validated_data[field] != getattr(instance, field) + for field in ('version', 'image_name') + ) + instance = super().update(instance, validated_data) + if image_changed: + instance.publications.update(status=PublishStatus.mismatch) + return instance + class VirtualAppPublicationSerializer(serializers.ModelSerializer): - app = ObjectRelatedField(attrs=('id', 'name', 'image_name',), label=_("Virtual app"), + app = ObjectRelatedField(attrs=('id', 'name', 'image_name', 'version'), 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) @@ -38,5 +49,12 @@ class VirtualAppPublicationSerializer(serializers.ModelSerializer): class Meta: model = VirtualAppPublication fields_mini = ['id', 'provider', 'app'] - read_only_fields = ['date_created', 'date_updated'] - fields = fields_mini + ['status', 'comment'] + read_only_fields + read_only_fields = ['date_created', 'date_updated', 'date_synced'] + fields = fields_mini + [ + 'status', 'app_version', 'image_digest', 'date_synced', 'comment' + ] + ['date_created', 'date_updated'] + + def update(self, instance, validated_data): + if {'status', 'app_version', 'image_digest'} & validated_data.keys(): + validated_data['date_synced'] = timezone.now() + return super().update(instance, validated_data) diff --git a/apps/terminal/serializers/virtualapp_provider.py b/apps/terminal/serializers/virtualapp_provider.py index bfce5e081..14611ec7e 100644 --- a/apps/terminal/serializers/virtualapp_provider.py +++ b/apps/terminal/serializers/virtualapp_provider.py @@ -1,25 +1,57 @@ +from django.conf import settings from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from common.serializers.fields import LabeledChoiceField +from common.serializers.fields import ObjectRelatedField +from common.const.choices import Status +from assets.models import Host from terminal import const -from ..models import AppProvider +from ..models import AppProvider, AppProviderDeployment -__all__ = ['AppProviderSerializer', 'AppProviderContainerSerializer', ] +__all__ = [ + 'AppProviderSerializer', 'AppProviderContainerSerializer', + 'AppProviderDeploymentSerializer', +] + + +class AppProviderDeployOptionsSerializer(serializers.Serializer): + CORE_HOST = serializers.CharField( + default=settings.SITE_URL, max_length=1024, label=_('Core API') + ) + IGNORE_VERIFY_CERTS = serializers.BooleanField( + default=True, label=_('Ignore Certificate Verification') + ) + PANDA_IMAGE = serializers.CharField( + default='jumpserver/panda:latest', max_length=255, label=_('Panda image') + ) + PANDA_RANGE_PORTS = serializers.CharField( + default='6900-7900', max_length=64, label=_('Container port range') + ) class AppProviderSerializer(serializers.ModelSerializer): + host = ObjectRelatedField( + queryset=Host.objects.all(), required=False, allow_null=True, + attrs=('id', 'name', 'address'), label=_('Host'), + ) load = LabeledChoiceField( read_only=True, label=_('Load status'), choices=const.ComponentLoad.choices, ) + deploy_options = AppProviderDeployOptionsSerializer( + required=False, label=_('Deploy options') + ) class Meta: model = AppProvider field_mini = ['id', 'name', 'hostname'] read_only_fields = [ - 'date_created', 'date_updated', + 'terminal', 'date_created', 'date_updated', ] - fields = field_mini + ['load', 'terminal'] + read_only_fields + fields = field_mini + [ + 'host', 'runtime_type', 'connection_mode', 'service_url', + 'deploy_options', 'load', 'terminal', + ] + read_only_fields class AppProviderContainerSerializer(serializers.Serializer): @@ -29,3 +61,36 @@ class AppProviderContainerSerializer(serializers.Serializer): container_status = serializers.CharField(label=_('Container Status')) container_ports = serializers.ListField(child=serializers.CharField(), label=_('Container Ports')) + +class AppProviderDeploymentSerializer(serializers.ModelSerializer): + status = LabeledChoiceField( + choices=Status.choices, read_only=True, label=_('Status') + ) + + class Meta: + model = AppProviderDeployment + fields = [ + 'id', 'provider', 'publication', 'status', 'task', 'comment', + 'date_start', 'date_finished', 'date_created', 'date_updated', + ] + read_only_fields = [ + 'status', 'task', 'date_start', 'date_finished', + 'date_created', 'date_updated', + ] + + def validate_provider(self, provider): + if not provider.host: + raise serializers.ValidationError(_('Provider host is required before deployment')) + if provider.runtime_type != AppProvider.RuntimeType.docker: + raise serializers.ValidationError(_('Only Docker runtime deployment is currently supported')) + return provider + + def validate(self, attrs): + attrs = super().validate(attrs) + provider = attrs.get('provider') + publication = attrs.get('publication') + if publication and publication.provider_id != provider.id: + raise serializers.ValidationError( + {'publication': _('Publication does not belong to this provider')} + ) + return attrs diff --git a/apps/terminal/tasks.py b/apps/terminal/tasks.py index f1d2978a5..7646c9ca8 100644 --- a/apps/terminal/tasks.py +++ b/apps/terminal/tasks.py @@ -19,7 +19,7 @@ from .backends import server_replay_storage from .const import ReplayStorageType, CommandStorageType from .models import ( Status, Session, Task, AppletHostDeployment, - AppletHost, ReplayStorage, CommandStorage + AppletHost, AppProviderDeployment, ReplayStorage, CommandStorage ) from .notifications import StorageConnectivityMessage @@ -133,6 +133,17 @@ def run_applet_host_deployment(did, install_applets): deployment.start(install_applets=install_applets) +@shared_task( + verbose_name=_('Run app provider deployment'), + activity_callback=lambda self, did, *args, **kwargs: ([did],), + description=_('Deploy the Panda runtime on an application provider host'), +) +def run_app_provider_deployment(did): + with tmp_to_builtin_org(system=1): + deployment = AppProviderDeployment.objects.get(id=did) + deployment.start() + + @shared_task( verbose_name=_('Install applet'), activity_callback=lambda self, ids, applet_id, *args, **kwargs: (ids,), diff --git a/apps/terminal/tests.py b/apps/terminal/tests.py index d8f292100..3a3ee0558 100644 --- a/apps/terminal/tests.py +++ b/apps/terminal/tests.py @@ -1,10 +1,15 @@ import os import tempfile +from unittest import mock from django.test import SimpleTestCase +from django.test.utils import override_settings +import yaml from assets.utils.platform_package import locate_package_root -from terminal.models import Applet, VirtualApp +from terminal.models import Applet, AppProvider, VirtualApp +from terminal.const import ComponentLoad +from terminal.automations.deploy_app_provider import DeployAppProviderManager class PackageRootLocateTests(SimpleTestCase): @@ -49,3 +54,202 @@ class PackageRootLocateTests(SimpleTestCase): root = VirtualApp.locate_pkg_root(extract_to, 'demo.zip') self.assertEqual(root, extract_to) + + +class VirtualAppProviderSelectionTests(SimpleTestCase): + def setUp(self): + self.app = VirtualApp(id='00000000-0000-0000-0000-000000000001', name='demo') + self.user = mock.Mock(id='00000000-0000-0000-0000-000000000002') + + @mock.patch('terminal.models.virtualapp.virtualapp.cache') + def test_select_provider_prefers_previous_available_provider(self, mocked_cache): + provider1 = mock.Mock( + id='00000000-0000-0000-0000-000000000003', + load=ComponentLoad.normal, + container_count=0, + ) + provider2 = mock.Mock( + id='00000000-0000-0000-0000-000000000004', + load=ComponentLoad.normal, + container_count=1, + ) + mocked_cache.get.return_value = str(provider2.id) + self.app.filter_available_providers = mock.Mock(return_value=[provider1, provider2]) + + selected = self.app.select_provider(self.user) + + self.assertIs(selected, provider2) + + @mock.patch('terminal.models.virtualapp.virtualapp.cache') + def test_select_provider_does_not_prefer_degraded_provider(self, mocked_cache): + normal = mock.Mock( + id='00000000-0000-0000-0000-000000000003', + load=ComponentLoad.normal, + container_count=1, + ) + preferred_high = mock.Mock( + id='00000000-0000-0000-0000-000000000004', + load=ComponentLoad.high, + container_count=0, + ) + mocked_cache.get.return_value = str(preferred_high.id) + self.app.filter_available_providers = mock.Mock( + return_value=[preferred_high, normal] + ) + + selected = self.app.select_provider(self.user) + + self.assertIs(selected, normal) + + @mock.patch('terminal.models.virtualapp.virtualapp.cache') + def test_select_provider_uses_lower_load_then_container_count(self, mocked_cache): + high = mock.Mock( + id='00000000-0000-0000-0000-000000000003', + load=ComponentLoad.high, + container_count=0, + ) + normal_busy = mock.Mock( + id='00000000-0000-0000-0000-000000000004', + load=ComponentLoad.normal, + container_count=2, + ) + normal_idle = mock.Mock( + id='00000000-0000-0000-0000-000000000005', + load=ComponentLoad.normal, + container_count=0, + ) + mocked_cache.get.return_value = None + self.app.filter_available_providers = mock.Mock( + return_value=[high, normal_busy, normal_idle] + ) + + selected = self.app.select_provider(self.user) + + self.assertIs(selected, normal_idle) + + def test_filter_available_providers_requires_success_and_online(self): + online = mock.Mock(load=ComponentLoad.normal, connection_ready=True) + offline = mock.Mock(load=ComponentLoad.offline, connection_ready=True) + publications = mock.Mock() + publications.select_related.return_value = [ + mock.Mock(provider=online), + mock.Mock(provider=offline), + ] + publication_manager = mock.Mock() + publication_manager.filter.return_value = publications + with mock.patch.object( + VirtualApp, 'publications', new=mock.PropertyMock(return_value=publication_manager) + ): + providers = self.app.filter_available_providers() + + self.assertEqual(providers, [online]) + + def test_filter_available_providers_excludes_unready_ssh_provider(self): + ready = mock.Mock(load=ComponentLoad.normal, connection_ready=True) + unready = mock.Mock(load=ComponentLoad.normal, connection_ready=False) + publications = mock.Mock() + publications.select_related.return_value = [ + mock.Mock(provider=ready), mock.Mock(provider=unready), + ] + publication_manager = mock.Mock() + publication_manager.filter.return_value = publications + with mock.patch.object( + VirtualApp, 'publications', new=mock.PropertyMock(return_value=publication_manager) + ): + providers = self.app.filter_available_providers() + + self.assertEqual(providers, [ready]) + + +class AppProviderRuntimeTests(SimpleTestCase): + def test_address_falls_back_to_legacy_hostname(self): + provider = AppProvider(hostname='192.0.2.10') + + self.assertEqual(provider.address, '192.0.2.10') + + def test_address_uses_bound_host(self): + provider = AppProvider(hostname='legacy-address') + provider.__dict__['host_id'] = '00000000-0000-0000-0000-000000000001' + host = mock.Mock(address='198.51.100.10') + with mock.patch.object( + AppProvider, 'host', new=mock.PropertyMock(return_value=host) + ): + self.assertEqual(provider.address, '198.51.100.10') + + @mock.patch('terminal.models.virtualapp.provider.cache') + def test_container_count_uses_reported_provider_status(self, mocked_cache): + provider = AppProvider(id='00000000-0000-0000-0000-000000000001') + mocked_cache.get.return_value = [{'id': 'one'}, {'id': 'two'}] + + self.assertEqual(provider.container_count, 2) + + def test_ssh_provider_requires_host_ssh_protocol_and_account(self): + provider = AppProvider(connection_mode=AppProvider.ConnectionMode.ssh) + provider.__dict__['host_id'] = '00000000-0000-0000-0000-000000000001' + host = mock.Mock() + host.protocols.filter.return_value.exists.return_value = True + host.accounts.active.return_value.order_by.return_value.first.return_value = mock.Mock() + with mock.patch.object( + AppProvider, 'host', new=mock.PropertyMock(return_value=host) + ): + self.assertTrue(provider.connection_ready) + + host.accounts.active.return_value.order_by.return_value.first.return_value = None + with mock.patch.object( + AppProvider, 'host', new=mock.PropertyMock(return_value=host) + ): + self.assertFalse(provider.connection_ready) + + +class AppProviderDeploymentTests(SimpleTestCase): + @override_settings( + SITE_URL='https://core.example.com', BOOTSTRAP_TOKEN='bootstrap-test', + DEBUG_DEV=True, + ) + def test_generated_playbook_binds_panda_to_provider(self): + provider = mock.Mock( + id='00000000-0000-0000-0000-000000000010', + host=mock.Mock(address='192.0.2.10'), + service_url='', + deploy_options={ + 'PANDA_IMAGE': 'jumpserver/panda:test', + 'PANDA_RANGE_PORTS': '7000-7100', + }, + ) + provider.name = 'provider-one' + deployment = mock.Mock(provider=provider, publication_id=None) + with tempfile.TemporaryDirectory() as ansible_dir, override_settings( + ANSIBLE_DIR=ansible_dir + ): + manager = DeployAppProviderManager(deployment) + path = manager.generate_playbook() + with open(path) as stream: + play = yaml.safe_load(stream)[0] + + variables = play['vars'] + self.assertEqual(variables['PROVIDER_ID'], str(provider.id)) + self.assertEqual(variables['PANDA_HOST_IP'], '192.0.2.10') + self.assertEqual(variables['PANDA_IMAGE'], 'jumpserver/panda:test') + self.assertEqual(provider.service_url, 'http://192.0.2.10:9001') + + @override_settings(SITE_URL='https://core.example.com', BOOTSTRAP_TOKEN='token') + def test_publish_playbook_pulls_virtual_app_image(self): + provider = mock.Mock( + id='00000000-0000-0000-0000-000000000010', + host=mock.Mock(address='192.0.2.10'), service_url='http://192.0.2.10:9001', + deploy_options={}, + ) + provider.name = 'provider-one' + publication = mock.Mock(app=mock.Mock(image_name='example/app:v1')) + deployment = mock.Mock( + provider=provider, publication_id='publication-id', publication=publication, + ) + with tempfile.TemporaryDirectory() as ansible_dir, override_settings( + ANSIBLE_DIR=ansible_dir + ): + path = DeployAppProviderManager(deployment).generate_playbook() + with open(path) as stream: + play = yaml.safe_load(stream)[0] + + self.assertEqual(play['vars']['APP_IMAGE'], 'example/app:v1') + self.assertEqual(len(play['tasks']), 1) diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index 663d44d2d..de3c48b46 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -30,6 +30,7 @@ router.register(r'applet-publications', api.AppletPublicationViewSet, 'applet-pu router.register(r'applet-host-deployments', api.AppletHostDeploymentViewSet, 'applet-host-deployment') router.register(r'virtual-apps', api.VirtualAppViewSet, 'virtual-app') router.register(r'app-providers', api.AppProviderViewSet, 'app-provider') +router.register(r'app-provider-deployments', api.AppProviderDeploymentViewSet, 'app-provider-deployment') router.register(r'app-providers/((?P[^/.]+)/)?apps', api.AppProviderAppViewSet, 'app-provider-app') router.register(r'virtual-app-publications', api.VirtualAppPublicationViewSet, 'virtual-app-publication')