Skip to content

Commit c9f2e73

Browse files
aviatcoAviat Cohenayeshurun
authored
feat: Introduce deploy command (#180)
Co-authored-by: Aviat Cohen <aviatcohen@microsoft.com> Co-authored-by: Alon Yeshurun <98805507+ayeshurun@users.noreply.github.com>
1 parent a69512b commit c9f2e73

26 files changed

+12454
-12
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: added
2+
body: Introduces the new deploy command that integrates with the fabric-cicd library, enabling users to deploy multiple Fabric items in a single
3+
time: 2026-03-05T09:17:28.270036405Z
4+
custom:
5+
Author: aviatco
6+
AuthorLink: https://github.com/aviatco

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"msal[broker]>=1.34,<2 ; platform_system != 'Linux'",
2020
"msal>=1.34,<2",
2121
"msal_extensions",
22+
"azure-core>=1.29.0",
2223
"questionary",
2324
"prompt_toolkit>=3.0.41",
2425
"cachetools>=5.5.0",
@@ -28,6 +29,7 @@ dependencies = [
2829
"psutil==7.0.0",
2930
"requests",
3031
"cryptography",
32+
"fabric-cicd>=0.3.0",
3133
]
3234

3335
[project.scripts]

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ argcomplete>=3.6.2
1111
psutil==7.0.0
1212
requests
1313
cryptography
14+
fabric-cicd>=0.3.0
1415

1516
# Testing and Building Requirements
1617
tox>=4.20.0
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import json
5+
from argparse import Namespace
6+
7+
from fabric_cicd import append_feature_flag, configure_external_file_logging, deploy_with_config, disable_file_logging # type: ignore
8+
9+
from fabric_cli.core import fab_constant, fab_state_config
10+
from fabric_cli.core import fab_logger
11+
from fabric_cli.core.fab_exceptions import FabricCLIError
12+
from fabric_cli.core.fab_msal_bridge import create_fabric_token_credential
13+
from fabric_cli.utils import fab_ui
14+
from fabric_cli.utils.fab_util import get_dict_from_params
15+
16+
17+
def deploy_with_config_file(args: Namespace) -> None:
18+
"""deploy fabric items to a workspace using a configuration file and target environment - delegates to CICD library."""
19+
20+
try:
21+
if fab_state_config.get_config(fab_constant.FAB_DEBUG_ENABLED) == "true":
22+
cli_logger = fab_logger.get_logger()
23+
# configure file logging for CICD library to use the same file handler as the CLI
24+
configure_external_file_logging(cli_logger)
25+
else:
26+
# prevent creation of a log file for fabric-cicd logs when debug mode is disabled
27+
disable_file_logging()
28+
29+
# feature flags to avoid printing identity info in logs
30+
append_feature_flag("disable_print_identity")
31+
32+
deploy_config_file = args.config
33+
deploy_parameters = get_dict_from_params(args.params, max_depth=1)
34+
for param in deploy_parameters:
35+
if isinstance(deploy_parameters[param], str):
36+
try:
37+
deploy_parameters[param] = json.loads(
38+
deploy_parameters[param])
39+
except json.JSONDecodeError:
40+
# If it's not a valid JSON string, keep it as is
41+
pass
42+
result = deploy_with_config(
43+
config_file_path=deploy_config_file,
44+
environment=args.target_env,
45+
token_credential=create_fabric_token_credential(), # MSAL bridge TokenCredential
46+
**deploy_parameters
47+
)
48+
49+
if result:
50+
fab_ui.print_output_format(
51+
args, message=result.message)
52+
53+
except Exception as e:
54+
raise FabricCLIError(
55+
f"Deployment failed: {str(e)}",
56+
fab_constant.ERROR_IN_DEPLOYMENT)

src/fabric_cli/commands/fs/fab_fs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from fabric_cli.commands.fs import fab_fs_export as fs_export
1616
from fabric_cli.commands.fs import fab_fs_get as fs_get
1717
from fabric_cli.commands.fs import fab_fs_import as fs_import
18+
from fabric_cli.commands.fs import fab_fs_deploy as fs_deploy
1819
from fabric_cli.commands.fs import fab_fs_ln as fs_ln
1920
from fabric_cli.commands.fs import fab_fs_ls as fs_ls
2021
from fabric_cli.commands.fs import fab_fs_mkdir as fs_mkdir
@@ -144,6 +145,12 @@ def import_command(args: Namespace) -> None:
144145
fs_import.exec_command(args, context)
145146

146147

148+
@handle_exceptions()
149+
@set_command_context()
150+
def deploy_command(args: Namespace) -> None:
151+
fs_deploy.exec_command(args)
152+
153+
147154
@handle_exceptions()
148155
@set_command_context()
149156
def set_command(args: Namespace) -> None:
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from argparse import Namespace
5+
6+
from fabric_cli.commands.fs.deploy.fab_fs_deploy_config_file import deploy_with_config_file
7+
from fabric_cli.utils import fab_ui
8+
9+
10+
def exec_command(args: Namespace) -> None:
11+
"""deploy fabric items to a workspace using a configuration file and target environment - CICD flow."""
12+
target_env_msg = 'without a target environment' if args.target_env == 'N/A' else f"to target environment '{args.target_env}'"
13+
if args.force or fab_ui.prompt_confirm(f"Are you sure you want to deploy {target_env_msg} using the specified configuration file?"):
14+
deploy_with_config_file(args)

src/fabric_cli/core/fab_auth.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,24 @@ def _is_token_defined(self, scope):
429429
status_code=con.ERROR_AUTHENTICATION_FAILED,
430430
)
431431

432-
def get_access_token(self, scope: list[str], interactive_renew=True) -> str:
432+
def acquire_token(self, scope: list[str], interactive_renew=True) -> dict:
433+
"""
434+
Core MSAL token acquisition method that returns the full MSAL result.
435+
436+
This method contains the common authentication logic shared between
437+
get_access_token and msal_bridge.get_token. It performs no validation
438+
on scopes - callers are responsible for their own validation needs.
439+
440+
Args:
441+
scope: The scopes for which to request the token
442+
interactive_renew: Whether to allow interactive authentication for user flows
443+
444+
Returns:
445+
Full MSAL result dictionary containing access_token, expires_on, etc.
446+
447+
Raises:
448+
FabricCLIError: When token acquisition fails
449+
"""
433450
token = None
434451
env_var_token = self._get_access_token_from_env_vars_if_exist(scope)
435452

@@ -476,8 +493,11 @@ def get_access_token(self, scope: list[str], interactive_renew=True) -> str:
476493
self.set_tenant(token.get("id_token_claims")["tid"])
477494

478495
if token and token.get("error"):
496+
fab_logger.log_debug(
497+
f"Error in get token: {token.get('error_description')}")
479498
raise FabricCLIError(
480-
ErrorMessages.Auth.access_token_error(token.get("error_description")),
499+
ErrorMessages.Auth.access_token_error(
500+
"Something went wrong while trying to acquire a token. Please try to run `fab auth logout` and then `fab auth login` to re-login and acquire new tokens."),
481501
status_code=con.ERROR_AUTHENTICATION_FAILED,
482502
)
483503
if token is None or not token.get("access_token"):
@@ -486,7 +506,27 @@ def get_access_token(self, scope: list[str], interactive_renew=True) -> str:
486506
status_code=con.ERROR_AUTHENTICATION_FAILED,
487507
)
488508

489-
return token.get("access_token", None)
509+
return token
510+
511+
def get_access_token(self, scope: list[str], interactive_renew=True) -> str | None:
512+
"""
513+
Get an access token string for the specified scopes.
514+
515+
This method maintains the existing CLI API - returns just the token string
516+
for backward compatibility. Uses the shared acquire_token method internally.
517+
518+
Args:
519+
scope: The scopes for which to request the token
520+
interactive_renew: Whether to allow interactive authentication for user flows
521+
522+
Returns:
523+
Access token string or None if acquisition fails
524+
525+
Raises:
526+
FabricCLIError: When token acquisition fails
527+
"""
528+
token_result = self.acquire_token(scope, interactive_renew)
529+
return token_result.get("access_token", None)
490530

491531
def logout(self):
492532
self._auth_info = {}

src/fabric_cli/core/fab_constant.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
FAB_VERSION = __version__
3333

3434
# Scopes
35-
SCOPE_FABRIC_DEFAULT = ["https://analysis.windows.net/powerbi/api/.default"]
35+
SCOPE_FABRIC_DEFAULT = ["https://api.fabric.microsoft.com/.default"]
3636
SCOPE_ONELAKE_DEFAULT = ["https://storage.azure.com/.default"]
3737
SCOPE_AZURE_DEFAULT = ["https://management.azure.com/.default"]
3838

@@ -183,6 +183,7 @@
183183
COMMAND_FS_EXPORT_DESCRIPTION = "Export an item."
184184
COMMAND_FS_GET_DESCRIPTION = "Get workspace or item properties."
185185
COMMAND_FS_IMPORT_DESCRIPTION = "Import an item to create or update it."
186+
COMMAND_FS_DEPLOY_DESCRIPTION = "Deploy items using a configuration file."
186187
COMMAND_FS_SET_DESCRIPTION = "Set workspace or item properties."
187188
COMMAND_FS_CLEAR_DESCRIPTION = "Clear the terminal screen."
188189
COMMAND_FS_LN_DESCRIPTION = "Create a shortcut."
@@ -282,6 +283,7 @@
282283
ERROR_UNIVERSAL_SECURITY_DISABLED = "UniversalSecurityDisabled"
283284
ERROR_SPN_AUTH_MISSING = "ServicePrincipalAuthMissing"
284285
ERROR_JOB_FAILED = "JobFailed"
286+
ERROR_IN_DEPLOYMENT = "DeploymentFailed"
285287

286288
# Exit codes
287289
EXIT_CODE_SUCCESS = 0

src/fabric_cli/core/fab_interactive.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,13 @@
1515
from fabric_cli.core.fab_decorators import singleton
1616
from fabric_cli.utils import fab_commands
1717
from fabric_cli.utils import fab_ui as utils_ui
18-
18+
from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers
1919

2020
@singleton
2121
class InteractiveCLI:
2222
def __init__(self, parser=None, subparsers=None):
2323
"""Initialize the interactive CLI."""
2424
if parser is None or subparsers is None:
25-
from fabric_cli.core.fab_parser_setup import (
26-
get_global_parser_and_subparsers,
27-
)
28-
2925
parser, subparsers = get_global_parser_and_subparsers()
3026

3127
self.parser = parser
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import os
5+
from typing import Optional
6+
7+
from azure.core.credentials import AccessToken, TokenCredential
8+
from azure.core.exceptions import ClientAuthenticationError
9+
10+
from fabric_cli.core import fab_constant as con
11+
from fabric_cli.core import fab_logger
12+
from fabric_cli.core.fab_auth import FabAuth
13+
14+
# Bridge-specific: strict .default scope validation
15+
VALID_DEFATULT_SCOPES = {
16+
con.SCOPE_FABRIC_DEFAULT[0],
17+
}
18+
19+
class MsalTokenCredential(TokenCredential):
20+
"""
21+
A TokenCredential implementation that wraps the existing Fabric CLI MSAL authentication.
22+
23+
This bridge uses the CLI user's existing authentication and provides it through
24+
the Azure Identity TokenCredential interface. It handles refresh token management
25+
automatically via MSAL's silent acquisition flow.
26+
27+
The credential will use whatever authentication the CLI user has already configured:
28+
- User authentication (from fab auth login)
29+
- Service principal (from environment variables)
30+
- Managed identity (when running in Azure)
31+
- Environment tokens (pre-acquired tokens)
32+
33+
Args:
34+
fab_auth: FabAuth instance containing the authentication configuration.
35+
"""
36+
37+
def __init__(self, fab_auth: FabAuth):
38+
self._fab_auth = fab_auth
39+
40+
def get_token(
41+
self,
42+
*scopes: str,
43+
claims: Optional[str] = None,
44+
tenant_id: Optional[str] = None,
45+
enable_cae: bool = False,
46+
**kwargs
47+
) -> AccessToken:
48+
"""
49+
Get an access token for the specified scopes.
50+
51+
Args:
52+
scopes: The scopes for which to request the token
53+
claims: Optional claims challenge
54+
tenant_id: Optional tenant ID (not used in this implementation)
55+
enable_cae: Whether to enable Continuous Access Evaluation (not used)
56+
**kwargs: Additional keyword arguments
57+
58+
Returns:
59+
AccessToken object containing the token and expiration time
60+
61+
Raises:
62+
ClientAuthenticationError: When authentication is not available
63+
"""
64+
for scope in scopes:
65+
if scope not in VALID_DEFATULT_SCOPES:
66+
fab_logger.log_debug(f"Invalid scope rejected: {scope}")
67+
raise ClientAuthenticationError(
68+
f"Security validation failed: requested scope is not supported."
69+
f"Invalid scope: {scope}. "
70+
f"Allowed scopes: {', '.join(VALID_DEFATULT_SCOPES)}"
71+
)
72+
try:
73+
msal_result = self._fab_auth.acquire_token(
74+
list(scopes),
75+
interactive_renew=False # Bridge is always headless
76+
)
77+
78+
return self._to_azure_access_token(msal_result)
79+
80+
except Exception as e:
81+
fab_logger.log_debug(f"Token acquisition failed: {e}")
82+
raise ClientAuthenticationError(
83+
f"\n{str(e)}"
84+
) from e
85+
86+
def _to_azure_access_token(self, msal_result: dict) -> AccessToken:
87+
"""Convert MSAL result to AccessToken object."""
88+
access_token = msal_result["access_token"]
89+
90+
# Handle expires_on - MSAL returns Unix timestamp as string or int
91+
expires_on = msal_result.get("expires_on")
92+
if expires_on:
93+
if isinstance(expires_on, str):
94+
expires_on = int(expires_on)
95+
else:
96+
# Fallback: calculate from expires_in if available
97+
expires_in = msal_result.get("expires_in")
98+
if expires_in:
99+
import time
100+
expires_on = int(time.time() + expires_in)
101+
else:
102+
raise ClientAuthenticationError(
103+
"Token expiration time is required but not available")
104+
return AccessToken(access_token, int(expires_on))
105+
106+
def close(self) -> None:
107+
"""Close the credential (no-op for this implementation)."""
108+
pass
109+
110+
111+
def create_fabric_token_credential() -> TokenCredential:
112+
"""
113+
Create a TokenCredential that uses the current Fabric CLI authentication.
114+
115+
This function creates a TokenCredential that wraps the existing MSAL authentication
116+
from the Fabric CLI. It will use whatever authentication the user has already
117+
configured (user login, service principal, managed identity, or environment tokens).
118+
Returns:
119+
TokenCredential that can be used with Azure SDKs
120+
121+
Raises:
122+
ClientAuthenticationError: When no authentication is configured
123+
"""
124+
fab_auth = FabAuth()
125+
126+
identity_type = fab_auth.get_identity_type()
127+
128+
fab_logger.log_debug(f"Creating TokenCredential for identity type: {identity_type}")
129+
return MsalTokenCredential(fab_auth)
130+

0 commit comments

Comments
 (0)