webappusers in new servers

edited 2023/11/24 by pedro
This commit is contained in:
jorgepastorr 2023-07-24 17:39:18 +02:00 committed by Marc Aymerich
parent 4b02ba15c5
commit d76f211d99
12 changed files with 454 additions and 56 deletions

View File

@ -9,8 +9,8 @@ from orchestra.contrib.accounts.filters import IsActiveListFilter
from .actions import set_permission, create_link from .actions import set_permission, create_link
from .filters import IsMainListFilter from .filters import IsMainListFilter
from .forms import SystemUserCreationForm, SystemUserChangeForm from .forms import SystemUserCreationForm, SystemUserChangeForm, WebappUserChangeForm, WebappUserCreationForm
from .models import SystemUser from .models import SystemUser, WebappUsers
class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
@ -78,4 +78,34 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende
return super(SystemUserAdmin, self).has_delete_permission(request, obj) return super(SystemUserAdmin, self).has_delete_permission(request, obj)
class WebappUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = (
'username', 'account_link', 'shell', 'home', 'target_server'
)
fieldsets = (
(None, {
'fields': ('account_link', 'username', 'password', )
}),
(_("System"), {
'fields': ('shell', 'home', 'target_server'),
}),
)
add_fieldsets = (
(None, {
'fields': ('account_link', 'username', 'password1', 'password2')
}),
(_("System"), {
'fields': ('shell', 'home', 'target_server'),
}),
)
search_fields = ('username', 'account__username')
readonly_fields = ('account_link',)
change_readonly_fields = ('username', 'home', 'target_server')
add_form = WebappUserCreationForm
form = WebappUserChangeForm
ordering = ('-id',)
admin.site.register(SystemUser, SystemUserAdmin) admin.site.register(SystemUser, SystemUserAdmin)
admin.site.register(WebappUsers, WebappUserAdmin)

View File

@ -2,6 +2,7 @@ import sys
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_migrate from django.db.models.signals import post_migrate
from django.utils.translation import gettext_lazy as _
from orchestra.core import services from orchestra.core import services
@ -11,11 +12,12 @@ class SystemUsersConfig(AppConfig):
verbose_name = "System users" verbose_name = "System users"
def ready(self): def ready(self):
from .models import SystemUser from .models import SystemUser, WebappUsers
services.register(SystemUser, icon='roleplaying.png') services.register(SystemUser, icon='roleplaying.png')
if 'migrate' in sys.argv and 'accounts' not in sys.argv: if 'migrate' in sys.argv and 'accounts' not in sys.argv:
post_migrate.connect(self.create_initial_systemuser, post_migrate.connect(self.create_initial_systemuser,
dispatch_uid="orchestra.contrib.systemusers.apps.create_initial_systemuser") dispatch_uid="orchestra.contrib.systemusers.apps.create_initial_systemuser")
services.register(WebappUsers, icon='roleplaying.png', verbose_name =_('WebApp User'), verbose_name_plural=_("Webapp users"))
def create_initial_systemuser(self, **kwargs): def create_initial_systemuser(self, **kwargs):
from .models import SystemUser from .models import SystemUser

View File

@ -554,44 +554,7 @@ class UNIXUserControllerNewServers(ServiceController):
done done
""") % context """) % context
) )
else:
self.append(textwrap.dedent("""
check_code=0
# Ensure no processes running as user to modify/create
if ps -u %(user)s &> /dev/null; then
pkill -u %(user)s; sleep 3;
pkill -9 -u %(user)s; sleep 2;
fi
# Update/create user state for %(user)s
if id %(user)s &> /dev/null; then
usermod %(user)s \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s' || check_code=$?
else
useradd %(user)s --home '/%(user)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s' || check_code=$?
fi
if [[ $check_code -ne 0 ]]; then
exit check_code
fi
# Ensure homedir exists and has correct perms
mkdir -p %(home)s
chown %(user)s:%(user)s %(home)s
chmod 750 %(home)s
# Create /chroots/$uid symlink into /home/$user.parent/webapps/
uid=$(id -u "%(user)s")
ln -n -f -s %(mainuser_home)s/webapps /chroots/$uid
""") % context
)
for member in settings.SYSTEMUSERS_DEFAULT_GROUP_MEMBERS: for member in settings.SYSTEMUSERS_DEFAULT_GROUP_MEMBERS:
context['member'] = member context['member'] = member
self.append('usermod -a -G %(user)s %(member)s || exit_code=$?' % context) self.append('usermod -a -G %(user)s %(member)s || exit_code=$?' % context)
@ -739,8 +702,6 @@ class UNIXUserControllerNewServers(ServiceController):
if user.is_main: if user.is_main:
groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True)) groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True))
groups.append("main-systemusers") groups.append("main-systemusers")
# groups = list(user.groups.values_list('username', flat=True))
# groups.append("webapp-systemusers")
return groups return groups
def get_context(self, user): def get_context(self, user):
@ -756,5 +717,116 @@ class UNIXUserControllerNewServers(ServiceController):
'base_home': user.get_base_home(), 'base_home': user.get_base_home(),
'mainuser_home': user.main.get_home(), 'mainuser_home': user.main.get_home(),
} }
# context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context
return replace(context, "'", '"')
class WebappUserController(ServiceController):
"""
Basic UNIX system user/group support based on <tt>useradd</tt>, <tt>usermod</tt>, <tt>userdel</tt> and <tt>groupdel</tt>.
Autodetects and uses ACL if available, for better permission management.
"""
verbose_name = _("SFTP Webapp user")
model = 'systemusers.WebappUsers'
actions = ('save', 'delete',)
doc_settings = (settings, (
'SYSTEMUSERS_DEFAULT_GROUP_MEMBERS',
'SYSTEMUSERS_MOVE_ON_DELETE_PATH',
'SYSTEMUSERS_FORBIDDEN_PATHS'
))
def save(self, user):
context = self.get_context(user)
if not context['user']:
return
self.append(textwrap.dedent("""
# Update/create user state for %(user)s
if id %(user)s &> /dev/null; then
usermod %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s'
else
useradd_code=0
useradd %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s' || useradd_code=$?
if [[ $useradd_code -eq 8 ]]; then
# User is logged in, kill and retry
pkill -u %(user)s; sleep 2
pkill -9 -u %(user)s; sleep 1
useradd %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s'
elif [[ $useradd_code -ne 0 ]]; then
exit $useradd_code
fi
fi
usermod -aG %(user)s %(parent)s
# Ensure homedir exists and has correct perms
mkdir -p '%(webapp_path)s' || exit_code=1
chown %(user)s:%(user)s %(webapp_path)s || exit_code=1
chmod 750 '%(webapp_path)s' || exit_code=1
# Create /chroots/$uid symlink into /home/$user.parent/webapps/
uid=$(id -u "%(user)s")
ln -n -f -s %(base_home)s/webapps /chroots/$uid || exit_code=1
""") % context
)
def delete(self, user):
context = self.get_context(user)
if not context['user']:
return
self.append(textwrap.dedent("""\
# Delete %(user)s user
uid=$(id -u "%(user)s")
nohup bash -c 'sleep 2 && killall -u %(user)s -s KILL' &> /dev/null &
killall -u %(user)s || true
userdel %(user)s || exit_code=$?
groupdel %(group)s || exit_code=$?
# Delete /chroots/$uid symlink into /home/$user.parent/webapps/
rm /chroots/$uid
""") % context
)
if context['deleted_home']:
self.append(textwrap.dedent("""\
# Move home into SYSTEMUSERS_MOVE_ON_DELETE_PATH, nesting if exists.
mv '%(webapp_path)s' '%(deleted_home)s' || exit_code=$?
""") % context
)
else:
self.append("rm -fr -- '%(webapp_path)s'" % context)
def get_groups(self, user):
groups = []
groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True))
groups.append("webapp-systemusers")
return groups
def get_context(self, user):
context = {
'object_id': user.pk,
'user': user.username,
'group': user.username,
'groups': ','.join(self.get_groups(user)),
'password': user.password, #if user.active else '*%s' % user.password,
'shell': user.shell,
'home': user.home,
'base_home': user.get_base_home(),
'webapp_path': os.path.normpath(user.get_base_home() + "/webapps/" + user.home),
'parent': user.get_parent(),
}
context['deleted_home'] = context['webapp_path'] + ".delete"
return replace(context, "'", '"') return replace(context, "'", '"')

View File

@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from orchestra.forms import UserCreationForm, UserChangeForm from orchestra.forms import UserCreationForm, UserChangeForm
from orchestra.contrib.webapps.settings import WEBAPP_NEW_SERVERS
from . import settings from . import settings
from .models import SystemUser from .models import SystemUser
@ -162,3 +163,27 @@ class PermissionForm(LinkForm):
('r', _("Read only")), ('r', _("Read only")),
('w', _("Write only")) ('w', _("Write only"))
)) ))
# ----------------------------
class WebappUserFormMixin(object):
def __init__(self, *args, **kwargs):
super(WebappUserFormMixin, self).__init__(*args, **kwargs)
def clean(self):
if not self.instance.pk:
server = self.cleaned_data.get('target_server')
if server:
if server.name not in WEBAPP_NEW_SERVERS:
self.add_error("target_server", _(f"{server} does not belong to the new servers"))
return self.cleaned_data
class WebappUserCreationForm(WebappUserFormMixin, UserCreationForm):
pass
class WebappUserChangeForm(WebappUserFormMixin, UserChangeForm):
pass

View File

@ -1,30 +1,32 @@
# -*- coding: utf-8 -*- # Generated by Django 2.2.28 on 2023-07-22 08:04
from __future__ import unicode_literals
from django.db import models, migrations
import orchestra.core.validators
from django.conf import settings from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True
dependencies = [ dependencies = [
# migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='SystemUser', name='SystemUser',
fields=[ fields=[
('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(validators=[orchestra.core.validators.validate_username], unique=True, help_text='Required. 64 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, verbose_name='username')), ('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, max_length=256, help_text='Starting location when login with this no-shell user.', verbose_name='home')), ('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('directory', models.CharField(blank=True, max_length=256, help_text="Optional directory relative to user's home.", verbose_name='directory')), ('directory', models.CharField(blank=True, help_text="Optional directory relative to user's home.", max_length=256, verbose_name='directory')),
('shell', models.CharField(default='/dev/null', max_length=32, choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/bin/bash', '/bin/bash'), ('/bin/sh', '/bin/sh')], verbose_name='shell')), ('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
# ('account', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='systemusers', verbose_name='Account')), ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='systemusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('groups', models.ManyToManyField(to='systemusers.SystemUser', blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?')), ('groups', models.ManyToManyField(blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?', to='systemusers.SystemUser')),
], ],
), ),
] ]

View File

@ -0,0 +1,33 @@
# Generated by Django 2.2.28 on 2023-07-22 08:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('orchestration', '__first__'),
('systemusers', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='WebappUsers',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server')),
],
options={
'unique_together': {('username', 'target_server')},
},
),
]

View File

@ -136,3 +136,43 @@ class SystemUser(models.Model):
def get_home(self): def get_home(self):
return os.path.normpath(os.path.join(self.home, self.directory)) return os.path.normpath(os.path.join(self.home, self.directory))
# ------------------
class WebappUsers(models.Model):
"""
System users for webapp
Username max_length determined by LINUX system user/group lentgh: 32
"""
username = models.CharField(_("username"), max_length=32, unique=True,
help_text=_("Required. 32 characters or fewer. Letters, digits and ./-/_ only."),
validators=[validators.validate_username])
password = models.CharField(_("password"), max_length=128)
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='accounts', on_delete=models.CASCADE)
home = models.CharField(_("WebappDir"), max_length=256, blank=True,
help_text=_("name dir webapp /home/&lt;main&gt;/webapps/&lt;DirName&gt;"),
validators=[validators.validate_string_dir])
shell = models.CharField(_("shell"), max_length=32, choices=settings.WEBAPPUSERS_SHELLS,
default='/dev/null')
target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE,
verbose_name=_("Server"))
class Meta:
unique_together = ('username', 'target_server')
verbose_name = 'WebAppUser'
verbose_name_plural = 'WebappUsers'
def __str__(self):
return self.username
def set_password(self, raw_password):
self.password = make_password(raw_password)
def get_base_home(self):
return os.path.normpath(self.account.main_systemuser.home)
def get_parent(self):
return self.account.main_systemuser

View File

@ -7,6 +7,13 @@ _names = ('user', 'username')
_backend_names = _names + ('group', 'shell', 'mainuser', 'home', 'base_home') _backend_names = _names + ('group', 'shell', 'mainuser', 'home', 'base_home')
WEBAPPUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS',
(
('/dev/null', _("No shell, SFTP only")),
('/bin/bash', "/bin/bash"),
),
)
SYSTEMUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS', SYSTEMUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS',
( (
('/dev/null', _("No shell, FTP only")), ('/dev/null', _("No shell, FTP only")),

View File

@ -0,0 +1,135 @@
import decimal
import textwrap
from orchestra.contrib.orchestration import ServiceController
from orchestra.contrib.resources import ServiceMonitor
from . import settings
class ProxmoxOVZ(ServiceController):
model = 'vps.VPS'
RESOURCES = (
('memory', 'mem'),
('swap', 'swap'),
('disk', 'disk')
)
GET_PROXMOX_INFO = textwrap.dedent("""
function get_vz_info () {
hostname=$1
version=$(pveversion | cut -d '/' -f2 | cut -d'.' -f1)
if [[ $version -lt 2 ]]; then
conf=$(grep "CID\\|:$hostname:" /var/lib/pve-manager/vzlist | grep -B1 ":$hostname:")
CID=$(echo "$conf" | head -n1 | cut -d':' -f2)
CTID=$(echo "$conf" | tail -n1 | cut -d':' -f1)
node=$(pveca -l | grep "^\\s*$CID\\s*:" | awk {'print $3'})
else
conf=$(grep -r "HOSTNAME=\\"$hostname\\"" /etc/pve/nodes/*/openvz/*.conf)
node=$(echo "${conf}" | cut -d"/" -f5)
CTID=$(echo "${conf}" | cut -d"/" -f7 | cut -d"\\." -f1)
fi
echo $CTID $node
}""")
def prepare(self):
super(ProxmoxOVZ, self).prepare()
self.append(self.GET_PROXMOX_INFO)
def get_vzset_args(self, context):
args = list(settings.VPS_DEFAULT_VZSET_ARGS)
for resource, arg_name in self.RESOURCES:
try:
allocation = context[resource]
except KeyError:
pass
else:
args.append('--%s %i' % (arg_name, allocation))
return ' '.join(args)
def run_ssh_commands(self, ssh_commands):
commands = '\n '.join(ssh_commands)
self.append(textwrap.dedent("""\
cat << EOF | ssh root@${info[1]}
%s
EOF""") % commands
)
def save(self, vps):
# TODO create the container
context = self.get_context(vps)
self.append(textwrap.dedent("""
info=( $(get_vz_info %(hostname)s) )
echo "Managing ${info[@]}"\
""") % context
)
ssh_commands = []
vzset_args = self.get_vzset_args(context)
if vzset_args:
context['vzset_args'] = vzset_args
ssh_commands.append("pvectl vzset ${info[0]} %(vzset_args)s" % context)
if hasattr(vps, 'password'):
context['password'] = vps.password.replace('$', '\\$')
ssh_commands.append(textwrap.dedent("""\
echo 'root:%(password)s' \\
| chroot /var/lib/vz/private/${info[0]} chpasswd -e""") % context
)
self.run_ssh_commands(ssh_commands)
def get_context(self, vps):
context = {
'hostname': vps.hostname,
}
for resource, __ in self.RESOURCES:
try:
allocation = getattr(vps.resources, resource).allocated
except AttributeError:
pass
else:
context[resource] = allocation
return context
class ProxmoxOpenVZTraffic(ServiceMonitor):
model = 'vps.VPS'
resource = ServiceMonitor.TRAFFIC
monthly_sum_old_values = True
GET_PROXMOX_INFO = ProxmoxOVZ.GET_PROXMOX_INFO
def prepare(self):
super(ProxmoxOpenVZTraffic, self).prepare()
self.append(self.GET_PROXMOX_INFO)
self.append(textwrap.dedent("""
function monitor () {
object_id=$1
hostname=$2
info=( $(get_vz_info $hostname) )
cat << EOF | ssh root@${info[1]}
vzctl exec ${info[0]} cat /proc/net/dev \\
| grep venet0 \\
| tr ':' ' ' \\
| awk '{sum=\\$2+\\$10} END {printf ("$object_id %0.0f\\n", sum)}'
EOF
}
""")
)
def process(self, line):
""" diff with last stored state """
object_id, value, state = super(ProxmoxOpenVZTraffic, self).process(line)
value = decimal.Decimal(value)
last = self.get_last_data(object_id)
if not last or last.state > value:
return object_id, value, value
return object_id, value-last.state, value
def monitor(self, vps):
""" Get OpenVZ container traffic on a Proxmox cluster """
context = self.get_context(vps)
self.append('monitor %(object_id)s %(hostname)s' % context)
def get_context(self, vps):
return {
'object_id': vps.id,
'hostname': vps.hostname,
}

View File

@ -282,3 +282,11 @@ WEBAPPS_CMS_CACHE_DIR = Setting('WEBAPPS_CMS_CACHE_DIR',
'/tmp/orchestra_cms_cache', '/tmp/orchestra_cms_cache',
help_text="Server-side cache directori for CMS tarballs.", help_text="Server-side cache directori for CMS tarballs.",
) )
WEBAPP_NEW_SERVERS = Setting('WEBAPP_NEW_SERVERS',
(
'bookworm',
'web-11.pangea.lan',
'web-12.pangea.lan',
)
)

View File

@ -177,3 +177,10 @@ def validate_phone(value, country):
raise ValidationError(msg) raise ValidationError(msg)
if not phonenumbers.is_valid_number(number): if not phonenumbers.is_valid_number(number):
raise ValidationError(msg) raise ValidationError(msg)
def validate_string_dir(value):
"""
A single non-empty line of free-form text with no whitespace.
"""
validators.RegexValidator('^[\_\-0-9a-z]+$',
_("Enter a valid name dir (spaceless lowercase text, number and _- )"), 'invalid')(value)

View File

@ -0,0 +1,37 @@
import textwrap
from django.templatetags.static import static
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from orchestra.utils.sys import run
def html_to_pdf(html, pagination=False):
""" converts HTL to PDF using wkhtmltopdf """
context = {
'pagination': textwrap.dedent("""\
--footer-center "Page [page] of [topage]" \\
--footer-font-name sans \\
--footer-font-size 7 \\
--footer-spacing 7"""
) if pagination else '',
}
cmd = textwrap.dedent("""\
PATH=$PATH:/usr/local/bin/
xvfb-run -a -s "-screen 0 2480x3508x16" wkhtmltopdf -q \\
--use-xserver \\
%(pagination)s \\
--margin-bottom 22 \\
--margin-top 20 - - \
""") % context
return run(cmd, stdin=html.encode('utf-8')).stdout
def get_on_site_link(url):
context = {
'title': _("View on site %s") % url,
'url': url,
'image': format_html('<img src="{}"></img>', static('orchestra/images/view-on-site.png')),
}
return format_html('<a href="{url}" title="{title}">{image}</a>', **context)