|
|
|
|
import os.path
|
|
|
|
|
import random
|
|
|
|
|
import shutil
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
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.translation import gettext_lazy as _
|
|
|
|
|
from rest_framework.serializers import ValidationError
|
|
|
|
|
|
|
|
|
|
from assets.models import Platform
|
|
|
|
|
from common.db.models import JMSBaseModel
|
|
|
|
|
from common.utils import lazyproperty, get_logger
|
|
|
|
|
from common.utils.yml import yaml_load_with_i18n
|
|
|
|
|
from terminal.const import PublishStatus
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
__all__ = ['Applet', 'AppletPublication']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Applet(JMSBaseModel):
|
|
|
|
|
class Type(models.TextChoices):
|
|
|
|
|
general = 'general', _('General')
|
|
|
|
|
web = 'web', _('Web')
|
|
|
|
|
|
|
|
|
|
class Edition(models.TextChoices):
|
|
|
|
|
community = 'community', _('Community edition')
|
|
|
|
|
enterprise = 'enterprise', _('Enterprise')
|
|
|
|
|
|
|
|
|
|
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'))
|
|
|
|
|
edition = models.CharField(max_length=128, choices=Edition.choices, default=Edition.community,
|
|
|
|
|
verbose_name=_('Edition'))
|
|
|
|
|
type = models.CharField(max_length=16, verbose_name=_('Type'), default='general', choices=Type.choices)
|
|
|
|
|
is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
|
|
|
|
|
builtin = models.BooleanField(default=False, verbose_name=_('Builtin'))
|
|
|
|
|
protocols = models.JSONField(default=list, verbose_name=_('Protocol'))
|
|
|
|
|
can_concurrent = models.BooleanField(default=False, verbose_name=_('Can concurrent'))
|
|
|
|
|
tags = models.JSONField(default=list, verbose_name=_('Tags'))
|
|
|
|
|
comment = models.TextField(default='', blank=True, verbose_name=_('Comment'))
|
|
|
|
|
hosts = models.ManyToManyField(
|
|
|
|
|
through_fields=('applet', 'host'), through='AppletPublication',
|
|
|
|
|
to='AppletHost', verbose_name=_('Hosts')
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
verbose_name = _("Applet")
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def path(self):
|
|
|
|
|
if self.builtin:
|
|
|
|
|
return os.path.join(settings.APPS_DIR, 'terminal', 'applets', self.name)
|
|
|
|
|
else:
|
|
|
|
|
return default_storage.path('applets/{}'.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 manifest(self):
|
|
|
|
|
path = os.path.join(self.path, 'manifest.yml')
|
|
|
|
|
if not os.path.exists(path):
|
|
|
|
|
return None
|
|
|
|
|
with open(path, 'r') as f:
|
|
|
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
|
|
|
|
@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, 'applets', self.name, 'icon.png')
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def validate_pkg(d):
|
|
|
|
|
files = ['manifest.yml', 'icon.png', 'setup.yml']
|
|
|
|
|
for name in files:
|
|
|
|
|
path = os.path.join(d, name)
|
|
|
|
|
if not os.path.exists(path):
|
|
|
|
|
raise ValidationError({'error': _('Applet pkg not valid, Missing file {}').format(name)})
|
|
|
|
|
|
|
|
|
|
with open(os.path.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
|
|
|
|
|
|
|
|
|
|
def load_platform_if_need(self, d):
|
|
|
|
|
from assets.serializers import PlatformSerializer
|
|
|
|
|
from assets.const import CustomTypes
|
|
|
|
|
|
|
|
|
|
if not os.path.exists(os.path.join(d, 'platform.yml')):
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
with open(os.path.join(d, 'platform.yml'), encoding='utf8') as f:
|
|
|
|
|
data = yaml_load_with_i18n(f)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise ValidationError({'error': _('Load platform.yml failed: {}').format(e)})
|
|
|
|
|
|
|
|
|
|
if data['category'] != 'custom':
|
|
|
|
|
raise ValidationError({'error': _('Only support custom platform')})
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
tp = data['type']
|
|
|
|
|
except KeyError:
|
|
|
|
|
raise ValidationError({'error': _('Missing type in platform.yml')})
|
|
|
|
|
|
|
|
|
|
if not data.get('automation'):
|
|
|
|
|
data['automation'] = CustomTypes._get_automation_constrains()['*']
|
|
|
|
|
|
|
|
|
|
created_by = 'Applet:{}'.format(self.name)
|
|
|
|
|
instance = self.get_related_platform()
|
|
|
|
|
s = PlatformSerializer(data=data, instance=instance)
|
|
|
|
|
s.add_type_choices(tp, tp)
|
|
|
|
|
s.is_valid(raise_exception=True)
|
|
|
|
|
p = s.save()
|
|
|
|
|
p.created_by = created_by
|
|
|
|
|
p.save(update_fields=['created_by'])
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def install_from_dir(cls, path, builtin=True):
|
|
|
|
|
from terminal.serializers import AppletSerializer
|
|
|
|
|
|
|
|
|
|
manifest = cls.validate_pkg(path)
|
|
|
|
|
name = manifest['name']
|
|
|
|
|
instance = cls.objects.filter(name=name).first()
|
|
|
|
|
serializer = AppletSerializer(instance=instance, data=manifest)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
instance = serializer.save(builtin=builtin)
|
|
|
|
|
instance.load_platform_if_need(path)
|
|
|
|
|
|
|
|
|
|
pkg_path = default_storage.path('applets/{}'.format(name))
|
|
|
|
|
if os.path.exists(pkg_path):
|
|
|
|
|
shutil.rmtree(pkg_path)
|
|
|
|
|
shutil.copytree(path, pkg_path)
|
|
|
|
|
return instance, serializer
|
|
|
|
|
|
|
|
|
|
host_prefer_key_tpl = 'applet_host_prefer_{}'
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def clear_host_prefer(cls):
|
|
|
|
|
cache.delete_pattern(cls.host_prefer_key_tpl.format('*'))
|
|
|
|
|
|
|
|
|
|
def _select_by_load(self, hosts):
|
|
|
|
|
using_keys = cache.keys(self.host_prefer_key_tpl.format('*'))
|
|
|
|
|
using_host_ids = cache.get_many(using_keys)
|
|
|
|
|
counts = defaultdict(int)
|
|
|
|
|
for host_id in using_host_ids.values():
|
|
|
|
|
counts[host_id] += 1
|
|
|
|
|
|
|
|
|
|
hosts = list(sorted(hosts, key=lambda h: counts[str(h.id)]))
|
|
|
|
|
return hosts[0] if hosts else None
|
|
|
|
|
|
|
|
|
|
def select_host(self, user, asset):
|
|
|
|
|
hosts = self.hosts.filter(is_active=True)
|
|
|
|
|
hosts = [host for host in hosts if host.load != 'offline']
|
|
|
|
|
if not hosts:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
spec_label_values = asset.get_labels().filter(
|
|
|
|
|
name__in=['AppletHost', '发布机']
|
|
|
|
|
).values_list('value', flat=True)
|
|
|
|
|
host_matched = [host for host in hosts if host.name in spec_label_values]
|
|
|
|
|
if host_matched:
|
|
|
|
|
return random.choice(host_matched)
|
|
|
|
|
|
|
|
|
|
hosts = [h for h in hosts if h.auto_create_accounts]
|
|
|
|
|
prefer_key = self.host_prefer_key_tpl.format(user.id)
|
|
|
|
|
prefer_host_id = cache.get(prefer_key, None)
|
|
|
|
|
pref_host = [host for host in hosts if host.id == prefer_host_id]
|
|
|
|
|
|
|
|
|
|
if pref_host:
|
|
|
|
|
host = pref_host[0]
|
|
|
|
|
else:
|
|
|
|
|
host = self._select_by_load(hosts)
|
|
|
|
|
if host is None:
|
|
|
|
|
return
|
|
|
|
|
cache.set(prefer_key, str(host.id), timeout=None)
|
|
|
|
|
return host
|
|
|
|
|
|
|
|
|
|
def get_related_platform(self):
|
|
|
|
|
created_by = 'Applet:{}'.format(self.name)
|
|
|
|
|
platform = Platform.objects.filter(created_by=created_by).first()
|
|
|
|
|
return platform
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def random_select_prefer_account(user, host, accounts):
|
|
|
|
|
msg = 'Applet host remain public accounts: {}: {}'.format(host.name, len(accounts))
|
|
|
|
|
if len(accounts) == 0:
|
|
|
|
|
logger.error(msg)
|
|
|
|
|
return None
|
|
|
|
|
prefer_host_account_key = 'applet_host_prefer_account_{}_{}'.format(user.id, host.id)
|
|
|
|
|
prefer_account_id = cache.get(prefer_host_account_key, None)
|
|
|
|
|
prefer_account = None
|
|
|
|
|
if prefer_account_id:
|
|
|
|
|
prefer_account = accounts.filter(id=prefer_account_id).first()
|
|
|
|
|
if prefer_account:
|
|
|
|
|
account = prefer_account
|
|
|
|
|
else:
|
|
|
|
|
account = random.choice(accounts)
|
|
|
|
|
cache.set(prefer_host_account_key, account.id, timeout=None)
|
|
|
|
|
return account
|
|
|
|
|
|
|
|
|
|
accounts_using_key_tmpl = 'applet_host_accounts_{}_{}_{}'
|
|
|
|
|
|
|
|
|
|
def select_a_public_account(self, user, host, valid_accounts):
|
|
|
|
|
using_keys = cache.keys(self.accounts_using_key_tmpl.format(host.id, '*', '*')) or []
|
|
|
|
|
accounts_username_used = list(cache.get_many(using_keys).values())
|
|
|
|
|
logger.debug('Applet host account using: {}: {}'.format(host.name, accounts_username_used))
|
|
|
|
|
accounts = valid_accounts.exclude(username__in=accounts_username_used)
|
|
|
|
|
public_accounts = accounts.filter(username__startswith='jms_')
|
|
|
|
|
if not public_accounts:
|
|
|
|
|
public_accounts = accounts \
|
|
|
|
|
.exclude(username__in=['Administrator', 'root']) \
|
|
|
|
|
.exclude(username__startswith='js_')
|
|
|
|
|
account = self.random_select_prefer_account(user, host, public_accounts)
|
|
|
|
|
return account
|
|
|
|
|
|
|
|
|
|
def try_to_use_private_account(self, user, host, valid_accounts):
|
|
|
|
|
host_can_concurrent = str(host.deploy_options.get('RDS_fSingleSessionPerUser', 0)) == '0'
|
|
|
|
|
app_can_concurrent = self.can_concurrent or self.type == 'web'
|
|
|
|
|
all_can_concurrent = host_can_concurrent and app_can_concurrent
|
|
|
|
|
|
|
|
|
|
private_account = valid_accounts.filter(username='js_{}'.format(user.username)).first()
|
|
|
|
|
if not private_account:
|
|
|
|
|
logger.debug('Private account not found ...')
|
|
|
|
|
return None
|
|
|
|
|
# 优先使用 private account,支持并发或者不支持并发时,如果私有没有被占用,则使用私有
|
|
|
|
|
account = None
|
|
|
|
|
# 如果都支持,不管私有是否被占用,都使用私有
|
|
|
|
|
if all_can_concurrent:
|
|
|
|
|
logger.debug('All can concurrent, use private account')
|
|
|
|
|
account = private_account
|
|
|
|
|
# 如果主机都不支持并发,则查询一下私有账号有没有任何应用使用,如果没有被使用,则使用私有
|
|
|
|
|
elif not host_can_concurrent:
|
|
|
|
|
private_using_key = self.accounts_using_key_tmpl.format(host.id, private_account.username, '*')
|
|
|
|
|
private_is_using = len(cache.keys(private_using_key))
|
|
|
|
|
logger.debug("Private account is using: {}".format(private_is_using))
|
|
|
|
|
if not private_is_using:
|
|
|
|
|
account = private_account
|
|
|
|
|
# 如果主机支持,但是应用不支持并发,则查询一下私有账号有没有被这个应用使用, 如果没有被使用,则使用私有
|
|
|
|
|
elif host_can_concurrent and not app_can_concurrent:
|
|
|
|
|
private_app_using_key = self.accounts_using_key_tmpl.format(host.id, private_account.username, self.name)
|
|
|
|
|
private_is_using_by_this_app = cache.get(private_app_using_key, False)
|
|
|
|
|
logger.debug("Private account is using {} by {}".format(private_is_using_by_this_app, self.name))
|
|
|
|
|
if not private_is_using_by_this_app:
|
|
|
|
|
account = private_account
|
|
|
|
|
return account
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def try_to_use_same_account(user, host):
|
|
|
|
|
from accounts.models import VirtualAccount
|
|
|
|
|
|
|
|
|
|
if not host.using_same_account:
|
|
|
|
|
return
|
|
|
|
|
account = VirtualAccount.get_same_account(user, host)
|
|
|
|
|
if not account.secret:
|
|
|
|
|
return
|
|
|
|
|
return account
|
|
|
|
|
|
|
|
|
|
def select_host_account(self, user, asset):
|
|
|
|
|
# 选择激活的发布机
|
|
|
|
|
host = self.select_host(user, asset)
|
|
|
|
|
if not host:
|
|
|
|
|
return None
|
|
|
|
|
logger.info('Select applet host: {}'.format(host.name))
|
|
|
|
|
if not self.is_available_on_host(host):
|
|
|
|
|
logger.debug('No available applet {} for applet host: {}'.format(self.name, host.name))
|
|
|
|
|
return None
|
|
|
|
|
valid_accounts = host.accounts.all().filter(is_active=True, privileged=False)
|
|
|
|
|
account = self.try_to_use_same_account(user, host)
|
|
|
|
|
if not account:
|
|
|
|
|
logger.debug('No same account, try to use private account')
|
|
|
|
|
account = self.try_to_use_private_account(user, host, valid_accounts)
|
|
|
|
|
|
|
|
|
|
if not account:
|
|
|
|
|
logger.debug('No private account, try to use public account')
|
|
|
|
|
account = self.select_a_public_account(user, host, valid_accounts)
|
|
|
|
|
|
|
|
|
|
if not account:
|
|
|
|
|
logger.debug('No available account for applet host: {}'.format(host.name))
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
ttl = 60 * 60 * 24
|
|
|
|
|
lock_key = self.accounts_using_key_tmpl.format(host.id, account.username, self.name)
|
|
|
|
|
cache.set(lock_key, account.username, ttl)
|
|
|
|
|
|
|
|
|
|
res = {
|
|
|
|
|
'host': host,
|
|
|
|
|
'account': account,
|
|
|
|
|
'lock_key': lock_key
|
|
|
|
|
}
|
|
|
|
|
logger.debug('Select host and account: {}-{}'.format(host.name, account.username))
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
|
def delete(self, using=None, keep_parents=False):
|
|
|
|
|
platform = self.get_related_platform()
|
|
|
|
|
if platform and platform.assets.count() == 0:
|
|
|
|
|
platform.delete()
|
|
|
|
|
return super().delete(using, keep_parents)
|
|
|
|
|
|
|
|
|
|
def is_available_on_host(self, host):
|
|
|
|
|
publication = AppletPublication.objects.filter(applet=self, host=host).first()
|
|
|
|
|
if not publication:
|
|
|
|
|
return False
|
|
|
|
|
if publication.status in [PublishStatus.pending, PublishStatus.failed]:
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AppletPublication(JMSBaseModel):
|
|
|
|
|
applet = models.ForeignKey('Applet', on_delete=models.CASCADE, related_name='publications',
|
|
|
|
|
verbose_name=_('Applet'))
|
|
|
|
|
host = models.ForeignKey('AppletHost', on_delete=models.CASCADE, related_name='publications',
|
|
|
|
|
verbose_name=_('Hosting'))
|
|
|
|
|
status = models.CharField(max_length=16, default='pending', verbose_name=_('Status'))
|
|
|
|
|
comment = models.TextField(default='', blank=True, verbose_name=_('Comment'))
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
unique_together = ('applet', 'host')
|
|
|
|
|
verbose_name = _("Applet Publication")
|