inmanta-module-files 0.1.0__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,5 @@
1
+ include inmanta_plugins/files/setup.cfg
2
+ include inmanta_plugins/files/py.typed
3
+ recursive-include inmanta_plugins/files/model *.cf
4
+ graft inmanta_plugins/files/files
5
+ graft inmanta_plugins/files/templates
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.1
2
+ Name: inmanta-module-files
3
+ Version: 0.1.0
4
+ Summary: Simple module containing various types of resource to manage files.
5
+ Author: Guillaume Everarts de Velp
6
+ Author-email: edvgui@gmail.com
7
+ License: ASL 2.0
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: inmanta-module-std
10
+
11
+ # inmanta-module-files
12
+
13
+ This package is an adapter that is meant to be used with the inmanta orchestrator: https://docs.inmanta.com
@@ -0,0 +1,3 @@
1
+ # inmanta-module-files
2
+
3
+ This package is an adapter that is meant to be used with the inmanta orchestrator: https://docs.inmanta.com
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.1
2
+ Name: inmanta-module-files
3
+ Version: 0.1.0
4
+ Summary: Simple module containing various types of resource to manage files.
5
+ Author: Guillaume Everarts de Velp
6
+ Author-email: edvgui@gmail.com
7
+ License: ASL 2.0
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: inmanta-module-std
10
+
11
+ # inmanta-module-files
12
+
13
+ This package is an adapter that is meant to be used with the inmanta orchestrator: https://docs.inmanta.com
@@ -0,0 +1,19 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ inmanta_module_files.egg-info/PKG-INFO
6
+ inmanta_module_files.egg-info/SOURCES.txt
7
+ inmanta_module_files.egg-info/dependency_links.txt
8
+ inmanta_module_files.egg-info/not-zip-safe
9
+ inmanta_module_files.egg-info/requires.txt
10
+ inmanta_module_files.egg-info/top_level.txt
11
+ inmanta_plugins/files/__init__.py
12
+ inmanta_plugins/files/base.py
13
+ inmanta_plugins/files/host.py
14
+ inmanta_plugins/files/json.py
15
+ inmanta_plugins/files/systemd_unit.py
16
+ tests/test_basics.py
17
+ tests/test_host_file.py
18
+ tests/test_json_file.py
19
+ tests/test_systemd_unit_file.py
@@ -0,0 +1 @@
1
+ inmanta-module-std
@@ -0,0 +1,17 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
@@ -0,0 +1,83 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ import typing
20
+
21
+ import inmanta.agent.agent
22
+ import inmanta.agent.handler
23
+ import inmanta.agent.io.local
24
+ import inmanta.const
25
+ import inmanta.execute.proxy
26
+ import inmanta.export
27
+ import inmanta.resources
28
+
29
+
30
+ class BaseFileResource(inmanta.resources.PurgeableResource):
31
+ fields = ("path", "permissions", "owner", "group")
32
+ path: str
33
+ permissions: typing.Optional[int]
34
+ owner: typing.Optional[str]
35
+ group: typing.Optional[str]
36
+
37
+
38
+ X = typing.TypeVar("X", bound=BaseFileResource)
39
+
40
+
41
+ class BaseFileHandler(inmanta.agent.handler.CRUDHandlerGeneric[X]):
42
+ _io: inmanta.agent.io.local.LocalIO
43
+
44
+ def read_resource(
45
+ self, ctx: inmanta.agent.handler.HandlerContext, resource: X
46
+ ) -> None:
47
+ if not self._io.file_exists(resource.path):
48
+ raise inmanta.agent.handler.ResourcePurged()
49
+
50
+ for key, value in self._io.file_stat(resource.path).items():
51
+ if getattr(resource, key) is not None:
52
+ setattr(resource, key, value)
53
+
54
+ def create_resource(
55
+ self, ctx: inmanta.agent.handler.HandlerContext, resource: X
56
+ ) -> None:
57
+ if resource.permissions is not None:
58
+ self._io.chmod(resource.path, str(resource.permissions))
59
+
60
+ if resource.owner is not None or resource.group is not None:
61
+ self._io.chown(resource.path, resource.owner, resource.group)
62
+
63
+ ctx.set_created()
64
+
65
+ def update_resource(
66
+ self,
67
+ ctx: inmanta.agent.handler.HandlerContext,
68
+ changes: dict[str, dict[str, object]],
69
+ resource: X,
70
+ ) -> None:
71
+ if "permissions" in changes:
72
+ self._io.chmod(resource.path, str(resource.permissions))
73
+
74
+ if "owner" in changes or "group" in changes:
75
+ self._io.chown(resource.path, resource.owner, resource.group)
76
+
77
+ ctx.set_updated()
78
+
79
+ def delete_resource(
80
+ self, ctx: inmanta.agent.handler.HandlerContext, resource: X
81
+ ) -> None:
82
+ self._io.remove(resource.path)
83
+ ctx.set_purged()
@@ -0,0 +1,204 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ import collections
20
+ import copy
21
+ import ipaddress
22
+ import typing
23
+
24
+ import inmanta.agent.agent
25
+ import inmanta.agent.handler
26
+ import inmanta.agent.io.local
27
+ import inmanta.const
28
+ import inmanta.execute.proxy
29
+ import inmanta.export
30
+ import inmanta.resources
31
+ import inmanta_plugins.files.base
32
+ import inmanta_plugins.files.json
33
+ from inmanta.util import dict_path
34
+
35
+ PARSED_HOST_FILE = dict[str, dict[str, typing.Optional[str]]]
36
+ """
37
+ Define an alias for the parsed host file format. The parsed host file
38
+ is a dict which has as keys, hostnames, and as values, a dict containing
39
+ two keys: address4 and address6, respectively representing the ipv4 and
40
+ ipv6 address defined in the file for the given hostname.
41
+
42
+ And example of such dict is:
43
+
44
+ .. code-block::python
45
+
46
+ {
47
+ "localhost": {"address4": "127.0.0.1", "address6": "::1"},
48
+ "ip6-loopback": {"address4": None, "address6": "::1"},
49
+ "example.com": {"address4": "93.184.216.34", "address6": None},
50
+ }
51
+
52
+ """
53
+
54
+
55
+ def parse_host_file(raw_content: str) -> PARSED_HOST_FILE:
56
+ """
57
+ Parse the content of an host file, and return it as a dict having as
58
+ keys the hostnames, and as values, the corresponding ips.
59
+ """
60
+ parsed_file: PARSED_HOST_FILE = collections.defaultdict(
61
+ lambda: {
62
+ "address4": None,
63
+ "address6": None,
64
+ }
65
+ )
66
+
67
+ for line in raw_content.splitlines():
68
+ if not line:
69
+ # No info in that line
70
+ continue
71
+
72
+ if line[0] not in "0123456789:":
73
+ # This is not a valid entry, probably a comment
74
+ continue
75
+
76
+ parsed_line = line.split()
77
+
78
+ # Parse the ip address
79
+ address = ipaddress.ip_address(parsed_line[0])
80
+
81
+ # For each hostname, add the ipaddress to the parsed file
82
+ for hostname in parsed_line[1:]:
83
+ parsed_file[hostname][f"address{address.version}"] = str(address)
84
+
85
+ return dict(parsed_file)
86
+
87
+
88
+ def write_host_file(parsed_file: PARSED_HOST_FILE) -> str:
89
+ """
90
+ Generate the content of the hostfile in a single string, that
91
+ can be written down into a file.
92
+ """
93
+ raw_content = ""
94
+
95
+ for hostname, addresses in parsed_file.items():
96
+ if addresses.get("address4") is not None:
97
+ raw_content += f"{addresses['address4']} {hostname}\n"
98
+
99
+ if addresses.get("address6") is not None:
100
+ raw_content += f"{addresses['address6']} {hostname}\n"
101
+
102
+ return raw_content
103
+
104
+
105
+ @inmanta.resources.resource(
106
+ name="files::HostFile",
107
+ id_attribute="path",
108
+ agent="host.name",
109
+ )
110
+ class HostFileResource(inmanta_plugins.files.base.BaseFileResource):
111
+ fields = ("values",)
112
+ values: list[dict]
113
+
114
+ @classmethod
115
+ def get_values(
116
+ cls,
117
+ _: inmanta.export.Exporter,
118
+ entity: inmanta.execute.proxy.DynamicProxy,
119
+ ) -> list[dict]:
120
+ return [
121
+ {
122
+ "path": str(dict_path.InDict(entry.hostname)),
123
+ "operation": entry.operation,
124
+ "value": {
125
+ "address4": entry.address4,
126
+ "address6": entry.address6,
127
+ },
128
+ }
129
+ for entry in entity.entries
130
+ ]
131
+
132
+
133
+ @inmanta.agent.handler.provider("files::HostFile", "")
134
+ class HostFileHandler(inmanta_plugins.files.base.BaseFileHandler[HostFileResource]):
135
+ _io: inmanta.agent.io.local.LocalIO
136
+
137
+ def read_resource(
138
+ self, ctx: inmanta.agent.handler.HandlerContext, resource: HostFileResource
139
+ ) -> None:
140
+ super().read_resource(ctx, resource)
141
+
142
+ # Load the content of the existing file
143
+ raw_content = self._io.read_binary(resource.path).decode()
144
+ ctx.debug("Reading existing file", raw_content=raw_content)
145
+ ctx.set("current_content", parse_host_file(raw_content))
146
+
147
+ def calculate_diff(
148
+ self,
149
+ ctx: inmanta.agent.handler.HandlerContext,
150
+ current: HostFileResource,
151
+ desired: HostFileResource,
152
+ ) -> dict[str, dict[str, object]]:
153
+ # For file permissions and ownership, we delegate to the parent class
154
+ changes = super().calculate_diff(ctx, current, desired)
155
+
156
+ # To check if some change content needs to be applied, we perform a "stable" addition
157
+ # operation: We apply our desired state to the current state, and check if we can then
158
+ # see any difference.
159
+ current_content = ctx.get("current_content")
160
+ desired_content = copy.deepcopy(current_content)
161
+
162
+ for value in desired.values:
163
+ inmanta_plugins.files.json.update(
164
+ desired_content,
165
+ dict_path.to_path(value["path"]),
166
+ inmanta_plugins.files.json.Operation(value["operation"]),
167
+ value["value"],
168
+ )
169
+
170
+ if current_content != desired_content:
171
+ changes["content"] = {
172
+ "current": current_content,
173
+ "desired": desired_content,
174
+ }
175
+
176
+ return changes
177
+
178
+ def create_resource(
179
+ self, ctx: inmanta.agent.handler.HandlerContext, resource: HostFileResource
180
+ ) -> None:
181
+ # Build a config based on all the values we want to manage
182
+ content = {}
183
+ for value in resource.values:
184
+ inmanta_plugins.files.json.update(
185
+ content,
186
+ dict_path.to_path(value["path"]),
187
+ inmanta_plugins.files.json.Operation(value["operation"]),
188
+ value["value"],
189
+ )
190
+ raw_content = write_host_file(content)
191
+ self._io.put(resource.path, raw_content.encode())
192
+ super().create_resource(ctx, resource)
193
+
194
+ def update_resource(
195
+ self,
196
+ ctx: inmanta.agent.handler.HandlerContext,
197
+ changes: dict[str, dict[str, object]],
198
+ resource: HostFileResource,
199
+ ) -> None:
200
+ if "content" in changes:
201
+ raw_content = write_host_file(changes["content"]["desired"])
202
+ self._io.put(resource.path, raw_content.encode())
203
+
204
+ super().update_resource(ctx, changes, resource)
@@ -0,0 +1,176 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ import copy
20
+ import enum
21
+ import json
22
+
23
+ import inmanta.agent.agent
24
+ import inmanta.agent.handler
25
+ import inmanta.agent.io.local
26
+ import inmanta.const
27
+ import inmanta.execute.proxy
28
+ import inmanta.export
29
+ import inmanta.resources
30
+ import inmanta_plugins.files.base
31
+ from inmanta.util import dict_path
32
+
33
+
34
+ class Operation(str, enum.Enum):
35
+ REPLACE = "replace"
36
+ REMOVE = "remove"
37
+ MERGE = "merge"
38
+
39
+
40
+ def update(
41
+ config: dict, path: dict_path.DictPath, operation: Operation, desired: object
42
+ ) -> dict:
43
+ """
44
+ Update the config config at the specified type, using given operation and desired value.
45
+
46
+ :param config: The configuration to update
47
+ :param path: The path pointing to an element of the config that should be modified
48
+ :param operation: The type of operation to apply to the config element
49
+ :param desired: The desired state to apply to the config element
50
+ """
51
+ if operation == Operation.REMOVE:
52
+ path.remove(config)
53
+ return config
54
+
55
+ if operation == Operation.REPLACE:
56
+ path.set_element(config, value=desired)
57
+ return config
58
+
59
+ if operation == Operation.MERGE:
60
+ if not isinstance(desired, dict):
61
+ raise ValueError(
62
+ f"Merge operation is only supported for dicts, but got {type(desired)} "
63
+ f"({desired})"
64
+ )
65
+ current = path.get_element(config, construct=True)
66
+ if not isinstance(current, dict):
67
+ raise ValueError(
68
+ f"A dict can only me merged to a dict, current value at path {path} "
69
+ f"is not a dict: {current} ({type(current)})"
70
+ )
71
+ current.update({k: v for k, v in desired.items() if v is not None})
72
+ return config
73
+
74
+ raise ValueError(f"Unsupported operation: {operation}")
75
+
76
+
77
+ @inmanta.resources.resource(
78
+ name="files::JsonFile",
79
+ id_attribute="path",
80
+ agent="host.name",
81
+ )
82
+ class JsonFileResource(inmanta_plugins.files.base.BaseFileResource):
83
+ fields = (
84
+ "indent",
85
+ "values",
86
+ )
87
+ values: list[dict]
88
+ indent: int
89
+
90
+ @classmethod
91
+ def get_values(cls, _, entity: inmanta.execute.proxy.DynamicProxy) -> list[dict]:
92
+ return [
93
+ {
94
+ "path": value.path,
95
+ "operation": value.operation,
96
+ "value": value.value,
97
+ }
98
+ for value in entity.values
99
+ ]
100
+
101
+
102
+ @inmanta.agent.handler.provider("files::JsonFile", "")
103
+ class JsonFileHandler(inmanta_plugins.files.base.BaseFileHandler[JsonFileResource]):
104
+ _io: inmanta.agent.io.local.LocalIO
105
+
106
+ def read_resource(
107
+ self, ctx: inmanta.agent.handler.HandlerContext, resource: JsonFileResource
108
+ ) -> None:
109
+ super().read_resource(ctx, resource)
110
+
111
+ # Load the content of the existing file
112
+ raw_content = self._io.read_binary(resource.path).decode()
113
+ ctx.debug("Reading existing file", raw_content=raw_content)
114
+ ctx.set("current_content", json.loads(raw_content))
115
+
116
+ def calculate_diff(
117
+ self,
118
+ ctx: inmanta.agent.handler.HandlerContext,
119
+ current: JsonFileResource,
120
+ desired: JsonFileResource,
121
+ ) -> dict[str, dict[str, object]]:
122
+ # For file permissions and ownership, we delegate to the parent class
123
+ changes = super().calculate_diff(ctx, current, desired)
124
+
125
+ # To check if some change content needs to be applied, we perform a "stable" addition
126
+ # operation: We apply our desired state to the current state, and check if we can then
127
+ # see any difference.
128
+ current_content = ctx.get("current_content")
129
+ desired_content = copy.deepcopy(current_content)
130
+
131
+ for value in desired.values:
132
+ update(
133
+ desired_content,
134
+ dict_path.to_path(value["path"]),
135
+ Operation(value["operation"]),
136
+ value["value"],
137
+ )
138
+
139
+ if current_content != desired_content:
140
+ changes["content"] = {
141
+ "current": current_content,
142
+ "desired": desired_content,
143
+ }
144
+
145
+ return changes
146
+
147
+ def create_resource(
148
+ self, ctx: inmanta.agent.handler.HandlerContext, resource: JsonFileResource
149
+ ) -> None:
150
+ # Build a config based on all the elements we want to manage
151
+ content = {}
152
+ for value in resource.values:
153
+ update(
154
+ content,
155
+ dict_path.to_path(value["path"]),
156
+ Operation(value["operation"]),
157
+ value["value"],
158
+ )
159
+
160
+ indent = resource.indent if resource.indent != 0 else None
161
+ raw_content = json.dumps(content, indent=indent)
162
+ self._io.put(resource.path, raw_content.encode())
163
+ super().create_resource(ctx, resource)
164
+
165
+ def update_resource(
166
+ self,
167
+ ctx: inmanta.agent.handler.HandlerContext,
168
+ changes: dict[str, dict[str, object]],
169
+ resource: JsonFileResource,
170
+ ) -> None:
171
+ if "content" in changes:
172
+ indent = resource.indent if resource.indent != 0 else None
173
+ raw_content = json.dumps(changes["content"]["desired"], indent=indent)
174
+ self._io.put(resource.path, raw_content.encode())
175
+
176
+ super().update_resource(ctx, changes, resource)
@@ -0,0 +1,73 @@
1
+ """
2
+ Copyright 2024 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ import inmanta.agent.agent
20
+ import inmanta.agent.handler
21
+ import inmanta.agent.io.local
22
+ import inmanta.const
23
+ import inmanta.execute.proxy
24
+ import inmanta.export
25
+ import inmanta.resources
26
+ import inmanta_plugins.files.base
27
+
28
+
29
+ @inmanta.resources.resource(
30
+ name="files::SystemdUnitFile",
31
+ id_attribute="path",
32
+ agent="host.name",
33
+ )
34
+ class SystemdUnitFileResource(inmanta_plugins.files.base.BaseFileResource):
35
+ fields = ("content",)
36
+ content: str
37
+
38
+
39
+ @inmanta.agent.handler.provider("files::SystemdUnitFile", "")
40
+ class SystemdUnitFileHandler(
41
+ inmanta_plugins.files.base.BaseFileHandler[SystemdUnitFileResource]
42
+ ):
43
+ _io: inmanta.agent.io.local.LocalIO
44
+
45
+ def read_resource(
46
+ self,
47
+ ctx: inmanta.agent.handler.HandlerContext,
48
+ resource: SystemdUnitFileResource,
49
+ ) -> None:
50
+ super().read_resource(ctx, resource)
51
+
52
+ # Load the content of the existing file
53
+ resource.content = self._io.read_binary(resource.path).decode()
54
+ ctx.debug("Reading existing file", content=resource.content)
55
+
56
+ def create_resource(
57
+ self,
58
+ ctx: inmanta.agent.handler.HandlerContext,
59
+ resource: SystemdUnitFileResource,
60
+ ) -> None:
61
+ self._io.put(resource.path, resource.content.encode())
62
+ super().create_resource(ctx, resource)
63
+
64
+ def update_resource(
65
+ self,
66
+ ctx: inmanta.agent.handler.HandlerContext,
67
+ changes: dict[str, dict[str, object]],
68
+ resource: SystemdUnitFileResource,
69
+ ) -> None:
70
+ if "content" in changes:
71
+ self._io.put(resource.path, resource.content.encode())
72
+
73
+ super().update_resource(ctx, changes, resource)
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,47 @@
1
+ [metadata]
2
+ name = inmanta-module-files
3
+ version = 0.1.0
4
+ description = Simple module containing various types of resource to manage files.
5
+ long_description = file: README.md
6
+ long_description_content_type = text/markdown
7
+ author = Guillaume Everarts de Velp
8
+ author_email = edvgui@gmail.com
9
+ license = ASL 2.0
10
+ copyright = 2023 Guillaume Everarts de Velp
11
+
12
+ [options]
13
+ zip_safe = False
14
+ include_package_data = True
15
+ packages = find_namespace:
16
+ install_requires =
17
+ inmanta-module-std
18
+
19
+ [options.packages.find]
20
+ include = inmanta_plugins*
21
+
22
+ [flake8]
23
+ ignore = H405,H404,H302,H306,H301,H101,H801,E402,W503,E252,E203
24
+ builtins = string,number,bool
25
+ max-line-length = 128
26
+ exclude = **/.env,.venv,.git,.tox,dist,doc,**egg
27
+ copyright-check = True
28
+ copyright-author = Guillaume Everarts de Velp
29
+ select = E,F,W,C,BLK,I
30
+
31
+ [isort]
32
+ multi_line_output = 3
33
+ include_trailing_comma = True
34
+ force_grid_wrap = 0
35
+ use_parentheses = True
36
+ line_length = 88
37
+ known_first_party = inmanta
38
+ known_third_party = pytest,pydantic,Jinja2
39
+
40
+ [black]
41
+ line-length = 128
42
+ target-version = 'py36', 'py37', 'py38'
43
+
44
+ [egg_info]
45
+ tag_build =
46
+ tag_date = 0
47
+
@@ -0,0 +1,23 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ from pytest_inmanta.plugin import Project
20
+
21
+
22
+ def test_basics(project: Project) -> None:
23
+ project.compile("import files")
@@ -0,0 +1,120 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ import grp
20
+ import os
21
+ import pathlib
22
+
23
+ import pytest
24
+ import pytest_inmanta.plugin
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ (
29
+ "file_path",
30
+ "purged",
31
+ ),
32
+ [
33
+ (pathlib.Path("/tmp/example"), False),
34
+ ],
35
+ )
36
+ def test_model(
37
+ project: pytest_inmanta.plugin.Project, file_path: pathlib.Path, purged: bool
38
+ ) -> None:
39
+ user = os.getlogin()
40
+ group = grp.getgrgid(os.getgid()).gr_name
41
+ model = f"""
42
+ import files
43
+ import files::host
44
+
45
+ import std
46
+
47
+ host = std::Host(
48
+ name="localhost",
49
+ os=std::linux,
50
+ )
51
+
52
+ files::HostFile(
53
+ host=host,
54
+ path={repr(str(file_path))},
55
+ owner={repr(user)},
56
+ group={repr(group)},
57
+ purged={str(purged).lower()},
58
+ entries=[
59
+ files::host::Entry(
60
+ hostname="example.com",
61
+ address4="192.168.10.10",
62
+ operation="replace",
63
+ ),
64
+ files::host::Entry(
65
+ hostname="example.be",
66
+ operation="remove",
67
+ ),
68
+ files::host::Entry(
69
+ hostname="example.eu",
70
+ address4="192.168.10.10",
71
+ operation="merge",
72
+ ),
73
+ ],
74
+ )
75
+ """
76
+
77
+ project.compile(model.strip("\n"), no_dedent=False)
78
+
79
+
80
+ def test_deploy(project: pytest_inmanta.plugin.Project, tmp_path: pathlib.Path) -> None:
81
+ file = tmp_path / "host"
82
+
83
+ # Create the file
84
+ test_model(project, file, purged=False)
85
+ assert project.dryrun_resource("files::HostFile")
86
+ project.deploy_resource("files::HostFile")
87
+ assert not project.dryrun_resource("files::HostFile")
88
+
89
+ # Manually remove a managed line from the file and make sure we detect a change
90
+ lines = file.read_text().splitlines()
91
+ lines = lines[1:]
92
+ file.write_text("\n".join(lines))
93
+ assert project.dryrun_resource("files::HostFile")
94
+ project.deploy_resource("files::HostFile")
95
+ assert not project.dryrun_resource("files::HostFile")
96
+
97
+ # Insert an extra entry in the file and me sure we don't detect any change as we don't
98
+ # manage that entry
99
+ lines = file.read_text().splitlines()
100
+ lines.append("127.0.0.1 localhost")
101
+ file.write_text("\n".join(lines))
102
+ assert not project.dryrun_resource("files::HostFile")
103
+
104
+ # Add the entry that should not be there and make sure it is removed, the unmanaged
105
+ # entry should remain untouched
106
+ lines.append("::1 example.be")
107
+ file.write_text("\n".join(lines))
108
+ assert project.dryrun_resource("files::HostFile")
109
+ project.deploy_resource("files::HostFile")
110
+ assert not project.dryrun_resource("files::HostFile")
111
+ lines = file.read_text().splitlines()
112
+ assert "::1 example.be" not in lines
113
+ assert "127.0.0.1 localhost" in lines
114
+
115
+ # Delete the file
116
+ test_model(project, file, purged=True)
117
+ assert project.dryrun_resource("files::HostFile")
118
+ project.deploy_resource("files::HostFile")
119
+ assert not project.dryrun_resource("files::HostFile")
120
+ assert not file.exists()
@@ -0,0 +1,122 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ import grp
20
+ import json
21
+ import os
22
+ import pathlib
23
+
24
+ import pytest
25
+ import pytest_inmanta.plugin
26
+
27
+
28
+ @pytest.mark.parametrize(
29
+ (
30
+ "file_path",
31
+ "purged",
32
+ ),
33
+ [
34
+ (pathlib.Path("/tmp/example"), False),
35
+ ],
36
+ )
37
+ def test_model(
38
+ project: pytest_inmanta.plugin.Project, file_path: pathlib.Path, purged: bool
39
+ ) -> None:
40
+ user = os.getlogin()
41
+ group = grp.getgrgid(os.getgid()).gr_name
42
+ model = f"""
43
+ import files
44
+ import files::json
45
+
46
+ import std
47
+
48
+ host = std::Host(
49
+ name="localhost",
50
+ os=std::linux,
51
+ )
52
+
53
+ files::JsonFile(
54
+ host=host,
55
+ path={repr(str(file_path))},
56
+ owner={repr(user)},
57
+ group={repr(group)},
58
+ purged={str(purged).lower()},
59
+ values=[
60
+ files::json::Object(
61
+ path="people[name=bob]",
62
+ operation="replace",
63
+ value={{"name": "bob", "age": 20}},
64
+ ),
65
+ files::json::Object(
66
+ path="people[name=alice]",
67
+ operation="merge",
68
+ value={{"name": "alice", "age": 20}},
69
+ ),
70
+ files::json::Object(
71
+ path="people[name=eve]",
72
+ operation="remove",
73
+ value={{}},
74
+ ),
75
+ ],
76
+ )
77
+ """
78
+
79
+ project.compile(model.strip("\n"), no_dedent=False)
80
+
81
+
82
+ def test_deploy(project: pytest_inmanta.plugin.Project, tmp_path: pathlib.Path) -> None:
83
+ file = tmp_path / "friends.json"
84
+
85
+ # Create the file
86
+ test_model(project, file, purged=False)
87
+ assert project.dryrun_resource("files::JsonFile")
88
+ project.deploy_resource("files::JsonFile")
89
+ assert not project.dryrun_resource("files::JsonFile")
90
+
91
+ # Manually remove a managed line from the file and make sure we detect a change
92
+ friends = json.loads(file.read_text())
93
+ del friends["people"]
94
+ file.write_text(json.dumps(friends))
95
+ assert project.dryrun_resource("files::JsonFile")
96
+ project.deploy_resource("files::JsonFile")
97
+ assert not project.dryrun_resource("files::JsonFile")
98
+
99
+ # Insert an extra entry in the file and me sure we don't detect any change as we don't
100
+ # manage that entry
101
+ friends = json.loads(file.read_text())
102
+ friends["people"].append({"name": "chris"})
103
+ file.write_text(json.dumps(friends))
104
+ assert not project.dryrun_resource("files::JsonFile")
105
+
106
+ # Add the entry that should not be there and make sure it is removed, the unmanaged
107
+ # entry should remain untouched
108
+ friends["people"].append({"name": "eve"})
109
+ file.write_text(json.dumps(friends))
110
+ assert project.dryrun_resource("files::JsonFile")
111
+ project.deploy_resource("files::JsonFile")
112
+ assert not project.dryrun_resource("files::JsonFile")
113
+ friends = json.loads(file.read_text())
114
+ assert friends["people"][2] == {"name": "chris"}
115
+ assert len(friends["people"]) == 3
116
+
117
+ # Delete the file
118
+ test_model(project, file, purged=True)
119
+ assert project.dryrun_resource("files::JsonFile")
120
+ project.deploy_resource("files::JsonFile")
121
+ assert not project.dryrun_resource("files::JsonFile")
122
+ assert not file.exists()
@@ -0,0 +1,134 @@
1
+ """
2
+ Copyright 2023 Guillaume Everarts de Velp
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: edvgui@gmail.com
17
+ """
18
+
19
+ import grp
20
+ import os
21
+ import pathlib
22
+
23
+ import pytest
24
+ import pytest_inmanta.plugin
25
+
26
+ EXAMPLE_UNIT = """
27
+ [Unit]
28
+ Description=Podman inmanta-orchestrator-net.service
29
+ Documentation=https://github.com/edvgui/inmanta-module-podman
30
+ RequiresMountsFor=%t/containers
31
+ Wants=network-online.target
32
+ Wants=inmanta-orchestrator-db.service
33
+ Wants=inmanta-orchestrator-server.service
34
+ Before=inmanta-orchestrator-db.service
35
+ Before=inmanta-orchestrator-server.service
36
+ After=network-online.target
37
+
38
+ [Service]
39
+ Restart=on-failure
40
+ TimeoutStopSec=70
41
+ ExecStartPre=/usr/bin/podman network create --ignore --subnet=172.42.0.0/24 inmanta-orchestrator-net
42
+ ExecStart=/usr/bin/bash -c "/usr/bin/sleep infinity & /usr/bin/podman network inspect inmanta-orchestrator-net"
43
+ ExecStopPost=/usr/bin/podman network rm -f inmanta-orchestrator-net
44
+ Type=forking
45
+
46
+ [Install]
47
+ WantedBy=default.target
48
+
49
+ """.lstrip(
50
+ "\n"
51
+ )
52
+
53
+
54
+ @pytest.mark.parametrize(
55
+ (
56
+ "file_path",
57
+ "purged",
58
+ ),
59
+ [
60
+ (pathlib.Path("/tmp/example"), False),
61
+ ],
62
+ )
63
+ def test_model(
64
+ project: pytest_inmanta.plugin.Project, file_path: pathlib.Path, purged: bool
65
+ ) -> None:
66
+ user = os.getlogin()
67
+ group = grp.getgrgid(os.getgid()).gr_name
68
+ model = f"""
69
+ import files
70
+ import files::systemd_unit
71
+
72
+ import std
73
+
74
+ host = std::Host(
75
+ name="localhost",
76
+ os=std::linux,
77
+ )
78
+
79
+ files::SystemdUnitFile(
80
+ host=host,
81
+ path={repr(str(file_path))},
82
+ owner={repr(user)},
83
+ group={repr(group)},
84
+ purged={str(purged).lower()},
85
+ unit=Unit(
86
+ description="Podman inmanta-orchestrator-net.service",
87
+ documentation=["https://github.com/edvgui/inmanta-module-podman"],
88
+ requires_mounts_for=["%t/containers"],
89
+ wants=[
90
+ "network-online.target",
91
+ "inmanta-orchestrator-db.service",
92
+ "inmanta-orchestrator-server.service",
93
+ ],
94
+ before=[
95
+ "inmanta-orchestrator-db.service",
96
+ "inmanta-orchestrator-server.service",
97
+ ],
98
+ after=["network-online.target"],
99
+ ),
100
+ service=Service(
101
+ restart="on-failure",
102
+ timeout_stop_sec=70,
103
+ exec_start_pre="/usr/bin/podman network create --ignore --subnet=172.42.0.0/24 inmanta-orchestrator-net",
104
+ exec_start="/usr/bin/bash -c \\"/usr/bin/sleep infinity & /usr/bin/podman network inspect inmanta-orchestrator-net\\"",
105
+ exec_stop_post="/usr/bin/podman network rm -f inmanta-orchestrator-net",
106
+ type="forking",
107
+ ),
108
+ install=Install(
109
+ wanted_by=["default.target"],
110
+ ),
111
+ )
112
+ """ # noqa: E501
113
+
114
+ project.compile(model.strip("\n"), no_dedent=False)
115
+
116
+
117
+ def test_deploy(project: pytest_inmanta.plugin.Project, tmp_path: pathlib.Path) -> None:
118
+ file = tmp_path / "inmanta-orchestrator-net.service"
119
+
120
+ # Create the file
121
+ test_model(project, file, purged=False)
122
+ assert project.dryrun_resource("files::SystemdUnitFile")
123
+ project.deploy_resource("files::SystemdUnitFile")
124
+ assert not project.dryrun_resource("files::SystemdUnitFile")
125
+
126
+ # Check that the file content is the expected one
127
+ assert file.read_text() == EXAMPLE_UNIT
128
+
129
+ # Delete the file
130
+ test_model(project, file, purged=True)
131
+ assert project.dryrun_resource("files::SystemdUnitFile")
132
+ project.deploy_resource("files::SystemdUnitFile")
133
+ assert not project.dryrun_resource("files::SystemdUnitFile")
134
+ assert not file.exists()