Skip to content

Commit 53a3620

Browse files
committed
Refactor provisioners for better modularity
This refactors the provisioning logic so that every provisioner is defined by its own class. This better encapsulates provisoner logic and makes adding new provisioners easier. While this is supposed to be a pure refactoring, it contains a small functional change, so that provisioning scripts can now not just be put as string only in the provisioner config, but following a "script" key as well, similar to all other provisioners.
1 parent 2fd7593 commit 53a3620

10 files changed

Lines changed: 444 additions & 346 deletions

File tree

incant/config_manager.py

Lines changed: 22 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import os
2-
import re
32
import sys
43
from pathlib import Path
54
from typing import Any, Dict, Optional
@@ -11,18 +10,22 @@
1110
from mako.template import Template
1211

1312
from .exceptions import ConfigurationError
13+
from .incus_cli import IncusCLI
14+
from .provisioners import REGISTERED_PROVISIONERS
1415
from .reporter import Reporter
1516
from .types import InstanceConfig, InstanceDict
1617

1718

1819
class ConfigManager:
1920
def __init__(
2021
self,
22+
incus: IncusCLI,
2123
reporter: Reporter,
2224
config_path: Optional[str] = None,
2325
verbose: bool = False,
2426
no_config: bool = False,
2527
):
28+
self.incus = incus
2629
self.reporter = reporter
2730
self.config_path = config_path
2831
self.verbose = verbose
@@ -130,13 +133,14 @@ def dump_config(self):
130133
except Exception as e: # pylint: disable=broad-exception-caught
131134
raise ConfigurationError(f"Error dumping configuration: {e}") from e
132135

133-
def _validate_provision_step(self, step, step_idx, name):
136+
def _validate_provision_step(self, step: Any, step_idx: int, name: str) -> None:
134137
if isinstance(step, str):
138+
REGISTERED_PROVISIONERS.get("script")(self.incus, self.reporter).validate_config(name, step)
135139
return
136140

137141
if not isinstance(step, dict):
138142
raise ConfigurationError(
139-
f"Provisioning step {step_idx} in instance '{name}' " "must be a string or a dictionary."
143+
f"Provisioning step {step_idx} in instance '{name}' must be a string or a dictionary."
140144
)
141145

142146
if len(step) != 1:
@@ -147,102 +151,32 @@ def _validate_provision_step(self, step, step_idx, name):
147151

148152
key, value = list(step.items())[0]
149153

150-
if key not in ["copy", "ssh", "llmnr"]:
154+
if key not in REGISTERED_PROVISIONERS.keys():
151155
raise ConfigurationError(
152156
f"Unknown provisioning step type '{key}' in instance '{name}'. "
153-
"Accepted types are 'copy', 'ssh', or 'llmnr'."
157+
f"Accepted types are {', '.join(REGISTERED_PROVISIONERS.keys())}."
154158
)
155159

156-
if key == "copy":
157-
if not isinstance(value, dict):
158-
raise ConfigurationError(
159-
f"Provisioning 'copy' step in instance '{name}' must have a dictionary value."
160-
)
161-
self._validate_copy_step(value, name)
162-
163-
if key == "ssh":
164-
self._validate_ssh_step(value, name)
160+
REGISTERED_PROVISIONERS.get(key)(self.incus, self.reporter).validate_config(name, value)
165161

166-
if key == "llmnr":
167-
self._validate_llmnr_step(value, name)
168-
169-
def _validate_copy_step(self, value, name):
170-
required_fields = ["source", "target"]
171-
missing = [field for field in required_fields if field not in value]
172-
if missing:
173-
raise ConfigurationError(
174-
(
175-
f"Provisioning 'copy' step in instance '{name}' is missing required "
176-
f"field(s): {', '.join(missing)}."
177-
)
178-
)
179-
if not isinstance(value["source"], str) or not isinstance(value["target"], str):
180-
raise ConfigurationError(
181-
(f"Provisioning 'copy' step in instance '{name}' must have string " "'source' and 'target'.")
182-
)
162+
def _validate_provisioning(self, instance: InstanceConfig, name: str):
163+
if instance.provision is None:
164+
return
183165

184-
if "uid" in value and not isinstance(value["uid"], int):
185-
raise ConfigurationError(
186-
(f"Provisioning 'copy' step in instance '{name}' has invalid 'uid': " "must be an integer.")
187-
)
188-
if "gid" in value and not isinstance(value["gid"], int):
189-
raise ConfigurationError(
190-
(f"Provisioning 'copy' step in instance '{name}' has invalid 'gid': " "must be an integer.")
191-
)
192-
if "mode" in value:
193-
mode_val = value["mode"]
194-
if not isinstance(mode_val, str):
195-
raise ConfigurationError(
196-
(
197-
f"Provisioning 'copy' step in instance '{name}' has invalid 'mode': "
198-
"must be a string like '0644'."
199-
)
200-
)
201-
if re.fullmatch(r"[0-7]{3,4}", mode_val) is None:
202-
raise ConfigurationError(
203-
(
204-
f"Provisioning 'copy' step in instance '{name}' has invalid 'mode': "
205-
"must be 3-4 octal digits (e.g., '644' or '0644')."
206-
)
207-
)
208-
if "recursive" in value and not isinstance(value["recursive"], bool):
209-
raise ConfigurationError(
210-
(
211-
f"Provisioning 'copy' step in instance '{name}' has invalid 'recursive': "
212-
"must be a boolean."
213-
)
214-
)
215-
if "create_dirs" in value and not isinstance(value["create_dirs"], bool):
216-
raise ConfigurationError(
217-
(
218-
f"Provisioning 'copy' step in instance '{name}' has invalid "
219-
"'create_dirs': must be a boolean."
220-
)
221-
)
166+
provisions = instance.provision
222167

223-
def _validate_ssh_step(self, value, name):
224-
if not isinstance(value, (bool, dict)):
225-
raise ConfigurationError(
226-
f"Provisioning 'ssh' step in instance '{name}' must have a boolean " "or dictionary value."
227-
)
168+
# Handle special "script" single-step provisioning.
169+
if isinstance(provisions, str):
170+
provisions = [provisions]
228171

229-
def _validate_llmnr_step(self, value, name):
230-
if not isinstance(value, bool):
172+
if isinstance(provisions, list):
173+
for step_idx, step in enumerate(provisions):
174+
self._validate_provision_step(step, step_idx, name)
175+
else:
231176
raise ConfigurationError(
232-
f"Provisioning 'llmnr' step in instance '{name}' must have a boolean value."
177+
f"Provisioning for instance '{name}' must be a string or a list of steps."
233178
)
234179

235-
def _validate_provisioning(self, instance: InstanceConfig, name: str):
236-
if instance.provision is not None:
237-
provisions = instance.provision
238-
if isinstance(provisions, list):
239-
for step_idx, step in enumerate(provisions):
240-
self._validate_provision_step(step, step_idx, name)
241-
elif not isinstance(provisions, str):
242-
raise ConfigurationError(
243-
f"Provisioning for instance '{name}' must be a string or a list of steps."
244-
)
245-
246180
def _validate_pre_launch(self, instance: InstanceConfig, name: str):
247181
if instance.pre_launch_cmds is not None:
248182
pre_launch_cmds = instance.pre_launch_cmds
@@ -261,7 +195,6 @@ def validate_config(self):
261195
raise ConfigurationError("No instances found in config")
262196

263197
for name, instance_config in self.instance_configs.items():
264-
265198
# Validate 'provision' field
266199
self._validate_provisioning(instance_config, name)
267200
self._validate_pre_launch(instance_config, name)

incant/incant.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,19 @@
1515
class Incant:
1616
def __init__(self, reporter: Reporter, **kwargs):
1717
self.reporter = reporter
18+
self.incus = IncusCLI(self.reporter)
1819
self.verbose = kwargs.get("verbose", False)
1920
self.no_config = kwargs.get("no_config", False)
2021
self.config_manager = ConfigManager(
22+
incus=self.incus,
2123
reporter=self.reporter,
2224
config_path=kwargs.get("config", None),
2325
verbose=self.verbose,
2426
no_config=self.no_config,
2527
)
2628
if not self.no_config:
2729
self.config_manager.validate_config()
28-
self.incus = IncusCLI(self.reporter)
29-
self.provisioner = ProvisionManager(self.incus, self.reporter)
30+
self.provision_manager = ProvisionManager(self.incus, self.reporter)
3031

3132
def _get_instance_configs(self, name: Optional[str] = None) -> InstanceDict:
3233
"""Helper to get instance configs, either all or a specific one."""
@@ -91,7 +92,7 @@ def provision(self, name: Optional[str] = None):
9192

9293
for instance_name, instance_config in instances_to_provision.items():
9394
if instance_config.provision:
94-
self.provisioner.provision(instance_name, instance_config.provision)
95+
self.provision_manager.provision(instance_name, instance_config.provision)
9596

9697
def destroy(self, name=None):
9798
instances_to_destroy = self._get_instance_configs(name)
@@ -119,8 +120,7 @@ def list_instances(self, no_error: bool = False):
119120
self.reporter.echo(f"{instance_name}")
120121

121122
def incant_init(self):
122-
example_config = textwrap.dedent(
123-
"""
123+
example_config = textwrap.dedent("""
124124
instances:
125125
basic-container:
126126
image: images:ubuntu/24.04
@@ -165,8 +165,7 @@ def incant_init(self):
165165
provision:
166166
- llmnr: true # LLMNR disabled by default on RHEL-based
167167
- ssh: true
168-
"""
169-
).lstrip()
168+
""").lstrip()
170169

171170
config_path = "incant.yaml"
172171

incant/provisioners/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .base import REGISTERED_PROVISIONERS, Provisioner
2+
from .copy_file import CopyFile
3+
from .llmr import LLMR
4+
from .script import Script
5+
from .ssh_server import SSHServer
6+
7+
__all__ = ["CopyFile", "LLMR", "Provisioner", "REGISTERED_PROVISIONERS", "Script", "SSHServer"]

incant/provisioners/base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import abc
2+
import typing
3+
from typing import ClassVar, Union
4+
5+
if typing.TYPE_CHECKING:
6+
from incant import IncusCLI
7+
from incant.reporter import Reporter
8+
9+
REGISTERED_PROVISIONERS: dict[str, type["Provisioner"]] = {}
10+
11+
12+
class Provisioner(abc.ABC):
13+
"""Abstract class, defining the interface for provisioners."""
14+
15+
# The name of the key used to identify this provisioner in the
16+
# incant config.
17+
config_key: ClassVar[str] = None
18+
19+
def __init__(self, incus_cli: "IncusCLI", reporter: Reporter):
20+
self.incus = incus_cli
21+
self.reporter = reporter
22+
23+
def __init_subclass__(cls, **kwargs):
24+
super().__init_subclass__(**kwargs)
25+
REGISTERED_PROVISIONERS[cls.config_key] = cls
26+
27+
@abc.abstractmethod
28+
def validate_config(self, instance_name: str, config: Union[bool, dict, str]):
29+
"""Validate the given config."""
30+
pass
31+
32+
@abc.abstractmethod
33+
def provision(self, instance_name: str, config: Union[bool, dict, str]):
34+
"""Run the provisioning logic."""
35+
pass

incant/provisioners/copy_file.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import re
2+
from typing import Union
3+
4+
from ..exceptions import ConfigurationError
5+
from ..types import FilePushConfig
6+
from .base import Provisioner
7+
8+
9+
class CopyFile(Provisioner):
10+
config_key = "copy"
11+
12+
def validate_config(self, instance_name: str, config: Union[bool, dict, str]):
13+
if not isinstance(config, dict):
14+
raise ConfigurationError(
15+
f"Provisioning 'copy' step in instance '{instance_name}' must have a dictionary value."
16+
)
17+
18+
required_fields = ["source", "target"]
19+
missing = [field for field in required_fields if field not in config]
20+
if missing:
21+
raise ConfigurationError(
22+
(
23+
f"Provisioning 'copy' step in instance '{instance_name}' is missing required "
24+
f"field(s): {', '.join(missing)}."
25+
)
26+
)
27+
if not isinstance(config["source"], str) or not isinstance(config["target"], str):
28+
raise ConfigurationError(
29+
(
30+
f"Provisioning 'copy' step in instance '{instance_name}' "
31+
"must have string 'source' and 'target'."
32+
)
33+
)
34+
35+
if "uid" in config and not isinstance(config["uid"], int):
36+
raise ConfigurationError(
37+
(
38+
f"Provisioning 'copy' step in instance '{instance_name}' "
39+
"has invalid 'uid': must be an integer."
40+
)
41+
)
42+
if "gid" in config and not isinstance(config["gid"], int):
43+
raise ConfigurationError(
44+
(
45+
f"Provisioning 'copy' step in instance '{instance_name}' "
46+
"has invalid 'gid': must be an integer."
47+
)
48+
)
49+
if "mode" in config:
50+
mode_val = config["mode"]
51+
if not isinstance(mode_val, str):
52+
raise ConfigurationError(
53+
(
54+
f"Provisioning 'copy' step in instance '{instance_name}' has invalid 'mode': "
55+
"must be a string like '0644'."
56+
)
57+
)
58+
if re.fullmatch(r"[0-7]{3,4}", mode_val) is None:
59+
raise ConfigurationError(
60+
(
61+
f"Provisioning 'copy' step in instance '{instance_name}' has invalid 'mode': "
62+
"must be 3-4 octal digits (e.g., '644' or '0644')."
63+
)
64+
)
65+
if "recursive" in config and not isinstance(config["recursive"], bool):
66+
raise ConfigurationError(
67+
(
68+
f"Provisioning 'copy' step in instance '{instance_name}'"
69+
"has invalid 'recursive': must be a boolean."
70+
)
71+
)
72+
if "create_dirs" in config and not isinstance(config["create_dirs"], bool):
73+
raise ConfigurationError(
74+
(
75+
f"Provisioning 'copy' step in instance '{instance_name}' "
76+
"has invalid 'create_dirs': must be a boolean."
77+
)
78+
)
79+
80+
def provision(self, instance_name: str, config: dict):
81+
"""Copy a file to the instance."""
82+
config["instance_name"] = instance_name
83+
self.incus.file_push(FilePushConfig(**config))

0 commit comments

Comments
 (0)