Skip to content

Commit 10b87b7

Browse files
committed
Add isolation options
1 parent 42fed44 commit 10b87b7

File tree

6 files changed

+190
-12
lines changed

6 files changed

+190
-12
lines changed

.config/dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ reqs
2727
sessionstart
2828
setenv
2929
treemaker
30+
unisolated
3031
usefixtures
3132
xdist
3233
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: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from .arg_parser import parse
1515
from .config import Config
16+
from .definitions import AnsibleCfg
1617
from .output import Output
1718
from .utils import TermFeatures
1819

@@ -92,8 +93,83 @@ def args_sanity(self) -> None:
9293
err = "Editable can not be used with a requirements file."
9394
self.output.critical(err)
9495

95-
def ensure_isolated(self) -> None:
96-
"""Ensure the environment is isolated."""
96+
def isolation_check(self) -> bool:
97+
"""Check the environment for isolation.
98+
99+
Returns:
100+
True if ade can continue, false otherwise.
101+
"""
102+
if not hasattr(self.args, "isolation_mode"):
103+
return True
104+
if self.args.isolation_mode == "restrictive":
105+
return self.isolation_restrictive()
106+
if self.args.isolation_mode == "cfg":
107+
return self.isolation_cfg()
108+
if self.args.isolation_mode == "none":
109+
return self.isolation_none()
110+
return False
111+
112+
def isolation_cfg(self) -> bool:
113+
"""Ensure the environment is isolated using cfg isolation.
114+
115+
Returns:
116+
True if ade can continue, false otherwise.
117+
"""
118+
if os.environ.get("ANSIBLE_CONFIG"):
119+
err = "ANSIBLE_CONFIG is set"
120+
self.output.error(err)
121+
hint = "Run `unset ANSIBLE_CONFIG` to unset it using cfg isolation mode."
122+
self.output.hint(hint)
123+
return False
124+
125+
cwd = AnsibleCfg(path=Path("./ansible.cfg"))
126+
home = AnsibleCfg(path=Path("~/.ansible.cfg").expanduser().resolve())
127+
system = AnsibleCfg(path=Path("/etc/ansible/ansible.cfg"))
128+
129+
if cwd.exists and cwd.collections_path_is_dot:
130+
self.output.info(f"{cwd.path} has collections_path=. which isolates this workspace.")
131+
return True
132+
if cwd.exists and not cwd.collections_path_is_dot:
133+
cwd.set_or_update_collection_path()
134+
self.output.warning(
135+
f"{cwd.path} has been updated with collections_path=. to isolate this workspace.",
136+
)
137+
return True
138+
if home.exists and not home.collections_path_is_dot:
139+
self.output.warning(
140+
f"{home.path} has been updated with collections_path=. to isolate this and all workspaces.",
141+
)
142+
return True
143+
if system.exists and system.collections_path_is_dot:
144+
self.output.info(
145+
f"{system.path} has collections_path=. which isolates this and all workspaces.",
146+
)
147+
return True
148+
149+
cwd.author_new()
150+
self.output.info(
151+
f"{cwd.path} has been created with collections_path=. to isolate this workspace.",
152+
)
153+
return True
154+
155+
def isolation_none(self) -> bool:
156+
"""No isolation.
157+
158+
Returns:
159+
True if ade can continue, false otherwise.
160+
"""
161+
self.output.warning(
162+
"An unisolated development environment can cause issues with conflicting dependency"
163+
" versions and the use of incompatible collections.",
164+
)
165+
return True
166+
167+
def isolation_restrictive(self) -> bool:
168+
"""Ensure the environment is isolated.
169+
170+
Returns:
171+
True if ade can continue, false otherwise.
172+
"""
97173
env_vars = os.environ
98174
errored = False
99175
if "ANSIBLE_COLLECTIONS_PATHS" in env_vars:
@@ -127,11 +203,11 @@ def ensure_isolated(self) -> None:
127203
hint = "Run `sudo rm -rf /usr/share/ansible/collections` to remove them."
128204
self.output.hint(hint)
129205
errored = True
130-
131206
if errored:
132207
err = "The development environment is not isolated, please resolve the above errors."
133-
134-
self.output.critical(err)
208+
self.output.warning(err)
209+
return False
210+
return True
135211

136212
def run(self) -> None:
137213
"""Run the application."""
@@ -145,9 +221,9 @@ def run(self) -> None:
145221
subcommand_cls = getattr(subcommands, self.config.args.subcommand.capitalize())
146222
subcommand = subcommand_cls(config=self.config, output=self.output)
147223
subcommand.run()
148-
self._exit()
224+
self.exit()
149225

150-
def _exit(self) -> None:
226+
def exit(self) -> None:
151227
"""Exit the application setting the return code."""
152228
if self.output.call_count["error"]:
153229
sys.exit(1)
@@ -171,6 +247,7 @@ def main(*, dry: bool = False) -> None:
171247
cli.output.warning(str(warn.message))
172248
warnings.resetwarnings()
173249
cli.args_sanity()
174-
cli.ensure_isolated()
250+
if not cli.isolation_check():
251+
cli.exit()
175252
if not dry:
176253
cli.run()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
14+
@dataclass
15+
class AnsibleCfg:
16+
"""ansible.cfg file abstraction.
17+
18+
Attributes:
19+
path: Path to the ansible.cfg file.
20+
"""
21+
22+
path: Path
23+
24+
@property
25+
def exists(self) -> bool:
26+
"""Check if the ansible.cfg file exists."""
27+
return self.path.exists()
28+
29+
@property
30+
def collections_path_is_dot(self) -> bool:
31+
"""Check if the collection path is a dot.
32+
33+
Returns:
34+
bool: True if the collection path is a dot.
35+
"""
36+
config = ConfigParser()
37+
config.read(self.path)
38+
return config.get("defaults", "collections_path", fallback=None) == "."
39+
40+
def set_or_update_collection_path(self) -> None:
41+
"""Set or update the collection path in the ansible.cfg file.
42+
43+
The configparser doesn't preserve comments, so we need to read the file
44+
and write it back with the new collection path.
45+
"""
46+
config = ConfigParser()
47+
config.read(self.path)
48+
if not config.has_section("defaults"):
49+
with self.path.open(mode="r+") as file:
50+
content_str = file.read()
51+
file.seek(0, 0)
52+
file.truncate()
53+
file.write("[defaults]\ncollections_path = .\n" + content_str)
54+
file.write("\n")
55+
return
56+
57+
if not config.has_option("defaults", "collections_path"):
58+
with self.path.open(mode="r+") as file:
59+
content_list = file.read().splitlines()
60+
idx = content_list.index("[defaults]") + 1
61+
content_list.insert(idx, "collections_path = .")
62+
file.seek(0, 0)
63+
file.truncate()
64+
file.write("\n".join(content_list))
65+
file.write("\n")
66+
return
67+
68+
with self.path.open(mode="r+") as file:
69+
content_list = file.read().splitlines()
70+
idx = next(
71+
i for i, line in enumerate(content_list) if line.startswith("collections_path")
72+
)
73+
content_list[idx] = "collections_path = ."
74+
file.seek(0, 0)
75+
file.truncate()
76+
file.write("\n".join(content_list))
77+
file.write("\n")
78+
return
79+
80+
def author_new(self) -> None:
81+
"""Author the file and update it."""
82+
config = ConfigParser()
83+
config.add_section("defaults")
84+
config.set("defaults", "collections_path", ".")
85+
with self.path.open(mode="w") as f:
86+
config.write(f)

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)