diff --git a/build_image/docker/agent/k8s-rest-agent/Dockerfile.in b/build_image/docker/agent/k8s-rest-agent/Dockerfile.in new file mode 100644 index 000000000..236debf15 --- /dev/null +++ b/build_image/docker/agent/k8s-rest-agent/Dockerfile.in @@ -0,0 +1,24 @@ +FROM python:3.8 + +COPY src/agent/k8s-rest-agent/requirements.txt / +COPY src/agent/k8s-rest-agent/pip /root/.pip + +RUN pip install -r /requirements.txt + +COPY src/agent/k8s-rest-agent/src /var/www/server +COPY src/agent/k8s-rest-agent/entrypoint.sh / +COPY src/agent/k8s-rest-agent/uwsgi/server.ini /etc/uwsgi/apps-enabled/ +RUN mkdir /var/log/supervisor + +ENV WEBROOT / +ENV WEB_CONCURRENCY 10 +ENV DEBUG False +ENV UWSGI_WORKERS 1 +ENV UWSGI_PROCESSES 1 +ENV UWSGI_OFFLOAD_THREADS 10 +ENV UWSGI_MODULE server.wsgi:application + +WORKDIR /var/www/server +RUN python manage.py collectstatic --noinput + +CMD bash /entrypoint.sh diff --git a/src/agent/k8s-rest-agent/Dockerfile b/src/agent/k8s-rest-agent/Dockerfile new file mode 100644 index 000000000..88e3750e0 --- /dev/null +++ b/src/agent/k8s-rest-agent/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.8 + +COPY requirements.txt / +COPY pip /root/.pip + +RUN pip install -r /requirements.txt + +COPY src /var/www/server +COPY entrypoint.sh / +COPY uwsgi/server.ini /etc/uwsgi/apps-enabled/ +RUN mkdir /var/log/supervisor + +ENV WEBROOT / +ENV WEB_CONCURRENCY 10 +ENV DEBUG False +ENV UWSGI_WORKERS 1 +ENV UWSGI_PROCESSES 1 +ENV UWSGI_OFFLOAD_THREADS 10 +ENV UWSGI_MODULE server.wsgi:application + +WORKDIR /var/www/server +RUN python manage.py collectstatic --noinput + +CMD bash /entrypoint.sh diff --git a/src/agent/k8s-rest-agent/entrypoint.sh b/src/agent/k8s-rest-agent/entrypoint.sh new file mode 100644 index 000000000..9325749ff --- /dev/null +++ b/src/agent/k8s-rest-agent/entrypoint.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +if [[ "$RUN_TYPE" == "SERVER" ]]; then + uwsgi --ini /etc/uwsgi/apps-enabled/server.ini; +else + if [[ "$RUN_TYPE" == "TASK" ]]; then + celery -A server worker --autoscale=20,6 -l info + elif [[ "$RUN_TYPE" == "BEAT_TASK" ]]; then + celery -A server beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile=/opt/celeryd.pid + fi +fi diff --git a/src/agent/k8s-rest-agent/pip/pip.conf b/src/agent/k8s-rest-agent/pip/pip.conf new file mode 100644 index 000000000..1c12d133f --- /dev/null +++ b/src/agent/k8s-rest-agent/pip/pip.conf @@ -0,0 +1,5 @@ +[global] +index-url=http://mirrors.cloud.aliyuncs.com/pypi/simple/ + +[install] +trusted-host=mirrors.cloud.aliyuncs.com diff --git a/src/agent/k8s-rest-agent/requirements.txt b/src/agent/k8s-rest-agent/requirements.txt new file mode 100644 index 000000000..a53c40ac7 --- /dev/null +++ b/src/agent/k8s-rest-agent/requirements.txt @@ -0,0 +1,18 @@ +Django>=3.0 +uwsgi +enum34 +djangorestframework +holdup>1.5.0,<=1.6.0 +drf-yasg<=1.17.0 +swagger_spec_validator<=2.4.1 +psycopg2-binary +celery<5.0,>=4.4 +redis +requests +supervisor +django-celery-beat +django-celery-results +django-3-jet +djangorestframework-jwt<=1.11.0 +python-jwt # 需要安装,否则会出现token解码失败错误 +shortuuid diff --git a/src/agent/k8s-rest-agent/src/api/__init__.py b/src/agent/k8s-rest-agent/src/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/admin.py b/src/agent/k8s-rest-agent/src/api/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/src/agent/k8s-rest-agent/src/api/apps.py b/src/agent/k8s-rest-agent/src/api/apps.py new file mode 100644 index 000000000..14b89a829 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "api" diff --git a/src/agent/k8s-rest-agent/src/api/auth.py b/src/agent/k8s-rest-agent/src/api/auth.py new file mode 100644 index 000000000..3aad1c636 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/auth.py @@ -0,0 +1,67 @@ +import base64 +import json +import logging + +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext as _ +from rest_framework import authentication +from rest_framework import exceptions +from rest_framework_jwt.authentication import ( + JSONWebTokenAuthentication as CoreJSONWebTokenAuthentication, +) +from rest_framework_jwt.settings import api_settings + +jwt_decode_handler = api_settings.JWT_DECODE_HANDLER +jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER +User = get_user_model() + +LOG = logging.getLogger(__name__) + + +class JSONWebTokenAuthentication(CoreJSONWebTokenAuthentication): + @staticmethod + def _get_or_create_user(user_id, payload=None): + if payload is None: + payload = {} + + user, _ = User.objects.get_or_create( + id=user_id, username=user_id, defaults={"password": user_id} + ) + + return user + + def authenticate_credentials(self, payload): + """ + Returns an active user that matches the payload's user id and email. + """ + username = jwt_get_username_from_payload(payload) + + if not username: + msg = _("Invalid payload.") + raise exceptions.AuthenticationFailed(msg) + + user = self._get_or_create_user(username, payload) + + if not user.is_active: + msg = _("User account is disabled.") + raise exceptions.AuthenticationFailed(msg) + + return user + + +class IstioJWTAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + token = request.META.get("HTTP_TOKEN", None) + if token is None: + return None + + token += "=" * (-len(token) % 4) + token = base64.b64decode(token) + token = json.loads(token) + user_id = token.get("sub", None) + if user_id is None: + return None + user, _ = User.objects.get_or_create( + id=user_id, username=user_id, defaults={"password": user_id} + ) + return user, None diff --git a/src/agent/k8s-rest-agent/src/api/management/__init__.py b/src/agent/k8s-rest-agent/src/api/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/management/commands/__init__.py b/src/agent/k8s-rest-agent/src/api/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/management/commands/test_task.py b/src/agent/k8s-rest-agent/src/api/management/commands/test_task.py new file mode 100644 index 000000000..9d8b32f6d --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/management/commands/test_task.py @@ -0,0 +1,14 @@ +from django.core.management import BaseCommand +from api.tasks import example_task +from django_celery_beat.models import IntervalSchedule, PeriodicTask + + +class Command(BaseCommand): + help = "Test Task" + + def handle(self, *args, **options): + interval = IntervalSchedule.objects.first() + PeriodicTask.objects.create( + interval=interval, name="example", task="server.tasks.example_task" + ) + # example_task.delay() diff --git a/src/agent/k8s-rest-agent/src/api/migrations/__init__.py b/src/agent/k8s-rest-agent/src/api/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/models/__init__.py b/src/agent/k8s-rest-agent/src/api/models/__init__.py new file mode 100644 index 000000000..ef4b6058c --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/models/__init__.py @@ -0,0 +1 @@ +from .user import User, Profile diff --git a/src/agent/k8s-rest-agent/src/api/models/user.py b/src/agent/k8s-rest-agent/src/api/models/user.py new file mode 100644 index 000000000..5e9419c0e --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/models/user.py @@ -0,0 +1,42 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.db.models.signals import post_save +from api.utils.db_functions import make_uuid + + +class User(AbstractUser): + roles = [] + + id = models.UUIDField( + primary_key=True, + help_text="ID of user", + default=make_uuid, + editable=True, + ) + username = models.CharField(default="", max_length=128, unique=True) + + def __str__(self): + return self.username + + +class Profile(models.Model): + user = models.OneToOneField( + User, related_name="profile", on_delete=models.CASCADE + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return "%s's profile" % self.user + + class Meta: + ordering = ("-created_at",) + + +def create_user_profile(sender, instance, created, **kwargs): + if created: + Profile.objects.create(user=instance) + + +post_save.connect(create_user_profile, sender=User) + +# Create your models here. diff --git a/src/agent/k8s-rest-agent/src/api/routes/__init__.py b/src/agent/k8s-rest-agent/src/api/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/routes/hello/__init__.py b/src/agent/k8s-rest-agent/src/api/routes/hello/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/routes/hello/views.py b/src/agent/k8s-rest-agent/src/api/routes/hello/views.py new file mode 100644 index 000000000..004f5dd36 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/routes/hello/views.py @@ -0,0 +1,40 @@ +import logging +import os + +from rest_framework import viewsets, status +from drf_yasg.utils import swagger_auto_schema +from rest_framework.decorators import action, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from api.auth import JSONWebTokenAuthentication, IstioJWTAuthentication +from api.utils.mixins import PermissionsPerMethodMixin + +LOG = logging.getLogger(__name__) +APP_VERSION = os.getenv("APP_VERSION", "v1") + + +class HelloViewSet(PermissionsPerMethodMixin, viewsets.ViewSet): + authentication_classes = (IstioJWTAuthentication,) + + @swagger_auto_schema( + operation_summary="Hello world", operation_description="Hello world" + ) + def list(self, request): + return Response( + {"hello": "world %s" % APP_VERSION}, status=status.HTTP_200_OK + ) + + @swagger_auto_schema(operation_summary="hello world need auth") + @action( + methods=["get"], + url_path="need-auth", + url_name="need-auth", + detail=False, + ) + # @permission_classes((IsAuthenticated,)) + def need_auth(self, request): + LOG.info("request user %s", request.user) + return Response( + {"hello": "auth world %s" % APP_VERSION}, status=status.HTTP_200_OK + ) diff --git a/src/agent/k8s-rest-agent/src/api/tasks/__init__.py b/src/agent/k8s-rest-agent/src/api/tasks/__init__.py new file mode 100644 index 000000000..1468bf421 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/tasks/__init__.py @@ -0,0 +1 @@ +from api.tasks.task.example import example_task diff --git a/src/agent/k8s-rest-agent/src/api/tasks/task/__init__.py b/src/agent/k8s-rest-agent/src/api/tasks/task/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/tasks/task/example.py b/src/agent/k8s-rest-agent/src/api/tasks/task/example.py new file mode 100644 index 000000000..f29ee66ac --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/tasks/task/example.py @@ -0,0 +1,12 @@ +import logging + +from server.celery import app + + +LOG = logging.getLogger(__name__) + + +@app.task(name="example_task") +def example_task(): + LOG.info("example task") + return True diff --git a/src/agent/k8s-rest-agent/src/api/tests.py b/src/agent/k8s-rest-agent/src/api/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/agent/k8s-rest-agent/src/api/utils/__init__.py b/src/agent/k8s-rest-agent/src/api/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agent/k8s-rest-agent/src/api/utils/common/__init__.py b/src/agent/k8s-rest-agent/src/api/utils/common/__init__.py new file mode 100644 index 000000000..2da9e9042 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/common/__init__.py @@ -0,0 +1,2 @@ +from .swagger import with_common_response +from .db import paginate_list diff --git a/src/agent/k8s-rest-agent/src/api/utils/common/db.py b/src/agent/k8s-rest-agent/src/api/utils/common/db.py new file mode 100644 index 000000000..d0ac7ca0e --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/common/db.py @@ -0,0 +1,26 @@ +from django.core.paginator import Paginator +from django.db.models import Func + + +class Round(Func): + function = "ROUND" + arity = 2 + + +def paginate_list(data=None, page=1, per_page=10, limit=None): + if not data: + data = [] + + total = len(data) + + if per_page != -1: + p = Paginator(data, per_page) + last_page = p.page_range[-1] + page = page if page <= last_page else last_page + data = p.page(page) + total = p.count + else: + if limit: + data = data[:limit] + + return data, total diff --git a/src/agent/k8s-rest-agent/src/api/utils/common/swagger.py b/src/agent/k8s-rest-agent/src/api/utils/common/swagger.py new file mode 100644 index 000000000..7c06a7478 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/common/swagger.py @@ -0,0 +1,62 @@ +from drf_yasg import openapi +from rest_framework import serializers +from rest_framework import status + +from api.utils.serializers import BadResponseSerializer + +basic_type_info = [ + (serializers.CharField, openapi.TYPE_STRING), + (serializers.BooleanField, openapi.TYPE_BOOLEAN), + (serializers.IntegerField, openapi.TYPE_INTEGER), + (serializers.FloatField, openapi.TYPE_NUMBER), + (serializers.FileField, openapi.TYPE_FILE), + (serializers.ImageField, openapi.TYPE_FILE), +] + + +def to_form_paras(self): + custom_paras = [] + for field_name, field in self.fields.items(): + type_str = openapi.TYPE_STRING + for field_class, type_format in basic_type_info: + if isinstance(field, field_class): + type_str = type_format + help_text = getattr(field, "help_text") + default = getattr(field, "default", None) + required = getattr(field, "required") + if callable(default): + custom_paras.append( + openapi.Parameter( + field_name, + openapi.IN_FORM, + help_text, + type=type_str, + required=required, + ) + ) + else: + custom_paras.append( + openapi.Parameter( + field_name, + openapi.IN_FORM, + help_text, + type=type_str, + required=required, + default=default, + ) + ) + return custom_paras + + +def with_common_response(responses=None): + if responses is None: + responses = {} + + responses.update( + { + status.HTTP_400_BAD_REQUEST: BadResponseSerializer, + status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Error", + } + ) + + return responses diff --git a/src/agent/k8s-rest-agent/src/api/utils/db_functions.py b/src/agent/k8s-rest-agent/src/api/utils/db_functions.py new file mode 100644 index 000000000..aa5da3f07 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/db_functions.py @@ -0,0 +1,14 @@ +import uuid +import shortuuid + + +def make_uuid(): + return str(uuid.uuid4()) + + +def make_uuid_hex(): + return uuid.uuid4().hex + + +def make_short_uuid(): + return shortuuid.ShortUUID().random(length=16) diff --git a/src/agent/k8s-rest-agent/src/api/utils/enums.py b/src/agent/k8s-rest-agent/src/api/utils/enums.py new file mode 100644 index 000000000..cab64c903 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/enums.py @@ -0,0 +1,102 @@ +import inspect +from enum import Enum, unique, EnumMeta + +from django.conf import settings + +ROLE_PREFIX = getattr(settings, "ROLE_PREFIX", "tea_cloud") + + +class EnumWithDisplayMeta(EnumMeta): + def __new__(mcs, name, bases, attrs): + display_strings = attrs.get("DisplayStrings") + + if display_strings is not None and inspect.isclass(display_strings): + del attrs["DisplayStrings"] + if hasattr(attrs, "_member_names"): + attrs._member_names.remove("DisplayStrings") + + obj = super().__new__(mcs, name, bases, attrs) + for m in obj: + m.display_string = getattr(display_strings, m.name, None) + + return obj + + +class ExtraEnum(Enum): + @classmethod + def get_info(cls, title="", list_str=False): + str_info = """ + """ + str_info += title + if list_str: + for name, member in cls.__members__.items(): + str_info += """ + %s + """ % ( + name.lower().replace("_", "."), + ) + else: + for name, member in cls.__members__.items(): + str_info += """ + %s: %s + """ % ( + member.value, + name, + ) + return str_info + + @classmethod + def to_choices(cls, string_as_value=False): + if string_as_value: + choices = [ + (name.lower().replace("_", "."), name) + for name, member in cls.__members__.items() + ] + else: + choices = [ + (member.value, name) + for name, member in cls.__members__.items() + ] + + return choices + + @classmethod + def values(cls): + return list(map(lambda c: c.value, cls.__members__.values())) + + @classmethod + def names(cls): + return [name.lower() for name, _ in cls.__members__.items()] + + +@unique +class ErrorCode(Enum, metaclass=EnumWithDisplayMeta): + Unknown = 20000 + ResourceNotFound = 20001 + CustomError = 20002 + ResourceExisted = 20003 + ValidationError = 20004 + ParseError = 20005 + + class DisplayStrings: + Unknown = "未知错误" + ResourceNotFound = "资源未找到" + CustomError = "自定义错误" + ResourceExisted = "资源已经存在" + ValidationError = "参数验证错误" + ParseError = "解析错误" + + @classmethod + def get_info(cls): + error_code_str = """ + Error Codes: + """ + for name, member in cls.__members__.items(): + error_code_str += """ + %s: %s + """ % ( + member.value, + member.display_string, + ) + + return error_code_str diff --git a/src/agent/k8s-rest-agent/src/api/utils/exception_handler.py b/src/agent/k8s-rest-agent/src/api/utils/exception_handler.py new file mode 100644 index 000000000..f24cb6701 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/exception_handler.py @@ -0,0 +1,34 @@ +from rest_framework import status +from rest_framework.exceptions import ErrorDetail +from rest_framework.exceptions import ValidationError, ParseError +from rest_framework.views import exception_handler + +from api.utils.enums import ErrorCode + + +def custom_exception_handler(exc, context): + # Call REST framework's default exception handler first, + # to get the standard error response. + response = exception_handler(exc, context) + + # Now add the HTTP status code to the response. + if response is not None: + if ( + response.status_code == status.HTTP_400_BAD_REQUEST + and "code" not in response.data + ): + if isinstance(exc, ValidationError): + response.data["code"] = ErrorCode.ValidationError.value + response.data[ + "detail" + ] = ErrorCode.ValidationError.display_string + elif isinstance(exc, ParseError): + response.data["code"] = ErrorCode.ParseError.value + response.data["detail"] = ErrorCode.ParseError.display_string + elif isinstance(response.data.get("detail"), ErrorDetail): + response.data["code"] = response.data.get("detail").code + else: + response.data["code"] = ErrorCode.Unknown.value + response.data["detail"] = ErrorCode.Unknown.display_string + + return response diff --git a/src/agent/k8s-rest-agent/src/api/utils/fast_enum.py b/src/agent/k8s-rest-agent/src/api/utils/fast_enum.py new file mode 100644 index 000000000..28b685cf6 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/fast_enum.py @@ -0,0 +1,283 @@ +import re +from functools import partial +from typing import ( + Any, + Text, + Dict, + List, + Tuple, + Type, + Optional, + Callable, + Iterable, +) + + +def _resolve_init(bases: Tuple[Type]) -> Optional[Callable]: + for bcls in bases: + for rcls in bcls.mro(): + resolved_init = getattr(rcls, "__init__") + if resolved_init and resolved_init is not object.__init__: + return resolved_init + + +def _resolve_new(bases: Tuple[Type]) -> Optional[Tuple[Callable, Type]]: + for bcls in bases: + new = getattr(bcls, "__new__", None) + if new not in { + None, + None.__new__, + object.__new__, + FastEnum.__new__, + getattr(FastEnum, "_FastEnum__new"), + }: + return new, bcls + + +class FastEnum(type): + """ + A metaclass that handles enum-classes creation. + Possible options for classes using this metaclass: + - auto-generated values (see examples.py `MixedEnum` and `LightEnum`) + - subclassing possible until actual enum is not declared + (see examples.py `ExtEnumOne` and `ExtEnumTwo`) + - late init hooking (see examples.py `HookedEnum`) + - enum modifications protection (see examples.py comment after `ExtendedEnum`) + """ + + # pylint: disable=bad-mcs-classmethod-argument,protected-access,too-many-locals + # pylint: disable=too-many-branches + def __new__(mcs, name, bases, namespace: Dict[Text, Any]): + attributes: List[Text] = [ + k + for k in namespace.keys() + if (not k.startswith("_") and k.isupper()) + ] + attributes += [ + k + for k, v in namespace.get("__annotations__", {}).items() + if (not k.startswith("_") and k.isupper() and v == name) + ] + light_val = 0 + int(not bool(namespace.get("_ZERO_VALUED"))) + for attr in attributes: + if attr in namespace: + continue + else: + namespace[attr] = light_val + light_val += 1 + + __itemsize__ = 0 + for bcls in bases: + if bcls is type: + continue + __itemsize__ = max(__itemsize__, bcls.__itemsize__) + + if not __itemsize__: + __slots__ = set(namespace.get("__slots__", tuple())) | { + "name", + "value", + "_value_to_instance_map", + "_base_typed", + } + namespace["__slots__"] = tuple(__slots__) + namespace["__new__"] = FastEnum.__new + + if "__init__" not in namespace: + namespace["__init__"] = _resolve_init(bases) or mcs.__init + if "__annotations__" not in namespace: + __annotations__ = dict(name=Text, value=Any) + for k in attributes: + __annotations__[k] = name + namespace["__annotations__"] = __annotations__ + namespace["__dir__"] = partial( + FastEnum.__dir, bases=bases, namespace=namespace + ) + typ = type.__new__(mcs, name, bases, namespace) + if attributes: + typ._value_to_instance_map = {} + for instance_name in attributes: + val = namespace[instance_name] + if not isinstance(val, tuple): + val = (val,) + if val[0] in typ._value_to_instance_map: + inst = typ._value_to_instance_map[val[0]] + else: + inst = typ(*val, name=instance_name) + typ._value_to_instance_map[inst.value] = inst + setattr(typ, instance_name, inst) + + # noinspection PyUnresolvedReferences + typ.__call__ = typ.__new__ = typ.get + del typ.__init__ + typ.__hash__ = mcs.__hash + typ.__eq__ = mcs.__eq + typ.__copy__ = mcs.__copy + typ.__deepcopy__ = mcs.__deepcopy + typ.__reduce__ = mcs.__reduce + if "__str__" not in namespace: + typ.__str__ = mcs.__str + if "__repr__" not in namespace: + typ.__repr__ = mcs.__repr + + if f"_{name}__init_late" in namespace: + fun = namespace[f"_{name}__init_late"] + for instance in typ._value_to_instance_map.values(): + fun(instance) + delattr(typ, f"_{name}__init_late") + + typ.__setattr__ = typ.__delattr__ = mcs.__restrict_modification + typ._finalized = True + return typ + + @staticmethod + def __new(cls, *values, **_): + __new__ = _resolve_new(cls.__bases__) + if __new__: + __new__, typ = __new__ + obj = __new__(cls, *values) + obj._base_typed = typ + return obj + + return object.__new__(cls) + + @staticmethod + def __init(instance, value: Any, name: Text): + base_val_type = getattr(instance, "_base_typed", None) + if base_val_type: + value = base_val_type(value) + instance.value = value + instance.name = name + + # pylint: disable=missing-docstring + @staticmethod + def get(typ, val=None): + # noinspection PyProtectedMember + if not isinstance(typ._value_to_instance_map, dict): + for cls in typ.mro(): + if cls is typ: + continue + if hasattr(cls, "_value_to_instance_map") and isinstance( + cls._value_to_instance_map, dict + ): + return cls._value_to_instance_map[val] + raise ValueError( + f"Value {val} is not found in this enum type declaration" + ) + # noinspection PyProtectedMember + member = typ._value_to_instance_map.get(val) + if member is None: + raise ValueError( + f"Value {val} is not found in this enum type declaration" + ) + return member + + @staticmethod + def __eq(val, other): + return isinstance(val, type(other)) and ( + val is other if type(other) is type(val) else val.value == other + ) + + def __hash(cls): + # noinspection PyUnresolvedReferences + return hash(cls.value) + + @staticmethod + def __restrict_modification(*a, **k): + raise TypeError( + f"Enum-like classes strictly prohibit changing any attribute/property" + f" after they are once set" + ) + + def __iter__(cls): + return iter(cls._value_to_instance_map.values()) + + def __setattr__(cls, key, value): + if hasattr(cls, "_finalized"): + cls.__restrict_modification() + super().__setattr__(key, value) + + def __delattr__(cls, item): + if hasattr(cls, "_finalized"): + cls.__restrict_modification() + super().__delattr__(item) + + def __getitem__(cls, item): + return getattr(cls, item) + + def has_value(cls, value): + return value in cls._value_to_instance_map + + def to_choices(cls): + return [(key, key) for key in cls._value_to_instance_map.keys()] + + def values(cls): + return cls._value_to_instance_map.keys() + + def key_description_list(cls): + result = [] + for key in cls._value_to_instance_map.keys(): + enum_key = "_".join( + re.sub( + "([A-Z][a-z]+)", r" \1", re.sub("([A-Z]+)", r" \1", key) + ).split() + ).upper() + result.append((key, cls[enum_key].description)) + return result + + # pylint: disable=unused-argument + # noinspection PyUnusedLocal,SpellCheckingInspection + def __deepcopy(cls, memodict=None): + return cls + + def __copy(cls): + return cls + + def __reduce(cls): + typ = type(cls) + # noinspection PyUnresolvedReferences + return typ.get, (typ, cls.value) + + @staticmethod + def __str(clz): + return f"{clz.__class__.__name__}.{clz.name}" + + @staticmethod + def __repr(clz): + return f"<{clz.__class__.__name__}.{clz.name}: {repr(clz.value)}>" + + def __dir__(self) -> Iterable[str]: + return [ + k + for k in super().__dir__() + if k not in ("_finalized", "_value_to_instance_map") + ] + + # def __choices__(self) -> Iterable[str]: + # return [()] + + @staticmethod + def __dir(bases, namespace, *_, **__): + keys = [ + k + for k in namespace.keys() + if k in ("__annotations__", "__module__", "__qualname__") + or not k.startswith("_") + ] + for bcls in bases: + keys.extend(dir(bcls)) + return list(set(keys)) + + +class KeyDescriptionEnum(metaclass=FastEnum): + description: Text + __slots__ = ("description",) + + def __init__(self, value, description, name): + # noinspection PyDunderSlots,PyUnresolvedReferences + self.value = value + # noinspection PyDunderSlots,PyUnresolvedReferences + self.name = name + self.description = description + + def describe(self): + return self.description diff --git a/src/agent/k8s-rest-agent/src/api/utils/jwt.py b/src/agent/k8s-rest-agent/src/api/utils/jwt.py new file mode 100644 index 000000000..54b7e0741 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/jwt.py @@ -0,0 +1,30 @@ +import logging + +from django.contrib.auth import get_user_model +from rest_framework import serializers + +User = get_user_model() +LOG = logging.getLogger(__name__) + + +class UserSerializer(serializers.ModelSerializer): + id = serializers.CharField(source="username") + + class Meta: + model = User + fields = ("id",) + extra_kwargs = {"id": {"validators": []}} + + +def jwt_response_payload_handler(token, user=None, request=None): + return { + "token": token, + "user": UserSerializer(user, context={"request": request}).data, + } + + +def jwt_get_username_from_payload_handler(payload): + """ + Override this function if username is formatted differently in payload + """ + return payload.get("sub") diff --git a/src/agent/k8s-rest-agent/src/api/utils/mixins.py b/src/agent/k8s-rest-agent/src/api/utils/mixins.py new file mode 100644 index 000000000..41becc97f --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/mixins.py @@ -0,0 +1,12 @@ +class PermissionsPerMethodMixin(object): + def get_permissions(self): + """ + Allows overriding default permissions with @permission_classes + """ + view = getattr(self, self.action) + if hasattr(view, "permission_classes"): + return [ + permission_class() + for permission_class in view.permission_classes + ] + return super().get_permissions() diff --git a/src/agent/k8s-rest-agent/src/api/utils/serializers.py b/src/agent/k8s-rest-agent/src/api/utils/serializers.py new file mode 100644 index 000000000..f1082c10b --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/utils/serializers.py @@ -0,0 +1,29 @@ +import textwrap + +from rest_framework import serializers +from api.utils.enums import ErrorCode + + +class PaginationSerializer(serializers.Serializer): + page = serializers.IntegerField(default=1, min_value=1, help_text="查询第几页") + per_page = serializers.IntegerField( + default=10, min_value=-1, help_text="查询分页的每页数量, 如果为-1则不限制分页数量" + ) + limit = serializers.IntegerField( + min_value=1, help_text="限制最大数量", required=False + ) + + +class PaginationResultSerializer(serializers.Serializer): + total = serializers.IntegerField( + min_value=0, help_text="Total Number of result" + ) + + +class BadResponseSerializer(serializers.Serializer): + code = serializers.IntegerField( + help_text=textwrap.dedent(ErrorCode.get_info()) + ) + detail = serializers.CharField( + required=False, help_text="Error Messages", allow_blank=True + ) diff --git a/src/agent/k8s-rest-agent/src/api/views.py b/src/agent/k8s-rest-agent/src/api/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/api/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/src/agent/k8s-rest-agent/src/manage.py b/src/agent/k8s-rest-agent/src/manage.py new file mode 100755 index 000000000..4546cf051 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/src/agent/k8s-rest-agent/src/server/__init__.py b/src/agent/k8s-rest-agent/src/server/__init__.py new file mode 100644 index 000000000..0165ba0dd --- /dev/null +++ b/src/agent/k8s-rest-agent/src/server/__init__.py @@ -0,0 +1,7 @@ +from __future__ import absolute_import, unicode_literals + +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/src/agent/k8s-rest-agent/src/server/asgi.py b/src/agent/k8s-rest-agent/src/server/asgi.py new file mode 100644 index 000000000..9fadff8ce --- /dev/null +++ b/src/agent/k8s-rest-agent/src/server/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for server project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") + +application = get_asgi_application() diff --git a/src/agent/k8s-rest-agent/src/server/celery.py b/src/agent/k8s-rest-agent/src/server/celery.py new file mode 100644 index 000000000..2393692e3 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/server/celery.py @@ -0,0 +1,15 @@ +from __future__ import absolute_import, unicode_literals +import os +from celery import Celery + +# set the default Django settings module for the 'celery' program. +from django.conf import settings + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") + +app = Celery("server") + +app.config_from_object(settings, namespace="CELERY") + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() diff --git a/src/agent/k8s-rest-agent/src/server/settings.py b/src/agent/k8s-rest-agent/src/server/settings.py new file mode 100644 index 000000000..5bfbc9534 --- /dev/null +++ b/src/agent/k8s-rest-agent/src/server/settings.py @@ -0,0 +1,217 @@ +""" +Django settings for server project. + +Generated by 'django-admin startproject' using Django 3.0.7. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +import os +from django.utils.translation import gettext_lazy as _ + +WEBROOT = os.getenv("WEBROOT", "/") +WEBROOT = WEBROOT if WEBROOT != "/" else "" +DB_HOST = os.getenv("DB_HOST", "") +DB_PORT = int(os.getenv("DB_PORT", "5432")) +DB_NAME = os.getenv("DB_NAME", "") +DB_USER = os.getenv("DB_USER", "") +DB_PASSWORD = os.getenv("DB_PASSWORD", "") +DEBUG = os.getenv("DEBUG", "False") +DEBUG = DEBUG == "True" +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +KEYCLOAK_PUBLIC_KEY = os.getenv("KEYCLOAK_PUBLIC_KEY", "") + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "xdpfxz9)__^3azxs2(59$j&chmo#6&gi*pu3#wpt^$m!vff)0w" + +# SECURITY WARNING: don't run with debug turned on in production! +# DEBUG = True + +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + "jet", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "server", + "api", + "drf_yasg", + "django_celery_beat", + "django_celery_results", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "server.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "server.wsgi.application" + + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = "zh-hans" +USE_I18N = True +USE_L10N = True + +LANGUAGES = [ + ("en", _("English")), + ("zh-hans", _("Simplified Chinese")), +] + +TIME_ZONE = "Asia/Shanghai" +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = WEBROOT + "/static/" +STATIC_ROOT = "/var/www/static" +MEDIA_ROOT = "/data/media" +MEDIA_URL = WEBROOT + "/media/" + +USE_X_FORWARDED_HOST = True +FORCE_SCRIPT_NAME = WEBROOT if WEBROOT != "" else "/" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": DB_NAME, + "USER": DB_USER, + "PASSWORD": DB_PASSWORD, + "HOST": DB_HOST, + "PORT": DB_PORT, + } +} + +REST_FRAMEWORK = { + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning", + "DEFAULT_METADATA_CLASS": "rest_framework.metadata.SimpleMetadata", + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", + "rest_framework.parsers.JSONParser", + ], + "EXCEPTION_HANDLER": "api.utils.exception_handler.custom_exception_handler", +} + +SWAGGER_SETTINGS = { + "VALIDATOR_URL": None, + "DEFAULT_INFO": "server.urls.swagger_info", + "SECURITY_DEFINITIONS": { + "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"} + }, + "USE_SESSION_AUTH": False, +} + +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "") +CELERY_RESULT_BACKEND = "django-db" +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" + +JWT_AUTH = { + "JWT_AUTH_HEADER_PREFIX": "Bearer", + "JWT_PUBLIC_KEY": """-----BEGIN PUBLIC KEY----- +%s +-----END PUBLIC KEY-----""" + % KEYCLOAK_PUBLIC_KEY, + "JWT_ALGORITHM": "RS256", + "JWT_AUDIENCE": "account", + "JWT_PAYLOAD_GET_USERNAME_HANDLER": "api.utils.jwt.jwt_get_username_from_payload_handler", + "JWT_RESPONSE_PAYLOAD_HANDLER": "api.utils.jwt.jwt_response_payload_handler", +} + +AUTH_USER_MODEL = "api.User" +AUTH_PROFILE_MODULE = "api.Profile" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" + }, + "simple": {"format": "%(levelname)s %(message)s"}, + }, + "handlers": { + "null": {"level": "DEBUG", "class": "logging.NullHandler",}, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "simple", + }, + }, + "loggers": { + "django": {"handlers": ["null"], "propagate": True, "level": "INFO",}, + "django.request": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + "api": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} diff --git a/src/agent/k8s-rest-agent/src/server/urls.py b/src/agent/k8s-rest-agent/src/server/urls.py new file mode 100644 index 000000000..f741a815e --- /dev/null +++ b/src/agent/k8s-rest-agent/src/server/urls.py @@ -0,0 +1,69 @@ +"""server URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +import os + +from api.routes.hello.views import HelloViewSet +from django.conf import settings +from django.contrib import admin +from django.urls import path, include +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions +from rest_framework.routers import DefaultRouter + +DEBUG = getattr(settings, "DEBUG", False) +VERSION = os.getenv("API_VERSION", "v1") + +router = DefaultRouter(trailing_slash=False) +router.register("hello", HelloViewSet, basename="hello") + +router.include_root_view = False + +urlpatterns = router.urls + +swagger_info = openapi.Info( + title="Django Example API", + default_version=VERSION, + description=""" + Django Example API + """, +) + +SchemaView = get_schema_view( + info=swagger_info, + validators=["flex"], + public=True, + permission_classes=(permissions.AllowAny,), +) + +urlpatterns += [ + path("admin/", admin.site.urls), + path("jet/", include("jet.urls", "jet")), +] + +if DEBUG: + urlpatterns += [ + path( + "docs/", + SchemaView.with_ui("swagger", cache_timeout=0), + name="docs", + ), + path( + "redoc/", + SchemaView.with_ui("redoc", cache_timeout=0), + name="redoc", + ), + ] diff --git a/src/agent/k8s-rest-agent/src/server/wsgi.py b/src/agent/k8s-rest-agent/src/server/wsgi.py new file mode 100644 index 000000000..11efb9c4d --- /dev/null +++ b/src/agent/k8s-rest-agent/src/server/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for server project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") + +application = get_wsgi_application() diff --git a/src/agent/k8s-rest-agent/supervisor/conf.d/server.conf b/src/agent/k8s-rest-agent/supervisor/conf.d/server.conf new file mode 100644 index 000000000..f4f1ad384 --- /dev/null +++ b/src/agent/k8s-rest-agent/supervisor/conf.d/server.conf @@ -0,0 +1,8 @@ +[program:beat_task] +environment=C_FORCE_ROOT="yes" +command=celery -A server beat -l info +directory=/var/www/server/ +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/server.log +redirect_stderr=true \ No newline at end of file diff --git a/src/agent/k8s-rest-agent/supervisor/supervisord.conf b/src/agent/k8s-rest-agent/supervisor/supervisord.conf new file mode 100644 index 000000000..d6bf70c31 --- /dev/null +++ b/src/agent/k8s-rest-agent/supervisor/supervisord.conf @@ -0,0 +1,28 @@ +; supervisor config file + +[unix_http_server] +file=/var/run/supervisor.sock ; (the path to the socket file) +chmod=0700 ; sockef file mode (default 0700) + +[supervisord] +logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log) +pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid) +childlogdir=/var/log/supervisor ; ('AUTO' child log dir, default $TEMP) + +; the below section must remain in the config file for RPC +; (supervisorctl/web interface) to work, additional interfaces may be +; added by defining them in separate rpcinterface: sections +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock ; use a unix:// URL for a unix socket + +; The [include] section can just contain the "files" setting. This +; setting can list multiple files (separated by whitespace or +; newlines). It can also contain wildcards. The filenames are +; interpreted as relative to this file. Included files *cannot* +; include files themselves. + +[include] +files = /etc/supervisor/conf.d/*.conf \ No newline at end of file diff --git a/src/agent/k8s-rest-agent/uwsgi/server.ini b/src/agent/k8s-rest-agent/uwsgi/server.ini new file mode 100644 index 000000000..72607ef42 --- /dev/null +++ b/src/agent/k8s-rest-agent/uwsgi/server.ini @@ -0,0 +1,71 @@ +[uwsgi] +module = $(UWSGI_MODULE) +processes = $(UWSGI_PROCESSES) +threads = $(UWSGI_THREADS) +procname-prefix-spaced = uwsgi: $(UWSGI_MODULE) + +http-socket = :80 +http-enable-proxy-protocol = 1 +http-auto-chunked = true +http-keepalive = 75 +http-timeout = 75 +stats = :1717 +stats-http = 1 +offload-threads = $(UWSGI_OFFLOAD_THREADS) + +# Better startup/shutdown in docker: +die-on-term = 1 +lazy-apps = 0 + +vacuum = 1 +master = 1 +enable-threads = true +thunder-lock = 1 +buffer-size = 65535 + +# Logging +log-x-forwarded-for = true +#memory-report = true +#disable-logging = true +#log-slow = 200 +#log-date = true + +# Avoid errors on aborted client connections +ignore-sigpipe = true +ignore-write-errors = true +disable-write-exception = true + +#listen=1000 +#max-fd=120000 +no-defer-accept = 1 + +# Limits, Kill requests after 120 seconds +harakiri = 120 +harakiri-verbose = true +post-buffering = 4096 + +# Custom headers +add-header = X-Content-Type-Options: nosniff +add-header = X-XSS-Protection: 1; mode=block +add-header = Strict-Transport-Security: max-age=16070400 +add-header = Connection: Keep-Alive + +# Static file serving with caching headers and gzip +static-map = /static=/var/www/static +static-map = /media=/data/media +static-safe = /usr/local/lib/python3.7/site-packages/ +static-safe = /var/www/static/ +static-gzip-dir = /var/www/static/ +static-expires = /var/www/static/CACHE/* 2592000 +static-expires = /data/media/cache/* 2592000 +static-expires = /var/www/static/frontend/img/* 2592000 +static-expires = /var/www/static/frontend/fonts/* 2592000 +static-expires = /var/www/* 3600 +route-uri = ^/static/ addheader:Vary: Accept-Encoding +error-route-uri = ^/static/ addheader:Cache-Control: no-cache + +# Cache stat() calls +cache2 = name=statcalls,items=30 +static-cache-paths = 86400 + +touch-reload = /tmp/server.txt \ No newline at end of file