openstoreplugin 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alexi Desvignes Nouvel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: openstoreplugin
3
+ Version: 0.0.1
4
+ Summary: A library to base your controller plugins for Open Store
5
+ License-File: LICENSE
6
+ Keywords: open,openstore,store,openstoreplugin,plugin
7
+ Author: Alexi Desvignes Nouvel
8
+ Requires-Python: >=3.10
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: docker (>=7.1.0,<8.0.0)
16
+ Requires-Dist: pydantic (>=2.11.4,<3.0.0)
17
+ Project-URL: Documentation, https://github.com/AlexiDN/openstoreplugin/wiki
18
+ Project-URL: Repository, https://github.com/AlexiDN/openstoreplugin
19
+ Description-Content-Type: text/markdown
20
+
21
+ # OpenStorPlugin
22
+
23
+
24
+ ## Features
25
+
26
+ TODO
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install openstoreplugin
32
+ ```
33
+ ## Usage
34
+
35
+ TODO
36
+
37
+ ## License
38
+
39
+ The project is released under the MIT License.
40
+
41
+ ## Project Status
42
+
43
+ > **NOTE** <br>
44
+ > The project is currently in development phase. If you have any remark or question feel free to open an Issue
@@ -0,0 +1,24 @@
1
+ # OpenStorPlugin
2
+
3
+
4
+ ## Features
5
+
6
+ TODO
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install openstoreplugin
12
+ ```
13
+ ## Usage
14
+
15
+ TODO
16
+
17
+ ## License
18
+
19
+ The project is released under the MIT License.
20
+
21
+ ## Project Status
22
+
23
+ > **NOTE** <br>
24
+ > The project is currently in development phase. If you have any remark or question feel free to open an Issue
@@ -0,0 +1,100 @@
1
+ [tool.poetry]
2
+ packages = [{ include = "openstoreplugin", from = "src" }]
3
+
4
+ [project]
5
+ name = "openstoreplugin"
6
+ version = "0.0.1"
7
+ readme = "README.md"
8
+ description = "A library to base your controller plugins for Open Store"
9
+ authors = [{name="Alexi Desvignes Nouvel"}]
10
+ keywords = [ "open", "openstore","store","openstoreplugin","plugin" ]
11
+ requires-python=">=3.10"
12
+ dynamic=["dependencies"]
13
+
14
+ [project.urls]
15
+ repository = "https://github.com/AlexiDN/openstoreplugin"
16
+ documentation = "https://github.com/AlexiDN/openstoreplugin/wiki"
17
+
18
+ # Common dependencies
19
+ [tool.poetry.dependencies]
20
+ pydantic = "^2.11.4"
21
+ docker = "^7.1.0"
22
+
23
+ [tool.poetry.group.lint.dependencies]
24
+ ruff = "^0.11.9"
25
+
26
+ [tool.poetry.group.test.dependencies]
27
+ pytest = "^8.4.1"
28
+
29
+ [tool.pytest.ini_options]
30
+ addopts = "-ra -q"
31
+ testpaths = ["tests"]
32
+ pythonpath = ["src", "tests"]
33
+
34
+ [tool.ruff]
35
+ src = ["src"]
36
+ exclude = [".cache"]
37
+ extend-exclude = []
38
+ line-length = 120
39
+ indent-width = 4
40
+
41
+ [tool.ruff.lint]
42
+ fixable = ["ALL"]
43
+ unfixable = []
44
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
45
+ select = ["ALL"]
46
+ ignore = [
47
+ "D107",
48
+ "D101",
49
+ "D400",
50
+ "D415",
51
+ "PLR0913",
52
+ "FBT001",
53
+ "FBT002",
54
+ "N815",
55
+ "PYI051",
56
+ "FA102",
57
+ "FA100",
58
+ "D100",
59
+ "I001",
60
+ "CPY001",
61
+ "D205",
62
+ "FBT003",
63
+ "FIX002",
64
+ "TD002",
65
+ "TD003",
66
+ "TD004",
67
+ "PLR0917",
68
+ "DOC201",
69
+ "D203",
70
+ "COM812",
71
+ "D213",
72
+ "RUF012",
73
+ "PGH004",
74
+ "DOC501",
75
+ "D401",
76
+ "BLE001",
77
+ "TRY003",
78
+ "EM101",
79
+ "D104",
80
+ "ANN401",
81
+ "T201",
82
+ "TC001",
83
+ "PTH120",
84
+ "PTH123",
85
+ "PTH103",
86
+ "F403",
87
+
88
+ ]
89
+
90
+ [tool.ruff.format]
91
+ quote-style = "double"
92
+ indent-style = "space"
93
+ skip-magic-trailing-comma = false
94
+ line-ending = "auto"
95
+ docstring-code-format = true
96
+
97
+
98
+ [build-system]
99
+ requires = ["poetry-core>=1.8.5"]
100
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,4 @@
1
+ from .controller_plugin import *
2
+ from .enums import *
3
+ from .models import *
4
+ from .connectors import *
@@ -0,0 +1 @@
1
+ from .docker_engine_connector import *
@@ -0,0 +1,88 @@
1
+ from docker import from_env
2
+ from docker.errors import NotFound
3
+ from docker.models.containers import Container, ExecResult
4
+ from docker.models.networks import Network
5
+
6
+ from openstoreplugin.models.docker import DockerContainerOptions, DockerNetworkOptions
7
+
8
+
9
+ class DockerEngineConnector:
10
+ user: str
11
+
12
+ def __init__(self, user: str = "root") -> None:
13
+ self.client = from_env()
14
+ self.user = user
15
+
16
+ def get_api_version(self) -> str:
17
+ """Return the docker API Version or 'Unknown' if it does not exist"""
18
+ return self.client.version().get("ApiVersion", "Unknown")
19
+
20
+ def get_container_from_hostname(self, container_hostname: str, allow_failure: bool = False) -> Container:
21
+ """Return the container associated to the hostname given or raise an error if it is not found"""
22
+ for container in self.client.containers.list():
23
+ hostname = container.attrs.get("Config", {})("Hostname")
24
+ if hostname == container_hostname:
25
+ return container
26
+ if allow_failure:
27
+ return None
28
+ msg = f"No container found with hostname {container_hostname}."
29
+ raise NotFound(msg)
30
+
31
+ def get_container_from_name(self, container_name: str, allow_failure: bool = False) -> Container:
32
+ """Return the container associated to the name given or raise an error if it is not found"""
33
+ try:
34
+ return self.client.containers.get(container_name)
35
+ except NotFound as err:
36
+ if allow_failure:
37
+ return None
38
+ msg = f"No container found with name {container_name}."
39
+ raise NotFound(msg) from err
40
+
41
+ def get_network_from_name(self, network_name: str, allow_failure: bool = False) -> Network:
42
+ """Return the network associated to the name given or raise an error if it is not found"""
43
+ try:
44
+ return self.client.networks.get(network_name)
45
+ except NotFound as err:
46
+ if allow_failure:
47
+ return None
48
+ msg = f"No network found with name {network_name}."
49
+ raise NotFound(msg) from err
50
+
51
+ def execute_command_on_container(self, container_name: str, command: str, user: str | None = None) -> ExecResult:
52
+ """Execute the command on the container described
53
+ by container_name and return the ExecResult object associated
54
+ """
55
+ return self.get_container_from_name(container_name).exec_run(command, user=user or self.user)
56
+
57
+ def deploy_container(self, options: DockerContainerOptions) -> str:
58
+ """Deploy a container and return its name"""
59
+ try:
60
+ self.get_network_from_name(options.network)
61
+ except NotFound as err:
62
+ msg = f"Can't deploy container {options.name} since the network {options.network} does not exist."
63
+ raise RuntimeError(msg) from err
64
+ data = options.to_docker(self.get_api_version())
65
+ container: Container = self.client.containers.run(detach=True, **data)
66
+ return container.name
67
+
68
+ def remove_container(self, container_name: str, force: bool = True) -> None:
69
+ """Remove a container from docker"""
70
+ if force:
71
+ self.stop_container(container_name)
72
+ return self.get_container_from_name(container_name).remove()
73
+
74
+ def start_container(self, container_name: str) -> None:
75
+ """Start an already created container"""
76
+ return self.get_container_from_name(container_name).start()
77
+
78
+ def stop_container(self, container_name: str, timeout: int = 60) -> None:
79
+ """Stop the container without removing it"""
80
+ return self.get_container_from_name(container_name).stop(timeout=timeout)
81
+
82
+ def create_network(self, network_options: DockerNetworkOptions) -> str:
83
+ """Create a docker network"""
84
+ return self.client.networks.create(**network_options.model_dump(mode="json", exclude_unset=True)).name
85
+
86
+ def remove_network(self, network_name: str) -> None:
87
+ """Remove a Docker network and return its name"""
88
+ return self.get_network_from_name(network_name).remove()
@@ -0,0 +1,109 @@
1
+ from typing import Any
2
+
3
+ from openstoreplugin.controller_tasks_args import CONTROLLER_DEFAULT_TASKS
4
+ from openstoreplugin.models.controller_task import ControllerTask
5
+
6
+ from openstoreplugin.models.store_config import OpenStoreConfigModel
7
+ from openstoreplugin.models.application_config import ApplicationConfiguration
8
+ from openstoreplugin.enums import TaskResponse, TaskStatus
9
+ from openstoreplugin.connectors import DockerEngineConnector
10
+
11
+
12
+ class ControllerPlugin:
13
+ application_config: ApplicationConfiguration
14
+ docker_connector: DockerEngineConnector
15
+ tasks: dict[str, ControllerTask]
16
+ store_config: OpenStoreConfigModel
17
+
18
+ def __init__(
19
+ self,
20
+ application_config: ApplicationConfiguration,
21
+ store_config: OpenStoreConfigModel,
22
+ ) -> None:
23
+ self.application_config = application_config
24
+ self.application_config.set_store_config(store_config)
25
+ self.docker_connector = DockerEngineConnector()
26
+ self.store_config = store_config
27
+ # create task mapping after verifying method exist
28
+ # TODO also verify args and make it better to declaire tasks using decorator @tasks to prefill arg of __init__
29
+ for task in CONTROLLER_DEFAULT_TASKS:
30
+ if not hasattr(self, task.controller_method.name):
31
+ msg = f"{self.__class__.__name__} does not have method \
32
+ {task.controller_method.name} required for task {task.name}"
33
+ raise ValueError(msg)
34
+ self.tasks = {task.name: task for task in CONTROLLER_DEFAULT_TASKS}
35
+
36
+ def execute_task(self, task: str, **kwargs: dict[str, Any]) -> TaskResponse:
37
+ """Execute a task using the tasks mapping of the instance.
38
+ Return the TaskResponse return by the method called
39
+ """
40
+ # verify task exist
41
+ if task in self.tasks:
42
+ method_info = self.tasks.get(task).controller_method
43
+ method_args = {arg.name: arg for arg in method_info.args}
44
+ # verify task arguments
45
+ for arg, value in kwargs.items():
46
+ if arg not in method_args or not isinstance(value, method_args[arg].type_):
47
+ msg = f"Arg {arg} does not match any arguments avalaible for task {task}.\
48
+ Available args: {method_args.keys()}"
49
+ raise ValueError(msg)
50
+ # extract method
51
+ method = getattr(self, method_info.name)
52
+ try:
53
+ return method(**kwargs) or TaskResponse(status=TaskStatus.SUCCESS)
54
+ except Exception as err:
55
+ return TaskResponse(
56
+ status=TaskStatus.FAILURE,
57
+ details=f"{err.__class__.__name__}:{err}",
58
+ )
59
+ msg = f"Task {task} does not exist."
60
+ raise RuntimeError(msg)
61
+
62
+ # ================
63
+ # Default tasks
64
+ # ================
65
+
66
+ def install(self) -> None:
67
+ """Install the application"""
68
+ self.create_bind_mounts()
69
+ self.deploy_docker()
70
+ return TaskResponse(status=TaskStatus.SUCCESS)
71
+
72
+ def create_bind_mounts(self) -> None:
73
+ """Create the directories and files mounted onto the docker container"""
74
+ for docker_config in self.application_config.docker_configs:
75
+ for volume in docker_config.container_options.volumes:
76
+ volume.create_on_host()
77
+
78
+ def delete_bind_mounts(self) -> None:
79
+ """Delete all bind mounts for the application"""
80
+ for docker_config in self.application_config.docker_configs:
81
+ for volume in docker_config.container_options.volumes:
82
+ volume.remove_from_host()
83
+
84
+ def deploy_docker(self) -> None:
85
+ """Deploy the docker part of the application"""
86
+ docker_configs = self.application_config.docker_configs
87
+ for docker_config in docker_configs:
88
+ # create network if it does not exist
89
+ if not self.docker_connector.get_network_from_name(
90
+ docker_config.container_options.network,
91
+ allow_failure=True,
92
+ ):
93
+ if not hasattr(docker_config, "network_options"):
94
+ msg = f"Network {docker_config.container_options.network} \
95
+ does not exist and is not declared in application config"
96
+ raise ValueError(msg)
97
+ self.docker_connector.create_network(docker_config.network_options)
98
+
99
+ # deploy the container
100
+ self.docker_connector.deploy_container(docker_config.container_options)
101
+
102
+ def uninstall(self, cleanup_volumes: bool = False) -> None:
103
+ """Uninstall the application"""
104
+ # remove docker containers
105
+ for docker_config in self.application_config.docker_configs:
106
+ self.docker_connector.remove_container(docker_config.container_options.name)
107
+ # cleanup volumes
108
+ if cleanup_volumes:
109
+ self.delete_bind_mounts()
@@ -0,0 +1,26 @@
1
+ from openstoreplugin.models.controller_task import ControllerMethod, ControllerTask, MethodArg
2
+
3
+
4
+ CONTROLLER_DEFAULT_TASKS = [
5
+ ControllerTask(
6
+ name="Install",
7
+ description="Install the application.",
8
+ controller_method=ControllerMethod(
9
+ name="install",
10
+ ),
11
+ ),
12
+ ControllerTask(
13
+ name="Uninstall",
14
+ description="Uninstall the application and optionnally remove its files.",
15
+ controller_method=ControllerMethod(
16
+ name="uninstall",
17
+ args=[
18
+ MethodArg(
19
+ name="cleanup_volumes",
20
+ type=bool,
21
+ description="Should the applications files be removed?",
22
+ )
23
+ ],
24
+ ),
25
+ ),
26
+ ]
@@ -0,0 +1,12 @@
1
+ from enum import Enum
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class TaskStatus(str, Enum):
6
+ SUCCESS = "success"
7
+ FAILURE = "failure"
8
+
9
+
10
+ class TaskResponse(BaseModel):
11
+ status: TaskStatus
12
+ details: str = ""
@@ -0,0 +1,4 @@
1
+ from .application_config import *
2
+ from .docker import *
3
+ from .docker import *
4
+ from .base_model import *
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+ from pydantic import Field
3
+
4
+ from openstoreplugin.models.base_model import BaseConfig
5
+
6
+ from openstoreplugin.models.docker import DockerContainerOptions, DockerNetworkOptions
7
+
8
+
9
+ class DockerConfig(BaseConfig):
10
+ container_options: DockerContainerOptions = Field(title="Options for the docker container")
11
+ network_options: DockerNetworkOptions | None = Field(
12
+ None, title="Options for the docker network used by the container if it does not already exist "
13
+ )
14
+
15
+
16
+ class ApplicationConfiguration(BaseConfig):
17
+ version: str = Field(title="Version of the Application")
18
+ docker_configs: list[DockerConfig] = Field(title="Options for the docker containers")
19
+ extra: dict[str, Any] = Field(default={}, title="Extra configuration")
@@ -0,0 +1,153 @@
1
+ from types import SimpleNamespace
2
+ from typing import Any, Literal
3
+ from collections.abc import Callable
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class BaseConfig(BaseModel):
8
+ model_variables: dict[str, Any] = Field(
9
+ default={},
10
+ description="The variables shared accross the model and its submodels. \
11
+ This is accessible using '{config.key_to_access}'",
12
+ )
13
+ _config: dict[str, Any] = None
14
+ _interpolate: bool = True
15
+
16
+ def __init__(self, *args: tuple, **kwargs: dict[str, Any]) -> None:
17
+ super().__init__(*args, **kwargs)
18
+ self._propagate_model_variables()
19
+
20
+ def model_dump(
21
+ self,
22
+ *,
23
+ mode: str | Literal["json", "python"] = "python",
24
+ include: Any | None = None,
25
+ exclude: Any | None = None,
26
+ context: dict | None = None,
27
+ by_alias: Any | None = None,
28
+ exclude_unset: bool = False,
29
+ exclude_defaults: bool = False,
30
+ exclude_none: bool = False,
31
+ round_trip: bool = False,
32
+ warnings: bool = True,
33
+ fallback: Callable | None = None,
34
+ serialize_as_any: bool = False,
35
+ interpolate: bool = True,
36
+ ) -> dict[str, Any]:
37
+ """Generate a dictionary representation of the model,
38
+ optionally specifying which fields to include or exclude.
39
+ """
40
+ if isinstance(context, dict):
41
+ interpolate = interpolate or context.get("interpolate", False)
42
+ context["interpolate"] = interpolate
43
+ if not context:
44
+ context = {"interpolate": interpolate}
45
+
46
+ data = super().model_dump(
47
+ mode=mode,
48
+ include=include,
49
+ exclude=exclude,
50
+ context=context,
51
+ by_alias=by_alias,
52
+ exclude_unset=exclude_unset,
53
+ exclude_defaults=exclude_defaults,
54
+ exclude_none=exclude_none,
55
+ round_trip=round_trip,
56
+ warnings=warnings,
57
+ fallback=fallback,
58
+ serialize_as_any=serialize_as_any,
59
+ )
60
+ if interpolate:
61
+ return BaseConfig._recursive_interpolate(data, SimpleNamespace(**self._config))
62
+ return data
63
+
64
+ def set_store_config(self, config: BaseModel) -> None:
65
+ """Set the _config variable using the store config object.
66
+ Apply it recursively to the model fields
67
+ Note that only lists and dicts iterables are checked
68
+ """
69
+ # set or update self._config with the config_model
70
+ if not self._config:
71
+ self._config = config.model_dump(mode="json")
72
+ else:
73
+ # Interpolate self_config with the store config before merging everything
74
+ interpolated_config = BaseConfig._recursive_interpolate(
75
+ self._config, SimpleNamespace(**config.model_dump(mode="json"))
76
+ )
77
+ # do not use .update since config does not have priority over model_variables
78
+ self._config = {**config.model_dump(mode="json"), **interpolated_config}
79
+ # propagate to model fields
80
+ for attr in vars(self).values():
81
+ self._recursive_call_method_on_fields(attr, "set_store_config", config)
82
+
83
+ def change_interpolation(self, interpolate: bool) -> None:
84
+ """Set the _interpolate variable using the boolean given.
85
+ Apply it recursively to the model fields
86
+ Note that only lists and dicts iterables are checked
87
+ """
88
+ self._interpolate = interpolate
89
+ for attr in vars(self).values():
90
+ self._recursive_call_method_on_fields(attr, "change_interpolation", interpolate)
91
+
92
+ def _propagate_model_variables(self, parent_model_variables: dict[str, Any] | None = None) -> None:
93
+ """Propagate model_variables to self._config for model and submodels"""
94
+ parent_model_variables = parent_model_variables or {}
95
+ # model_variables are top priority then parent ones then _config
96
+ base_config = {**(self._config or {}), **parent_model_variables}
97
+ # try to interpolate variables that can be interpolated
98
+ interpolated_model_variables = BaseConfig._recursive_interpolate(
99
+ self.model_variables, SimpleNamespace(**base_config)
100
+ )
101
+ self._config = {
102
+ **(self._config or {}),
103
+ **parent_model_variables,
104
+ **interpolated_model_variables,
105
+ }
106
+ # propagate to model fields
107
+ for attr in vars(self).values():
108
+ self._recursive_call_method_on_fields(attr, "_propagate_model_variables", self._config)
109
+
110
+ @staticmethod
111
+ def _recursive_call_method_on_fields(obj: Any, method_name: str, *args: tuple, **kwargs: dict[str, Any]) -> None:
112
+ """Recursively call method on fields with the args and kwargs given.
113
+ Only lists and dicts iterables are checked
114
+ """
115
+ if isinstance(obj, BaseConfig):
116
+ method: Callable = getattr(obj, method_name)
117
+ method(*args, **kwargs)
118
+ elif isinstance(obj, list):
119
+ for elem in obj:
120
+ BaseConfig._recursive_call_method_on_fields(elem, method_name, *args, **kwargs)
121
+ elif isinstance(obj, dict):
122
+ for v in obj.values():
123
+ BaseConfig._recursive_call_method_on_fields(v, method_name, *args, **kwargs)
124
+
125
+ def __getattribute__(self, name: str) -> Any:
126
+ """Return the attribute requested after interpolating it with self._config"""
127
+ res = super().__getattribute__(name)
128
+ if (
129
+ not name.startswith("_") # exclude private attributes
130
+ and name in self.__pydantic_fields__ # select only fields
131
+ and self.__pydantic_private__.get("_interpolate") # is interpolation active
132
+ and self.__pydantic_private__.get("_config") # is _config set
133
+ and name != "model_variables" # prevent double interpolation
134
+ ):
135
+ return BaseConfig._recursive_interpolate(res, SimpleNamespace(**self._config))
136
+ return res
137
+
138
+ @staticmethod
139
+ def _recursive_interpolate(data: Any, config: SimpleNamespace) -> Any:
140
+ """Recursively interpolate strings with a config object"""
141
+ # case dict
142
+ if isinstance(data, dict):
143
+ return {k: BaseConfig._recursive_interpolate(v, config) for k, v in data.items()}
144
+ # case list/tuple
145
+ if isinstance(data, list):
146
+ return [BaseConfig._recursive_interpolate(v, config) for v in data]
147
+ # ensure data is string
148
+ if isinstance(data, str) and "{config" in data:
149
+ try:
150
+ return data.format(config=config)
151
+ except Exception:
152
+ return data
153
+ return data
@@ -0,0 +1,18 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class MethodArg(BaseModel):
5
+ name: str = Field(description="Name of the arg. ex: 'timeout'.")
6
+ type_: type = Field(description="Type of the arg. ex: int.", alias="type")
7
+ description: str = Field(description="Description of the arg. ex: 'Timeout of the action'.")
8
+
9
+
10
+ class ControllerMethod(BaseModel):
11
+ name: str = Field(description="Name of the method. ex: 'install'.")
12
+ args: list[MethodArg] = Field([], description="List of the args of the method.")
13
+
14
+
15
+ class ControllerTask(BaseModel):
16
+ name: str = Field(description="Name of the task. ex: 'Install'.")
17
+ description: str = Field(description="Description of the task. ex: 'Install the app'.")
18
+ controller_method: ControllerMethod = Field(description="Controller Method associated.")
@@ -0,0 +1,185 @@
1
+ from fnmatch import fnmatch
2
+ from pathlib import Path
3
+ import shutil
4
+ from typing import Any, Literal, Self
5
+ from pydantic import (
6
+ Field,
7
+ FieldSerializationInfo,
8
+ field_serializer,
9
+ model_validator,
10
+ )
11
+ from openstoreplugin.models.base_model import BaseConfig
12
+ from docker.types.networks import EndpointConfig
13
+
14
+ # NETWORKS
15
+
16
+
17
+ class IPAMPool(BaseConfig):
18
+ subnet: str = Field(description="Subnet in CIDR format (e.g. '172.28.0.0/16')")
19
+ gateway: str | None = Field(None, description="Gateway address within the subnet")
20
+
21
+
22
+ class IPAMConfig(BaseConfig):
23
+ pool_configs: list[IPAMPool] = Field(description="List of IPAM pools for the network")
24
+
25
+
26
+ class DockerNetworkOptions(BaseConfig):
27
+ name: str = Field(description="Name of the network")
28
+ driver: str | None = Field(None, description="Name of the driver used to create the network")
29
+ internal: bool = Field(False, description="Restrict external access to the network.")
30
+ check_duplicate: bool | None = Field(None, description="Request daemon to check for networks with same name.")
31
+ ipam: IPAMConfig | None = Field(None, description="IPAM configuration for the network")
32
+
33
+
34
+ # CONTAINERS
35
+
36
+
37
+ class DockerVolume(BaseConfig):
38
+ host_path: str = Field(description="Path of the volume on the host")
39
+ container_path: str = Field(description="Path of the volume on the container")
40
+ mode: Literal["ro", "rw"] | None = Field(None, description="Mode of the mount (rw or ro)")
41
+ type: Literal["dir", "file"] | None = Field(None, description="Type of mount (dir or file)")
42
+ removable_globs: list[str] | bool | None = Field(
43
+ None,
44
+ description="The globs of files that are removable during uninstall operation. \
45
+ Can be True to specify that all the volume is deletable. It will be ignored if content is not None",
46
+ )
47
+ content: str | None = Field(None, description="Content of the file if the volume is a file")
48
+
49
+ @model_validator(mode="after")
50
+ def validate_model(self) -> Self:
51
+ """Validate coherence of fields"""
52
+ if self.content:
53
+ if not Path(self.host_path).suffix:
54
+ raise ValueError(
55
+ "Volume can't hold content if the host path does not point to a file (ie has a suffix)"
56
+ )
57
+ if self.type == "dir":
58
+ raise ValueError("Volume can't hold content if its type is directory")
59
+ return self
60
+
61
+ def create_on_host(self) -> None:
62
+ """Create the volume on host.
63
+ If there is content -> Create parent directories and file with content
64
+ If there is no content -> Create the volume only if it is a directory (also create parents)
65
+ """
66
+ # if volume is a file with a specified content
67
+ if self.content:
68
+ path = Path(self.host_path)
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ path.write_text(self.content, encoding="utf-8")
71
+ # if volume is a directory
72
+ elif self.get_type() == "dir":
73
+ Path(self.host_path).mkdir(parents=True, exist_ok=True)
74
+
75
+ def remove_from_host(self) -> None:
76
+ """Remove all files and subdirectories of the volumes neccessary depending on self.removable_globs.
77
+ Note: if volume has a content field which is not null it will be removed
78
+ """
79
+ # Full removal (if content->volume is custom file so it should be removed)
80
+ if self.content or self.removable_globs is True:
81
+ # If volume is a file remove it
82
+ if self.get_type() == "file":
83
+ Path(self.host_path).unlink()
84
+ # If volume is a dir delete all the subtree
85
+ else:
86
+ shutil.rmtree(Path(self.host_path))
87
+ # Partial removal
88
+ elif self.removable_globs:
89
+ # If volume is a file remove it if it match a glob
90
+ if self.get_type() == "file" and any(
91
+ fnmatch.fnmatch(self.host_path, pattern) for pattern in self.removable_globs
92
+ ):
93
+ Path(self.host_path).unlink()
94
+ # if volume is a dir
95
+ else:
96
+ for glob in self.removable_globs:
97
+ # Delete the glob
98
+ for path in Path(self.host_path).glob(glob):
99
+ # Dinstinguish directories from files
100
+ if path.is_file() or path.is_symlink():
101
+ path.unlink()
102
+ elif path.is_dir():
103
+ shutil.rmtree(path)
104
+
105
+ def get_type(self) -> Literal["dir", "file"]:
106
+ """Return the type of the volume: either file or dir"""
107
+ if self.type == "file" or self.content or Path(self.host_path).suffix:
108
+ return "file"
109
+ return "dir"
110
+
111
+ def to_docker(self) -> str:
112
+ """Convert to a string interpretable by docker"""
113
+ return f"{self.host_path}:{self.container_path}" + (f":{self.mode}" if self.mode else "")
114
+
115
+
116
+ class EndpointConfiguration(BaseConfig):
117
+ ipv4_address: str = Field(description="Custom IPv4 address for the container")
118
+ ipv6_address: str | None = Field(None, description="Custom IPv6 address for the container")
119
+
120
+ def to_docker(self, api_version: str) -> EndpointConfig:
121
+ """Convert to EndpointConfig model from docker.types.networks"""
122
+ return EndpointConfig(api_version, **self.model_dump(mode="json", exclude_unset=True))
123
+
124
+
125
+ class RestartPolicy(BaseConfig):
126
+ Name: Literal["on-failure", "always"] = Field(description="The type of policy")
127
+ MaximumRetryCount: int = Field(ge=0, description="Maximum number of retry before stoping the container")
128
+
129
+
130
+ class DockerContainerOptions(BaseConfig, arbitrary_types_allowed=True):
131
+ name: str = Field(description="The name for this container")
132
+ hostname: str = Field(description="Hostname for the container")
133
+ image: str = Field(description="The image to run")
134
+ network: str = Field(description="Name of the network this container will be connected to at creation time")
135
+ networking_config: dict[str, EndpointConfiguration] | None = Field(
136
+ None,
137
+ description="Dictionary of EndpointConfig objects for each container network. \
138
+ The key is the name of the network. Defaults to None.Used in conjuction with network.",
139
+ )
140
+ restart_policy: RestartPolicy | None = Field(None, description="Restart the container when it exits.")
141
+ command: str | list[str] | None = Field(None, description="The command to run in the container")
142
+ entrypoint: str | list[str] | None = Field(None, description="The entrypoint for the container")
143
+ ports: dict[int | str, int | list[int], tuple[str, int]] | None = Field(
144
+ None,
145
+ description="Ports to bind inside the container.\n\
146
+ The keys of the dictionary are the ports to bind inside the container,\
147
+ either as an integer or a string in the form port/protocol, where the protocol is either tcp, udp, or sctp.",
148
+ )
149
+ volumes: list[DockerVolume] = Field(
150
+ [],
151
+ description="A list of volumes mounted inside the container.",
152
+ )
153
+ environment: list[str] | dict[str, str] | None = Field(
154
+ None, description="Environment variables to set inside the container"
155
+ )
156
+ labels: dict[str, str] | list[str] | None = Field(None, description="The labels of the container")
157
+ user: str | int | None = Field(None, description="Username or UID to run commands as inside the container.")
158
+ privileged: bool = Field(False, description="Give extended privileges to this container.")
159
+
160
+ @field_serializer("networking_config", when_used="json")
161
+ @staticmethod
162
+ def serialize_networking_config(
163
+ networking_config: dict[str, EndpointConfiguration] | None,
164
+ info: FieldSerializationInfo,
165
+ ) -> EndpointConfig | None: # Optional[dict[str, EndpointConfiguration]]:
166
+ """Serialize networking_config"""
167
+ if networking_config and isinstance(info.context, dict) and info.context.get("api_version"):
168
+ api_version = info.context.get("docker", "no_version")
169
+ return {key: value.to_docker(api_version) for key, value in networking_config.items()}
170
+ return {key: value.model_dump(**info.__dict__) for key, value in networking_config.items()}
171
+
172
+ @field_serializer("volumes", when_used="json")
173
+ @staticmethod
174
+ def serialize_volumes(
175
+ volumes: list[DockerVolume] | None,
176
+ info: FieldSerializationInfo,
177
+ ) -> list[str] | None:
178
+ """Serialize volumes"""
179
+ if volumes and isinstance(info.context, dict) and info.context.get("api_version"):
180
+ return [volume.to_docker() for volume in volumes]
181
+ return [volume.model_dump(**info.__dict__) for volume in volumes]
182
+
183
+ def to_docker(self, api_version: str) -> dict[str, Any]:
184
+ """Dump the model to be compatible with docker client.containers.run() command"""
185
+ return self.model_dump(mode="json", exclude_unset=True, context={"api_version": api_version})
@@ -0,0 +1,16 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from openstoreplugin.models.application_config import ApplicationConfiguration
4
+
5
+
6
+ class OpenStoreConfigModel(BaseModel):
7
+ # SMARTHOST
8
+ REMOTE_STORE_URL: str = Field("The url of the remote store")
9
+ # HOST
10
+ HOST_USER: str = Field("The user used on the host")
11
+ SERVER_BASE_DOMAIN: str = Field("The base domain of the server (example: test.com)")
12
+ SERVER_PUBLIC_IP: str = Field("The public ip of the server")
13
+ SERVER_PRIVATE_IP: str = Field("The private ip of the server")
14
+ # DOCKER
15
+ VOLUMES_ROOT_DIR: str = Field("The root directory for docker volumes on the host")
16
+ TRAEFIK_CONF: ApplicationConfiguration = Field("The private ip of treafik")