Skip to content

Commit 8e90407

Browse files
authored
[Resource] az bicep: Add snapshot and run subcommands (#33398)
1 parent d7d1701 commit 8e90407

6 files changed

Lines changed: 309 additions & 3 deletions

File tree

src/azure-cli/azure/cli/command_modules/resource/_help.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2763,6 +2763,42 @@
27632763
text: az bicep lint --file {bicep_file} --diagnostics-format {diagnostics_format}
27642764
"""
27652765

2766+
helps['bicep snapshot'] = """
2767+
type: command
2768+
short-summary: Capture or validate a snapshot of the resources predicted to be deployed by a .bicepparam file.
2769+
long-summary: |
2770+
Compiles a .bicepparam file together with its referenced Bicep template and writes a deployment
2771+
snapshot (a `*.snapshot.json` file) next to the .bicepparam file. When run with `--mode Validate`,
2772+
the existing snapshot is compared against the current template and the command fails if they
2773+
differ. This command requires Bicep CLI v0.41.2 or later.
2774+
examples:
2775+
- name: Capture a snapshot for a .bicepparam file.
2776+
text: az bicep snapshot --file main.bicepparam
2777+
- name: Validate that the existing snapshot still matches the current template.
2778+
text: az bicep snapshot --file main.bicepparam --mode Validate
2779+
- name: Capture a snapshot with explicit Azure context.
2780+
text: az bicep snapshot --file main.bicepparam --subscription-id 00000000-0000-0000-0000-000000000000 --resource-group myRg --location westus
2781+
"""
2782+
2783+
helps['bicep run'] = """
2784+
type: command
2785+
short-summary: Forward a raw command to the installed Bicep CLI.
2786+
long-summary: |
2787+
Runs the Bicep CLI with the arguments supplied via `--command`, allowing use of Bicep CLI
2788+
features that do not yet have a dedicated `az bicep` wrapper. The string passed to
2789+
`--command` is split using shell-style quoting and forwarded to the Bicep CLI verbatim.
2790+
When the forwarded command itself starts with `--` (for example `--version`), use the
2791+
`--command=<value>` form so the CLI parser does not mistake the value for another option.
2792+
2793+
Because the value is forwarded to the Bicep CLI without validation, do not pass strings
2794+
derived from untrusted input.
2795+
examples:
2796+
- name: Forward a build command to the Bicep CLI.
2797+
text: az bicep run --command "build main.bicep"
2798+
- name: Show the Bicep CLI help (use the --command=<value> form for option-like values).
2799+
text: az bicep run --command=--help
2800+
"""
2801+
27662802
helps['resourcemanagement'] = """
27672803
type: group
27682804
short-summary: resourcemanagement CLI command group.

src/azure-cli/azure/cli/command_modules/resource/_params.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,27 @@ def load_arguments(self, _):
757757
c.argument('no_restore', arg_type=bicep_no_restore_type, help="When set, generates the parameters file without restoring external modules.")
758758
c.argument('diagnostics_format', arg_type=get_enum_type(['default', 'sarif']), help="Set diagnostics format.")
759759

760+
with self.argument_context('bicep snapshot') as c:
761+
c.argument('file', arg_type=bicep_file_type, help="The path to the .bicepparam file to capture a snapshot for.")
762+
c.argument('mode', arg_type=get_enum_type(['Overwrite', 'Validate']),
763+
help="The snapshot mode. 'Overwrite' (default) writes the snapshot file. 'Validate' compares the existing snapshot against the current template and fails if differences are detected.")
764+
c.argument('tenant_id', options_list=['--tenant-id'],
765+
help="Tenant ID forwarded to the Bicep CLI as the deployment context used to resolve `existing` references when capturing the snapshot. This does not affect Azure CLI authentication.")
766+
c.argument('subscription_id', options_list=['--subscription-id'],
767+
help="Subscription ID forwarded to the Bicep CLI as the deployment context used to resolve `existing` references when capturing the snapshot. This does not affect Azure CLI authentication; use the global `--subscription` argument to switch the active subscription.")
768+
c.argument('management_group_id', options_list=['--management-group-id'],
769+
help="Management group ID forwarded to the Bicep CLI as the deployment context used to resolve `existing` references when capturing the snapshot.")
770+
c.argument('location', arg_type=get_location_type(self.cli_ctx),
771+
help="Location forwarded to the Bicep CLI as the deployment context used to resolve `existing` references when capturing the snapshot.")
772+
c.argument('resource_group', arg_type=resource_group_name_type,
773+
help="Resource group name forwarded to the Bicep CLI as the deployment context used to resolve `existing` references when capturing the snapshot.")
774+
c.argument('deployment_name', options_list=['--deployment-name'],
775+
help="Deployment name forwarded to the Bicep CLI as the deployment context used to resolve `existing` references when capturing the snapshot.")
776+
777+
with self.argument_context('bicep run') as c:
778+
c.argument('command_string', options_list=['--command', '-c'],
779+
help="The Bicep CLI command to run, including its arguments, as a single quoted string (e.g. \"build main.bicep\").")
780+
760781
with self.argument_context('resourcemanagement private-link create') as c:
761782
c.argument('resource_group', arg_type=resource_group_name_type,
762783
help='The name of the resource group.')

src/azure-cli/azure/cli/command_modules/resource/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,8 @@ def load_command_table(self, _):
638638
g.custom_command('list-versions', 'list_bicep_cli_versions')
639639
g.custom_command('generate-params', 'generate_params_file')
640640
g.custom_command('lint', 'lint_bicep_file')
641+
g.custom_command('snapshot', 'snapshot_bicep_file')
642+
g.custom_command('run', 'run_bicep_cli_passthrough')
641643

642644
with self.command_group('resourcemanagement private-link', resource_resourcemanagementprivatelink_sdk, resource_type=ResourceType.MGMT_RESOURCE_PRIVATELINKS) as g:
643645
g.custom_command('create', 'create_resourcemanager_privatelink')

src/azure-cli/azure/cli/command_modules/resource/custom.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from azure.mgmt.resource.deployments.models import DeploymentMode
2424
import azure.mgmt.resource.deploymentstacks.models as StackModels
2525

26-
from azure.cli.core.azclierror import ArgumentUsageError, InvalidArgumentValueError, ResourceNotFoundError
26+
from azure.cli.core.azclierror import ArgumentUsageError, InvalidArgumentValueError, ResourceNotFoundError, ValidationError
2727
from azure.cli.core.parser import IncorrectUsageError
2828
from azure.cli.core.util import get_file_json, read_file_content, shell_safe_json_parse, sdk_no_wait
2929
from azure.cli.core.commands import LongRunningOperation
@@ -4580,6 +4580,63 @@ def lint_bicep_file(cmd, file, no_restore=None, diagnostics_format=None):
45804580
logger.error("az bicep lint could not be executed with the current version of Bicep CLI. Please upgrade Bicep CLI to v%s or later.", minimum_supported_version)
45814581

45824582

4583+
def snapshot_bicep_file(cmd, file, mode=None, tenant_id=None, subscription_id=None,
4584+
management_group_id=None, location=None, resource_group=None,
4585+
deployment_name=None):
4586+
ensure_bicep_installation(cmd.cli_ctx, stdout=False)
4587+
4588+
minimum_supported_version = "0.41.2"
4589+
if bicep_version_greater_than_or_equal_to(cmd.cli_ctx, minimum_supported_version):
4590+
args = ["snapshot", file]
4591+
if mode:
4592+
args += ["--mode", mode]
4593+
if tenant_id:
4594+
args += ["--tenant-id", tenant_id]
4595+
if subscription_id:
4596+
args += ["--subscription-id", subscription_id]
4597+
if management_group_id:
4598+
args += ["--management-group-id", management_group_id]
4599+
if location:
4600+
args += ["--location", location]
4601+
if resource_group:
4602+
args += ["--resource-group", resource_group]
4603+
if deployment_name:
4604+
args += ["--deployment-name", deployment_name]
4605+
4606+
output = run_bicep_command(cmd.cli_ctx, args)
4607+
4608+
if output:
4609+
print(output)
4610+
else:
4611+
raise ValidationError(
4612+
f"az bicep snapshot could not be executed with the current version of Bicep CLI. "
4613+
f"Please upgrade Bicep CLI to v{minimum_supported_version} or later."
4614+
)
4615+
4616+
4617+
def run_bicep_cli_passthrough(cmd, command_string):
4618+
import shlex
4619+
4620+
ensure_bicep_installation(cmd.cli_ctx, stdout=False)
4621+
4622+
# Use non-POSIX mode so that backslashes in Windows paths are preserved.
4623+
# In non-POSIX mode, shlex retains the surrounding quotes on quoted tokens,
4624+
# so strip them so the values are passed through cleanly to the Bicep CLI.
4625+
args = []
4626+
for token in shlex.split(command_string, posix=False):
4627+
if len(token) >= 2 and token[0] in ('"', "'") and token[0] == token[-1]:
4628+
token = token[1:-1]
4629+
args.append(token)
4630+
4631+
if not args:
4632+
raise InvalidArgumentValueError("--command must not be empty.")
4633+
4634+
output = run_bicep_command(cmd.cli_ctx, args)
4635+
4636+
if output:
4637+
print(output)
4638+
4639+
45834640
def create_resourcemanager_privatelink(
45844641
cmd, resource_group, name, location):
45854642
rcf = _resource_privatelinks_client_factory(cmd.cli_ctx)

src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_resource.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5669,6 +5669,68 @@ def test_bicep_lint_diagnostics_format_sarif(self):
56695669

56705670
self.cmd('az bicep lint -f {tf} --diagnostics-format sarif')
56715671

5672+
5673+
class BicepSnapshotTest(LiveScenarioTest):
5674+
def setUp(self):
5675+
super().setUp()
5676+
self.cmd('az bicep uninstall')
5677+
5678+
def tearDown(self):
5679+
super().tearDown()
5680+
self.cmd('az bicep uninstall')
5681+
5682+
def test_bicep_snapshot(self):
5683+
curr_dir = os.path.dirname(os.path.realpath(__file__))
5684+
params_file = os.path.join(curr_dir, 'sample_params.bicepparam').replace('\\', '\\\\')
5685+
snapshot_path = os.path.join(curr_dir, 'sample_params.snapshot.json')
5686+
self.kwargs.update({
5687+
'pf': params_file,
5688+
})
5689+
5690+
try:
5691+
# Capture (default mode).
5692+
self.cmd('az bicep snapshot --file {pf}')
5693+
self.assertTrue(os.path.exists(snapshot_path))
5694+
5695+
# Validate against the just-captured snapshot.
5696+
self.cmd('az bicep snapshot --file {pf} --mode Validate')
5697+
finally:
5698+
if os.path.exists(snapshot_path):
5699+
os.remove(snapshot_path)
5700+
5701+
5702+
class BicepRunTest(LiveScenarioTest):
5703+
def setUp(self):
5704+
super().setUp()
5705+
self.cmd('az bicep uninstall')
5706+
5707+
def tearDown(self):
5708+
super().tearDown()
5709+
self.cmd('az bicep uninstall')
5710+
5711+
def test_bicep_run_version(self):
5712+
# Ensure Bicep CLI is installed so the passthrough has something to call.
5713+
self.cmd('az bicep install')
5714+
# Use the --option=value form because the value itself starts with --,
5715+
# which argparse otherwise treats as another option flag.
5716+
self.cmd('az bicep run --command=--version')
5717+
5718+
def test_bicep_run_build(self):
5719+
curr_dir = os.path.dirname(os.path.realpath(__file__))
5720+
bf = os.path.join(curr_dir, 'sample_params.bicep').replace('\\', '\\\\')
5721+
self.kwargs.update({
5722+
'bf': bf,
5723+
})
5724+
5725+
self.cmd('az bicep install')
5726+
self.cmd('az bicep run --command "build {bf} --stdout"')
5727+
5728+
def test_bicep_run_empty_command_fails(self):
5729+
from azure.cli.core.azclierror import InvalidArgumentValueError
5730+
with self.assertRaises(InvalidArgumentValueError):
5731+
self.cmd('az bicep run --command " "')
5732+
5733+
56725734
class BicepInstallationTest(LiveScenarioTest):
56735735
def setup(self):
56745736
super().setup()

src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_resource_bicep.py

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
_get_bicep_download_url,
2020
_bicep_version_check_file_path,
2121
)
22-
from azure.cli.core.azclierror import InvalidTemplateError
22+
from azure.cli.command_modules.resource.custom import (
23+
run_bicep_cli_passthrough,
24+
snapshot_bicep_file,
25+
)
26+
from azure.cli.core.azclierror import InvalidArgumentValueError, InvalidTemplateError
2327
from azure.cli.core.mock import DummyCli
2428

2529

@@ -302,4 +306,128 @@ def test_bicep_version_greater_than_or_equal_to_use_cli_managed_binary(self, use
302306
result = bicep_version_greater_than_or_equal_to(self.cli_ctx, "0.13.2")
303307

304308
self.assertFalse(result)
305-
run_command_mock.assert_called_once_with(".azure/bin/bicep", ["--version"])
309+
run_command_mock.assert_called_once_with(".azure/bin/bicep", ["--version"])
310+
311+
312+
class TestBicepSnapshot(unittest.TestCase):
313+
def setUp(self):
314+
self.cli_ctx = DummyCli(random_config_dir=True)
315+
self.cmd = mock.Mock()
316+
self.cmd.cli_ctx = self.cli_ctx
317+
318+
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
319+
@mock.patch("azure.cli.command_modules.resource.custom.bicep_version_greater_than_or_equal_to")
320+
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
321+
def test_snapshot_bicep_file_passes_minimum_args(
322+
self, ensure_bicep_installation_mock, bicep_version_check_mock, run_bicep_command_mock
323+
):
324+
bicep_version_check_mock.return_value = True
325+
run_bicep_command_mock.return_value = ""
326+
327+
snapshot_bicep_file(self.cmd, "main.bicepparam")
328+
329+
ensure_bicep_installation_mock.assert_called_once_with(self.cli_ctx, stdout=False)
330+
bicep_version_check_mock.assert_called_once_with(self.cli_ctx, "0.41.2")
331+
run_bicep_command_mock.assert_called_once_with(self.cli_ctx, ["snapshot", "main.bicepparam"])
332+
333+
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
334+
@mock.patch("azure.cli.command_modules.resource.custom.bicep_version_greater_than_or_equal_to")
335+
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
336+
def test_snapshot_bicep_file_passes_all_optional_args(
337+
self, ensure_bicep_installation_mock, bicep_version_check_mock, run_bicep_command_mock
338+
):
339+
bicep_version_check_mock.return_value = True
340+
run_bicep_command_mock.return_value = ""
341+
342+
snapshot_bicep_file(
343+
self.cmd,
344+
"main.bicepparam",
345+
mode="Validate",
346+
tenant_id="tenant-id",
347+
subscription_id="sub-id",
348+
management_group_id="mg-id",
349+
location="westus",
350+
resource_group="myRg",
351+
deployment_name="myDeployment",
352+
)
353+
354+
run_bicep_command_mock.assert_called_once_with(
355+
self.cli_ctx,
356+
[
357+
"snapshot",
358+
"main.bicepparam",
359+
"--mode", "Validate",
360+
"--tenant-id", "tenant-id",
361+
"--subscription-id", "sub-id",
362+
"--management-group-id", "mg-id",
363+
"--location", "westus",
364+
"--resource-group", "myRg",
365+
"--deployment-name", "myDeployment",
366+
],
367+
)
368+
369+
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
370+
@mock.patch("azure.cli.command_modules.resource.custom.bicep_version_greater_than_or_equal_to")
371+
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
372+
def test_snapshot_bicep_file_errors_when_bicep_too_old(
373+
self, ensure_bicep_installation_mock, bicep_version_check_mock, run_bicep_command_mock
374+
):
375+
from azure.cli.core.azclierror import ValidationError
376+
377+
bicep_version_check_mock.return_value = False
378+
379+
with self.assertRaisesRegex(ValidationError, "az bicep snapshot.*0\\.41\\.2"):
380+
snapshot_bicep_file(self.cmd, "main.bicepparam")
381+
382+
run_bicep_command_mock.assert_not_called()
383+
384+
385+
class TestBicepRun(unittest.TestCase):
386+
def setUp(self):
387+
self.cli_ctx = DummyCli(random_config_dir=True)
388+
self.cmd = mock.Mock()
389+
self.cmd.cli_ctx = self.cli_ctx
390+
391+
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
392+
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
393+
def test_run_bicep_cli_passthrough_forwards_split_args(self, ensure_bicep_installation_mock, run_bicep_command_mock):
394+
run_bicep_command_mock.return_value = ""
395+
396+
run_bicep_cli_passthrough(self.cmd, "build main.bicep --stdout")
397+
398+
ensure_bicep_installation_mock.assert_called_once_with(self.cli_ctx, stdout=False)
399+
run_bicep_command_mock.assert_called_once_with(
400+
self.cli_ctx, ["build", "main.bicep", "--stdout"]
401+
)
402+
403+
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
404+
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
405+
def test_run_bicep_cli_passthrough_preserves_quoted_args(self, ensure_bicep_installation_mock, run_bicep_command_mock):
406+
run_bicep_command_mock.return_value = ""
407+
408+
run_bicep_cli_passthrough(self.cmd, 'build "path with spaces/main.bicep"')
409+
410+
run_bicep_command_mock.assert_called_once_with(
411+
self.cli_ctx, ["build", "path with spaces/main.bicep"]
412+
)
413+
414+
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
415+
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
416+
def test_run_bicep_cli_passthrough_preserves_windows_path_backslashes(self, ensure_bicep_installation_mock, run_bicep_command_mock):
417+
run_bicep_command_mock.return_value = ""
418+
419+
# Windows paths use backslashes which collide with POSIX shell escape semantics.
420+
# The passthrough must preserve them so the Bicep CLI receives a valid path.
421+
run_bicep_cli_passthrough(self.cmd, r"build D:\azure-cli\samples\main.bicep --stdout")
422+
423+
run_bicep_command_mock.assert_called_once_with(
424+
self.cli_ctx, ["build", r"D:\azure-cli\samples\main.bicep", "--stdout"]
425+
)
426+
427+
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
428+
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
429+
def test_run_bicep_cli_passthrough_raises_when_command_empty(self, ensure_bicep_installation_mock, run_bicep_command_mock):
430+
with self.assertRaisesRegex(InvalidArgumentValueError, "--command must not be empty."):
431+
run_bicep_cli_passthrough(self.cmd, " ")
432+
433+
run_bicep_command_mock.assert_not_called()

0 commit comments

Comments
 (0)