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.
- openstoreplugin-0.0.1/LICENSE +21 -0
- openstoreplugin-0.0.1/PKG-INFO +44 -0
- openstoreplugin-0.0.1/README.md +24 -0
- openstoreplugin-0.0.1/pyproject.toml +100 -0
- openstoreplugin-0.0.1/src/openstoreplugin/__init__.py +4 -0
- openstoreplugin-0.0.1/src/openstoreplugin/connectors/__init__.py +1 -0
- openstoreplugin-0.0.1/src/openstoreplugin/connectors/docker_engine_connector.py +88 -0
- openstoreplugin-0.0.1/src/openstoreplugin/controller_plugin.py +109 -0
- openstoreplugin-0.0.1/src/openstoreplugin/controller_tasks_args.py +26 -0
- openstoreplugin-0.0.1/src/openstoreplugin/enums.py +12 -0
- openstoreplugin-0.0.1/src/openstoreplugin/models/__init__.py +4 -0
- openstoreplugin-0.0.1/src/openstoreplugin/models/application_config.py +19 -0
- openstoreplugin-0.0.1/src/openstoreplugin/models/base_model.py +153 -0
- openstoreplugin-0.0.1/src/openstoreplugin/models/controller_task.py +18 -0
- openstoreplugin-0.0.1/src/openstoreplugin/models/docker.py +185 -0
- openstoreplugin-0.0.1/src/openstoreplugin/models/store_config.py +16 -0
|
@@ -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 @@
|
|
|
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,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")
|