Skip to content

Commit 87c3278

Browse files
committed
Address GitHub Copilot review comments
- Add validation for aggregate function values (must be in AGGREGATE_FUNCTIONS) - Add validation for duration-unit and granularity-unit (must be min/hr/day) - Enforce entity-ids requirement for non-objectstorage services - Validate that granularity and granularity-unit are provided together - Add -h flag handling for help (in addition to --help) - Make payload printing debug-only (only shown with --debug flag) - Add @requires_jwe_token skip decorator for integration tests - Add new test cases for validation improvements
1 parent 6a28939 commit 87c3278

File tree

2 files changed

+181
-5
lines changed

2 files changed

+181
-5
lines changed

linodecli/plugins/monitor-api.py

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def get_auth_token():
4545
# Aggregate functions
4646
AGGREGATE_FUNCTIONS = ["sum", "avg", "max", "min", "count"]
4747

48+
# Allowed time units
49+
ALLOWED_TIME_UNITS = ["min", "hr", "day"]
50+
4851

4952
@dataclass
5053
class MetricsConfig:
@@ -123,8 +126,32 @@ def parse_metrics(metrics: List[str]) -> List[dict]:
123126
for metric in metrics:
124127
if ":" in metric:
125128
metric_name, agg_func = metric.split(":", 1)
129+
metric_name = metric_name.strip()
130+
agg_func = agg_func.strip().lower()
131+
if not metric_name:
132+
print(
133+
f"Metric name is required for metric '{metric}'",
134+
file=sys.stderr,
135+
)
136+
print(
137+
f"Format: 'metric_name:function' where function is one of: "
138+
f"{', '.join(AGGREGATE_FUNCTIONS)}",
139+
file=sys.stderr,
140+
)
141+
sys.exit(ExitCodes.REQUEST_FAILED)
142+
if agg_func not in AGGREGATE_FUNCTIONS:
143+
print(
144+
f"Invalid aggregate function '{agg_func}' for metric '{metric}'",
145+
file=sys.stderr,
146+
)
147+
print(
148+
f"Aggregate function must be one of: "
149+
f"{', '.join(AGGREGATE_FUNCTIONS)}",
150+
file=sys.stderr,
151+
)
152+
sys.exit(ExitCodes.REQUEST_FAILED)
126153
parsed_metrics.append(
127-
{"aggregate_function": agg_func, "name": metric_name.strip()}
154+
{"aggregate_function": agg_func, "name": metric_name}
128155
)
129156
else:
130157
print(
@@ -206,7 +233,7 @@ def build_payload(config: MetricsConfig) -> dict:
206233
return payload
207234

208235

209-
def get_metrics(config: MetricsConfig):
236+
def get_metrics(config: MetricsConfig, debug: bool = False):
210237
"""Get metrics for specified service entities"""
211238
payload = build_payload(config)
212239

@@ -216,7 +243,12 @@ def get_metrics(config: MetricsConfig):
216243
)
217244
else:
218245
print(f"Fetching metrics for {config.service_name} (all entities)")
219-
print(f"Request payload: {json.dumps(payload, indent=2)}")
246+
247+
if debug:
248+
print(
249+
f"Request payload: {json.dumps(payload, indent=2)}",
250+
file=sys.stderr,
251+
)
220252

221253
try:
222254
status, response = make_api_request(
@@ -432,6 +464,18 @@ def validate_arguments(parsed):
432464
print(" --metrics: required", file=sys.stderr)
433465
return False
434466

467+
# Validate entity_ids requirement (only objectstorage allows querying all entities)
468+
if not parsed.entity_ids and parsed.service.lower() != "objectstorage":
469+
print(
470+
f"--entity-ids is required for service '{parsed.service}'",
471+
file=sys.stderr,
472+
)
473+
print(
474+
"Only 'objectstorage' service allows querying all entities without --entity-ids",
475+
file=sys.stderr,
476+
)
477+
return False
478+
435479
# Validate time duration arguments
436480
has_relative = (
437481
parsed.duration is not None and parsed.duration_unit is not None
@@ -451,6 +495,42 @@ def validate_arguments(parsed):
451495
)
452496
return False
453497

498+
# Validate duration-unit if provided
499+
if parsed.duration is not None and parsed.duration_unit is not None:
500+
if parsed.duration_unit not in ALLOWED_TIME_UNITS:
501+
print(
502+
f"Invalid duration unit '{parsed.duration_unit}'",
503+
file=sys.stderr,
504+
)
505+
print(
506+
f"Allowed units: {', '.join(ALLOWED_TIME_UNITS)}",
507+
file=sys.stderr,
508+
)
509+
return False
510+
511+
# Validate granularity arguments
512+
if (parsed.granularity is not None) != (
513+
parsed.granularity_unit is not None
514+
):
515+
print(
516+
"Both --granularity and --granularity-unit must be provided together",
517+
file=sys.stderr,
518+
)
519+
return False
520+
521+
# Validate granularity-unit if provided
522+
if parsed.granularity_unit is not None:
523+
if parsed.granularity_unit not in ALLOWED_TIME_UNITS:
524+
print(
525+
f"Invalid granularity unit '{parsed.granularity_unit}'",
526+
file=sys.stderr,
527+
)
528+
print(
529+
f"Allowed units: {', '.join(ALLOWED_TIME_UNITS)}",
530+
file=sys.stderr,
531+
)
532+
return False
533+
454534
return True
455535

456536

@@ -473,7 +553,12 @@ def call(args, context=None): # pylint: disable=unused-argument
473553
parsed, remaining_args = parser.parse_known_args(args)
474554

475555
# Handle help cases
476-
if not parsed.command or parsed.command == "help" or "--help" in args:
556+
if (
557+
not parsed.command
558+
or parsed.command == "help"
559+
or "--help" in args
560+
or "-h" in args
561+
):
477562
print_help(parser)
478563
sys.exit(ExitCodes.SUCCESS)
479564

@@ -527,4 +612,4 @@ def call(args, context=None): # pylint: disable=unused-argument
527612
associated_entity_region=parsed.associated_entity_region,
528613
)
529614

530-
get_metrics(config)
615+
get_metrics(config, debug=parsed.debug)

tests/integration/monitor/test_plugin_get_metrics.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Integration tests for the get_metrics plugin
33
"""
44

5+
import os
6+
57
import pytest
68

79
from linodecli.exit_codes import ExitCodes
@@ -13,6 +15,12 @@
1315
# Base command for monitor-api plugin
1416
BASE_CMD = ["linode-cli", "monitor-api", "get-metrics"]
1517

18+
# Skip decorator for tests that require JWE_TOKEN
19+
requires_jwe_token = pytest.mark.skipif(
20+
not os.getenv("JWE_TOKEN"),
21+
reason="JWE_TOKEN environment variable not set",
22+
)
23+
1624

1725
def test_missing_required_args():
1826
"""Test error handling for missing required arguments"""
@@ -135,6 +143,7 @@ def test_conflicting_time_params():
135143

136144

137145
@pytest.mark.smoke
146+
@requires_jwe_token
138147
def test_objstorage_metrics_basic():
139148
"""Test get_metrics with objectstorage service (with authentication)"""
140149
# Use objectstorage service which doesn't require entity-ids
@@ -157,6 +166,7 @@ def test_objstorage_metrics_basic():
157166
assert "Fetching metrics" in output or "data" in output.lower()
158167

159168

169+
@requires_jwe_token
160170
def test_obj_metrics_with_filters():
161171
"""Test get_metrics with objectstorage service and filters"""
162172
output = exec_test_command(
@@ -179,6 +189,7 @@ def test_obj_metrics_with_filters():
179189
assert "Fetching metrics" in output or "data" in output.lower()
180190

181191

192+
@requires_jwe_token
182193
def test_absolute_time_metrics():
183194
"""Test get_metrics with objectstorage service and absolute time range"""
184195
output = exec_test_command(
@@ -220,3 +231,83 @@ def test_malformed_filters():
220231
],
221232
expected_code=ExitCodes.REQUEST_FAILED,
222233
)
234+
235+
236+
def test_invalid_aggregate_function_value():
237+
"""Test handling of invalid aggregate function values"""
238+
exec_failing_test_command(
239+
BASE_CMD
240+
+ [
241+
"nodebalancer",
242+
"--entity-ids",
243+
"123",
244+
"--metrics",
245+
"cpu_usage:invalid_func", # Invalid aggregate function
246+
"--duration",
247+
"15",
248+
"--duration-unit",
249+
"min",
250+
],
251+
expected_code=ExitCodes.REQUEST_FAILED,
252+
)
253+
254+
255+
def test_entity_ids_required_for_non_objectstorage():
256+
"""Test that entity-ids is required for non-objectstorage services"""
257+
exec_failing_test_command(
258+
BASE_CMD
259+
+ [
260+
"dbaas",
261+
"--metrics",
262+
"cpu_usage:avg",
263+
"--duration",
264+
"15",
265+
"--duration-unit",
266+
"min",
267+
],
268+
expected_code=ExitCodes.REQUEST_FAILED,
269+
)
270+
271+
272+
def test_invalid_granularity_unit():
273+
"""Test handling of invalid granularity unit"""
274+
exec_failing_test_command(
275+
BASE_CMD
276+
+ [
277+
"nodebalancer",
278+
"--entity-ids",
279+
"123",
280+
"--metrics",
281+
"cpu_usage:avg",
282+
"--duration",
283+
"15",
284+
"--duration-unit",
285+
"min",
286+
"--granularity",
287+
"5",
288+
"--granularity-unit",
289+
"invalid_unit",
290+
],
291+
expected_code=ExitCodes.REQUEST_FAILED,
292+
)
293+
294+
295+
def test_granularity_without_unit():
296+
"""Test that granularity requires granularity-unit"""
297+
exec_failing_test_command(
298+
BASE_CMD
299+
+ [
300+
"nodebalancer",
301+
"--entity-ids",
302+
"123",
303+
"--metrics",
304+
"cpu_usage:avg",
305+
"--duration",
306+
"15",
307+
"--duration-unit",
308+
"min",
309+
"--granularity",
310+
"5",
311+
],
312+
expected_code=ExitCodes.REQUEST_FAILED,
313+
)

0 commit comments

Comments
 (0)