Skip to content

Commit b1ca8e8

Browse files
lcaflcplaffitt
authored andcommitted
feat(config): Autodetect cluster
Change the config cluster loading management Allow the following things: - warn if no cluster is defined in configuration - if no cluster selected in the cli options list what's available - if there is only one cluster in the configuration, simply use it instead of mandate a selection - cluster selection case-insensitive
1 parent 362b3de commit b1ca8e8

7 files changed

Lines changed: 213 additions & 21 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ pip-delete-this-directory.txt
4242
.vscode/
4343

4444
.pytest*
45+
CLAUDE.md

src/pvecontrol/__init__.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import urllib3
1212

1313
from pvecontrol import actions
14+
from pvecontrol.config import config, list_clusters
1415
from pvecontrol.utils import OutputFormats
1516

1617

@@ -57,8 +58,8 @@ def _is_defaulting_to_help(self, ctx, args):
5758
return False
5859

5960
def parse_args(self, ctx, args):
60-
if self._is_defaulting_to_help(ctx, args):
61-
self.ignoring = True
61+
self.ignoring = self._is_defaulting_to_help(ctx, args)
62+
if self.ignoring:
6263
for param in self.params:
6364
param.required = False
6465

@@ -113,7 +114,7 @@ def format_commands(self, ctx, formatter) -> None:
113114
"--cluster",
114115
metavar="NAME",
115116
envvar="CLUSTER",
116-
required=True,
117+
default=None,
117118
help="Proxmox cluster name as defined in configuration",
118119
)
119120
@click.option(
@@ -135,11 +136,32 @@ def pvecontrol(ctx, debug, output, cluster, unicode, color):
135136
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
136137

137138
if not ctx.command.ignoring:
138-
# get cli arguments
139-
args = SimpleNamespace(output=output, cluster=cluster, unicode=unicode, color=color)
140-
141139
# configure logging
142140
logging.basicConfig(encoding="utf-8", level=logging.DEBUG if debug else logging.INFO)
141+
142+
if cluster is None:
143+
clusters = list_clusters()
144+
logging.debug("No cluster specified, found %d cluster(s) in config: %s", len(clusters), clusters)
145+
if len(clusters) == 1:
146+
cluster = clusters[0]
147+
logging.debug("Auto-selected single cluster: %s", cluster)
148+
elif len(clusters) == 0:
149+
logging.error("No cluster configured. Please add a cluster to your configuration file.")
150+
logging.error("Configuration file: %s", config.user_config_path())
151+
ctx.exit(1)
152+
else:
153+
available = "\n".join(f" {name}" for name in clusters)
154+
logging.error(
155+
"No cluster specified. Available clusters:\n%s\n\n"
156+
"Use -c / --cluster NAME or set the PVECONTROL_CLUSTER environment variable.",
157+
available,
158+
)
159+
ctx.exit(1)
160+
else:
161+
logging.debug("Cluster specified: %s", cluster)
162+
163+
# get cli arguments
164+
args = SimpleNamespace(output=output, cluster=cluster, unicode=unicode, color=color)
143165
logging.debug("Arguments: %s", args)
144166

145167
ctx.ensure_object(dict)

src/pvecontrol/config.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,40 @@
4848
config = confuse.LazyConfig("pvecontrol", __name__)
4949

5050

51+
def _load_config():
52+
try:
53+
validconfig = config.get(configtemplate)
54+
except confuse.ConfigReadError as e:
55+
logging.error("Cannot read configuration file: %s", e)
56+
sys.exit(1)
57+
except confuse.NotFoundError as e:
58+
logging.error("Missing required configuration key: %s", e)
59+
sys.exit(1)
60+
except confuse.ConfigError as e:
61+
logging.error("Invalid configuration: %s", e)
62+
sys.exit(1)
63+
return validconfig
64+
65+
66+
def list_clusters():
67+
validconfig = _load_config()
68+
return [c.name for c in validconfig.clusters]
69+
70+
5171
def set_config(cluster_name):
52-
validconfig = config.get(configtemplate)
72+
validconfig = _load_config()
5373
logging.debug("configuration is %s", validconfig)
5474

5575
# FIXME trouver une methode plus clean pour recuperer la configuration du bon cluster
5676
# Peut etre rework la configuration completement avec un dict
57-
clusterconfig = False
58-
for c in validconfig.clusters:
59-
if c.name == cluster_name:
60-
clusterconfig = c
61-
if not clusterconfig:
77+
matches = [c for c in validconfig.clusters if c.name.lower() == cluster_name.lower()]
78+
if not matches:
6279
logging.error('No such cluster "%s"', cluster_name)
6380
sys.exit(1)
81+
if len(matches) > 1:
82+
logging.error('Ambiguous cluster name "%s": matches %s', cluster_name, [c.name for c in matches])
83+
sys.exit(1)
84+
clusterconfig = matches[0]
6485
logging.debug("clusterconfig is %s", clusterconfig)
6586

6687
for k, v in validconfig.node.items():

src/pvecontrol/config_default.yaml

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
---
22

3-
clusters:
4-
- name: cluster1
5-
host: 127.0.0.1
6-
user: pvecontrol@pve
7-
timeout: 60
8-
ssl_verify: false
9-
# password: somerandomsecret
10-
# token_name: mytokenname
11-
# token_value: somemorerandomsecret
3+
clusters: []
124

135
node:
146
cpufactor: 2.5

src/tests/sanitycheck/test_vm_disks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# pylint: disable=duplicate-code
12
from unittest.mock import patch
23
from pvecontrol.models.cluster import PVECluster
34
from pvecontrol.sanitycheck.tests.vm import DiskUnused

src/tests/test_config.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import importlib
2+
import unittest
3+
from unittest.mock import MagicMock, patch
4+
5+
import confuse
6+
7+
from pvecontrol.config import list_clusters, set_config
8+
9+
# pvecontrol/__init__.py does `from pvecontrol.config import config`, which
10+
# overwrites the `config` attribute on the pvecontrol package with the LazyConfig
11+
# instance. importlib.import_module resolves via sys.modules directly, bypassing
12+
# that shadowing, and returns the actual module.
13+
config_module = importlib.import_module("pvecontrol.config")
14+
15+
16+
def _make_cluster(name, node=None, vm=None):
17+
c = MagicMock()
18+
c.name = name
19+
c.node = node if node is not None else {}
20+
c.vm = vm if vm is not None else {}
21+
return c
22+
23+
24+
def _make_validconfig(cluster_names):
25+
vc = MagicMock()
26+
vc.clusters = [_make_cluster(n) for n in cluster_names]
27+
vc.node = {"cpufactor": 2.5, "memoryminimum": 8589934592}
28+
vc.vm = {"max_last_backup": 1500}
29+
return vc
30+
31+
32+
class TestLoadConfigErrors(unittest.TestCase):
33+
34+
def test_config_read_error(self):
35+
with patch.object(config_module, "config") as mock_config:
36+
mock_config.get.side_effect = confuse.ConfigReadError("config.yaml")
37+
with self.assertRaises(SystemExit) as cm:
38+
list_clusters()
39+
self.assertEqual(cm.exception.code, 1)
40+
41+
def test_not_found_error(self):
42+
with patch.object(config_module, "config") as mock_config:
43+
mock_config.get.side_effect = confuse.NotFoundError()
44+
with self.assertRaises(SystemExit) as cm:
45+
list_clusters()
46+
self.assertEqual(cm.exception.code, 1)
47+
48+
def test_config_type_error(self):
49+
with patch.object(config_module, "config") as mock_config:
50+
mock_config.get.side_effect = confuse.ConfigTypeError()
51+
with self.assertRaises(SystemExit) as cm:
52+
list_clusters()
53+
self.assertEqual(cm.exception.code, 1)
54+
55+
56+
class TestListClusters(unittest.TestCase):
57+
58+
def test_empty(self):
59+
with patch.object(config_module, "config") as mock_config:
60+
mock_config.get.return_value = _make_validconfig([])
61+
self.assertEqual(list_clusters(), [])
62+
63+
def test_returns_names(self):
64+
with patch.object(config_module, "config") as mock_config:
65+
mock_config.get.return_value = _make_validconfig(["prod", "staging"])
66+
self.assertEqual(list_clusters(), ["prod", "staging"])
67+
68+
69+
class TestSetConfig(unittest.TestCase):
70+
71+
def test_case_insensitive_lower_input(self):
72+
with patch.object(config_module, "config") as mock_config:
73+
mock_config.get.return_value = _make_validconfig(["PROD"])
74+
result = set_config("prod")
75+
self.assertEqual(result.name, "PROD")
76+
77+
def test_case_insensitive_upper_input(self):
78+
with patch.object(config_module, "config") as mock_config:
79+
mock_config.get.return_value = _make_validconfig(["prod"])
80+
result = set_config("PROD")
81+
self.assertEqual(result.name, "prod")
82+
83+
def test_exact_match(self):
84+
with patch.object(config_module, "config") as mock_config:
85+
mock_config.get.return_value = _make_validconfig(["prod"])
86+
result = set_config("prod")
87+
self.assertEqual(result.name, "prod")
88+
89+
def test_not_found(self):
90+
with patch.object(config_module, "config") as mock_config:
91+
mock_config.get.return_value = _make_validconfig(["prod", "staging"])
92+
with self.assertRaises(SystemExit) as cm:
93+
set_config("unknown")
94+
self.assertEqual(cm.exception.code, 1)
95+
96+
def test_ambiguous_names(self):
97+
vc = MagicMock()
98+
vc.clusters = [_make_cluster("prod"), _make_cluster("PROD")]
99+
vc.node = {}
100+
vc.vm = {}
101+
with patch.object(config_module, "config") as mock_config:
102+
mock_config.get.return_value = vc
103+
with self.assertRaises(SystemExit) as cm:
104+
set_config("prod")
105+
self.assertEqual(cm.exception.code, 1)
106+
107+
def test_ambiguous_names_error_lists_conflicts(self):
108+
vc = MagicMock()
109+
vc.clusters = [_make_cluster("prod"), _make_cluster("PROD")]
110+
vc.node = {}
111+
vc.vm = {}
112+
with patch.object(config_module, "config") as mock_config:
113+
mock_config.get.return_value = vc
114+
with self.assertLogs("root", level="ERROR") as log:
115+
with self.assertRaises(SystemExit):
116+
set_config("prod")
117+
self.assertTrue(any("prod" in msg and "PROD" in msg for msg in log.output))

src/tests/test_pvecontrol.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from unittest.mock import patch
2+
3+
from click.testing import CliRunner
4+
15
from pvecontrol import pvecontrol, get_leaf_command
26
from pvecontrol.utils import reorder_keys
37
from pvecontrol.actions.node import root as node, evacuate
@@ -26,3 +30,37 @@ def test_get_leaf_command():
2630
leaf_cmd, leaf_args = get_leaf_command(pvecontrol, ctx, testcase[1])
2731
assert leaf_cmd == testcase[0]
2832
assert leaf_args == testcase[2]
33+
34+
35+
@patch("pvecontrol.list_clusters", return_value=[])
36+
def test_no_cluster_configured(_, caplog):
37+
result = CliRunner().invoke(pvecontrol, ["status"])
38+
assert result.exit_code == 1
39+
assert "No cluster configured" in caplog.text
40+
41+
42+
@patch("pvecontrol.list_clusters", return_value=[])
43+
def test_no_cluster_configured_shows_config_path(_, caplog):
44+
CliRunner().invoke(pvecontrol, ["status"])
45+
assert "Configuration file:" in caplog.text
46+
47+
48+
@patch("pvecontrol.list_clusters", return_value=["prod", "staging"])
49+
def test_multiple_clusters_lists_names(_, caplog):
50+
result = CliRunner().invoke(pvecontrol, ["status"])
51+
assert result.exit_code == 1
52+
assert "prod" in caplog.text
53+
assert "staging" in caplog.text
54+
55+
56+
@patch("pvecontrol.list_clusters", return_value=["prod", "staging"])
57+
def test_multiple_clusters_shows_usage_hint(_, caplog):
58+
CliRunner().invoke(pvecontrol, ["status"])
59+
assert "--cluster" in caplog.text
60+
61+
62+
@patch("pvecontrol.actions.cluster.PVECluster.create_from_config")
63+
@patch("pvecontrol.list_clusters", return_value=["prod"])
64+
def test_auto_select_single_cluster(_, mock_create):
65+
CliRunner().invoke(pvecontrol, ["status"])
66+
mock_create.assert_called_once_with("prod")

0 commit comments

Comments
 (0)