diff --git a/config_backend/compositions/default.yaml b/config_backend/compositions/default.yaml new file mode 100644 index 0000000..f9345c1 --- /dev/null +++ b/config_backend/compositions/default.yaml @@ -0,0 +1,5 @@ +name: generic +services: + lok: lok_next + rekuest: rekuest_next + fluss: fluss_next \ No newline at end of file diff --git a/config_backend/instances/fluss_next.yaml b/config_backend/instances/fluss_next.yaml new file mode 100644 index 0000000..338b79a --- /dev/null +++ b/config_backend/instances/fluss_next.yaml @@ -0,0 +1,4 @@ +endpoint_url: "{{"https" if request.is_secure else "http" }}://{{"fluss" if request.host == "lok" else request.host + ":11090"}}/graphql" +healthz: "{{"https" if request.is_secure else "http" }}://{{"fluss" if request.host == "lok" else request.host + ":11090"}}/ht" +ws_endpoint_url: "{{"wss" if request.is_secure else "ws" }}://{{"fluss" if request.host == "lok" else request.host + ":11090"}}/graphql" +__service: "live.arkitekt.fluss" \ No newline at end of file diff --git a/config_backend/instances/lok_next.yaml b/config_backend/instances/lok_next.yaml new file mode 100644 index 0000000..e69624c --- /dev/null +++ b/config_backend/instances/lok_next.yaml @@ -0,0 +1,15 @@ +base_url: "{{"https" if request.is_secure else "http" }}://{{"lok" if request.host == "lok" else request.host + ":12000"}}/o" +userinfo_url: "{{"https" if request.is_secure else "http" }}://{{"lok" if request.host == "lok" else request.host + ":12000"}}/o/userinfo" +endpoint_url: "{{"https" if request.is_secure else "http" }}://{{"lok" if request.host == "lok" else request.host + ":12000"}}/graphql" +healthz: "{{"https" if request.is_secure else "http" }}://{{"lok" if request.host == "lok" else request.host + ":12000"}}/ht" +secure: false +ws_endpoint_url: "{{"wss" if request.is_secure else "ws" }}://{{"lok" if request.host == "lok" else request.host + ":12000"}}/graphql" +client_id: "{{client.client_id}}" +client_secret: "{{client.client_secret}}" +grant_type: "{{client.authorization_grant_type}}" +name: "{{client.name}}" +scopes: + {% for item in manifest.scopes %} + - {{item}} + {% endfor %} +__service: "live.arkitekt.lok" \ No newline at end of file diff --git a/config_backend/instances/rekuest_next.yaml b/config_backend/instances/rekuest_next.yaml new file mode 100644 index 0000000..d2c1f54 --- /dev/null +++ b/config_backend/instances/rekuest_next.yaml @@ -0,0 +1,6 @@ +agent: + endpoint_url: "{{"wss" if request.is_secure else "ws" }}://{{"rekuest" if request.host == "lok" else request.host + ":11020"}}/agi" +endpoint_url: "{{"https" if request.is_secure else "http" }}://{{"rekuest" if request.host == "lok" else request.host + ":11020"}}/graphql" +healthz: "{{"https" if request.is_secure else "http" }}://{{"rekuest" if request.host == "lok" else request.host + ":11020"}}/ht" +ws_endpoint_url: "{{"wss" if request.is_secure else "ws" }}://{{"rekuest" if request.host == "lok" else request.host + ":11020"}}/graphql" +__service: "live.arkitekt.rekuest-next" \ No newline at end of file diff --git a/config_backend/services/live.arkitekt.fluss.yaml b/config_backend/services/live.arkitekt.fluss.yaml new file mode 100644 index 0000000..cb97ef2 --- /dev/null +++ b/config_backend/services/live.arkitekt.fluss.yaml @@ -0,0 +1,2 @@ +key: fluss +description: "The Next generation fluss" \ No newline at end of file diff --git a/config_backend/services/live.arkitekt.lok.yaml b/config_backend/services/live.arkitekt.lok.yaml new file mode 100644 index 0000000..f3a6f2a --- /dev/null +++ b/config_backend/services/live.arkitekt.lok.yaml @@ -0,0 +1,2 @@ +key: lok +description: "The Next generation Lok" \ No newline at end of file diff --git a/config_backend/services/live.arkitekt.rekuest-next.yaml b/config_backend/services/live.arkitekt.rekuest-next.yaml new file mode 100644 index 0000000..14c533c --- /dev/null +++ b/config_backend/services/live.arkitekt.rekuest-next.yaml @@ -0,0 +1,2 @@ +key: rekuest_next +description: "The best service to have ever lived" diff --git a/contrib/config_backend/__init__.py b/contrib/config_backend/__init__.py new file mode 100644 index 0000000..a59faee --- /dev/null +++ b/contrib/config_backend/__init__.py @@ -0,0 +1,3 @@ +from .backend import ConfigBackend + +__all__ = ["ConfigBackend"] \ No newline at end of file diff --git a/contrib/config_backend/backend.py b/contrib/config_backend/backend.py new file mode 100644 index 0000000..658d5a4 --- /dev/null +++ b/contrib/config_backend/backend.py @@ -0,0 +1,164 @@ +from fakts.backends.backend_registry import BackendBase, InstanceDescriptor, CompositionDescriptor, InstanceMap, ServiceDescriptor +from fakts.base_models import LinkingContext, LinkingRequest, Manifest, LinkingClient +from typing import Dict, Any +from pathlib import Path +import yaml +from jinja2 import Template, TemplateSyntaxError, TemplateError + + +class ConfigBackend(BackendBase): + """ + This class is used to store the configuration of the server. It is + responsible for reading and writing the configuration to the file system. + """ + + def __init__(self, config: dict) -> None: + self.config = config + self.service_dir = Path(self.config.get("SERVICE_DIR", "/workspace/config_backend/services")) + self.instance_dr = Path(self.config.get("INSTANCE_DIR", "/workspace/config_backend/instances")) + self.composition_dir = Path(self.config.get("COMPOSITION_DIR", "/workspace/config_backend/compositions")) + + assert self.service_dir.exists(), f"Service directory {self.service_dir} does not exist" + assert self.composition_dir.exists(), f"Composition directory {self.composition_dir} does not exist" + + + self.loaded_services = self._load_services() + print(self.loaded_services) + + self.loaded_instances = self._load_instances() + print(self.loaded_instances) + + self.loaded_compositions = self._load_compositions() + print(self.loaded_compositions) + + + def _load_instances(self) -> dict[str, InstanceDescriptor]: + instances = {} + + for instance_file in self.instance_dr.iterdir(): + if instance_file.is_file(): + instances[instance_file.stem] = self.retrieve_instance(instance_file) + + return instances + + def _load_services(self) -> dict[str, ServiceDescriptor]: + instances = {} + + for service_file in self.service_dir.iterdir(): + if service_file.is_file(): + instances[service_file.stem] = self.retrieve_serve(service_file) + + return instances + + def _load_compositions(self) -> dict[str, CompositionDescriptor]: + + compositions = {} + + for composition_file in self.composition_dir.iterdir(): + if composition_file.is_file(): + compositions[composition_file.stem] = self.retrieve_composition(composition_file) + + return compositions + + + def retrieve_composition(self, composition_file: Path) -> CompositionDescriptor: + with open(composition_file, "r") as file: + context = yaml.load(file.read(), Loader=yaml.SafeLoader) + + assert context.get("name"), f"Composition file {composition_file} does not contain a name" + assert context.get("services"), f"Composition file {composition_file} does not contain any services" + + service_dict = {} + + for service_name, instance_name in context.get("services").items(): + assert self.loaded_instances.get(instance_name), f"Composition file {composition_file} contains an unknown service instance {instance_name}" + service_dict[service_name] = InstanceMap(instance_identifier=instance_name, backend_identifier=self.get_name()) + + return CompositionDescriptor( + name=context.get("name"), + services=service_dict + ) + + def retrieve_serve(self, service_file: Path) -> ServiceDescriptor: + with open(service_file, "r") as file: + context = yaml.load(file.read(), Loader=yaml.SafeLoader) + + assert context.get("key"), f"Service file {service_file} does not contain a key" + + + return ServiceDescriptor( + identifier=service_file.stem, + key=context.get("key"), + logo=context.get("logo"), + description=context.get("description"), + ) + + + + def retrieve_instance(self, service_file: Path) -> InstanceDescriptor: + with open(service_file, "r") as file: + template = file.read() + + + fake_context = LinkingContext( + request=LinkingRequest( + host="example.com", + port="443", + is_secure=True, + ), + manifest=Manifest( + identifier="com.example.app", + version="1.0", + scopes=["scope1", "scope2"], + redirect_uris=["https://example.com"], + ), + client=LinkingClient( + client_id="@client_id", + client_secret="@client_secret", + client_type="@client_type", + authorization_grant_type="authorization_grant_type", + name="@name", + ), + ) + + + result = yaml.load(Template(template).render(fake_context), Loader=yaml.SafeLoader) + + assert result.get("__service"), f"Service file {service_file} does not contain an service identifier (\"__service\")" + + return InstanceDescriptor( + backend_identifier=self.get_name(), + instance_identifier=service_file.stem, + service_identifier=result.get("__service"), + ) + + + + + def render(self, instance_id: str, linking: LinkingContext) -> Dict[str, Any]: + with open(self.instance_dr / f"{instance_id}.yaml", "r") as file: + template = file.read() + + answer = yaml.load(Template(template).render(linking.dict()), Loader=yaml.SafeLoader) + print(answer) + return answer + + + + + @classmethod + def get_name(cls) -> str: + return cls.__name__ + + + def get_service_descriptors(self) -> list[ServiceDescriptor]: + return self.loaded_services.values() + + def get_instance_descriptors(self) -> list[InstanceDescriptor]: + return self.loaded_instances.values() + + def get_composition_descriptors(self) -> list[CompositionDescriptor]: + return self.loaded_compositions.values() + + + \ No newline at end of file diff --git a/contrib/config_backend/validators.py b/contrib/config_backend/validators.py new file mode 100644 index 0000000..66025be --- /dev/null +++ b/contrib/config_backend/validators.py @@ -0,0 +1,65 @@ +from django.core.exceptions import ValidationError +from fakts.base_models import LinkingContext, LinkingRequest, Manifest, LinkingClient +from jinja2 import Template, TemplateSyntaxError, TemplateError, StrictUndefined +import yaml + +def is_valid_jinja2_template(template_string, render_context=None): + """ Checks if a template string is a valid Jinja2 template. And if it is, + it checks if it is a valid YAML file, wher rendered with a fakt context If it is not, it raises a ValueError""" + try: + template = Template(template_string, undefined=StrictUndefined) + try: + rendered_template = template.render(render_context ) + yaml.safe_load(rendered_template) + except (TemplateError, yaml.YAMLError) as e: + raise ValueError(f"Rendering error: {e}") from e + except TemplateSyntaxError as e: + raise ValueError(f"Template syntax error: {e}") from e + + + +def jinja2_yaml_template_validator(value): + """ Validates that a string is a valid Jinja2 template. And if it is, + it checks if it is a valid YAML file, wher rendered with a fakt context If it is not, it raises a ValidationError + """ + + + fake_context = LinkingContext( + request=LinkingRequest( + host="example.com", + port="443", + is_secure=True, + ), + manifest=Manifest( + identifier="com.example.app", + version="1.0", + scopes=["scope1", "scope2"], + redirect_uris=["https://example.com"], + ), + client=LinkingClient( + client_id="@client_id", + client_secret="@client_secret", + client_type="@client_type", + authorization_grant_type="authorization_grant_type", + name="@name", + ), + ) + + + try: + is_valid_jinja2_template(value, fake_context.dict()) + except ValidationError as e: + raise ValueError(e) + + return value + + +def fake_load_yaml(value): + """ Loads a string as a YAML file. If it is not a valid YAML file, it raises a ValidationError""" + + + + try: + return yaml.safe_load(value) + except yaml.YAMLError as e: + raise ValidationError(f"YAML error: {e}") from e \ No newline at end of file diff --git a/contrib/docker_backend.py b/contrib/docker_backend.py new file mode 100644 index 0000000..433558b --- /dev/null +++ b/contrib/docker_backend.py @@ -0,0 +1,29 @@ +from fakts.backends.backend_registry import BackendBase, InstanceDescriptor, CompositionDescriptor, ServiceDescriptor +from typing import Dict, Any +from fakts.base_models import LinkingContext + + +class DockerBackend(BackendBase): + """ + This class is used to store the configuration of the server. It is + responsible for reading and writing the configuration to the file system. + """ + + def __init__(self, config_file): + self.config_file = config_file + + @classmethod + def get_name(cls) -> str: + return cls.__name__ + + def render(cls, service_instance: str, context: LinkingContext) -> Dict[str, Any]: + pass + + def get_instance_descriptors(cls) -> list[InstanceDescriptor]: + return [] + + def get_composition_descriptors(self) -> list[CompositionDescriptor]: + return [] + + def get_service_descriptors(self) -> list[ServiceDescriptor]: + return [] \ No newline at end of file diff --git a/fakts/admin.py b/fakts/admin.py index 77ddf00..67443ba 100644 --- a/fakts/admin.py +++ b/fakts/admin.py @@ -5,5 +5,7 @@ admin.site.register(App) admin.site.register(Release) admin.site.register(Composition) +admin.site.register(Service) +admin.site.register(ServiceInstance) admin.site.register(Client) admin.site.register(DeviceCode) diff --git a/fakts/backends/__init__.py b/fakts/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fakts/backends/backend_registry.py b/fakts/backends/backend_registry.py new file mode 100644 index 0000000..302c3cf --- /dev/null +++ b/fakts/backends/backend_registry.py @@ -0,0 +1,164 @@ +from django.conf import settings +from django.utils.module_loading import import_string +from typing import Protocol +from abc import ABC, abstractclassmethod, abstractmethod +from typing import Dict, Any +from pydantic import BaseModel +from typing import Optional +from fakts.base_models import LinkingContext + + + +class ServiceDescriptor(BaseModel): + """ A service descriptor + + This is a pydantic model that represents a service. It is used to + represent a service in the database and in the code. + """ + identifier: str + """ The service identifier""" + key: str + """ The service key""" + name: Optional[str] + """ The service name""" + logo: Optional[str] + """ The service logo""" + description: Optional[str] + """ The service description""" + +class InstanceDescriptor(BaseModel): + """ An instance of a service + + This is a pydantic model that represents an instance of a service. It is used to + represent a service instance in the database and in the code. + """ + backend_identifier: str + """ The backend identifier""" + instance_identifier: str + """ The instande_idenrifier (unique for backend)""" + service_identifier: str + """ The service identifier""" + + + + +class InstanceMap(BaseModel): + instance_identifier: str + backend_identifier: str + + +class CompositionDescriptor(BaseModel): + """ A composition of services + + This is a pydantic model that represents a composition of services. It is used to + represent a service composition in the database and in the code. + + """ + name: Optional[str] + services: dict[str, InstanceMap] + """ A map of service identifiers to the correct instance on a backend""" + + + +class Backend(Protocol): + + def __init__(self, config: Dict[str, Any]) -> None: ... + + def render(self, service_identifier, context: LinkingContext) -> Dict[str, Any]: ... + + @classmethod + def get_name(cls) -> str: ... + + def get_service_descriptors(cls) -> list[ServiceDescriptor]: ... + + def get_instance_descriptors(cls) -> list[InstanceDescriptor]: ... + + def get_composition_descriptors(cls) -> list[CompositionDescriptor]: ... + + + +class BackendBase(ABC): + + @abstractclassmethod + def get_name(cls) -> str: + return cls.__name__ + + def render(cls, service_instance, context: LinkingContext) -> Dict[str, Any]: + pass + + @abstractmethod + def get_service_descriptors(cls) -> list[ServiceDescriptor]: + pass + + @abstractmethod + def get_instance_descriptors(cls) -> list[InstanceDescriptor]: + pass + + @abstractmethod + def get_composition_descriptors(cls) -> list[CompositionDescriptor]: + pass + + + + +class BackendRegistry: + backends: dict[str, Backend] = {} + + def __init__(self) -> None: + if not hasattr(settings, "FAKTS_BACKENDS"): + self.backend_configs = [] + else: + self.backend_configs = settings.FAKTS_BACKENDS + + for i in self.backend_configs: + assert isinstance(i, dict), "Backend configuration must be a dictionary" + assert "NAME" in i, "Backend configuration must have a NAME" + self.register(import_string(i["NAME"])(i)) + + pass + + def get_backend_identifiers(self): + names = [] + + for i in self.backends.values(): + names.append(i.get_name()) + + return names + + + def register(self, backend): + self.backends[backend.get_name()] = backend + + def get(self, name): + return self.backends[name] + + def all(self): + return self.backends + + def get_service_descriptors(self) -> list[ServiceDescriptor]: + services = [] + for i in self.backends.values(): + services.extend(i.get_service_descriptors()) + + return services + + + def get_composition_descriptors(self) -> list[CompositionDescriptor]: + compositions = [] + for i in self.backends.values(): + compositions.extend(i.get_composition_descriptors()) + + return compositions + + def get_instance_descriptors(self) -> list[InstanceDescriptor]: + instances = [] + for i in self.backends.values(): + instances.extend(i.get_instance_descriptors()) + + return instances + + + + + + diff --git a/fakts/backends/base.py b/fakts/backends/base.py new file mode 100644 index 0000000..3dc94ab --- /dev/null +++ b/fakts/backends/base.py @@ -0,0 +1,2 @@ +from typing import List, Dict, Any, Optional + diff --git a/fakts/backends/enums.py b/fakts/backends/enums.py new file mode 100644 index 0000000..9a7935d --- /dev/null +++ b/fakts/backends/enums.py @@ -0,0 +1,7 @@ +from fakts.backends.instances import registry +import strawberry +from enum import Enum + + + +BackendType = strawberry.enum(Enum('BackendType', {i: i for i in registry.get_backend_identifiers()})) \ No newline at end of file diff --git a/fakts/backends/instances.py b/fakts/backends/instances.py new file mode 100644 index 0000000..8685b78 --- /dev/null +++ b/fakts/backends/instances.py @@ -0,0 +1,7 @@ + + +from .backend_registry import BackendRegistry +import strawberry +from enum import Enum + +registry = BackendRegistry() diff --git a/fakts/backends/types.py b/fakts/backends/types.py new file mode 100644 index 0000000..e69de29 diff --git a/fakts/enums.py b/fakts/enums.py index 8d83405..2973e55 100644 --- a/fakts/enums.py +++ b/fakts/enums.py @@ -3,6 +3,7 @@ from django.db.models import TextChoices + class ClientKindChoices(TextChoices): """Event Type for the Event Operator""" diff --git a/fakts/errors.py b/fakts/errors.py index c4c64d4..5ca5a4c 100644 --- a/fakts/errors.py +++ b/fakts/errors.py @@ -16,3 +16,7 @@ class ConfigurationRequestMalformed(ConfigurationError): class NoConfigurationFound(Exception): pass + + +class BackendNotAvailable(ConfigurationError): + pass diff --git a/fakts/forms.py b/fakts/forms.py index 269a7ed..f108746 100644 --- a/fakts/forms.py +++ b/fakts/forms.py @@ -5,7 +5,7 @@ class ConfigureForm(forms.Form): device_code = forms.CharField(required=False, widget=forms.HiddenInput()) - composition = forms.ModelChoiceField(models.Composition.objects.all(), required=False, initial=1, widget=forms.HiddenInput()) + composition = forms.ModelChoiceField(models.Composition.objects.all(), required=False) class DeviceForm(forms.Form): diff --git a/fakts/logic.py b/fakts/logic.py index 3b87bba..ff4d299 100644 --- a/fakts/logic.py +++ b/fakts/logic.py @@ -9,12 +9,39 @@ from fakts import fields, errors from django.http import HttpRequest from uuid import uuid4 +from fakts.backends.instances import registry as backend_registry -def render_template(composition: models.Composition, context: base_models.LinkingContext) -> dict: - return yaml.load(Template(composition.template).render(context), Loader=yaml.SafeLoader) +def render_composition(composition: models.Composition, context: base_models.LinkingContext) -> dict: + + config_dict = {} + + config_dict["self"] = {} + config_dict["self"]["deployment_name"] = context.deployment_name + + + for instance in composition.instances.all(): + + instance = models.ServiceInstance.objects.get(identifier=instance.identifier) + + if instance.backend not in backend_registry.backends: + raise errors.BackendNotAvailable(f"The backend {instance.backend} for this instance is not available") + + backend = backend_registry.backends[instance.backend] + + value = backend.render(instance.identifier, context) + + if not isinstance(value, dict): + raise errors.ConfigurationError(f"The backend {instance.backend} for this instance did not return a dictionary") + + config_dict[instance.service.key] = value + + return config_dict + + + def create_api_token(): diff --git a/fakts/management/commands/ensurecompositions.py b/fakts/management/commands/ensurecompositions.py index 3af2d05..6f50bdf 100644 --- a/fakts/management/commands/ensurecompositions.py +++ b/fakts/management/commands/ensurecompositions.py @@ -5,6 +5,9 @@ from fakts import models, base_models import yaml +from fakts.backends.instances import registry + + # import required module from pathlib import Path @@ -20,21 +23,46 @@ class Command(BaseCommand): help = "Creates an admin user non-interactively if it doesn't exist" def handle(self, *args, **kwargs): - # Ensuring templates - - # iterate over files in - # that directory - templates = Path(settings.COMPOSITIONS_DIR).glob("*.yaml") - for file in templates: - filename = os.path.basename(file).split(".")[0] - - with open(file, "r") as file: - template = file.read() - - validated = base_models.CompositionInputModel(template=template, name=filename) - graph, _ = models.Composition.objects.update_or_create( - name=validated.name, defaults=dict(template=validated.template) - ) - print("Ensured template", filename) + + + for service_description in registry.get_service_descriptors(): + + service, created = models.Service.objects.update_or_create( + identifier=service_description.identifier, defaults=dict(name=service_description.name or service_description.identifier, logo=service_description.logo, description=service_description.description, key=service_description.key) + ) + + print("Ensured service", service_description.identifier) + + for instance_description in registry.get_instance_descriptors(): + try: + service = models.Service.objects.get(identifier=instance_description.service_identifier) + except models.Service.DoesNotExist: + print("Service", instance_description.service_identifier, "not found") + raise + + instance, created = models.ServiceInstance.objects.update_or_create( + identifier=instance_description.instance_identifier, backend=instance_description.backend_identifier, defaults=dict(service=service) + ) + + print("Ensured instance", instance_description.instance_identifier) + + for composition_description in registry.get_composition_descriptors(): + graph, created = models.Composition.objects.update_or_create( + name=composition_description.name + ) + + print("Ensured Composition", composition_description.name) + + for service, instance in composition_description.services.items(): + instance = models.ServiceInstance.objects.get(identifier=instance.instance_identifier) + graph.instances.add(instance) + print("Ensured service", instance.identifier, "in composition", graph.name) + + + + + + + diff --git a/fakts/migrations/0001_initial.py b/fakts/migrations/0001_initial.py index d4259fc..298ac43 100644 --- a/fakts/migrations/0001_initial.py +++ b/fakts/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 4.2.4 on 2023-09-05 15:26 +# Generated by Django 4.2.5 on 2024-03-08 11:20 +from django.conf import settings from django.db import migrations, models import django.db.models.deletion import django_choices_field.fields @@ -12,7 +13,10 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] operations = [ migrations.CreateModel( @@ -100,7 +104,66 @@ class Migration(migrations.Migration): ), ), ("name", models.CharField(max_length=1000, unique=True)), + ( + "description", + models.TextField(blank=True, max_length=1000, null=True), + ), + ], + ), + migrations.CreateModel( + name="Service", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=1000)), + ("identifier", fakts.fields.IdentifierField(max_length=1000)), + ( + "logo", + fakts.fields.S3ImageField(blank=True, max_length=1000, null=True), + ), + ("description", models.TextField()), + ( + "key", + models.CharField(default=uuid.uuid4, max_length=1000, unique=True), + ), + ], + ), + migrations.CreateModel( + name="ServiceInstance", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("backend", models.CharField(max_length=1000)), + ("identifier", models.CharField(max_length=1000)), ("template", models.TextField()), + ( + "composition", + models.ManyToManyField( + related_name="instances", to="fakts.composition" + ), + ), + ( + "service", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="instances", + to="fakts.service", + ), + ), ], ), migrations.CreateModel( @@ -169,6 +232,7 @@ class Migration(migrations.Migration): "staging_logo", fakts.fields.S3ImageField(blank=True, max_length=1000, null=True), ), + ("staging_public", models.BooleanField(default=False)), ("staging_redirect_uris", models.JSONField(default=list)), ("expires_at", models.DateTimeField()), ("denied", models.BooleanField(default=False)), @@ -180,6 +244,81 @@ class Migration(migrations.Migration): to="fakts.client", ), ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), + migrations.AddField( + model_name="client", + name="composition", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="clients", + to="fakts.composition", + ), + ), + migrations.AddField( + model_name="client", + name="oauth2_client", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="client", + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), + migrations.AddField( + model_name="client", + name="release", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="clients", + to="fakts.release", + ), + ), + migrations.AddField( + model_name="client", + name="tenant", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="managed_clients", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="client", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="clients", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddConstraint( + model_name="serviceinstance", + constraint=models.UniqueConstraint( + fields=("backend", "identifier"), + name="Only one instance per backend and identifier", + ), + ), + migrations.AddConstraint( + model_name="release", + constraint=models.UniqueConstraint( + fields=("app", "version"), name="Only one per app and version" + ), + ), + migrations.AddConstraint( + model_name="client", + constraint=models.UniqueConstraint( + fields=("release", "user", "kind"), + name="Only one per releast, tenankt and kind", + ), + ), ] diff --git a/fakts/migrations/0002_initial.py b/fakts/migrations/0002_initial.py deleted file mode 100644 index 193c03f..0000000 --- a/fakts/migrations/0002_initial.py +++ /dev/null @@ -1,87 +0,0 @@ -# Generated by Django 4.2.4 on 2023-09-05 15:26 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("fakts", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="devicecode", - name="user", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name="client", - name="composition", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="clients", - to="fakts.composition", - ), - ), - migrations.AddField( - model_name="client", - name="oauth2_client", - field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="client", - to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, - ), - ), - migrations.AddField( - model_name="client", - name="release", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="clients", - to="fakts.release", - ), - ), - migrations.AddField( - model_name="client", - name="tenant", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="managed_clients", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name="client", - name="user", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="clients", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddConstraint( - model_name="release", - constraint=models.UniqueConstraint( - fields=("app", "version"), name="Only one per app and version" - ), - ), - migrations.AddConstraint( - model_name="client", - constraint=models.UniqueConstraint( - fields=("release", "user", "kind"), - name="Only one per releast, tenankt and kind", - ), - ), - ] diff --git a/fakts/migrations/0003_devicecode_staging_public.py b/fakts/migrations/0003_devicecode_staging_public.py deleted file mode 100644 index e8350c9..0000000 --- a/fakts/migrations/0003_devicecode_staging_public.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.5 on 2024-01-05 20:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("fakts", "0002_initial"), - ] - - operations = [ - migrations.AddField( - model_name="devicecode", - name="staging_public", - field=models.BooleanField(default=False), - ), - ] diff --git a/fakts/models.py b/fakts/models.py index 8e20b0a..43d6b96 100644 --- a/fakts/models.py +++ b/fakts/models.py @@ -17,14 +17,48 @@ +class Service(models.Model): + name = models.CharField(max_length=1000) + identifier = fields.IdentifierField() + logo = fields.S3ImageField() + description = models.TextField() + key: str = models.CharField(max_length=1000, unique=True, default=uuid.uuid4) + + def __str__(self): + return f"{self.identifier}: {self.key}" + + + + +class ServiceInstance(models.Model): + backend = models.CharField(max_length=1000) + composition = models.ManyToManyField("Composition", related_name="instances") + service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name="instances") + identifier = models.CharField(max_length=1000) + template = models.TextField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["backend", "identifier"], + name="Only one instance per backend and identifier", + ) + ] + + def __str__(self): + return f"{self.service}:{self.backend}:{self.identifier}" + + + class Composition(models.Model): """A template for a configuration""" name = models.CharField(max_length=1000, unique=True) - template = models.TextField() + description = models.TextField(max_length=1000, null=True, blank=True) def __str__(self) -> str: return self.name + diff --git a/fakts/scalars.py b/fakts/scalars.py index a49f7d4..7762f7e 100644 --- a/fakts/scalars.py +++ b/fakts/scalars.py @@ -14,4 +14,11 @@ description="The `Version` represents a semver version string", serialize=lambda v: v, parse_value=lambda v: v, +) + +ServiceIdentifier = strawberry.scalar( + NewType("ServiceIdentifier", str), + description="The Service identifier is a unique identifier for a service. It is used to identify the service in the database and in the code. We encourage you to use the reverse domain name notation. E.g. `com.example.myservice`", + serialize=lambda v: v, + parse_value=lambda v: v, ) \ No newline at end of file diff --git a/fakts/types.py b/fakts/types.py index 4530801..ea19558 100644 --- a/fakts/types.py +++ b/fakts/types.py @@ -13,8 +13,7 @@ import datetime from fakts import models, scalars, enums, filters from oauth2_provider.models import Application - - +from fakts.backends import enums as fb_enums @strawberry.type(description="A scope that can be assigned to a client. Scopes are used to limit the access of a client to a user's data. They represent app-level permissions.") class Scope: label: str = strawberry.field(description="The label of the scope. This is the human readable name of the scope.") @@ -22,12 +21,29 @@ class Scope: value: str = strawberry.field(description="The value of the scope. This is the value that is used in the OAuth2 flow.") + +@strawberry_django.type(models.Service, description="A Service is a Webservice that a Client might want to access. It is not the configured instance of the service, but the service itself.") +class Service: + id: strawberry.ID + name: str = strawberry.field(description="The name of the service") + identifier: scalars.ServiceIdentifier = strawberry.field(description="The identifier of the service. This should be a globally unique string that identifies the service. We encourage you to use the reverse domain name notation. E.g. `com.example.myservice`") + logo: str = strawberry.field(description="The logo of the service. This should be a url to a logo that can be used to represent the service.") + description: str = strawberry.field(description="The description of the service. This should be a human readable description of the service.") + +@strawberry_django.type(models.ServiceInstance, description="A ServiceInstance is a configured instance of a Service. It will be configured by a configuration backend and will be used to send to the client as a configuration. It should never contain sensitive information.") +class ServiceInstance: + id: strawberry.ID + service: Service = strawberry.field(description="The service that this instance belongs to.") + backend: fb_enums.BackendType = strawberry.field(description="The backend that this instance belongs to.") + composition: "Composition" = strawberry.field(description="The composition that this instance belongs to.") + name: str = strawberry.field(description="The name of the instance. This is a human readable name of the instance.") + @strawberry_django.type(models.Composition) class Composition: id: strawberry.ID name: str = strawberry.field(description="The name of the composition") template: str = strawberry.field(description="The template of the composition. This is a Jinja2 YAML template that will be rendered with the LinkingContext as context. The result of the rendering will be used to send to the client as a configuration. It should never contain sensitive information.") - + instances: list[ServiceInstance] = strawberry.field(description="The instances of the composition. An instance is a configured instance of a service that will be used to send to the client as a configuration. It should never contain sensitive information.") @strawberry_django.type(models.App, description="An App is the Arkitekt equivalent of a Software Application. It is a collection of `Releases` that can be all part of the same application. E.g the App `Napari` could have the releases `0.1.0` and `0.2.0`.") class App: diff --git a/fakts/views.py b/fakts/views.py index 1139b09..1c64b27 100644 --- a/fakts/views.py +++ b/fakts/views.py @@ -132,7 +132,12 @@ def form_valid(self, form): device_code = form.cleaned_data["device_code"] - composition = form.cleaned_data["composition"] + composition = form.cleaned_data.get("composition", None) + + if not composition: + composition = models.Composition.objects.first() + if not composition: + raise Exception("No composition found") if action == "allow": @@ -490,7 +495,7 @@ def post(self, request, *args, **kwargs): else: composition = client.composition - config = logic.render_template(composition, context) + config = logic.render_composition(composition, context) return JsonResponse( data={ diff --git a/lok/settings.py b/lok/settings.py index e6b7cad..ad6cf7f 100644 --- a/lok/settings.py +++ b/lok/settings.py @@ -62,6 +62,16 @@ ] +FAKTS_BACKENDS = [ + { + "NAME": "contrib.docker_backend.DockerBackend", + }, + { + "NAME": "contrib.config_backend.backend.ConfigBackend", + }, +] + + ACCOUNT_EMAIL_VERIFICATION = "none" # we don't have an smpt server by default # Authentikate section