Skip to content

Commit 1f0ed24

Browse files
serbrechCopilot
andauthored
[fleet] add support for inline json to --stages argument in CLI (#9000)
* Initial plan * Implement inline JSON support for --stages argument Co-authored-by: serbrech <[email protected]> * Replace unit tests to actually test get_update_run_strategy function Co-authored-by: serbrech <[email protected]> * Move imports to top of file and consolidate azure.cli.core.util imports Co-authored-by: serbrech <[email protected]> * Update test_stages_json.py * remove unused json import * Update version to 1.6.1 and add changelog entry for --stages argument enhancement Co-authored-by: serbrech <[email protected]> * fix tests --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: serbrech <[email protected]>
1 parent 98453e3 commit 1f0ed24

File tree

5 files changed

+208
-11
lines changed

5 files changed

+208
-11
lines changed

src/fleet/HISTORY.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,8 @@ Release History
133133
* Upgrade SDK version to 2025-04-01-preview
134134
* Add Fleet Gates support
135135
* Add TargetKubernetesVersion channel support
136-
* Add Fleet Member labels support
136+
* Add Fleet Member labels support
137+
138+
1.6.1
139+
++++++
140+
* Modified parameter handling to accept both file paths and inline JSON strings for the --stages argument

src/fleet/azext_fleet/_params.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ def load_arguments(self, _):
9595
c.argument('kubernetes_version', validator=validate_kubernetes_version)
9696
c.argument('node_image_selection', arg_type=get_enum_type(['Latest', 'Consistent']),
9797
help='Node Image Selection is an option that lets you choose how your clusters\' nodes are upgraded')
98-
c.argument('stages', type=file_type, completer=FilesCompleter(),
99-
help='Path to a json file that defines stages to upgrade a fleet. See examples for further reference.')
98+
c.argument('stages',
99+
help='Path to a JSON file that defines stages to upgrade a fleet, or a JSON string. See examples for further reference.')
100100
c.argument('update_strategy_name', validator=validate_update_strategy_name,
101101
help='The name of the update strategy to use for this update run. If not specified, the default update strategy will be used.')
102102

@@ -110,8 +110,8 @@ def load_arguments(self, _):
110110
c.argument('fleet_name', options_list=['--fleet-name', '-f'], help='Specify the fleet name.')
111111

112112
with self.argument_context('fleet updatestrategy create') as c:
113-
c.argument('stages', type=file_type, completer=FilesCompleter(),
114-
help='Path to a json file that defines an update strategy.')
113+
c.argument('stages',
114+
help='Path to a JSON file that defines an update strategy, or a JSON string.')
115115

116116
with self.argument_context('fleet autoupgradeprofile') as c:
117117
c.argument('name', options_list=['--name', '-n'], help='Specify name for the auto upgrade profile.')

src/fleet/azext_fleet/custom.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44
# --------------------------------------------------------------------------------------------
55

66
import os
7-
import json
87

98
from knack.util import CLIError
109

1110
from azure.cli.core.commands.client_factory import get_subscription_id
12-
from azure.cli.core.util import sdk_no_wait
11+
from azure.cli.core.util import sdk_no_wait, get_file_json, shell_safe_json_parse
1312

1413
from azext_fleet._client_factory import CUSTOM_MGMT_FLEET
1514
from azext_fleet._helpers import print_or_merge_credentials
@@ -462,9 +461,11 @@ def get_update_run_strategy(cmd, operation_group, stages):
462461
if stages is None:
463462
return None
464463

465-
with open(stages, 'r', encoding='utf-8') as fp:
466-
data = json.load(fp)
467-
fp.close()
464+
# Check if the input is a file path or inline JSON
465+
if os.path.exists(stages):
466+
data = get_file_json(stages)
467+
else:
468+
data = shell_safe_json_parse(stages)
468469

469470
update_group_model = cmd.get_models(
470471
"UpdateGroup",
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
import unittest
7+
import json
8+
import tempfile
9+
import os
10+
from unittest.mock import MagicMock, patch
11+
from azext_fleet.custom import get_update_run_strategy
12+
from azext_fleet.vendored_sdks.v2025_04_01_preview.models import UpdateRunStrategy, UpdateStage, UpdateGroup
13+
from azure.cli.core.azclierror import (
14+
InvalidArgumentValueError,
15+
)
16+
17+
class TestStagesJsonHandling(unittest.TestCase):
18+
"""Test inline JSON support for --stages argument in fleet commands."""
19+
20+
def setUp(self):
21+
"""Set up test data and mock objects."""
22+
self.test_data = {
23+
"stages": [
24+
{
25+
"name": "stage1",
26+
"groups": [
27+
{"name": "group1"},
28+
{"name": "group2"}
29+
],
30+
"afterStageWaitInSeconds": 3600
31+
}
32+
]
33+
}
34+
35+
# Mock cmd object that provides get_models method
36+
self.mock_cmd = MagicMock()
37+
38+
# Set up get_models to return our mock classes
39+
def mock_get_models(model_name, **kwargs):
40+
if model_name == "UpdateGroup":
41+
return UpdateGroup
42+
elif model_name == "UpdateStage":
43+
return UpdateStage
44+
elif model_name == "UpdateRunStrategy":
45+
return UpdateRunStrategy
46+
else:
47+
return MagicMock()
48+
49+
self.mock_cmd.get_models = mock_get_models
50+
51+
def test_file_path_stages(self):
52+
"""Test that file paths for stages work correctly with get_update_run_strategy."""
53+
# Create a temporary file with test data
54+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
55+
json.dump(self.test_data, f)
56+
temp_file_path = f.name
57+
58+
try:
59+
# Test the actual function
60+
result = get_update_run_strategy(self.mock_cmd, "fleet_update_runs", temp_file_path)
61+
62+
# Verify the returned strategy
63+
self.assertIsNotNone(result)
64+
self.assertIsInstance(result, UpdateRunStrategy)
65+
self.assertEqual(len(result.stages), 1)
66+
67+
# Verify first stage
68+
stage = result.stages[0]
69+
self.assertIsInstance(stage, UpdateStage)
70+
self.assertEqual(stage.name, "stage1")
71+
self.assertEqual(stage.after_stage_wait_in_seconds, 3600)
72+
self.assertEqual(len(stage.groups), 2)
73+
74+
# Verify groups
75+
self.assertIsInstance(stage.groups[0], UpdateGroup)
76+
self.assertEqual(stage.groups[0].name, "group1")
77+
self.assertEqual(stage.groups[1].name, "group2")
78+
79+
finally:
80+
# Clean up
81+
os.unlink(temp_file_path)
82+
83+
def test_inline_json_stages(self):
84+
"""Test that inline JSON strings work correctly with get_update_run_strategy."""
85+
inline_json = json.dumps(self.test_data)
86+
87+
# Test the actual function
88+
result = get_update_run_strategy(self.mock_cmd, "fleet_update_runs", inline_json)
89+
90+
# Verify the returned strategy
91+
self.assertIsNotNone(result)
92+
self.assertIsInstance(result, UpdateRunStrategy)
93+
self.assertEqual(len(result.stages), 1)
94+
95+
# Verify first stage
96+
stage = result.stages[0]
97+
self.assertIsInstance(stage, UpdateStage)
98+
self.assertEqual(stage.name, "stage1")
99+
self.assertEqual(stage.after_stage_wait_in_seconds, 3600)
100+
self.assertEqual(len(stage.groups), 2)
101+
102+
# Verify groups
103+
self.assertIsInstance(stage.groups[0], UpdateGroup)
104+
self.assertEqual(stage.groups[0].name, "group1")
105+
self.assertEqual(stage.groups[1].name, "group2")
106+
107+
def test_inline_json_minimal_stages(self):
108+
"""Test inline JSON with minimal required fields."""
109+
minimal_data = {
110+
"stages": [
111+
{
112+
"name": "minimal-stage",
113+
"groups": [
114+
{"name": "minimal-group"}
115+
]
116+
}
117+
]
118+
}
119+
120+
inline_json = json.dumps(minimal_data)
121+
result = get_update_run_strategy(self.mock_cmd, "fleet_update_runs", inline_json)
122+
123+
# Verify minimal structure works
124+
self.assertIsNotNone(result)
125+
self.assertEqual(len(result.stages), 1)
126+
127+
stage = result.stages[0]
128+
self.assertEqual(stage.name, "minimal-stage")
129+
self.assertEqual(stage.after_stage_wait_in_seconds, 0) # Should default to 0
130+
self.assertEqual(len(stage.groups), 1)
131+
self.assertEqual(stage.groups[0].name, "minimal-group")
132+
133+
def test_complex_stages_structure(self):
134+
"""Test more complex stages structure with multiple stages and groups."""
135+
complex_data = {
136+
"stages": [
137+
{
138+
"name": "stage1",
139+
"groups": [
140+
{"name": "group1"},
141+
{"name": "group2"}
142+
],
143+
"afterStageWaitInSeconds": 1800
144+
},
145+
{
146+
"name": "stage2",
147+
"groups": [
148+
{"name": "group3"}
149+
],
150+
"afterStageWaitInSeconds": 3600
151+
}
152+
]
153+
}
154+
155+
inline_json = json.dumps(complex_data)
156+
result = get_update_run_strategy(self.mock_cmd, "fleet_update_runs", inline_json)
157+
158+
# Verify complex structure
159+
self.assertIsNotNone(result)
160+
self.assertEqual(len(result.stages), 2)
161+
162+
# Verify first stage
163+
stage1 = result.stages[0]
164+
self.assertEqual(stage1.name, "stage1")
165+
self.assertEqual(stage1.after_stage_wait_in_seconds, 1800)
166+
self.assertEqual(len(stage1.groups), 2)
167+
self.assertEqual(stage1.groups[0].name, "group1")
168+
self.assertEqual(stage1.groups[1].name, "group2")
169+
170+
# Verify second stage
171+
stage2 = result.stages[1]
172+
self.assertEqual(stage2.name, "stage2")
173+
self.assertEqual(stage2.after_stage_wait_in_seconds, 3600)
174+
self.assertEqual(len(stage2.groups), 1)
175+
self.assertEqual(stage2.groups[0].name, "group3")
176+
177+
def test_none_stages_returns_none(self):
178+
"""Test that None stages input returns None."""
179+
result = get_update_run_strategy(self.mock_cmd, "fleet_update_runs", None)
180+
self.assertIsNone(result)
181+
182+
def test_invalid_json_raises_error(self):
183+
"""Test that invalid JSON strings raise appropriate errors."""
184+
invalid_json = '{"stages": [{"name": "test", invalid_syntax}]}'
185+
186+
# Should raise an error when parsing invalid JSON
187+
with self.assertRaises(InvalidArgumentValueError):
188+
get_update_run_strategy(self.mock_cmd, "fleet_update_runs", invalid_json)
189+
190+
191+
if __name__ == "__main__":
192+
unittest.main()

src/fleet/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
# TODO: Confirm this is the right version number you want and it matches your
1818
# HISTORY.rst entry.
19-
VERSION = '1.6.0'
19+
VERSION = '1.6.1'
2020

2121
# The full list of classifiers is available at
2222
# https://pypi.python.org/pypi?%3Aaction=list_classifiers

0 commit comments

Comments
 (0)