diff --git a/docs/cli/mobile-agent/forwarding-profile-regional-and-custom-proxy.md b/docs/cli/mobile-agent/forwarding-profile-regional-and-custom-proxy.md new file mode 100644 index 0000000..9a5e49d --- /dev/null +++ b/docs/cli/mobile-agent/forwarding-profile-regional-and-custom-proxy.md @@ -0,0 +1,208 @@ +# Forwarding Profile Regional and Custom Proxy + +Forwarding profile regional and custom proxies define how GlobalProtect forwarding profiles steer traffic to regional Prisma Access locations or custom explicit proxies in Strata Cloud Manager. The `scm` CLI provides commands to create, update, delete, show, backup, and load regional and custom proxies. + +## Overview + +The `forwarding-profile-regional-and-custom-proxy` commands allow you to: + +- Create regional and custom proxy definitions for forwarding profiles +- Configure primary/secondary custom proxy servers, connectivity preferences, and fallback behavior +- Update existing proxy configurations +- Delete proxies that are no longer needed +- Bulk import proxies from YAML files +- Export proxies for backup or migration + +!!! note + Forwarding profile regional and custom proxies live exclusively in the `Mobile Users` folder. Snippet and device locations are not supported. + +## Field Reference + +| Field | Values | Description | +| --- | --- | --- | +| `type` | `gp-and-pac` (default), `ztna-agent` | Proxy type | +| `proxy_1` / `proxy_2` | `fqdn`, `port` (1-65535), `location` | Custom proxy servers | +| `connectivity_preference` | `tunnel`, `proxy`, `adns`, `masque` (each with `enabled`) | Connectivity preference entries | +| `fallback_option` | `fail-open`, `fail-safe` | Behavior when the proxy is unreachable | +| `location_preference` | `best-available-pa-location`, `specific-pa-location` | Prisma Access location selection | +| `prisma_access_locations` | `name` (`americas`, `europe`, `apac`) + `locations` list | Specific Prisma Access locations | + +## Set Forwarding Profile Regional and Custom Proxy + +Create or update a forwarding profile regional and custom proxy. + +### Syntax + +```bash +scm set mobile-agent forwarding-profile-regional-and-custom-proxy [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the regional and custom proxy | Yes | +| `--type TEXT` | Proxy type (`gp-and-pac`, `ztna-agent`) | No | +| `--proxy-1-fqdn TEXT` | Primary proxy server FQDN | No | +| `--proxy-1-port INT` | Primary proxy server port (1-65535) | No | +| `--proxy-1-location TEXT` | Primary proxy server location | No | +| `--proxy-2-fqdn TEXT` | Secondary proxy server FQDN | No | +| `--proxy-2-port INT` | Secondary proxy server port (1-65535) | No | +| `--proxy-2-location TEXT` | Secondary proxy server location | No | +| `--fallback-option TEXT` | Fallback option (`fail-open`, `fail-safe`) | No | +| `--location-preference TEXT` | Location preference | No | +| `--description TEXT` | Description | No | + +!!! tip + Nested `connectivity_preference` and `prisma_access_locations` entries are supported via the load command's YAML schema. + +### Examples + +```bash +$ scm set mobile-agent forwarding-profile-regional-and-custom-proxy \ + --folder "Mobile Users" \ + --name "emea-proxy" \ + --type gp-and-pac \ + --proxy-1-fqdn "proxy1.example.com" \ + --proxy-1-port 8080 \ + --fallback-option fail-open +Created forwarding profile regional and custom proxy: emea-proxy in folder Mobile Users +``` + +## Show Forwarding Profile Regional and Custom Proxy + +Display forwarding profile regional and custom proxies. + +### Syntax + +```bash +scm show mobile-agent forwarding-profile-regional-and-custom-proxy [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the regional and custom proxy to show | No | + +### Examples + +```bash +# List all regional and custom proxies in the folder +$ scm show mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" + +# Show a specific regional and custom proxy by name +$ scm show mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" --name "emea-proxy" +``` + +## Delete Forwarding Profile Regional and Custom Proxy + +Remove a forwarding profile regional and custom proxy. + +### Syntax + +```bash +scm delete mobile-agent forwarding-profile-regional-and-custom-proxy [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the regional and custom proxy | Yes | +| `--force` | Skip confirmation prompt | No | + +### Examples + +```bash +$ scm delete mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" --name "emea-proxy" --force +Deleted forwarding profile regional and custom proxy: emea-proxy from folder Mobile Users +``` + +## Backup Forwarding Profile Regional and Custom Proxy + +Export all forwarding profile regional and custom proxies from a folder to a YAML file. + +### Syntax + +```bash +scm backup mobile-agent forwarding-profile-regional-and-custom-proxy [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder to backup from (defaults to `Mobile Users`) | No | +| `--file PATH` | Output file path (defaults to `forwarding-profile-regional-and-custom-proxy-{location}.yaml`) | No | + +### Examples + +```bash +$ scm backup mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" +Successfully backed up 2 forwarding profile regional and custom proxies to forwarding-profile-regional-and-custom-proxy-mobile-users.yaml +``` + +## Load Forwarding Profile Regional and Custom Proxy + +Bulk import forwarding profile regional and custom proxies from a YAML file. All nested fields are supported. + +### Syntax + +```bash +scm load mobile-agent forwarding-profile-regional-and-custom-proxy [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--file PATH` | YAML file to load from | Yes | +| `--dry-run` | Simulate execution without applying changes | No | +| `--folder TEXT` | Override folder location for all objects | No | + +### YAML Schema + +```yaml +forwarding_profile_regional_and_custom_proxies: + - name: emea-proxy + folder: "Mobile Users" + type: gp-and-pac + proxy_1: + fqdn: proxy1.example.com + port: 8080 + proxy_2: + fqdn: proxy2.example.com + port: 8080 + connectivity_preference: + - name: tunnel + enabled: true + - name: proxy + enabled: false + fallback_option: fail-open + - name: ztna-proxy + folder: "Mobile Users" + type: ztna-agent + location_preference: specific-pa-location + prisma_access_locations: + - name: europe + locations: + - "Frankfurt" +``` + +### Examples + +```bash +# Preview without applying +$ scm load mobile-agent forwarding-profile-regional-and-custom-proxy --file regional_proxies.yml --dry-run + +# Apply the configurations +$ scm load mobile-agent forwarding-profile-regional-and-custom-proxy --file regional_proxies.yml +Created forwarding profile regional and custom proxy: emea-proxy +Created forwarding profile regional and custom proxy: ztna-proxy + +Summary: 2 created, 0 updated, 0 unchanged +``` diff --git a/docs/cli/mobile-agent/forwarding-profile-source-application.md b/docs/cli/mobile-agent/forwarding-profile-source-application.md new file mode 100644 index 0000000..13510ed --- /dev/null +++ b/docs/cli/mobile-agent/forwarding-profile-source-application.md @@ -0,0 +1,171 @@ +# Forwarding Profile Source Application + +Forwarding profile source applications define the lists of applications used as source-application match criteria by GlobalProtect forwarding profiles in Strata Cloud Manager. The `scm` CLI provides commands to create, update, delete, show, backup, and load source applications. + +## Overview + +The `forwarding-profile-source-application` commands allow you to: + +- Create source application lists for forwarding profile rules +- Update existing source application configurations +- Delete source applications that are no longer needed +- Bulk import source applications from YAML files +- Export source applications for backup or migration + +!!! note + Forwarding profile source applications live exclusively in the `Mobile Users` folder. Snippet and device locations are not supported. + +## Set Forwarding Profile Source Application + +Create or update a forwarding profile source application. + +### Syntax + +```bash +scm set mobile-agent forwarding-profile-source-application [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the source application | Yes | +| `--application TEXT` | Application name (repeatable) | Yes | +| `--description TEXT` | Description | No | + +### Examples + +```bash +$ scm set mobile-agent forwarding-profile-source-application \ + --folder "Mobile Users" \ + --name "office-apps" \ + --application slack \ + --application zoom \ + --description "Collaboration applications" +Created forwarding profile source application: office-apps in folder Mobile Users +``` + +## Show Forwarding Profile Source Application + +Display forwarding profile source applications. + +### Syntax + +```bash +scm show mobile-agent forwarding-profile-source-application [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the source application to show | No | + +### Examples + +```bash +# List all source applications in the folder +$ scm show mobile-agent forwarding-profile-source-application --folder "Mobile Users" + +# Show a specific source application by name +$ scm show mobile-agent forwarding-profile-source-application --folder "Mobile Users" --name "office-apps" +``` + +## Delete Forwarding Profile Source Application + +Remove a forwarding profile source application. + +### Syntax + +```bash +scm delete mobile-agent forwarding-profile-source-application [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the source application | Yes | +| `--force` | Skip confirmation prompt | No | + +### Examples + +```bash +$ scm delete mobile-agent forwarding-profile-source-application --folder "Mobile Users" --name "office-apps" --force +Deleted forwarding profile source application: office-apps from folder Mobile Users +``` + +## Backup Forwarding Profile Source Application + +Export all forwarding profile source applications from a folder to a YAML file. + +### Syntax + +```bash +scm backup mobile-agent forwarding-profile-source-application [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder to backup from (defaults to `Mobile Users`) | No | +| `--file PATH` | Output file path (defaults to `forwarding-profile-source-application-{location}.yaml`) | No | + +### Examples + +```bash +$ scm backup mobile-agent forwarding-profile-source-application --folder "Mobile Users" +Successfully backed up 2 forwarding profile source applications to forwarding-profile-source-application-mobile-users.yaml +``` + +## Load Forwarding Profile Source Application + +Bulk import forwarding profile source applications from a YAML file. + +### Syntax + +```bash +scm load mobile-agent forwarding-profile-source-application [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--file PATH` | YAML file to load from | Yes | +| `--dry-run` | Simulate execution without applying changes | No | +| `--folder TEXT` | Override folder location for all objects | No | + +### YAML Schema + +```yaml +forwarding_profile_source_applications: + - name: office-apps + folder: "Mobile Users" + description: Collaboration applications + applications: + - slack + - zoom + - name: dev-apps + folder: "Mobile Users" + applications: + - github +``` + +### Examples + +```bash +# Preview without applying +$ scm load mobile-agent forwarding-profile-source-application --file source_applications.yml --dry-run + +# Apply the configurations +$ scm load mobile-agent forwarding-profile-source-application --file source_applications.yml +Created forwarding profile source application: office-apps +Created forwarding profile source application: dev-apps + +Summary: 2 created, 0 updated, 0 unchanged +``` diff --git a/docs/cli/mobile-agent/forwarding-profile-user-location.md b/docs/cli/mobile-agent/forwarding-profile-user-location.md new file mode 100644 index 0000000..58123ba --- /dev/null +++ b/docs/cli/mobile-agent/forwarding-profile-user-location.md @@ -0,0 +1,193 @@ +# Forwarding Profile User Location + +Forwarding profile user locations define where a GlobalProtect user is located — by IP address ranges or by internal host detection — for use as match criteria in forwarding profiles in Strata Cloud Manager. The `scm` CLI provides commands to create, update, delete, show, backup, and load user locations. + +## Overview + +The `forwarding-profile-user-location` commands allow you to: + +- Create user locations based on IP address entries or internal host detection +- Update existing user location configurations +- Delete user locations that are no longer needed +- Bulk import user locations from YAML files +- Export user locations for backup or migration + +!!! note + Forwarding profile user locations live exclusively in the `Mobile Users` folder. Snippet and device locations are not supported. + +## Location Matching Criteria + +Each user location uses exactly **one** of the following: + +| Criteria | Options | Description | +| --- | --- | --- | +| IP addresses | `--ip-address` (repeatable) | IPv4 entries, with optional wildcards (`10.2.*.*`) or CIDR suffix (`10.1.0.0/16`) | +| Internal host detection | `--internal-host-ip`, `--internal-host-fqdn` | Detect the user's network by resolving a known internal host | + +Providing both (or neither) is a validation error. + +## Set Forwarding Profile User Location + +Create or update a forwarding profile user location. + +### Syntax + +```bash +scm set mobile-agent forwarding-profile-user-location [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the user location | Yes | +| `--ip-address TEXT` | User location IP address (repeatable) | One criteria | +| `--internal-host-ip TEXT` | Internal host detection IP address | One criteria | +| `--internal-host-fqdn TEXT` | Internal host detection FQDN | One criteria | +| `--description TEXT` | Description | No | + +### Examples + +```bash +# IP address based location +$ scm set mobile-agent forwarding-profile-user-location \ + --folder "Mobile Users" \ + --name "branch-network" \ + --ip-address "10.1.0.0/16" \ + --ip-address "10.2.*.*" +Created forwarding profile user location: branch-network in folder Mobile Users + +# Internal host detection based location +$ scm set mobile-agent forwarding-profile-user-location \ + --folder "Mobile Users" \ + --name "corp-office" \ + --internal-host-ip "192.168.1.1" \ + --internal-host-fqdn "intranet.example.com" +Created forwarding profile user location: corp-office in folder Mobile Users +``` + +## Show Forwarding Profile User Location + +Display forwarding profile user locations. + +### Syntax + +```bash +scm show mobile-agent forwarding-profile-user-location [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the user location to show | No | + +### Examples + +```bash +# List all user locations in the folder +$ scm show mobile-agent forwarding-profile-user-location --folder "Mobile Users" + +# Show a specific user location by name +$ scm show mobile-agent forwarding-profile-user-location --folder "Mobile Users" --name "branch-network" +``` + +## Delete Forwarding Profile User Location + +Remove a forwarding profile user location. + +### Syntax + +```bash +scm delete mobile-agent forwarding-profile-user-location [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder location (must be `Mobile Users`) | Yes | +| `--name TEXT` | Name of the user location | Yes | +| `--force` | Skip confirmation prompt | No | + +### Examples + +```bash +$ scm delete mobile-agent forwarding-profile-user-location --folder "Mobile Users" --name "branch-network" --force +Deleted forwarding profile user location: branch-network from folder Mobile Users +``` + +## Backup Forwarding Profile User Location + +Export all forwarding profile user locations from a folder to a YAML file. The exported YAML uses the same flat schema accepted by the load command. + +### Syntax + +```bash +scm backup mobile-agent forwarding-profile-user-location [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--folder TEXT` | Folder to backup from (defaults to `Mobile Users`) | No | +| `--file PATH` | Output file path (defaults to `forwarding-profile-user-location-{location}.yaml`) | No | + +### Examples + +```bash +$ scm backup mobile-agent forwarding-profile-user-location --folder "Mobile Users" +Successfully backed up 2 forwarding profile user locations to forwarding-profile-user-location-mobile-users.yaml +``` + +## Load Forwarding Profile User Location + +Bulk import forwarding profile user locations from a YAML file. + +### Syntax + +```bash +scm load mobile-agent forwarding-profile-user-location [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--file PATH` | YAML file to load from | Yes | +| `--dry-run` | Simulate execution without applying changes | No | +| `--folder TEXT` | Override folder location for all objects | No | + +### YAML Schema + +Each entry uses either `ip_addresses` or the internal host detection fields (`internal_host_ip` / `internal_host_fqdn`): + +```yaml +forwarding_profile_user_locations: + - name: branch-network + folder: "Mobile Users" + ip_addresses: + - 10.1.0.0/16 + - 10.2.*.* + - name: corp-office + folder: "Mobile Users" + internal_host_ip: 192.168.1.1 + internal_host_fqdn: intranet.example.com +``` + +### Examples + +```bash +# Preview without applying +$ scm load mobile-agent forwarding-profile-user-location --file user_locations.yml --dry-run + +# Apply the configurations +$ scm load mobile-agent forwarding-profile-user-location --file user_locations.yml +Created forwarding profile user location: branch-network +Created forwarding profile user location: corp-office + +Summary: 2 created, 0 updated, 0 unchanged +``` diff --git a/mkdocs.yml b/mkdocs.yml index 64ea0dd..9bec4dc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -163,6 +163,9 @@ nav: - Auth Setting: cli/mobile-agent/auth-setting.md - Forwarding Profile: cli/mobile-agent/forwarding-profile.md - Forwarding Profile Destination: cli/mobile-agent/forwarding-profile-destination.md + - Forwarding Profile Regional and Custom Proxy: cli/mobile-agent/forwarding-profile-regional-and-custom-proxy.md + - Forwarding Profile Source Application: cli/mobile-agent/forwarding-profile-source-application.md + - Forwarding Profile User Location: cli/mobile-agent/forwarding-profile-user-location.md - Tunnel Profile: cli/mobile-agent/tunnel-profile.md - Global Setting: cli/mobile-agent/global-setting.md - Infrastructure Setting: cli/mobile-agent/infrastructure-setting.md diff --git a/src/scm_cli/commands/mobile_agent.py b/src/scm_cli/commands/mobile_agent.py index 6702415..eee66c2 100644 --- a/src/scm_cli/commands/mobile_agent.py +++ b/src/scm_cli/commands/mobile_agent.py @@ -15,7 +15,18 @@ from ..utils.config import load_from_yaml, settings from ..utils.context import get_current_context from ..utils.sdk_client import scm_client -from ..utils.validators import AgentProfile, AuthSetting, ForwardingProfile, ForwardingProfileDestination, GlobalSetting, InfrastructureSetting, TunnelProfile +from ..utils.validators import ( + AgentProfile, + AuthSetting, + ForwardingProfile, + ForwardingProfileDestination, + ForwardingProfileRegionalAndCustomProxy, + ForwardingProfileSourceApplication, + ForwardingProfileUserLocation, + GlobalSetting, + InfrastructureSetting, + TunnelProfile, +) # ============================================================================================================================================================================================= # HELPER FUNCTIONS @@ -2131,3 +2142,1011 @@ def set_global_setting( except Exception as e: typer.echo(f"Error updating global settings: {str(e)}", err=True) raise typer.Exit(code=1) from e + + +# ============================================================================================================================================================================================= +# FORWARDING PROFILE SOURCE APPLICATION COMMANDS +# ============================================================================================================================================================================================= + +APPLICATION_OPTION = typer.Option( + None, + "--application", + help="Application name (repeatable)", +) + + +@backup_app.command("forwarding-profile-source-application") +def backup_forwarding_profile_source_application( + folder: str = BACKUP_FOLDER_OPTION, + file: Path | None = BACKUP_FILE_OPTION, +): + """Backup all forwarding profile source applications from a folder to a YAML file. + + Examples + -------- + # Backup from the Mobile Users folder + scm backup mobile-agent forwarding-profile-source-application --folder "Mobile Users" + + """ + try: + folder = folder or "Mobile Users" + + source_applications = scm_client.list_forwarding_profile_source_applications(folder=folder) + + if not source_applications: + typer.echo(f"No forwarding profile source applications found in folder '{folder}'") + return + + # Convert SDK models to dictionaries, excluding unset values + backup_data = [] + for app in source_applications: + app_dict = {k: v for k, v in app.items() if v is not None} + # Remove system fields that shouldn't be in backup + app_dict.pop("id", None) + backup_data.append(app_dict) + + # Create the YAML structure + yaml_data = {"forwarding_profile_source_applications": backup_data} + + # Generate filename + if file is None: + file = Path(get_default_backup_filename("forwarding-profile-source-application", "folder", folder)) + + # Write to YAML file + with file.open("w") as f: + yaml.dump(yaml_data, f, default_flow_style=False, sort_keys=False) + + typer.echo(f"Successfully backed up {len(backup_data)} forwarding profile source applications to {file}") + return str(file) + + except Exception as e: + typer.echo(f"Error backing up forwarding profile source applications: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@delete_app.command("forwarding-profile-source-application") +def delete_forwarding_profile_source_application( + folder: str = FOLDER_OPTION, + name: str = NAME_OPTION, + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt"), +): + """Delete a forwarding profile source application. + + Examples + -------- + scm delete mobile-agent forwarding-profile-source-application --folder "Mobile Users" --name "office-apps" + + """ + try: + if not force: + typer.confirm(f"Delete forwarding profile source application '{name}' from folder '{folder}'?", abort=True) + result = scm_client.delete_forwarding_profile_source_application(folder=folder, name=name) + if result: + typer.echo(f"Deleted forwarding profile source application: {name} from folder {folder}") + return result + except Exception as e: + typer.echo(f"Error deleting forwarding profile source application: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@load_app.command("forwarding-profile-source-application") +def load_forwarding_profile_source_application( + file: Path = FILE_OPTION, + dry_run: bool = DRY_RUN_OPTION, + folder: str = LOAD_FOLDER_OPTION, +): + """Load forwarding profile source applications from a YAML file. + + Examples + -------- + # Load from file with original locations + scm load mobile-agent forwarding-profile-source-application --file config/source_applications.yml + + # Load with folder override + scm load mobile-agent forwarding-profile-source-application --file config/source_applications.yml --folder "Mobile Users" + + """ + try: + # Load and parse the YAML file + config = load_from_yaml(str(file), "forwarding_profile_source_applications") + + if dry_run: + typer.echo("Dry run mode: would apply the following configurations:") + typer.echo(yaml.dump(config["forwarding_profile_source_applications"])) + return None + + # Apply each source application + results = [] + created_count = 0 + updated_count = 0 + no_change_count = 0 + + for app_data in config["forwarding_profile_source_applications"]: + try: + # Apply container override if specified + if folder: + app_data["folder"] = folder + + # Validate using the Pydantic model + source_application = ForwardingProfileSourceApplication(**app_data) + sdk_data = source_application.to_sdk_model() + + # Create the source application via SDK client + result = scm_client.create_forwarding_profile_source_application(**sdk_data) + + # Track action + action = result.pop("__action__", "created") + if action == "created": + created_count += 1 + typer.echo(f"Created forwarding profile source application: {result.get('name', 'N/A')}") + elif action == "updated": + updated_count += 1 + typer.echo(f"Updated forwarding profile source application: {result.get('name', 'N/A')}") + elif action == "no_change": + no_change_count += 1 + typer.echo(f"No changes needed for forwarding profile source application: {result.get('name', 'N/A')}") + + results.append(result) + + except Exception as e: + typer.echo(f"Error loading forwarding profile source application '{app_data.get('name', 'unknown')}': {str(e)}", err=True) + + # Summary + typer.echo(f"\nSummary: {created_count} created, {updated_count} updated, {no_change_count} unchanged") + + return results + + except ValidationError as e: + typer.echo(f"Validation error: {e}", err=True) + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Error loading forwarding profile source applications: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@set_app.command("forwarding-profile-source-application") +def set_forwarding_profile_source_application( + folder: str = FOLDER_OPTION, + name: str = NAME_OPTION, + description: str | None = DESCRIPTION_OPTION, + application: list[str] | None = APPLICATION_OPTION, +): + r"""Create or update a forwarding profile source application. + + Examples + -------- + scm set mobile-agent forwarding-profile-source-application \ + --folder "Mobile Users" \ + --name "office-apps" \ + --application slack \ + --application zoom + + """ + try: + # Build source application data + app_data: dict[str, Any] = { + "name": name, + } + + if folder: + app_data["folder"] = folder + if description is not None: + app_data["description"] = description + if application: + app_data["applications"] = application + + # Validate using the Pydantic model + source_application = ForwardingProfileSourceApplication(**app_data) + sdk_data = source_application.to_sdk_model() + + # Call the SDK client + result = scm_client.create_forwarding_profile_source_application(**sdk_data) + + # Get the action performed + action = result.pop("__action__", "created") + + if action == "created": + typer.echo(f"Created forwarding profile source application: {result.get('name', name)} in folder {result.get('folder', folder)}") + elif action == "updated": + typer.echo(f"Updated forwarding profile source application: {result.get('name', name)} in folder {result.get('folder', folder)}") + elif action == "no_change": + typer.echo(f"No changes needed for forwarding profile source application: {result.get('name', name)} in folder {result.get('folder', folder)}") + + return result + + except ValidationError as e: + typer.echo(f"Validation error: {str(e)}", err=True) + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Error creating/updating forwarding profile source application: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@show_app.command("forwarding-profile-source-application") +def show_forwarding_profile_source_application( + folder: str = FOLDER_OPTION, + name: str | None = typer.Option(None, "--name", help="Name of the source application to show"), +): + """Display forwarding profile source applications. + + Examples + -------- + # List all source applications in a folder (default behavior) + scm show mobile-agent forwarding-profile-source-application --folder "Mobile Users" + + # Show a specific source application by name + scm show mobile-agent forwarding-profile-source-application --folder "Mobile Users" --name "office-apps" + + """ + try: + show_context_info() + + if name: + # Get a specific source application by name + app = scm_client.get_forwarding_profile_source_application(folder=folder, name=name) + + typer.echo(f"\nForwarding Profile Source Application: {app.get('name', 'N/A')}") + typer.echo("=" * 80) + + # Display container location + if app.get("folder"): + typer.echo(f"Location: Folder '{app['folder']}'") + else: + typer.echo("Location: N/A") + + # Display source application details + if app.get("description"): + typer.echo(f"Description: {app['description']}") + if app.get("applications"): + typer.echo(f"Applications: {', '.join(app['applications'])}") + if app.get("id"): + typer.echo(f"ID: {app['id']}") + + return app + + else: + # Default: list all source applications + apps_list = scm_client.list_forwarding_profile_source_applications(folder=folder) + + if not apps_list: + typer.echo(f"No forwarding profile source applications found in folder '{folder}'") + return + + typer.echo(f"\nForwarding Profile Source Applications in folder '{folder}':") + typer.echo("-" * 60) + + for app in apps_list: + typer.echo(f"Name: {app.get('name', 'N/A')}") + if app.get("applications"): + typer.echo(f" Applications: {', '.join(app['applications'])}") + if app.get("description"): + typer.echo(f" Description: {app['description']}") + typer.echo("-" * 60) + + return apps_list + + except Exception as e: + typer.echo(f"Error showing forwarding profile source application: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +# ============================================================================================================================================================================================= +# FORWARDING PROFILE USER LOCATION COMMANDS +# ============================================================================================================================================================================================= + +INTERNAL_HOST_IP_OPTION = typer.Option( + None, + "--internal-host-ip", + help="Internal host detection IP address", +) +INTERNAL_HOST_FQDN_OPTION = typer.Option( + None, + "--internal-host-fqdn", + help="Internal host detection FQDN", +) +USER_LOCATION_IP_OPTION = typer.Option( + None, + "--ip-address", + help="User location IP address (repeatable; supports wildcards or CIDR suffix)", +) + + +def _format_user_location_choice(choice: dict[str, Any]) -> list[str]: + """Format a user location choice dictionary for display.""" + lines = [] + if choice.get("ip_addresses"): + ips = ", ".join(entry.get("name", "N/A") for entry in choice["ip_addresses"]) + lines.append(f"IP Addresses: {ips}") + internal_host = choice.get("internal_host_detection") + if internal_host: + if internal_host.get("ip_address"): + lines.append(f"Internal Host IP: {internal_host['ip_address']}") + if internal_host.get("fqdn"): + lines.append(f"Internal Host FQDN: {internal_host['fqdn']}") + return lines + + +@backup_app.command("forwarding-profile-user-location") +def backup_forwarding_profile_user_location( + folder: str = BACKUP_FOLDER_OPTION, + file: Path | None = BACKUP_FILE_OPTION, +): + """Backup all forwarding profile user locations from a folder to a YAML file. + + Examples + -------- + # Backup from the Mobile Users folder + scm backup mobile-agent forwarding-profile-user-location --folder "Mobile Users" + + """ + try: + folder = folder or "Mobile Users" + + user_locations = scm_client.list_forwarding_profile_user_locations(folder=folder) + + if not user_locations: + typer.echo(f"No forwarding profile user locations found in folder '{folder}'") + return + + # Convert SDK models to dictionaries, excluding unset values + backup_data = [] + for location in user_locations: + location_dict = {k: v for k, v in location.items() if v is not None} + # Remove system fields that shouldn't be in backup + location_dict.pop("id", None) + # Flatten choice into the CLI YAML schema + choice = location_dict.pop("choice", None) or {} + if choice.get("ip_addresses"): + location_dict["ip_addresses"] = [entry["name"] for entry in choice["ip_addresses"] if entry.get("name")] + internal_host = choice.get("internal_host_detection") or {} + if internal_host.get("ip_address"): + location_dict["internal_host_ip"] = internal_host["ip_address"] + if internal_host.get("fqdn"): + location_dict["internal_host_fqdn"] = internal_host["fqdn"] + backup_data.append(location_dict) + + # Create the YAML structure + yaml_data = {"forwarding_profile_user_locations": backup_data} + + # Generate filename + if file is None: + file = Path(get_default_backup_filename("forwarding-profile-user-location", "folder", folder)) + + # Write to YAML file + with file.open("w") as f: + yaml.dump(yaml_data, f, default_flow_style=False, sort_keys=False) + + typer.echo(f"Successfully backed up {len(backup_data)} forwarding profile user locations to {file}") + return str(file) + + except Exception as e: + typer.echo(f"Error backing up forwarding profile user locations: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@delete_app.command("forwarding-profile-user-location") +def delete_forwarding_profile_user_location( + folder: str = FOLDER_OPTION, + name: str = NAME_OPTION, + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt"), +): + """Delete a forwarding profile user location. + + Examples + -------- + scm delete mobile-agent forwarding-profile-user-location --folder "Mobile Users" --name "branch-network" + + """ + try: + if not force: + typer.confirm(f"Delete forwarding profile user location '{name}' from folder '{folder}'?", abort=True) + result = scm_client.delete_forwarding_profile_user_location(folder=folder, name=name) + if result: + typer.echo(f"Deleted forwarding profile user location: {name} from folder {folder}") + return result + except Exception as e: + typer.echo(f"Error deleting forwarding profile user location: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@load_app.command("forwarding-profile-user-location") +def load_forwarding_profile_user_location( + file: Path = FILE_OPTION, + dry_run: bool = DRY_RUN_OPTION, + folder: str = LOAD_FOLDER_OPTION, +): + """Load forwarding profile user locations from a YAML file. + + Examples + -------- + # Load from file with original locations + scm load mobile-agent forwarding-profile-user-location --file config/user_locations.yml + + # Load with folder override + scm load mobile-agent forwarding-profile-user-location --file config/user_locations.yml --folder "Mobile Users" + + """ + try: + # Load and parse the YAML file + config = load_from_yaml(str(file), "forwarding_profile_user_locations") + + if dry_run: + typer.echo("Dry run mode: would apply the following configurations:") + typer.echo(yaml.dump(config["forwarding_profile_user_locations"])) + return None + + # Apply each user location + results = [] + created_count = 0 + updated_count = 0 + no_change_count = 0 + + for location_data in config["forwarding_profile_user_locations"]: + try: + # Apply container override if specified + if folder: + location_data["folder"] = folder + + # Validate using the Pydantic model + user_location = ForwardingProfileUserLocation(**location_data) + sdk_data = user_location.to_sdk_model() + + # Create the user location via SDK client + result = scm_client.create_forwarding_profile_user_location(**sdk_data) + + # Track action + action = result.pop("__action__", "created") + if action == "created": + created_count += 1 + typer.echo(f"Created forwarding profile user location: {result.get('name', 'N/A')}") + elif action == "updated": + updated_count += 1 + typer.echo(f"Updated forwarding profile user location: {result.get('name', 'N/A')}") + elif action == "no_change": + no_change_count += 1 + typer.echo(f"No changes needed for forwarding profile user location: {result.get('name', 'N/A')}") + + results.append(result) + + except Exception as e: + typer.echo(f"Error loading forwarding profile user location '{location_data.get('name', 'unknown')}': {str(e)}", err=True) + + # Summary + typer.echo(f"\nSummary: {created_count} created, {updated_count} updated, {no_change_count} unchanged") + + return results + + except ValidationError as e: + typer.echo(f"Validation error: {e}", err=True) + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Error loading forwarding profile user locations: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@set_app.command("forwarding-profile-user-location") +def set_forwarding_profile_user_location( + folder: str = FOLDER_OPTION, + name: str = NAME_OPTION, + description: str | None = DESCRIPTION_OPTION, + internal_host_ip: str | None = INTERNAL_HOST_IP_OPTION, + internal_host_fqdn: str | None = INTERNAL_HOST_FQDN_OPTION, + ip_address: list[str] | None = USER_LOCATION_IP_OPTION, +): + r"""Create or update a forwarding profile user location. + + Provide either IP address entries (--ip-address, repeatable) or internal host + detection settings (--internal-host-ip / --internal-host-fqdn), but not both. + + Examples + -------- + # IP address based location + scm set mobile-agent forwarding-profile-user-location \ + --folder "Mobile Users" \ + --name "branch-network" \ + --ip-address "10.1.0.0/16" + + # Internal host detection based location + scm set mobile-agent forwarding-profile-user-location \ + --folder "Mobile Users" \ + --name "corp-office" \ + --internal-host-fqdn "intranet.example.com" + + """ + try: + # Build user location data + location_data: dict[str, Any] = { + "name": name, + } + + if folder: + location_data["folder"] = folder + if description is not None: + location_data["description"] = description + if internal_host_ip is not None: + location_data["internal_host_ip"] = internal_host_ip + if internal_host_fqdn is not None: + location_data["internal_host_fqdn"] = internal_host_fqdn + if ip_address: + location_data["ip_addresses"] = ip_address + + # Validate using the Pydantic model + user_location = ForwardingProfileUserLocation(**location_data) + sdk_data = user_location.to_sdk_model() + + # Call the SDK client + result = scm_client.create_forwarding_profile_user_location(**sdk_data) + + # Get the action performed + action = result.pop("__action__", "created") + + if action == "created": + typer.echo(f"Created forwarding profile user location: {result.get('name', name)} in folder {result.get('folder', folder)}") + elif action == "updated": + typer.echo(f"Updated forwarding profile user location: {result.get('name', name)} in folder {result.get('folder', folder)}") + elif action == "no_change": + typer.echo(f"No changes needed for forwarding profile user location: {result.get('name', name)} in folder {result.get('folder', folder)}") + + return result + + except ValidationError as e: + typer.echo(f"Validation error: {str(e)}", err=True) + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Error creating/updating forwarding profile user location: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@show_app.command("forwarding-profile-user-location") +def show_forwarding_profile_user_location( + folder: str = FOLDER_OPTION, + name: str | None = typer.Option(None, "--name", help="Name of the user location to show"), +): + """Display forwarding profile user locations. + + Examples + -------- + # List all user locations in a folder (default behavior) + scm show mobile-agent forwarding-profile-user-location --folder "Mobile Users" + + # Show a specific user location by name + scm show mobile-agent forwarding-profile-user-location --folder "Mobile Users" --name "branch-network" + + """ + try: + show_context_info() + + if name: + # Get a specific user location by name + location = scm_client.get_forwarding_profile_user_location(folder=folder, name=name) + + typer.echo(f"\nForwarding Profile User Location: {location.get('name', 'N/A')}") + typer.echo("=" * 80) + + # Display container location + if location.get("folder"): + typer.echo(f"Location: Folder '{location['folder']}'") + else: + typer.echo("Location: N/A") + + # Display user location details + if location.get("description"): + typer.echo(f"Description: {location['description']}") + for line in _format_user_location_choice(location.get("choice") or {}): + typer.echo(line) + if location.get("id"): + typer.echo(f"ID: {location['id']}") + + return location + + else: + # Default: list all user locations + locations_list = scm_client.list_forwarding_profile_user_locations(folder=folder) + + if not locations_list: + typer.echo(f"No forwarding profile user locations found in folder '{folder}'") + return + + typer.echo(f"\nForwarding Profile User Locations in folder '{folder}':") + typer.echo("-" * 60) + + for location in locations_list: + typer.echo(f"Name: {location.get('name', 'N/A')}") + for line in _format_user_location_choice(location.get("choice") or {}): + typer.echo(f" {line}") + if location.get("description"): + typer.echo(f" Description: {location['description']}") + typer.echo("-" * 60) + + return locations_list + + except Exception as e: + typer.echo(f"Error showing forwarding profile user location: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +# ============================================================================================================================================================================================= +# FORWARDING PROFILE REGIONAL AND CUSTOM PROXY COMMANDS +# ============================================================================================================================================================================================= + +PROXY_TYPE_OPTION = typer.Option( + None, + "--type", + help="Proxy type (gp-and-pac, ztna-agent)", +) +PROXY_1_FQDN_OPTION = typer.Option( + None, + "--proxy-1-fqdn", + help="Primary proxy server FQDN", +) +PROXY_1_PORT_OPTION = typer.Option( + None, + "--proxy-1-port", + help="Primary proxy server port (1-65535)", +) +PROXY_1_LOCATION_OPTION = typer.Option( + None, + "--proxy-1-location", + help="Primary proxy server location", +) +PROXY_2_FQDN_OPTION = typer.Option( + None, + "--proxy-2-fqdn", + help="Secondary proxy server FQDN", +) +PROXY_2_PORT_OPTION = typer.Option( + None, + "--proxy-2-port", + help="Secondary proxy server port (1-65535)", +) +PROXY_2_LOCATION_OPTION = typer.Option( + None, + "--proxy-2-location", + help="Secondary proxy server location", +) +FALLBACK_OPTION_OPTION = typer.Option( + None, + "--fallback-option", + help="Fallback option (fail-open, fail-safe)", +) +LOCATION_PREFERENCE_OPTION = typer.Option( + None, + "--location-preference", + help="Location preference (best-available-pa-location, specific-pa-location)", +) + + +@backup_app.command("forwarding-profile-regional-and-custom-proxy") +def backup_forwarding_profile_regional_and_custom_proxy( + folder: str = BACKUP_FOLDER_OPTION, + file: Path | None = BACKUP_FILE_OPTION, +): + """Backup all forwarding profile regional and custom proxies from a folder to a YAML file. + + Examples + -------- + # Backup from the Mobile Users folder + scm backup mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" + + """ + try: + folder = folder or "Mobile Users" + + proxies = scm_client.list_forwarding_profile_regional_and_custom_proxies(folder=folder) + + if not proxies: + typer.echo(f"No forwarding profile regional and custom proxies found in folder '{folder}'") + return + + # Convert SDK models to dictionaries, excluding unset values + backup_data = [] + for proxy in proxies: + proxy_dict = {k: v for k, v in proxy.items() if v is not None} + # Remove system fields that shouldn't be in backup + proxy_dict.pop("id", None) + backup_data.append(proxy_dict) + + # Create the YAML structure + yaml_data = {"forwarding_profile_regional_and_custom_proxies": backup_data} + + # Generate filename + if file is None: + file = Path(get_default_backup_filename("forwarding-profile-regional-and-custom-proxy", "folder", folder)) + + # Write to YAML file + with file.open("w") as f: + yaml.dump(yaml_data, f, default_flow_style=False, sort_keys=False) + + typer.echo(f"Successfully backed up {len(backup_data)} forwarding profile regional and custom proxies to {file}") + return str(file) + + except Exception as e: + typer.echo(f"Error backing up forwarding profile regional and custom proxies: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@delete_app.command("forwarding-profile-regional-and-custom-proxy") +def delete_forwarding_profile_regional_and_custom_proxy( + folder: str = FOLDER_OPTION, + name: str = NAME_OPTION, + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt"), +): + """Delete a forwarding profile regional and custom proxy. + + Examples + -------- + scm delete mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" --name "emea-proxy" + + """ + try: + if not force: + typer.confirm(f"Delete forwarding profile regional and custom proxy '{name}' from folder '{folder}'?", abort=True) + result = scm_client.delete_forwarding_profile_regional_and_custom_proxy(folder=folder, name=name) + if result: + typer.echo(f"Deleted forwarding profile regional and custom proxy: {name} from folder {folder}") + return result + except Exception as e: + typer.echo(f"Error deleting forwarding profile regional and custom proxy: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@load_app.command("forwarding-profile-regional-and-custom-proxy") +def load_forwarding_profile_regional_and_custom_proxy( + file: Path = FILE_OPTION, + dry_run: bool = DRY_RUN_OPTION, + folder: str = LOAD_FOLDER_OPTION, +): + """Load forwarding profile regional and custom proxies from a YAML file. + + Nested fields (proxy_1, proxy_2, connectivity_preference, prisma_access_locations) + are fully supported in the YAML schema. + + Examples + -------- + # Load from file with original locations + scm load mobile-agent forwarding-profile-regional-and-custom-proxy --file config/regional_proxies.yml + + # Load with folder override + scm load mobile-agent forwarding-profile-regional-and-custom-proxy --file config/regional_proxies.yml --folder "Mobile Users" + + """ + try: + # Load and parse the YAML file + config = load_from_yaml(str(file), "forwarding_profile_regional_and_custom_proxies") + + if dry_run: + typer.echo("Dry run mode: would apply the following configurations:") + typer.echo(yaml.dump(config["forwarding_profile_regional_and_custom_proxies"])) + return None + + # Apply each regional and custom proxy + results = [] + created_count = 0 + updated_count = 0 + no_change_count = 0 + + for proxy_data in config["forwarding_profile_regional_and_custom_proxies"]: + try: + # Apply container override if specified + if folder: + proxy_data["folder"] = folder + + # Validate using the Pydantic model + regional_proxy = ForwardingProfileRegionalAndCustomProxy(**proxy_data) + sdk_data = regional_proxy.to_sdk_model() + + # Create the regional and custom proxy via SDK client + result = scm_client.create_forwarding_profile_regional_and_custom_proxy(**sdk_data) + + # Track action + action = result.pop("__action__", "created") + if action == "created": + created_count += 1 + typer.echo(f"Created forwarding profile regional and custom proxy: {result.get('name', 'N/A')}") + elif action == "updated": + updated_count += 1 + typer.echo(f"Updated forwarding profile regional and custom proxy: {result.get('name', 'N/A')}") + elif action == "no_change": + no_change_count += 1 + typer.echo(f"No changes needed for forwarding profile regional and custom proxy: {result.get('name', 'N/A')}") + + results.append(result) + + except Exception as e: + typer.echo(f"Error loading forwarding profile regional and custom proxy '{proxy_data.get('name', 'unknown')}': {str(e)}", err=True) + + # Summary + typer.echo(f"\nSummary: {created_count} created, {updated_count} updated, {no_change_count} unchanged") + + return results + + except ValidationError as e: + typer.echo(f"Validation error: {e}", err=True) + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Error loading forwarding profile regional and custom proxies: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@set_app.command("forwarding-profile-regional-and-custom-proxy") +def set_forwarding_profile_regional_and_custom_proxy( + folder: str = FOLDER_OPTION, + name: str = NAME_OPTION, + description: str | None = DESCRIPTION_OPTION, + type: str | None = PROXY_TYPE_OPTION, + proxy_1_fqdn: str | None = PROXY_1_FQDN_OPTION, + proxy_1_port: int | None = PROXY_1_PORT_OPTION, + proxy_1_location: str | None = PROXY_1_LOCATION_OPTION, + proxy_2_fqdn: str | None = PROXY_2_FQDN_OPTION, + proxy_2_port: int | None = PROXY_2_PORT_OPTION, + proxy_2_location: str | None = PROXY_2_LOCATION_OPTION, + fallback_option: str | None = FALLBACK_OPTION_OPTION, + location_preference: str | None = LOCATION_PREFERENCE_OPTION, +): + r"""Create or update a forwarding profile regional and custom proxy. + + Nested connectivity_preference and prisma_access_locations entries are + supported via the load command's YAML schema. + + Examples + -------- + scm set mobile-agent forwarding-profile-regional-and-custom-proxy \ + --folder "Mobile Users" \ + --name "emea-proxy" \ + --type gp-and-pac \ + --proxy-1-fqdn "proxy1.example.com" \ + --proxy-1-port 8080 \ + --fallback-option fail-open + + """ + try: + # Build regional and custom proxy data + proxy_data: dict[str, Any] = { + "name": name, + } + + if folder: + proxy_data["folder"] = folder + if description is not None: + proxy_data["description"] = description + if type is not None: + proxy_data["type"] = type + + proxy_1: dict[str, Any] = {} + if proxy_1_fqdn is not None: + proxy_1["fqdn"] = proxy_1_fqdn + if proxy_1_port is not None: + proxy_1["port"] = proxy_1_port + if proxy_1_location is not None: + proxy_1["location"] = proxy_1_location + if proxy_1: + proxy_data["proxy_1"] = proxy_1 + + proxy_2: dict[str, Any] = {} + if proxy_2_fqdn is not None: + proxy_2["fqdn"] = proxy_2_fqdn + if proxy_2_port is not None: + proxy_2["port"] = proxy_2_port + if proxy_2_location is not None: + proxy_2["location"] = proxy_2_location + if proxy_2: + proxy_data["proxy_2"] = proxy_2 + + if fallback_option is not None: + proxy_data["fallback_option"] = fallback_option + if location_preference is not None: + proxy_data["location_preference"] = location_preference + + # Validate using the Pydantic model + regional_proxy = ForwardingProfileRegionalAndCustomProxy(**proxy_data) + sdk_data = regional_proxy.to_sdk_model() + + # Call the SDK client + result = scm_client.create_forwarding_profile_regional_and_custom_proxy(**sdk_data) + + # Get the action performed + action = result.pop("__action__", "created") + + if action == "created": + typer.echo(f"Created forwarding profile regional and custom proxy: {result.get('name', name)} in folder {result.get('folder', folder)}") + elif action == "updated": + typer.echo(f"Updated forwarding profile regional and custom proxy: {result.get('name', name)} in folder {result.get('folder', folder)}") + elif action == "no_change": + typer.echo(f"No changes needed for forwarding profile regional and custom proxy: {result.get('name', name)} in folder {result.get('folder', folder)}") + + return result + + except ValidationError as e: + typer.echo(f"Validation error: {str(e)}", err=True) + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Error creating/updating forwarding profile regional and custom proxy: {str(e)}", err=True) + raise typer.Exit(code=1) from e + + +@show_app.command("forwarding-profile-regional-and-custom-proxy") +def show_forwarding_profile_regional_and_custom_proxy( + folder: str = FOLDER_OPTION, + name: str | None = typer.Option(None, "--name", help="Name of the regional and custom proxy to show"), +): + """Display forwarding profile regional and custom proxies. + + Examples + -------- + # List all regional and custom proxies in a folder (default behavior) + scm show mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" + + # Show a specific regional and custom proxy by name + scm show mobile-agent forwarding-profile-regional-and-custom-proxy --folder "Mobile Users" --name "emea-proxy" + + """ + try: + show_context_info() + + if name: + # Get a specific regional and custom proxy by name + proxy = scm_client.get_forwarding_profile_regional_and_custom_proxy(folder=folder, name=name) + + typer.echo(f"\nForwarding Profile Regional and Custom Proxy: {proxy.get('name', 'N/A')}") + typer.echo("=" * 80) + + # Display container location + if proxy.get("folder"): + typer.echo(f"Location: Folder '{proxy['folder']}'") + else: + typer.echo("Location: N/A") + + # Display regional and custom proxy details + if proxy.get("description"): + typer.echo(f"Description: {proxy['description']}") + if proxy.get("type"): + typer.echo(f"Type: {proxy['type']}") + for proxy_key, proxy_label in (("proxy_1", "Proxy 1"), ("proxy_2", "Proxy 2")): + server = proxy.get(proxy_key) + if server: + parts = [server.get("fqdn", "N/A")] + if server.get("port"): + parts.append(f"port {server['port']}") + if server.get("location"): + parts.append(f"location {server['location']}") + typer.echo(f"{proxy_label}: {', '.join(parts)}") + if proxy.get("connectivity_preference"): + prefs = ", ".join(f"{pref.get('name', 'N/A')}={'enabled' if pref.get('enabled') else 'disabled'}" for pref in proxy["connectivity_preference"]) + typer.echo(f"Connectivity Preference: {prefs}") + if proxy.get("fallback_option"): + typer.echo(f"Fallback Option: {proxy['fallback_option']}") + if proxy.get("location_preference"): + typer.echo(f"Location Preference: {proxy['location_preference']}") + if proxy.get("prisma_access_locations"): + for region in proxy["prisma_access_locations"]: + locations = ", ".join(region.get("locations") or []) + typer.echo(f"Prisma Access Region: {region.get('name', 'N/A')}{f' ({locations})' if locations else ''}") + if proxy.get("id"): + typer.echo(f"ID: {proxy['id']}") + + return proxy + + else: + # Default: list all regional and custom proxies + proxies_list = scm_client.list_forwarding_profile_regional_and_custom_proxies(folder=folder) + + if not proxies_list: + typer.echo(f"No forwarding profile regional and custom proxies found in folder '{folder}'") + return + + typer.echo(f"\nForwarding Profile Regional and Custom Proxies in folder '{folder}':") + typer.echo("-" * 60) + + for proxy in proxies_list: + typer.echo(f"Name: {proxy.get('name', 'N/A')}") + if proxy.get("type"): + typer.echo(f" Type: {proxy['type']}") + if proxy.get("description"): + typer.echo(f" Description: {proxy['description']}") + typer.echo("-" * 60) + + return proxies_list + + except Exception as e: + typer.echo(f"Error showing forwarding profile regional and custom proxy: {str(e)}", err=True) + raise typer.Exit(code=1) from e diff --git a/src/scm_cli/utils/sdk_client.py b/src/scm_cli/utils/sdk_client.py index 00068de..c22d234 100644 --- a/src/scm_cli/utils/sdk_client.py +++ b/src/scm_cli/utils/sdk_client.py @@ -11777,6 +11777,635 @@ def update_global_settings( # ---------------------------------------------------------------- agent2: Global Settings (end) ---------------------------------------------------------------- + # ---------------------------------------------------------------------- Forwarding Profile Source Application ---------------------------------------------------------------------- + + def create_forwarding_profile_source_application( + self, + folder: str | None = None, + name: str = None, + description: str | None = None, + applications: list[str] | None = None, + ) -> dict[str, Any]: + """Create or update a forwarding profile source application using smart upsert logic. + + Args: + folder: Folder to create the source application in (must be "Mobile Users") + name: Name of the source application + description: Optional description + applications: List of applications + + Returns: + dict[str, Any]: The created/updated source application object with __action__ field + + """ + self.logger.info(f"Creating or updating forwarding profile source application: {name} in folder {folder}") + + if not self.client: + # Return mock data if no client is available + result = { + "id": f"fpsa-{name}", + "folder": folder, + "name": name, + "description": description, + "applications": applications, + "__action__": "created", + } + return {k: v for k, v in result.items() if v is not None} + + try: + # Step 1: Try to fetch existing source application + existing = None + try: + existing = self.client.forwarding_profile_source_application.fetch(name=name, folder=folder) + self.logger.info(f"Found existing forwarding profile source application '{name}' in folder '{folder}'") + except NotFoundError: + self.logger.info(f"Forwarding profile source application '{name}' not found in folder '{folder}', will create new") + except Exception as fetch_error: + self.logger.warning(f"Error fetching forwarding profile source application '{name}': {str(fetch_error)}") + + if existing: + # Step 2: Compare fields and update if needed + needs_update = False + update_fields = [] + + if description is not None and getattr(existing, "description", None) != description: + existing.description = description + update_fields.append("description") + needs_update = True + + if applications is not None and getattr(existing, "applications", None) != applications: + existing.applications = applications + update_fields.append("applications") + needs_update = True + + if needs_update: + self.logger.info(f"Updating forwarding profile source application fields: {', '.join(update_fields)}") + result = self.client.forwarding_profile_source_application.update(existing) + self.logger.info(f"Successfully updated forwarding profile source application '{name}' in folder '{folder}'") + response = json.loads(result.model_dump_json(exclude_unset=True)) + response["__action__"] = "updated" + return response + else: + self.logger.info(f"No changes detected for forwarding profile source application '{name}', skipping update") + response = json.loads(existing.model_dump_json(exclude_unset=True)) + response["__action__"] = "no_change" + return response + else: + # Step 3: Create new source application (folder is a query param, not payload) + setting_data: dict[str, Any] = { + "name": name, + "applications": applications or [], + } + if description is not None: + setting_data["description"] = description + + result = self.client.forwarding_profile_source_application.create(setting_data, folder=folder) + self.logger.info(f"Successfully created forwarding profile source application '{name}' in folder '{folder}'") + response = json.loads(result.model_dump_json(exclude_unset=True)) + response["__action__"] = "created" + return response + + except Exception as e: + self._handle_api_exception("create/update", folder or "", name or "", e) + + def get_forwarding_profile_source_application( + self, + folder: str | None = None, + name: str = None, + ) -> dict[str, Any]: + """Get a forwarding profile source application by name. + + Args: + folder: Folder containing the source application (must be "Mobile Users") + name: Name of the source application to get + + Returns: + dict[str, Any]: The source application object + + """ + self.logger.info(f"Getting forwarding profile source application: {name} from folder {folder}") + + if not self.client: + # Return mock data if no client is available + return { + "id": f"fpsa-{name}", + "folder": folder or "Mobile Users", + "name": name, + "description": f"Mock forwarding profile source application {name}", + "applications": ["slack", "zoom"], + } + + try: + result = self.client.forwarding_profile_source_application.fetch(name=name, folder=folder) + + if result is not None: + return json.loads(result.model_dump_json(exclude_unset=True)) + else: + raise ValueError(f"Forwarding profile source application '{name}' not found") + except Exception as e: + self._handle_api_exception("getting", folder or "", name or "", e) + + def list_forwarding_profile_source_applications( + self, + folder: str | None = None, + ) -> list[dict[str, Any]]: + """List forwarding profile source applications. + + Args: + folder: Folder to list from (must be "Mobile Users") + + Returns: + list[dict[str, Any]]: List of source application objects + + """ + self.logger.info(f"Listing forwarding profile source applications in folder: {folder}") + + if not self.client: + # Return mock data if no client is available + return [ + { + "id": "fpsa-mock1", + "folder": folder or "Mobile Users", + "name": "office-apps", + "description": "Office applications", + "applications": ["slack", "zoom"], + }, + ] + + try: + results = self.client.forwarding_profile_source_application.list(folder=folder) + return [json.loads(result.model_dump_json(exclude_unset=True)) for result in results] + except Exception as e: + self._handle_api_exception("listing", folder or "", "forwarding profile source applications", e) + + def delete_forwarding_profile_source_application( + self, + folder: str | None = None, + name: str = None, + ) -> bool: + """Delete a forwarding profile source application. + + Args: + folder: Folder containing the source application (must be "Mobile Users") + name: Name of the source application to delete + + Returns: + bool: True if deletion was successful + + """ + self.logger.info(f"Deleting forwarding profile source application: {name} from folder {folder}") + + if not self.client: + return True + + try: + # Get the source application first to get its ID + setting = self.client.forwarding_profile_source_application.fetch(name=name, folder=folder) + if setting is None: + raise ValueError(f"Forwarding profile source application '{name}' not found") + self.client.forwarding_profile_source_application.delete(str(setting.id)) + return True + except Exception as e: + self._handle_api_exception("deletion", folder or "", name or "", e) + + # ------------------------------------------------------------------------- Forwarding Profile User Location ------------------------------------------------------------------------- + + def create_forwarding_profile_user_location( + self, + folder: str | None = None, + name: str = None, + description: str | None = None, + choice: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Create or update a forwarding profile user location using smart upsert logic. + + Args: + folder: Folder to create the user location in (must be "Mobile Users") + name: Name of the user location + description: Optional description + choice: Location matching criteria (internal_host_detection or ip_addresses) + + Returns: + dict[str, Any]: The created/updated user location object with __action__ field + + """ + self.logger.info(f"Creating or updating forwarding profile user location: {name} in folder {folder}") + + if not self.client: + # Return mock data if no client is available + result = { + "id": f"fpul-{name}", + "folder": folder, + "name": name, + "description": description, + "choice": choice, + "__action__": "created", + } + return {k: v for k, v in result.items() if v is not None} + + try: + # Step 1: Try to fetch existing user location + existing = None + try: + existing = self.client.forwarding_profile_user_location.fetch(name=name, folder=folder) + self.logger.info(f"Found existing forwarding profile user location '{name}' in folder '{folder}'") + except NotFoundError: + self.logger.info(f"Forwarding profile user location '{name}' not found in folder '{folder}', will create new") + except Exception as fetch_error: + self.logger.warning(f"Error fetching forwarding profile user location '{name}': {str(fetch_error)}") + + if existing: + # Step 2: Compare fields and update if needed + needs_update = False + update_fields = [] + + if description is not None and getattr(existing, "description", None) != description: + existing.description = description + update_fields.append("description") + needs_update = True + + if choice is not None: + existing_choice = json.loads(existing.choice.model_dump_json(exclude_none=True)) if getattr(existing, "choice", None) else None + if existing_choice != choice: + existing.choice = choice + update_fields.append("choice") + needs_update = True + + if needs_update: + self.logger.info(f"Updating forwarding profile user location fields: {', '.join(update_fields)}") + result = self.client.forwarding_profile_user_location.update(existing) + self.logger.info(f"Successfully updated forwarding profile user location '{name}' in folder '{folder}'") + response = json.loads(result.model_dump_json(exclude_unset=True)) + response["__action__"] = "updated" + return response + else: + self.logger.info(f"No changes detected for forwarding profile user location '{name}', skipping update") + response = json.loads(existing.model_dump_json(exclude_unset=True)) + response["__action__"] = "no_change" + return response + else: + # Step 3: Create new user location (folder is a query param, not payload) + setting_data: dict[str, Any] = { + "name": name, + "choice": choice or {}, + } + if description is not None: + setting_data["description"] = description + + result = self.client.forwarding_profile_user_location.create(setting_data, folder=folder) + self.logger.info(f"Successfully created forwarding profile user location '{name}' in folder '{folder}'") + response = json.loads(result.model_dump_json(exclude_unset=True)) + response["__action__"] = "created" + return response + + except Exception as e: + self._handle_api_exception("create/update", folder or "", name or "", e) + + def get_forwarding_profile_user_location( + self, + folder: str | None = None, + name: str = None, + ) -> dict[str, Any]: + """Get a forwarding profile user location by name. + + Args: + folder: Folder containing the user location (must be "Mobile Users") + name: Name of the user location to get + + Returns: + dict[str, Any]: The user location object + + """ + self.logger.info(f"Getting forwarding profile user location: {name} from folder {folder}") + + if not self.client: + # Return mock data if no client is available + return { + "id": f"fpul-{name}", + "folder": folder or "Mobile Users", + "name": name, + "description": f"Mock forwarding profile user location {name}", + "choice": {"ip_addresses": [{"name": "10.1.0.0/16"}]}, + } + + try: + result = self.client.forwarding_profile_user_location.fetch(name=name, folder=folder) + + if result is not None: + return json.loads(result.model_dump_json(exclude_unset=True)) + else: + raise ValueError(f"Forwarding profile user location '{name}' not found") + except Exception as e: + self._handle_api_exception("getting", folder or "", name or "", e) + + def list_forwarding_profile_user_locations( + self, + folder: str | None = None, + ) -> list[dict[str, Any]]: + """List forwarding profile user locations. + + Args: + folder: Folder to list from (must be "Mobile Users") + + Returns: + list[dict[str, Any]]: List of user location objects + + """ + self.logger.info(f"Listing forwarding profile user locations in folder: {folder}") + + if not self.client: + # Return mock data if no client is available + return [ + { + "id": "fpul-mock1", + "folder": folder or "Mobile Users", + "name": "branch-network", + "description": "Branch office network", + "choice": {"ip_addresses": [{"name": "10.1.0.0/16"}]}, + }, + ] + + try: + results = self.client.forwarding_profile_user_location.list(folder=folder) + return [json.loads(result.model_dump_json(exclude_unset=True)) for result in results] + except Exception as e: + self._handle_api_exception("listing", folder or "", "forwarding profile user locations", e) + + def delete_forwarding_profile_user_location( + self, + folder: str | None = None, + name: str = None, + ) -> bool: + """Delete a forwarding profile user location. + + Args: + folder: Folder containing the user location (must be "Mobile Users") + name: Name of the user location to delete + + Returns: + bool: True if deletion was successful + + """ + self.logger.info(f"Deleting forwarding profile user location: {name} from folder {folder}") + + if not self.client: + return True + + try: + # Get the user location first to get its ID + setting = self.client.forwarding_profile_user_location.fetch(name=name, folder=folder) + if setting is None: + raise ValueError(f"Forwarding profile user location '{name}' not found") + self.client.forwarding_profile_user_location.delete(str(setting.id)) + return True + except Exception as e: + self._handle_api_exception("deletion", folder or "", name or "", e) + + # ------------------------------------------------------------------ Forwarding Profile Regional and Custom Proxy ------------------------------------------------------------------ + + def create_forwarding_profile_regional_and_custom_proxy( + self, + folder: str | None = None, + name: str = None, + description: str | None = None, + type: str | None = None, + proxy_1: dict[str, Any] | None = None, + proxy_2: dict[str, Any] | None = None, + connectivity_preference: list[dict[str, Any]] | None = None, + fallback_option: str | None = None, + location_preference: str | None = None, + prisma_access_locations: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Create or update a forwarding profile regional and custom proxy using smart upsert logic. + + Args: + folder: Folder to create the regional and custom proxy in (must be "Mobile Users") + name: Name of the regional and custom proxy + description: Optional description + type: Proxy type (gp-and-pac, ztna-agent) + proxy_1: Primary proxy server (fqdn, port, location) + proxy_2: Secondary proxy server (fqdn, port, location) + connectivity_preference: Connectivity preference entries (name, enabled) + fallback_option: Fallback option (fail-open, fail-safe) + location_preference: Location preference + prisma_access_locations: Prisma Access locations (name, locations) + + Returns: + dict[str, Any]: The created/updated regional and custom proxy object with __action__ field + + """ + self.logger.info(f"Creating or updating forwarding profile regional and custom proxy: {name} in folder {folder}") + + if not self.client: + # Return mock data if no client is available + result = { + "id": f"fprcp-{name}", + "folder": folder, + "name": name, + "description": description, + "type": type, + "proxy_1": proxy_1, + "proxy_2": proxy_2, + "connectivity_preference": connectivity_preference, + "fallback_option": fallback_option, + "location_preference": location_preference, + "prisma_access_locations": prisma_access_locations, + "__action__": "created", + } + return {k: v for k, v in result.items() if v is not None} + + def nested_dump(value: Any) -> Any: + """Dump nested SDK models to plain data for comparison.""" + if value is None: + return None + if isinstance(value, list): + return [json.loads(item.model_dump_json(exclude_none=True)) for item in value] + return json.loads(value.model_dump_json(exclude_none=True)) + + try: + # Step 1: Try to fetch existing regional and custom proxy + existing = None + try: + existing = self.client.forwarding_profile_regional_and_custom_proxy.fetch(name=name, folder=folder) + self.logger.info(f"Found existing forwarding profile regional and custom proxy '{name}' in folder '{folder}'") + except NotFoundError: + self.logger.info(f"Forwarding profile regional and custom proxy '{name}' not found in folder '{folder}', will create new") + except Exception as fetch_error: + self.logger.warning(f"Error fetching forwarding profile regional and custom proxy '{name}': {str(fetch_error)}") + + if existing: + # Step 2: Compare fields and update if needed + needs_update = False + update_fields = [] + + scalar_fields = { + "description": description, + "type": type, + "fallback_option": fallback_option, + "location_preference": location_preference, + } + for field_name, new_value in scalar_fields.items(): + if new_value is not None and getattr(existing, field_name, None) != new_value: + setattr(existing, field_name, new_value) + update_fields.append(field_name) + needs_update = True + + nested_fields = { + "proxy_1": proxy_1, + "proxy_2": proxy_2, + "connectivity_preference": connectivity_preference, + "prisma_access_locations": prisma_access_locations, + } + for field_name, new_value in nested_fields.items(): + if new_value is not None and nested_dump(getattr(existing, field_name, None)) != new_value: + setattr(existing, field_name, new_value) + update_fields.append(field_name) + needs_update = True + + if needs_update: + self.logger.info(f"Updating forwarding profile regional and custom proxy fields: {', '.join(update_fields)}") + result = self.client.forwarding_profile_regional_and_custom_proxy.update(existing) + self.logger.info(f"Successfully updated forwarding profile regional and custom proxy '{name}' in folder '{folder}'") + response = json.loads(result.model_dump_json(exclude_unset=True)) + response["__action__"] = "updated" + return response + else: + self.logger.info(f"No changes detected for forwarding profile regional and custom proxy '{name}', skipping update") + response = json.loads(existing.model_dump_json(exclude_unset=True)) + response["__action__"] = "no_change" + return response + else: + # Step 3: Create new regional and custom proxy (folder is a query param, not payload) + setting_data: dict[str, Any] = { + "name": name, + } + if description is not None: + setting_data["description"] = description + if type is not None: + setting_data["type"] = type + if proxy_1 is not None: + setting_data["proxy_1"] = proxy_1 + if proxy_2 is not None: + setting_data["proxy_2"] = proxy_2 + if connectivity_preference is not None: + setting_data["connectivity_preference"] = connectivity_preference + if fallback_option is not None: + setting_data["fallback_option"] = fallback_option + if location_preference is not None: + setting_data["location_preference"] = location_preference + if prisma_access_locations is not None: + setting_data["prisma_access_locations"] = prisma_access_locations + + result = self.client.forwarding_profile_regional_and_custom_proxy.create(setting_data, folder=folder) + self.logger.info(f"Successfully created forwarding profile regional and custom proxy '{name}' in folder '{folder}'") + response = json.loads(result.model_dump_json(exclude_unset=True)) + response["__action__"] = "created" + return response + + except Exception as e: + self._handle_api_exception("create/update", folder or "", name or "", e) + + def get_forwarding_profile_regional_and_custom_proxy( + self, + folder: str | None = None, + name: str = None, + ) -> dict[str, Any]: + """Get a forwarding profile regional and custom proxy by name. + + Args: + folder: Folder containing the regional and custom proxy (must be "Mobile Users") + name: Name of the regional and custom proxy to get + + Returns: + dict[str, Any]: The regional and custom proxy object + + """ + self.logger.info(f"Getting forwarding profile regional and custom proxy: {name} from folder {folder}") + + if not self.client: + # Return mock data if no client is available + return { + "id": f"fprcp-{name}", + "folder": folder or "Mobile Users", + "name": name, + "description": f"Mock forwarding profile regional and custom proxy {name}", + "type": "gp-and-pac", + "proxy_1": {"fqdn": "proxy1.example.com", "port": 8080}, + } + + try: + result = self.client.forwarding_profile_regional_and_custom_proxy.fetch(name=name, folder=folder) + + if result is not None: + return json.loads(result.model_dump_json(exclude_unset=True)) + else: + raise ValueError(f"Forwarding profile regional and custom proxy '{name}' not found") + except Exception as e: + self._handle_api_exception("getting", folder or "", name or "", e) + + def list_forwarding_profile_regional_and_custom_proxies( + self, + folder: str | None = None, + ) -> list[dict[str, Any]]: + """List forwarding profile regional and custom proxies. + + Args: + folder: Folder to list from (must be "Mobile Users") + + Returns: + list[dict[str, Any]]: List of regional and custom proxy objects + + """ + self.logger.info(f"Listing forwarding profile regional and custom proxies in folder: {folder}") + + if not self.client: + # Return mock data if no client is available + return [ + { + "id": "fprcp-mock1", + "folder": folder or "Mobile Users", + "name": "emea-proxy", + "description": "EMEA regional proxy", + "type": "gp-and-pac", + }, + ] + + try: + results = self.client.forwarding_profile_regional_and_custom_proxy.list(folder=folder) + return [json.loads(result.model_dump_json(exclude_unset=True)) for result in results] + except Exception as e: + self._handle_api_exception("listing", folder or "", "forwarding profile regional and custom proxies", e) + + def delete_forwarding_profile_regional_and_custom_proxy( + self, + folder: str | None = None, + name: str = None, + ) -> bool: + """Delete a forwarding profile regional and custom proxy. + + Args: + folder: Folder containing the regional and custom proxy (must be "Mobile Users") + name: Name of the regional and custom proxy to delete + + Returns: + bool: True if deletion was successful + + """ + self.logger.info(f"Deleting forwarding profile regional and custom proxy: {name} from folder {folder}") + + if not self.client: + return True + + try: + # Get the regional and custom proxy first to get its ID + setting = self.client.forwarding_profile_regional_and_custom_proxy.fetch(name=name, folder=folder) + if setting is None: + raise ValueError(f"Forwarding profile regional and custom proxy '{name}' not found") + self.client.forwarding_profile_regional_and_custom_proxy.delete(str(setting.id)) + return True + except Exception as e: + self._handle_api_exception("deletion", folder or "", name or "", e) + # ====================================================================================================================================================================================== # SETUP CONFIGURATION METHODS # ====================================================================================================================================================================================== diff --git a/src/scm_cli/utils/validators.py b/src/scm_cli/utils/validators.py index d54479e..5cd46ae 100644 --- a/src/scm_cli/utils/validators.py +++ b/src/scm_cli/utils/validators.py @@ -5087,6 +5087,174 @@ def to_sdk_model(self) -> dict[str, Any]: # ---------------------------------------------------- agent2: GlobalProtect infrastructure/global settings (end) ---------------------------------------------------- +# ============================================================================================================================================================================================= +# GLOBALPROTECT FORWARDING PROFILE SUB-RESOURCE MODELS +# ============================================================================================================================================================================================= + + +class ForwardingProfileSourceApplication(BaseModel): + """Model for mobile agent forwarding profile source application configurations.""" + + name: str = Field(..., description="Name of the source application") + folder: str | None = Field(None, description="Folder path (only 'Mobile Users' is supported)") + description: str | None = Field(None, description="Description of the source application") + applications: list[str] = Field(..., description="List of applications") + + @model_validator(mode="after") + def validate_container(self) -> "ForwardingProfileSourceApplication": + """Validate that a folder is provided. + + Returns: + The validated source application model + + Raises: + ValueError: If no folder is provided + + """ + if not self.folder: + raise ValueError("folder must be provided (only 'Mobile Users' is supported)") + return self + + def to_sdk_model(self) -> dict[str, Any]: + """Convert CLI model to SDK model format. + + Returns: + dict[str, Any]: SDK-compatible dictionary + + """ + model_data: dict[str, Any] = { + "name": self.name, + "folder": self.folder, + "applications": self.applications, + } + + if self.description is not None: + model_data["description"] = self.description + + return model_data + + +class ForwardingProfileUserLocation(BaseModel): + """Model for mobile agent forwarding profile user location configurations.""" + + name: str = Field(..., description="Name of the user location") + folder: str | None = Field(None, description="Folder path (only 'Mobile Users' is supported)") + description: str | None = Field(None, description="Description of the user location") + internal_host_ip: str | None = Field(None, description="Internal host detection IP address") + internal_host_fqdn: str | None = Field(None, description="Internal host detection FQDN") + ip_addresses: list[str] | None = Field(None, description="List of user location IP addresses") + + @model_validator(mode="after") + def validate_container(self) -> "ForwardingProfileUserLocation": + """Validate folder and that exactly one location matching criteria is provided. + + Returns: + The validated user location model + + Raises: + ValueError: If no folder is provided or the choice fields are invalid + + """ + if not self.folder: + raise ValueError("folder must be provided (only 'Mobile Users' is supported)") + + has_internal_host = bool(self.internal_host_ip or self.internal_host_fqdn) + has_ip_addresses = bool(self.ip_addresses) + if has_internal_host == has_ip_addresses: + raise ValueError("Exactly one of internal host detection (internal_host_ip/internal_host_fqdn) or ip_addresses must be provided") + return self + + def to_sdk_model(self) -> dict[str, Any]: + """Convert CLI model to SDK model format. + + Returns: + dict[str, Any]: SDK-compatible dictionary + + """ + choice: dict[str, Any] + if self.ip_addresses: + choice = {"ip_addresses": [{"name": ip} for ip in self.ip_addresses]} + else: + internal_host_detection: dict[str, Any] = {} + if self.internal_host_ip is not None: + internal_host_detection["ip_address"] = self.internal_host_ip + if self.internal_host_fqdn is not None: + internal_host_detection["fqdn"] = self.internal_host_fqdn + choice = {"internal_host_detection": internal_host_detection} + + model_data: dict[str, Any] = { + "name": self.name, + "folder": self.folder, + "choice": choice, + } + + if self.description is not None: + model_data["description"] = self.description + + return model_data + + +class ForwardingProfileRegionalAndCustomProxy(BaseModel): + """Model for mobile agent forwarding profile regional and custom proxy configurations.""" + + name: str = Field(..., description="Name of the regional and custom proxy") + folder: str | None = Field(None, description="Folder path (only 'Mobile Users' is supported)") + description: str | None = Field(None, description="Description of the regional and custom proxy") + type: str | None = Field(None, description="Proxy type (gp-and-pac, ztna-agent)") + proxy_1: dict[str, Any] | None = Field(None, description="Primary proxy server (fqdn, port, location)") + proxy_2: dict[str, Any] | None = Field(None, description="Secondary proxy server (fqdn, port, location)") + connectivity_preference: list[dict[str, Any]] | None = Field(None, description="Connectivity preference entries (name, enabled)") + fallback_option: str | None = Field(None, description="Fallback option (fail-open, fail-safe)") + location_preference: str | None = Field(None, description="Location preference (best-available-pa-location, specific-pa-location)") + prisma_access_locations: list[dict[str, Any]] | None = Field(None, description="Prisma Access locations (name, locations)") + + @model_validator(mode="after") + def validate_container(self) -> "ForwardingProfileRegionalAndCustomProxy": + """Validate that a folder is provided. + + Returns: + The validated regional and custom proxy model + + Raises: + ValueError: If no folder is provided + + """ + if not self.folder: + raise ValueError("folder must be provided (only 'Mobile Users' is supported)") + return self + + def to_sdk_model(self) -> dict[str, Any]: + """Convert CLI model to SDK model format. + + Returns: + dict[str, Any]: SDK-compatible dictionary + + """ + model_data: dict[str, Any] = { + "name": self.name, + "folder": self.folder, + } + + if self.description is not None: + model_data["description"] = self.description + if self.type is not None: + model_data["type"] = self.type + if self.proxy_1 is not None: + model_data["proxy_1"] = self.proxy_1 + if self.proxy_2 is not None: + model_data["proxy_2"] = self.proxy_2 + if self.connectivity_preference is not None: + model_data["connectivity_preference"] = self.connectivity_preference + if self.fallback_option is not None: + model_data["fallback_option"] = self.fallback_option + if self.location_preference is not None: + model_data["location_preference"] = self.location_preference + if self.prisma_access_locations is not None: + model_data["prisma_access_locations"] = self.prisma_access_locations + + return model_data + + # ============================================================================================================================================================================================= # INSIGHTS AND MONITORING MODELS # ============================================================================================================================================================================================= diff --git a/tests/test_forwarding_subresource_commands.py b/tests/test_forwarding_subresource_commands.py new file mode 100644 index 0000000..4c30614 --- /dev/null +++ b/tests/test_forwarding_subresource_commands.py @@ -0,0 +1,1071 @@ +"""Tests for the GlobalProtect forwarding profile sub-resource commands. + +Covers forwarding-profile-source-application, forwarding-profile-user-location, +and forwarding-profile-regional-and-custom-proxy commands. +""" + +import typer # noqa: I001 +from scm_cli.commands.mobile_agent import ( + backup_forwarding_profile_regional_and_custom_proxy, + backup_forwarding_profile_source_application, + backup_forwarding_profile_user_location, + delete_forwarding_profile_regional_and_custom_proxy, + delete_forwarding_profile_source_application, + delete_forwarding_profile_user_location, + load_forwarding_profile_regional_and_custom_proxy, + load_forwarding_profile_source_application, + load_forwarding_profile_user_location, + set_forwarding_profile_regional_and_custom_proxy, + set_forwarding_profile_source_application, + set_forwarding_profile_user_location, + show_forwarding_profile_regional_and_custom_proxy, + show_forwarding_profile_source_application, + show_forwarding_profile_user_location, +) + + +class TestForwardingProfileSourceApplicationCommands: + """Test the forwarding profile source application commands.""" + + def test_set_source_application(self, runner, monkeypatch): + """Test creating a forwarding profile source application.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fpsa-1", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "applications": kwargs.get("applications"), + "__action__": "created", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_source_application", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "office-apps", + "--application", "slack", + "--application", "zoom", + ], + ) + + assert result.exit_code == 0 + assert "Created forwarding profile source application" in result.stdout + assert "office-apps" in result.stdout + + def test_set_source_application_update(self, runner, monkeypatch): + """Test updating a forwarding profile source application.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fpsa-1", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "__action__": "updated", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_source_application", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "office-apps", + "--application", "slack", + ], + ) + + assert result.exit_code == 0 + assert "Updated forwarding profile source application" in result.stdout + + def test_set_source_application_no_change(self, runner, monkeypatch): + """Test set source application with no changes.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fpsa-1", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "__action__": "no_change", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_source_application", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "office-apps", + "--application", "slack", + ], + ) + + assert result.exit_code == 0 + assert "No changes needed" in result.stdout + + def test_set_source_application_missing_folder(self, runner, monkeypatch): + """Test container validation error when folder is missing.""" + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + [ + "--name", "office-apps", + "--application", "slack", + ], + ) + + assert result.exit_code == 1 + + def test_show_source_application_list(self, runner, monkeypatch): + """Test listing forwarding profile source applications.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [ + { + "id": "fpsa-1", + "folder": "Mobile Users", + "name": "office-apps", + "applications": ["slack", "zoom"], + }, + { + "id": "fpsa-2", + "folder": "Mobile Users", + "name": "dev-apps", + "applications": ["github"], + }, + ] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_source_applications", mock_list) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "office-apps" in result.stdout + assert "dev-apps" in result.stdout + + def test_show_source_application_detail(self, runner, monkeypatch): + """Test showing a specific forwarding profile source application.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_get(*args, **kwargs): + return { + "id": "fpsa-1", + "folder": "Mobile Users", + "name": "office-apps", + "description": "Office applications", + "applications": ["slack", "zoom"], + } + + monkeypatch.setattr(scm_client, "get_forwarding_profile_source_application", mock_get) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "office-apps"], + ) + + assert result.exit_code == 0 + assert "office-apps" in result.stdout + assert "slack" in result.stdout + + def test_show_source_application_empty(self, runner, monkeypatch): + """Test listing source applications when none exist.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_source_applications", mock_list) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "No forwarding profile source applications found" in result.stdout + + def test_delete_source_application(self, runner, monkeypatch): + """Test deleting a forwarding profile source application.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_delete(*args, **kwargs): + return True + + monkeypatch.setattr(scm_client, "delete_forwarding_profile_source_application", mock_delete) + + test_app = typer.Typer() + test_app.command()(delete_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "office-apps", "--force"], + ) + + assert result.exit_code == 0 + assert "Deleted forwarding profile source application" in result.stdout + + def test_delete_source_application_error(self, runner, monkeypatch): + """Test deleting a source application with an error.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_error(*args, **kwargs): + raise Exception("Not found") + + monkeypatch.setattr(scm_client, "delete_forwarding_profile_source_application", mock_error) + + test_app = typer.Typer() + test_app.command()(delete_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "nonexistent", "--force"], + ) + + assert result.exit_code == 1 + + def test_load_source_application(self, runner, monkeypatch, tmp_path): + """Test loading source applications from YAML.""" + from scm_cli.utils.sdk_client import scm_client + + yaml_content = """ +forwarding_profile_source_applications: + - name: office-apps + folder: "Mobile Users" + applications: + - slack + - zoom + - name: dev-apps + folder: "Mobile Users" + applications: + - github +""" + test_file = tmp_path / "source_applications.yml" + test_file.write_text(yaml_content) + + def mock_create(*args, **kwargs): + return { + "name": kwargs.get("name"), + "folder": kwargs.get("folder"), + "__action__": "created", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_source_application", mock_create) + + test_app = typer.Typer() + test_app.command()(load_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--file", str(test_file)], + ) + + assert result.exit_code == 0 + assert "Created forwarding profile source application" in result.stdout + assert "2 created" in result.stdout + + def test_load_source_application_dry_run(self, runner, monkeypatch, tmp_path): + """Test loading source applications in dry run mode.""" + yaml_content = """ +forwarding_profile_source_applications: + - name: office-apps + folder: "Mobile Users" + applications: + - slack +""" + test_file = tmp_path / "source_applications.yml" + test_file.write_text(yaml_content) + + test_app = typer.Typer() + test_app.command()(load_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--file", str(test_file), "--dry-run"], + ) + + assert result.exit_code == 0 + assert "Dry run mode" in result.stdout + + def test_backup_source_application(self, runner, monkeypatch, tmp_path): + """Test backing up source applications.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [ + { + "id": "fpsa-1", + "folder": "Mobile Users", + "name": "office-apps", + "applications": ["slack", "zoom"], + }, + ] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_source_applications", mock_list) + monkeypatch.chdir(tmp_path) + + test_app = typer.Typer() + test_app.command()(backup_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "Successfully backed up" in result.stdout + assert "1 forwarding profile source applications" in result.stdout + + def test_set_source_application_error(self, runner, monkeypatch): + """Test set source application with an API error.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_error(*args, **kwargs): + raise Exception("API error") + + monkeypatch.setattr(scm_client, "create_forwarding_profile_source_application", mock_error) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_source_application) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "fail-apps", + "--application", "slack", + ], + ) + + assert result.exit_code == 1 + + +class TestForwardingProfileUserLocationCommands: + """Test the forwarding profile user location commands.""" + + def test_set_user_location_ip_addresses(self, runner, monkeypatch): + """Test creating a user location with IP address entries.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fpul-1", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "choice": kwargs.get("choice"), + "__action__": "created", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_user_location", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "branch-network", + "--ip-address", "10.1.0.0/16", + "--ip-address", "10.2.*.*", + ], + ) + + assert result.exit_code == 0 + assert "Created forwarding profile user location" in result.stdout + assert "branch-network" in result.stdout + + def test_set_user_location_internal_host(self, runner, monkeypatch): + """Test creating a user location with internal host detection.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fpul-2", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "choice": kwargs.get("choice"), + "__action__": "created", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_user_location", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "corp-office", + "--internal-host-ip", "192.168.1.1", + "--internal-host-fqdn", "intranet.example.com", + ], + ) + + assert result.exit_code == 0 + assert "Created forwarding profile user location" in result.stdout + + def test_set_user_location_update(self, runner, monkeypatch): + """Test updating a user location.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fpul-1", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "__action__": "updated", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_user_location", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "branch-network", + "--ip-address", "10.1.0.0/16", + ], + ) + + assert result.exit_code == 0 + assert "Updated forwarding profile user location" in result.stdout + + def test_set_user_location_choice_conflict(self, runner, monkeypatch): + """Test validation error when both choice variants are provided.""" + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "bad-location", + "--ip-address", "10.1.0.0/16", + "--internal-host-fqdn", "intranet.example.com", + ], + ) + + assert result.exit_code == 1 + + def test_set_user_location_choice_missing(self, runner, monkeypatch): + """Test validation error when no choice variant is provided.""" + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "bad-location", + ], + ) + + assert result.exit_code == 1 + + def test_set_user_location_missing_folder(self, runner, monkeypatch): + """Test container validation error when folder is missing.""" + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + [ + "--name", "branch-network", + "--ip-address", "10.1.0.0/16", + ], + ) + + assert result.exit_code == 1 + + def test_show_user_location_list(self, runner, monkeypatch): + """Test listing user locations.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [ + { + "id": "fpul-1", + "folder": "Mobile Users", + "name": "branch-network", + "choice": {"ip_addresses": [{"name": "10.1.0.0/16"}]}, + }, + { + "id": "fpul-2", + "folder": "Mobile Users", + "name": "corp-office", + "choice": {"internal_host_detection": {"fqdn": "intranet.example.com"}}, + }, + ] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_user_locations", mock_list) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "branch-network" in result.stdout + assert "corp-office" in result.stdout + + def test_show_user_location_detail(self, runner, monkeypatch): + """Test showing a specific user location.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_get(*args, **kwargs): + return { + "id": "fpul-1", + "folder": "Mobile Users", + "name": "branch-network", + "description": "Branch office network", + "choice": {"ip_addresses": [{"name": "10.1.0.0/16"}]}, + } + + monkeypatch.setattr(scm_client, "get_forwarding_profile_user_location", mock_get) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "branch-network"], + ) + + assert result.exit_code == 0 + assert "branch-network" in result.stdout + assert "10.1.0.0/16" in result.stdout + + def test_show_user_location_empty(self, runner, monkeypatch): + """Test listing user locations when none exist.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_user_locations", mock_list) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "No forwarding profile user locations found" in result.stdout + + def test_delete_user_location(self, runner, monkeypatch): + """Test deleting a user location.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_delete(*args, **kwargs): + return True + + monkeypatch.setattr(scm_client, "delete_forwarding_profile_user_location", mock_delete) + + test_app = typer.Typer() + test_app.command()(delete_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "branch-network", "--force"], + ) + + assert result.exit_code == 0 + assert "Deleted forwarding profile user location" in result.stdout + + def test_delete_user_location_error(self, runner, monkeypatch): + """Test deleting a user location with an error.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_error(*args, **kwargs): + raise Exception("Not found") + + monkeypatch.setattr(scm_client, "delete_forwarding_profile_user_location", mock_error) + + test_app = typer.Typer() + test_app.command()(delete_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "nonexistent", "--force"], + ) + + assert result.exit_code == 1 + + def test_load_user_location(self, runner, monkeypatch, tmp_path): + """Test loading user locations from YAML.""" + from scm_cli.utils.sdk_client import scm_client + + yaml_content = """ +forwarding_profile_user_locations: + - name: branch-network + folder: "Mobile Users" + ip_addresses: + - 10.1.0.0/16 + - name: corp-office + folder: "Mobile Users" + internal_host_fqdn: intranet.example.com +""" + test_file = tmp_path / "user_locations.yml" + test_file.write_text(yaml_content) + + def mock_create(*args, **kwargs): + return { + "name": kwargs.get("name"), + "folder": kwargs.get("folder"), + "__action__": "created", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_user_location", mock_create) + + test_app = typer.Typer() + test_app.command()(load_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--file", str(test_file)], + ) + + assert result.exit_code == 0 + assert "Created forwarding profile user location" in result.stdout + assert "2 created" in result.stdout + + def test_load_user_location_dry_run(self, runner, monkeypatch, tmp_path): + """Test loading user locations in dry run mode.""" + yaml_content = """ +forwarding_profile_user_locations: + - name: branch-network + folder: "Mobile Users" + ip_addresses: + - 10.1.0.0/16 +""" + test_file = tmp_path / "user_locations.yml" + test_file.write_text(yaml_content) + + test_app = typer.Typer() + test_app.command()(load_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--file", str(test_file), "--dry-run"], + ) + + assert result.exit_code == 0 + assert "Dry run mode" in result.stdout + + def test_backup_user_location(self, runner, monkeypatch, tmp_path): + """Test backing up user locations.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [ + { + "id": "fpul-1", + "folder": "Mobile Users", + "name": "branch-network", + "choice": {"ip_addresses": [{"name": "10.1.0.0/16"}]}, + }, + ] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_user_locations", mock_list) + monkeypatch.chdir(tmp_path) + + test_app = typer.Typer() + test_app.command()(backup_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "Successfully backed up" in result.stdout + assert "1 forwarding profile user locations" in result.stdout + + def test_set_user_location_error(self, runner, monkeypatch): + """Test set user location with an API error.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_error(*args, **kwargs): + raise Exception("API error") + + monkeypatch.setattr(scm_client, "create_forwarding_profile_user_location", mock_error) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_user_location) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "fail-location", + "--ip-address", "10.1.0.0/16", + ], + ) + + assert result.exit_code == 1 + + +class TestForwardingProfileRegionalAndCustomProxyCommands: + """Test the forwarding profile regional and custom proxy commands.""" + + def test_set_regional_proxy(self, runner, monkeypatch): + """Test creating a regional and custom proxy.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fprcp-1", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "type": kwargs.get("type"), + "__action__": "created", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_regional_and_custom_proxy", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "emea-proxy", + "--type", "gp-and-pac", + "--proxy-1-fqdn", "proxy1.example.com", + "--proxy-1-port", "8080", + "--fallback-option", "fail-open", + ], + ) + + assert result.exit_code == 0 + assert "Created forwarding profile regional and custom proxy" in result.stdout + assert "emea-proxy" in result.stdout + + def test_set_regional_proxy_update(self, runner, monkeypatch): + """Test updating a regional and custom proxy.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_create(*args, **kwargs): + return { + "id": "fprcp-1", + "folder": kwargs.get("folder"), + "name": kwargs.get("name"), + "__action__": "updated", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_regional_and_custom_proxy", mock_create) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "emea-proxy", + "--proxy-1-fqdn", "proxy2.example.com", + ], + ) + + assert result.exit_code == 0 + assert "Updated forwarding profile regional and custom proxy" in result.stdout + + def test_set_regional_proxy_missing_folder(self, runner, monkeypatch): + """Test container validation error when folder is missing.""" + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--name", "emea-proxy"], + ) + + assert result.exit_code == 1 + + def test_show_regional_proxy_list(self, runner, monkeypatch): + """Test listing regional and custom proxies.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [ + { + "id": "fprcp-1", + "folder": "Mobile Users", + "name": "emea-proxy", + "type": "gp-and-pac", + }, + { + "id": "fprcp-2", + "folder": "Mobile Users", + "name": "ztna-proxy", + "type": "ztna-agent", + }, + ] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_regional_and_custom_proxies", mock_list) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "emea-proxy" in result.stdout + assert "ztna-proxy" in result.stdout + + def test_show_regional_proxy_detail(self, runner, monkeypatch): + """Test showing a specific regional and custom proxy.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_get(*args, **kwargs): + return { + "id": "fprcp-1", + "folder": "Mobile Users", + "name": "emea-proxy", + "description": "EMEA regional proxy", + "type": "gp-and-pac", + "proxy_1": {"fqdn": "proxy1.example.com", "port": 8080}, + "fallback_option": "fail-open", + "location_preference": "best-available-pa-location", + } + + monkeypatch.setattr(scm_client, "get_forwarding_profile_regional_and_custom_proxy", mock_get) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "emea-proxy"], + ) + + assert result.exit_code == 0 + assert "emea-proxy" in result.stdout + assert "proxy1.example.com" in result.stdout + + def test_show_regional_proxy_empty(self, runner, monkeypatch): + """Test listing regional and custom proxies when none exist.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_regional_and_custom_proxies", mock_list) + + test_app = typer.Typer() + test_app.command()(show_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "No forwarding profile regional and custom proxies found" in result.stdout + + def test_delete_regional_proxy(self, runner, monkeypatch): + """Test deleting a regional and custom proxy.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_delete(*args, **kwargs): + return True + + monkeypatch.setattr(scm_client, "delete_forwarding_profile_regional_and_custom_proxy", mock_delete) + + test_app = typer.Typer() + test_app.command()(delete_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "emea-proxy", "--force"], + ) + + assert result.exit_code == 0 + assert "Deleted forwarding profile regional and custom proxy" in result.stdout + + def test_delete_regional_proxy_error(self, runner, monkeypatch): + """Test deleting a regional and custom proxy with an error.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_error(*args, **kwargs): + raise Exception("Not found") + + monkeypatch.setattr(scm_client, "delete_forwarding_profile_regional_and_custom_proxy", mock_error) + + test_app = typer.Typer() + test_app.command()(delete_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users", "--name", "nonexistent", "--force"], + ) + + assert result.exit_code == 1 + + def test_load_regional_proxy(self, runner, monkeypatch, tmp_path): + """Test loading regional and custom proxies from YAML, including nested fields.""" + from scm_cli.utils.sdk_client import scm_client + + yaml_content = """ +forwarding_profile_regional_and_custom_proxies: + - name: emea-proxy + folder: "Mobile Users" + type: gp-and-pac + proxy_1: + fqdn: proxy1.example.com + port: 8080 + connectivity_preference: + - name: tunnel + enabled: true + - name: proxy + enabled: false + fallback_option: fail-open + - name: ztna-proxy + folder: "Mobile Users" + type: ztna-agent + location_preference: specific-pa-location + prisma_access_locations: + - name: europe + locations: + - "Frankfurt" +""" + test_file = tmp_path / "regional_proxies.yml" + test_file.write_text(yaml_content) + + def mock_create(*args, **kwargs): + return { + "name": kwargs.get("name"), + "folder": kwargs.get("folder"), + "__action__": "created", + } + + monkeypatch.setattr(scm_client, "create_forwarding_profile_regional_and_custom_proxy", mock_create) + + test_app = typer.Typer() + test_app.command()(load_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--file", str(test_file)], + ) + + assert result.exit_code == 0 + assert "Created forwarding profile regional and custom proxy" in result.stdout + assert "2 created" in result.stdout + + def test_load_regional_proxy_dry_run(self, runner, monkeypatch, tmp_path): + """Test loading regional and custom proxies in dry run mode.""" + yaml_content = """ +forwarding_profile_regional_and_custom_proxies: + - name: emea-proxy + folder: "Mobile Users" + type: gp-and-pac +""" + test_file = tmp_path / "regional_proxies.yml" + test_file.write_text(yaml_content) + + test_app = typer.Typer() + test_app.command()(load_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--file", str(test_file), "--dry-run"], + ) + + assert result.exit_code == 0 + assert "Dry run mode" in result.stdout + + def test_backup_regional_proxy(self, runner, monkeypatch, tmp_path): + """Test backing up regional and custom proxies.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_list(*args, **kwargs): + return [ + { + "id": "fprcp-1", + "folder": "Mobile Users", + "name": "emea-proxy", + "type": "gp-and-pac", + "proxy_1": {"fqdn": "proxy1.example.com", "port": 8080}, + }, + ] + + monkeypatch.setattr(scm_client, "list_forwarding_profile_regional_and_custom_proxies", mock_list) + monkeypatch.chdir(tmp_path) + + test_app = typer.Typer() + test_app.command()(backup_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + ["--folder", "Mobile Users"], + ) + + assert result.exit_code == 0 + assert "Successfully backed up" in result.stdout + assert "1 forwarding profile regional and custom proxies" in result.stdout + + def test_set_regional_proxy_error(self, runner, monkeypatch): + """Test set regional and custom proxy with an API error.""" + from scm_cli.utils.sdk_client import scm_client + + def mock_error(*args, **kwargs): + raise Exception("API error") + + monkeypatch.setattr(scm_client, "create_forwarding_profile_regional_and_custom_proxy", mock_error) + + test_app = typer.Typer() + test_app.command()(set_forwarding_profile_regional_and_custom_proxy) + + result = runner.invoke( + test_app, + [ + "--folder", "Mobile Users", + "--name", "fail-proxy", + ], + ) + + assert result.exit_code == 1