Skip to content

Commit 86795f9

Browse files
authored
Add isolation options (#313)
* Add isolation options * Cleanup logic * Cleanup logic * Add some tests * No fail for ansible_config * Additional tests * Update test * Update tests
1 parent 6bc7450 commit 86795f9

File tree

7 files changed

+364
-12
lines changed

7 files changed

+364
-12
lines changed

.config/dictionary.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
acfg
12
argnames
23
argvalues
34
bindep
@@ -27,6 +28,7 @@ reqs
2728
sessionstart
2829
setenv
2930
treemaker
31+
unisolated
3032
usefixtures
3133
xdist
3234
xmltodict

src/ansible_dev_environment/arg_parser.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,20 @@ def parse() -> argparse.Namespace:
203203
help="Install seed packages inside the virtual environment (ansible-dev-tools).",
204204
)
205205

206+
install.add_argument(
207+
"--im",
208+
"--isolation-mode",
209+
dest="isolation_mode",
210+
help=(
211+
"Isolation mode to use. restrictive: Exit if collections are found in ansible home or the system collection directory. "
212+
" cfg: Update or add an ansible.cfg file in the current working directory to isolate the workspace"
213+
" none: No isolation, not recommended."
214+
),
215+
default="restrictive",
216+
choices=["restrictive", "cfg", "none"],
217+
type=str,
218+
)
219+
206220
_uninstall = subparsers.add_parser(
207221
"uninstall",
208222
formatter_class=CustomHelpFormatter,

src/ansible_dev_environment/cli.py

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
from .arg_parser import parse
1515
from .config import Config
16+
from .definitions import COLLECTIONS_PATH as CP
17+
from .definitions import AnsibleCfg
1618
from .output import Output
1719
from .utils import TermFeatures
1820

@@ -30,6 +32,10 @@ def __init__(self) -> None:
3032
self.config: Config
3133
self.output: Output
3234
self.term_features: TermFeatures
35+
self.acfg_cwd = AnsibleCfg(path=Path("./ansible.cfg"))
36+
self.acfg_home = AnsibleCfg(path=Path("~/.ansible.cfg").expanduser().resolve())
37+
self.acfg_system = AnsibleCfg(path=Path("/etc/ansible/ansible.cfg"))
38+
self.acfg_trusted: Path | None
3339

3440
def parse_args(self) -> None:
3541
"""Parse the command line arguments."""
@@ -92,8 +98,92 @@ def args_sanity(self) -> None:
9298
err = "Editable can not be used with a requirements file."
9399
self.output.critical(err)
94100

95-
def ensure_isolated(self) -> None:
96-
"""Ensure the environment is isolated."""
101+
def isolation_check(self) -> bool:
102+
"""Check the environment for isolation.
103+
104+
Returns:
105+
True if ade can continue, false otherwise.
106+
"""
107+
if not hasattr(self.args, "isolation_mode"):
108+
return True
109+
if self.args.isolation_mode == "restrictive":
110+
return self.isolation_restrictive()
111+
if self.args.isolation_mode == "cfg":
112+
return self.isolation_cfg()
113+
if self.args.isolation_mode == "none":
114+
return self.isolation_none()
115+
self.acfg_trusted = None
116+
return False
117+
118+
def isolation_cfg(self) -> bool:
119+
"""Ensure the environment is isolated using cfg isolation.
120+
121+
Returns:
122+
True if ade can continue, false otherwise.
123+
"""
124+
if os.environ.get("ANSIBLE_CONFIG"):
125+
err = "ANSIBLE_CONFIG is set"
126+
self.output.error(err)
127+
hint = "Run `unset ANSIBLE_CONFIG` to unset it using cfg isolation mode."
128+
self.output.hint(hint)
129+
self.acfg_trusted = None
130+
return False
131+
132+
if self.acfg_cwd.exists:
133+
if self.acfg_cwd.collections_path_is_dot:
134+
msg = f"{self.acfg_cwd.path} has '{CP}' which isolates this workspace."
135+
self.output.info(msg)
136+
else:
137+
self.acfg_cwd.set_or_update_collections_path()
138+
msg = f"{self.acfg_cwd.path} updated with '{CP}' to isolate this workspace."
139+
self.output.warning(msg)
140+
self.acfg_trusted = self.acfg_cwd.path
141+
return True
142+
143+
if self.acfg_home.exists:
144+
if self.acfg_home.collections_path_is_dot:
145+
msg = f"{self.acfg_home.path} has '{CP}' which isolates this and all workspaces."
146+
self.output.info(msg)
147+
else:
148+
self.acfg_home.set_or_update_collections_path()
149+
msg = (
150+
f"{self.acfg_home.path} updated with '{CP}' to isolate this and all workspaces."
151+
)
152+
self.output.warning(msg)
153+
self.acfg_trusted = self.acfg_home.path
154+
return True
155+
156+
if self.acfg_system.exists and self.acfg_system.collections_path_is_dot:
157+
msg = f"{self.acfg_system.path} has '{CP}' which isolates this and all workspaces."
158+
self.output.info(msg)
159+
self.acfg_trusted = self.acfg_system.path
160+
return True
161+
162+
self.acfg_cwd.author_new()
163+
msg = f"{self.acfg_cwd.path} created with '{CP}' to isolate this workspace."
164+
self.output.info(msg)
165+
self.acfg_trusted = self.acfg_cwd.path
166+
return True
167+
168+
def isolation_none(self) -> bool:
169+
"""No isolation.
170+
171+
Returns:
172+
True if ade can continue, false otherwise.
173+
"""
174+
self.output.warning(
175+
"An unisolated development environment can cause issues with conflicting dependency"
176+
" versions and the use of incompatible collections.",
177+
)
178+
self.acfg_trusted = None
179+
return True
180+
181+
def isolation_restrictive(self) -> bool:
182+
"""Ensure the environment is isolated.
183+
184+
Returns:
185+
True if ade can continue, false otherwise.
186+
"""
97187
env_vars = os.environ
98188
errored = False
99189
if "ANSIBLE_COLLECTIONS_PATHS" in env_vars:
@@ -127,11 +217,11 @@ def ensure_isolated(self) -> None:
127217
hint = "Run `sudo rm -rf /usr/share/ansible/collections` to remove them."
128218
self.output.hint(hint)
129219
errored = True
130-
131220
if errored:
132221
err = "The development environment is not isolated, please resolve the above errors."
133-
134-
self.output.critical(err)
222+
self.output.warning(err)
223+
return False
224+
return True
135225

136226
def run(self) -> None:
137227
"""Run the application."""
@@ -145,9 +235,9 @@ def run(self) -> None:
145235
subcommand_cls = getattr(subcommands, self.config.args.subcommand.capitalize())
146236
subcommand = subcommand_cls(config=self.config, output=self.output)
147237
subcommand.run()
148-
self._exit()
238+
self.exit()
149239

150-
def _exit(self) -> None:
240+
def exit(self) -> None:
151241
"""Exit the application setting the return code."""
152242
if self.output.call_count["error"]:
153243
sys.exit(1)
@@ -171,6 +261,7 @@ def main(*, dry: bool = False) -> None:
171261
cli.output.warning(str(warn.message))
172262
warnings.resetwarnings()
173263
cli.args_sanity()
174-
cli.ensure_isolated()
264+
if not cli.isolation_check():
265+
cli.exit()
175266
if not dry:
176267
cli.run()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Some common definitions."""
2+
3+
from __future__ import annotations
4+
5+
from configparser import ConfigParser
6+
from dataclasses import dataclass
7+
from typing import TYPE_CHECKING
8+
9+
10+
if TYPE_CHECKING:
11+
from pathlib import Path
12+
13+
COLLECTIONS_PATH = "collections_path = ."
14+
15+
16+
@dataclass
17+
class AnsibleCfg:
18+
"""ansible.cfg file abstraction.
19+
20+
Attributes:
21+
path: Path to the ansible.cfg file.
22+
"""
23+
24+
path: Path
25+
26+
@property
27+
def exists(self) -> bool:
28+
"""Check if the ansible.cfg file exists."""
29+
return self.path.exists()
30+
31+
@property
32+
def collections_path_is_dot(self) -> bool:
33+
"""Check if the collection path is a dot.
34+
35+
Returns:
36+
bool: True if the collection path is a dot.
37+
"""
38+
config = ConfigParser()
39+
config.read(self.path)
40+
return config.get("defaults", "collections_path", fallback=None) == "."
41+
42+
def set_or_update_collections_path(self) -> None:
43+
"""Set or update the collection path in the ansible.cfg file.
44+
45+
The configparser doesn't preserve comments, so we need to read the file
46+
and write it back with the new collection path.
47+
"""
48+
contents = self.path.read_text().splitlines()
49+
50+
if "[defaults]" not in contents:
51+
contents.insert(0, "[defaults]")
52+
53+
idx = [i for i, line in enumerate(contents) if line.startswith("collections_path")]
54+
55+
if idx:
56+
contents[idx[0]] = COLLECTIONS_PATH
57+
else:
58+
insert_at = contents.index("[defaults]") + 1
59+
contents.insert(insert_at, COLLECTIONS_PATH)
60+
61+
with self.path.open(mode="w") as file:
62+
file.write("\n".join(contents) + "\n")
63+
64+
def author_new(self) -> None:
65+
"""Author the file and update it."""
66+
contents = ["[defaults]", COLLECTIONS_PATH]
67+
with self.path.open(mode="w") as file:
68+
file.write("\n".join(contents) + "\n")

tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def session_venv(session_dir: Path, monkey_session: pytest.MonkeyPatch) -> Confi
212212
cli.parse_args()
213213
cli.init_output()
214214
cli.args_sanity()
215-
cli.ensure_isolated()
215+
cli.isolation_check()
216216
with pytest.raises(SystemExit):
217217
cli.run()
218218
return cli.config
@@ -257,7 +257,7 @@ def function_venv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config:
257257
cli.parse_args()
258258
cli.init_output()
259259
cli.args_sanity()
260-
cli.ensure_isolated()
260+
cli.isolation_check()
261261
with pytest.raises(SystemExit):
262262
cli.run()
263263
return cli.config

tests/unit/test_cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ def test_exit_code_one(
266266
cli.init_output()
267267
cli.output.error("Test error")
268268
with pytest.raises(SystemExit) as excinfo:
269-
cli._exit()
269+
cli.exit()
270270
expected = 1
271271
assert excinfo.value.code == expected
272272
captured = capsys.readouterr()
@@ -292,7 +292,7 @@ def test_exit_code_two(
292292
cli.init_output()
293293
cli.output.warning("Test warning")
294294
with pytest.raises(SystemExit) as excinfo:
295-
cli._exit()
295+
cli.exit()
296296
expected = 2
297297
assert excinfo.value.code == expected
298298
captured = capsys.readouterr()

0 commit comments

Comments
 (0)