Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/azure-cli/azure/cli/command_modules/resource/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2763,6 +2763,39 @@
text: az bicep lint --file {bicep_file} --diagnostics-format {diagnostics_format}
"""

helps['bicep snapshot'] = """
type: command
short-summary: Capture or validate a snapshot of the resources predicted to be deployed by a .bicepparam file.
long-summary: |
Compiles a .bicepparam file together with its referenced Bicep template and writes a deployment
snapshot (a `*.snapshot.json` file) next to the .bicepparam file. When run with `--mode Validate`,
the existing snapshot is compared against the current template and the command fails if they
differ. This command requires Bicep CLI v0.41.2 or later.
examples:
- name: Capture a snapshot for a .bicepparam file.
text: az bicep snapshot --file main.bicepparam
- name: Validate that the existing snapshot still matches the current template.
text: az bicep snapshot --file main.bicepparam --mode Validate
- name: Capture a snapshot with explicit Azure context.
text: az bicep snapshot --file main.bicepparam --subscription-id 00000000-0000-0000-0000-000000000000 --resource-group myRg --location westus
"""

helps['bicep run'] = """
type: command
short-summary: Forward a raw command to the installed Bicep CLI.
long-summary: |
Runs the Bicep CLI with the arguments supplied via `--command`, allowing use of Bicep CLI
features that do not yet have a dedicated `az bicep` wrapper. The string passed to
`--command` is split using shell-style quoting and forwarded to the Bicep CLI verbatim.
When the forwarded command itself starts with `--` (for example `--version`), use the
`--command=<value>` form so the CLI parser does not mistake the value for another option.
examples:
- name: Forward a build command to the Bicep CLI.
text: az bicep run --command "build main.bicep"
- name: Show the Bicep CLI help (use the --command=<value> form for option-like values).
text: az bicep run --command=--help
"""

helps['resourcemanagement'] = """
type: group
short-summary: resourcemanagement CLI command group.
Expand Down
15 changes: 15 additions & 0 deletions src/azure-cli/azure/cli/command_modules/resource/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,21 @@ def load_arguments(self, _):
c.argument('no_restore', arg_type=bicep_no_restore_type, help="When set, generates the parameters file without restoring external modules.")
c.argument('diagnostics_format', arg_type=get_enum_type(['default', 'sarif']), help="Set diagnostics format.")

with self.argument_context('bicep snapshot') as c:
c.argument('file', arg_type=bicep_file_type, help="The path to the .bicepparam file to capture a snapshot for.")
c.argument('mode', arg_type=get_enum_type(['Overwrite', 'Validate']),
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.")
c.argument('tenant_id', options_list=['--tenant-id'], help="The Azure tenant ID to use when capturing the snapshot.")
c.argument('subscription_id', options_list=['--subscription-id'], help="The Azure subscription ID to use when capturing the snapshot.")
Copy link
Copy Markdown
Contributor Author

@shenglol shenglol May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified in d88079b by expanding the --help text for --tenant-id, --subscription-id, --management-group-id, --location, --resource-group, and --deployment-name to call out that they are forwarded to the Bicep CLI as the deployment context used to resolve existing references in the snapshot, and are unrelated to Azure CLI authentication. The help for --subscription-id also explicitly points users to the global --subscription argument for switching the active subscription.

We intentionally kept the names aligned with bicep snapshot's native flags so that users coming from the Bicep CLI don't have to learn a new vocabulary. Happy to revisit (e.g. --snapshot-subscription-id) if you'd prefer the rename.

c.argument('management_group_id', options_list=['--management-group-id'], help="The Azure management group ID to use when capturing the snapshot.")
c.argument('location', options_list=['--location'], help="The Azure location to use when capturing the snapshot.")
c.argument('resource_group', options_list=['--resource-group'], help="The Azure resource group name to use when capturing the snapshot.")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d88079b — switched --resource-group to resource_group_name_type and --location to get_location_type(self.cli_ctx), so users now get the standard -g / -l short flags, completion, and az configure --defaults integration consistent with the rest of the module.

c.argument('deployment_name', options_list=['--deployment-name'], help="The deployment name to use when capturing the snapshot.")

with self.argument_context('bicep run') as c:
c.argument('command_string', options_list=['--command', '-c'],
help="The Bicep CLI command to run, including its arguments, as a single quoted string (e.g. \"build main.bicep\").")

with self.argument_context('resourcemanagement private-link create') as c:
c.argument('resource_group', arg_type=resource_group_name_type,
help='The name of the resource group.')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,8 @@ def load_command_table(self, _):
g.custom_command('list-versions', 'list_bicep_cli_versions')
g.custom_command('generate-params', 'generate_params_file')
g.custom_command('lint', 'lint_bicep_file')
g.custom_command('snapshot', 'snapshot_bicep_file')
g.custom_command('run', 'run_bicep_cli_passthrough')

with self.command_group('resourcemanagement private-link', resource_resourcemanagementprivatelink_sdk, resource_type=ResourceType.MGMT_RESOURCE_PRIVATELINKS) as g:
g.custom_command('create', 'create_resourcemanager_privatelink')
Expand Down
52 changes: 52 additions & 0 deletions src/azure-cli/azure/cli/command_modules/resource/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -4580,6 +4580,58 @@ def lint_bicep_file(cmd, file, no_restore=None, diagnostics_format=None):
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)


def snapshot_bicep_file(cmd, file, mode=None, tenant_id=None, subscription_id=None,
management_group_id=None, location=None, resource_group=None,
deployment_name=None):
ensure_bicep_installation(cmd.cli_ctx, stdout=False)

minimum_supported_version = "0.41.2"
if bicep_version_greater_than_or_equal_to(cmd.cli_ctx, minimum_supported_version):
args = ["snapshot", file]
if mode:
args += ["--mode", mode]
if tenant_id:
args += ["--tenant-id", tenant_id]
if subscription_id:
args += ["--subscription-id", subscription_id]
if management_group_id:
args += ["--management-group-id", management_group_id]
if location:
args += ["--location", location]
if resource_group:
args += ["--resource-group", resource_group]
if deployment_name:
args += ["--deployment-name", deployment_name]

output = run_bicep_command(cmd.cli_ctx, args)

if output:
print(output)
else:
logger.error("az bicep snapshot could not be executed with the current version of Bicep CLI. Please upgrade Bicep CLI to v%s or later.", minimum_supported_version)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d88079bsnapshot_bicep_file now raises ValidationError (from azure.cli.core.azclierror) when the installed Bicep CLI is older than the minimum supported version, so the command exits with a non-zero code and CI/scripts can detect the failure. test_snapshot_bicep_file_errors_when_bicep_too_old was updated to assert the raise.



def run_bicep_cli_passthrough(cmd, command_string):
import shlex
Comment on lines +4617 to +4618
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

az bicep run is intentionally a thin escape hatch for users to consume new Bicep CLI features (or one-off subcommands) without waiting for an az bicep wrapper to ship in azure-cli — so an allowlist of subcommands would defeat its purpose. The command runs the Bicep CLI as the same user, so it can't grant elevated privileges that the user wouldn't already have by invoking bicep directly, but the untrusted-input concern is fair: in d88079b the long-summary in --help now warns "Because the value is forwarded to the Bicep CLI without validation, do not pass strings derived from untrusted input." Wrappers for the most useful Bicep CLI subcommands continue to be added over time, and az bicep run is meant to bridge the gap in the meantime.


# Use non-POSIX mode so that backslashes in Windows paths are preserved.
# In non-POSIX mode, shlex retains the surrounding quotes on quoted tokens,
# so strip them so the values are passed through cleanly to the Bicep CLI.
args = []
for token in shlex.split(command_string, posix=False):
Comment on lines +4617 to +4626
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d88079brun_bicep_cli_passthrough now calls ensure_bicep_installation(cmd.cli_ctx, stdout=False) at the start, matching the other az bicep wrappers, and the unit tests assert this. A fresh environment will now auto-install the Bicep CLI rather than failing with a confusing error.

if len(token) >= 2 and token[0] in ('"', "'") and token[0] == token[-1]:
token = token[1:-1]
args.append(token)

if not args:
raise InvalidArgumentValueError("--command must not be empty.")

output = run_bicep_command(cmd.cli_ctx, args)

if output:
print(output)


def create_resourcemanager_privatelink(
cmd, resource_group, name, location):
rcf = _resource_privatelinks_client_factory(cmd.cli_ctx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5669,6 +5669,68 @@ def test_bicep_lint_diagnostics_format_sarif(self):

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


class BicepSnapshotTest(LiveScenarioTest):
def setup(self):
super().setup()
self.cmd('az bicep uninstall')
Comment on lines +5673 to +5676
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d88079b — renamed both the method and the super() call to setUp in BicepSnapshotTest.


def tearDown(self):
super().tearDown()
self.cmd('az bicep uninstall')

def test_bicep_snapshot(self):
curr_dir = os.path.dirname(os.path.realpath(__file__))
params_file = os.path.join(curr_dir, 'sample_params.bicepparam').replace('\\', '\\\\')
snapshot_path = os.path.join(curr_dir, 'sample_params.snapshot.json')
Comment on lines +5683 to +5685
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixtures already exist in this directory and are reused by other Bicep tests (e.g. BicepBuildParamsTest). They were originally added in #26781. No new files are required for this PR.

self.kwargs.update({
'pf': params_file,
})

try:
# Capture (default mode).
self.cmd('az bicep snapshot --file {pf}')
self.assertTrue(os.path.exists(snapshot_path))

# Validate against the just-captured snapshot.
self.cmd('az bicep snapshot --file {pf} --mode Validate')
finally:
if os.path.exists(snapshot_path):
os.remove(snapshot_path)


class BicepRunTest(LiveScenarioTest):
def setup(self):
super().setup()
self.cmd('az bicep uninstall')
Comment on lines +5702 to +5705
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d88079b — same correction applied to BicepRunTest.


def tearDown(self):
super().tearDown()
self.cmd('az bicep uninstall')

def test_bicep_run_version(self):
# Ensure Bicep CLI is installed so the passthrough has something to call.
self.cmd('az bicep install')
# Use the --option=value form because the value itself starts with --,
# which argparse otherwise treats as another option flag.
self.cmd('az bicep run --command=--version')

def test_bicep_run_build(self):
curr_dir = os.path.dirname(os.path.realpath(__file__))
bf = os.path.join(curr_dir, 'sample_params.bicep').replace('\\', '\\\\')
self.kwargs.update({
'bf': bf,
})

self.cmd('az bicep install')
self.cmd('az bicep run --command "build {bf} --stdout"')

def test_bicep_run_empty_command_fails(self):
from azure.cli.core.azclierror import InvalidArgumentValueError
with self.assertRaises(InvalidArgumentValueError):
self.cmd('az bicep run --command " "')


class BicepInstallationTest(LiveScenarioTest):
def setup(self):
super().setup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
_get_bicep_download_url,
_bicep_version_check_file_path,
)
from azure.cli.core.azclierror import InvalidTemplateError
from azure.cli.command_modules.resource.custom import (
run_bicep_cli_passthrough,
snapshot_bicep_file,
)
from azure.cli.core.azclierror import InvalidArgumentValueError, InvalidTemplateError
from azure.cli.core.mock import DummyCli


Expand Down Expand Up @@ -302,4 +306,124 @@ def test_bicep_version_greater_than_or_equal_to_use_cli_managed_binary(self, use
result = bicep_version_greater_than_or_equal_to(self.cli_ctx, "0.13.2")

self.assertFalse(result)
run_command_mock.assert_called_once_with(".azure/bin/bicep", ["--version"])
run_command_mock.assert_called_once_with(".azure/bin/bicep", ["--version"])


class TestBicepSnapshot(unittest.TestCase):
def setUp(self):
self.cli_ctx = DummyCli(random_config_dir=True)
self.cmd = mock.Mock()
self.cmd.cli_ctx = self.cli_ctx

@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
@mock.patch("azure.cli.command_modules.resource.custom.bicep_version_greater_than_or_equal_to")
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
def test_snapshot_bicep_file_passes_minimum_args(
self, ensure_bicep_installation_mock, bicep_version_check_mock, run_bicep_command_mock
):
bicep_version_check_mock.return_value = True
run_bicep_command_mock.return_value = ""

snapshot_bicep_file(self.cmd, "main.bicepparam")

ensure_bicep_installation_mock.assert_called_once_with(self.cli_ctx, stdout=False)
bicep_version_check_mock.assert_called_once_with(self.cli_ctx, "0.41.2")
run_bicep_command_mock.assert_called_once_with(self.cli_ctx, ["snapshot", "main.bicepparam"])

@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
@mock.patch("azure.cli.command_modules.resource.custom.bicep_version_greater_than_or_equal_to")
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
def test_snapshot_bicep_file_passes_all_optional_args(
self, ensure_bicep_installation_mock, bicep_version_check_mock, run_bicep_command_mock
):
bicep_version_check_mock.return_value = True
run_bicep_command_mock.return_value = ""

snapshot_bicep_file(
self.cmd,
"main.bicepparam",
mode="Validate",
tenant_id="tenant-id",
subscription_id="sub-id",
management_group_id="mg-id",
location="westus",
resource_group="myRg",
deployment_name="myDeployment",
)

run_bicep_command_mock.assert_called_once_with(
self.cli_ctx,
[
"snapshot",
"main.bicepparam",
"--mode", "Validate",
"--tenant-id", "tenant-id",
"--subscription-id", "sub-id",
"--management-group-id", "mg-id",
"--location", "westus",
"--resource-group", "myRg",
"--deployment-name", "myDeployment",
],
)

@mock.patch("azure.cli.command_modules.resource.custom.logger.error")
@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
@mock.patch("azure.cli.command_modules.resource.custom.bicep_version_greater_than_or_equal_to")
@mock.patch("azure.cli.command_modules.resource.custom.ensure_bicep_installation")
def test_snapshot_bicep_file_errors_when_bicep_too_old(
self, ensure_bicep_installation_mock, bicep_version_check_mock, run_bicep_command_mock, logger_error_mock
):
bicep_version_check_mock.return_value = False

snapshot_bicep_file(self.cmd, "main.bicepparam")

run_bicep_command_mock.assert_not_called()
logger_error_mock.assert_called_once()
self.assertIn("az bicep snapshot", logger_error_mock.call_args.args[0])
self.assertEqual(logger_error_mock.call_args.args[1], "0.41.2")


class TestBicepRun(unittest.TestCase):
def setUp(self):
self.cli_ctx = DummyCli(random_config_dir=True)
self.cmd = mock.Mock()
self.cmd.cli_ctx = self.cli_ctx

@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
def test_run_bicep_cli_passthrough_forwards_split_args(self, run_bicep_command_mock):
run_bicep_command_mock.return_value = ""

run_bicep_cli_passthrough(self.cmd, "build main.bicep --stdout")

run_bicep_command_mock.assert_called_once_with(
self.cli_ctx, ["build", "main.bicep", "--stdout"]
)

@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
def test_run_bicep_cli_passthrough_preserves_quoted_args(self, run_bicep_command_mock):
run_bicep_command_mock.return_value = ""

run_bicep_cli_passthrough(self.cmd, 'build "path with spaces/main.bicep"')

run_bicep_command_mock.assert_called_once_with(
self.cli_ctx, ["build", "path with spaces/main.bicep"]
)

@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
def test_run_bicep_cli_passthrough_preserves_windows_path_backslashes(self, run_bicep_command_mock):
run_bicep_command_mock.return_value = ""

# Windows paths use backslashes which collide with POSIX shell escape semantics.
# The passthrough must preserve them so the Bicep CLI receives a valid path.
run_bicep_cli_passthrough(self.cmd, r"build D:\azure-cli\samples\main.bicep --stdout")

run_bicep_command_mock.assert_called_once_with(
self.cli_ctx, ["build", r"D:\azure-cli\samples\main.bicep", "--stdout"]
)

@mock.patch("azure.cli.command_modules.resource.custom.run_bicep_command")
def test_run_bicep_cli_passthrough_raises_when_command_empty(self, run_bicep_command_mock):
with self.assertRaisesRegex(InvalidArgumentValueError, "--command must not be empty."):
run_bicep_cli_passthrough(self.cmd, " ")

run_bicep_command_mock.assert_not_called()
Loading