Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication
- `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early
- `MosPrerequisitesRequirementCheck` — validates MOS service principals before `a365 publish` proceeds

### Fixed
- macOS/Linux: device code fallback when browser authentication is unavailable (#309)
- Linux: MSAL fallback when PowerShell `Connect-MgGraph` fails in non-TTY environments (#309)
Expand Down
54 changes: 44 additions & 10 deletions src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
using Microsoft.Extensions.Logging;
using Microsoft.Agents.A365.DevTools.Cli.Constants;
using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
using Microsoft.Agents.A365.DevTools.Cli.Services;
using Microsoft.Agents.A365.DevTools.Cli.Services.Internal;
using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks;
using Microsoft.Agents.A365.DevTools.Cli.Models;
using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;

namespace Microsoft.Agents.A365.DevTools.Cli.Commands;

Expand All @@ -17,14 +20,22 @@ public class CleanupCommand
private const string AgenticUsersKey = "agentic users";
private const string IdentitySpsKey = "identity SPs";

/// <summary>
/// Returns the base requirement checks for cleanup operations:
/// Azure authentication only.
/// </summary>
public static List<Services.Requirements.IRequirementCheck> GetBaseChecks(AzureAuthValidator auth)
=> [new AzureAuthRequirementCheck(auth)];

public static Command CreateCommand(
ILogger<CleanupCommand> logger,
IConfigService configService,
IBotConfigurator botConfigurator,
CommandExecutor executor,
AgentBlueprintService agentBlueprintService,
IConfirmationProvider confirmationProvider,
FederatedCredentialService federatedCredentialService)
FederatedCredentialService federatedCredentialService,
AzureAuthValidator authValidator)
{
var cleanupCommand = new Command("cleanup", "Clean up ALL resources (blueprint, instance, Azure) - use subcommands for granular cleanup");

Expand Down Expand Up @@ -55,7 +66,7 @@ public static Command CreateCommand(

// Add subcommands for granular control
cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService));
cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor));
cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor, authValidator));
cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor));

return cleanupCommand;
Expand Down Expand Up @@ -301,13 +312,20 @@ private static Command CreateBlueprintCleanupCommand(
return command;
}

/// <summary>
/// Returns the requirement checks for the <c>cleanup azure</c> subcommand.
/// </summary>
internal static List<Services.Requirements.IRequirementCheck> GetAzureCleanupChecks(AzureAuthValidator auth)
=> GetBaseChecks(auth);

private static Command CreateAzureCleanupCommand(
ILogger<CleanupCommand> logger,
IConfigService configService,
CommandExecutor executor)
CommandExecutor executor,
AzureAuthValidator authValidator)
{
var command = new Command("azure", "Remove Azure resources (App Service, App Service Plan)");

var configOption = new Option<FileInfo?>(
new[] { "--config", "-c" },
"Path to configuration file")
Expand All @@ -319,18 +337,28 @@ private static Command CreateAzureCleanupCommand(
new[] { "--verbose", "-v" },
description: "Enable verbose logging");

var dryRunOption = new Option<bool>("--dry-run", "Show resources that would be deleted without making any changes");

command.AddOption(configOption);
command.AddOption(verboseOption);
command.AddOption(dryRunOption);

command.SetHandler(async (configFile, verbose) =>
command.SetHandler(async (configFile, verbose, dryRun) =>
{
try
{
logger.LogInformation("Starting Azure cleanup...");

if (!dryRun)
logger.LogInformation("Starting Azure cleanup...");

var config = await LoadConfigAsync(configFile, logger, configService);
if (config == null) return;

if (!dryRun)
{
var checks = GetAzureCleanupChecks(authValidator);
await RequirementsSubcommand.RunChecksOrExitAsync(checks, config, logger, CancellationToken.None);
}

logger.LogInformation("");
logger.LogInformation("Azure Cleanup Preview:");
logger.LogInformation("=========================");
Expand All @@ -341,6 +369,12 @@ private static Command CreateAzureCleanupCommand(
logger.LogInformation(" Resource Group: {ResourceGroup}", config.ResourceGroup);
logger.LogInformation("");

if (dryRun)
{
logger.LogInformation("DRY RUN: No changes made.");
return;
}

Console.Write("Continue with Azure cleanup? (y/N): ");
var response = Console.ReadLine()?.Trim().ToLowerInvariant();
if (response != "y" && response != "yes")
Expand Down Expand Up @@ -397,7 +431,7 @@ private static Command CreateAzureCleanupCommand(
{
logger.LogError(ex, "Azure cleanup failed with exception");
}
}, configOption, verboseOption);
}, configOption, verboseOption, dryRunOption);

return command;
}
Expand Down Expand Up @@ -962,9 +996,9 @@ private static void PrintOrphanSummary(
logger.LogInformation("Loaded configuration successfully from {ConfigFile}", configPath);
return config;
}
catch (FileNotFoundException ex)
catch (ConfigFileNotFoundException ex)
{
logger.LogError("Configuration file not found: {Message}", ex.Message);
logger.LogError("{Message}", ex.IssueDescription);
return null;
}
catch (Exception ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Agents.A365.DevTools.Cli.Constants;
using Microsoft.Agents.A365.DevTools.Cli.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Models;
using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
using Microsoft.Agents.A365.DevTools.Cli.Services;
using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
using Microsoft.Extensions.Logging;
Expand All @@ -18,7 +19,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands;
public class CreateInstanceCommand
{
public static Command CreateCommand(ILogger<CreateInstanceCommand> logger, IConfigService configService, CommandExecutor executor,
IBotConfigurator botConfigurator, GraphApiService graphApiService, IAzureValidator azureValidator)
IBotConfigurator botConfigurator, GraphApiService graphApiService)
{
// Command description - deprecated
// Old: Create and configure agent user identities with appropriate
Expand Down Expand Up @@ -75,12 +76,6 @@ public static Command CreateCommand(ILogger<CreateInstanceCommand> logger, IConf
var instanceConfig = await LoadConfigAsync(logger, configService, config.FullName);
if (instanceConfig == null) Environment.Exit(1);

// Validate Azure CLI authentication, subscription, and environment
if (!await azureValidator.ValidateAllAsync(instanceConfig.SubscriptionId))
{
logger.LogError("Instance creation cannot proceed without proper Azure CLI authentication and subscription");
Environment.Exit(1);
}
logger.LogInformation("");

// Step 1-3: Identity, Licenses, and MCP Registration
Expand Down Expand Up @@ -505,16 +500,9 @@ private static Command CreateLicensesSubcommand(
: await configService.LoadAsync();
return config;
}
catch (FileNotFoundException ex)
catch (ConfigFileNotFoundException ex)
{
logger.LogError("Configuration file not found: {Message}", ex.Message);
logger.LogInformation("");
logger.LogInformation("To get started:");
logger.LogInformation(" 1. Copy a365.config.example.json to a365.config.json");
logger.LogInformation(" 2. Edit a365.config.json with your Azure tenant and subscription details");
logger.LogInformation(" 3. Run 'a365 setup' to initialize your environment first");
logger.LogInformation(" 4. Then run 'a365 createinstance' to create agent instances");
logger.LogInformation("");
logger.LogError("{Message}", ex.IssueDescription);
return null;
}
catch (Exception ex)
Expand Down
69 changes: 57 additions & 12 deletions src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
using Microsoft.Agents.A365.DevTools.Cli.Constants;
using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
using Microsoft.Agents.A365.DevTools.Cli.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Models;
using Microsoft.Agents.A365.DevTools.Cli.Services;
using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements;
using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks;
using Microsoft.Extensions.Logging;
using System.CommandLine;

Expand All @@ -19,7 +22,7 @@ public static Command CreateCommand(
IConfigService configService,
CommandExecutor executor,
DeploymentService deploymentService,
IAzureValidator azureValidator,
AzureAuthValidator authValidator,
GraphApiService graphApiService,
AgentBlueprintService blueprintService)
{
Expand Down Expand Up @@ -53,7 +56,7 @@ public static Command CreateCommand(
command.AddOption(restartOption);

// Add subcommands
command.AddCommand(CreateAppSubcommand(logger, configService, executor, deploymentService, azureValidator));
command.AddCommand(CreateAppSubcommand(logger, configService, executor, deploymentService, authValidator));
command.AddCommand(CreateMcpSubcommand(logger, configService, executor, graphApiService, blueprintService));

// Single handler for the deploy command - runs only the application deployment flow
Expand Down Expand Up @@ -82,7 +85,7 @@ public static Command CreateCommand(
}

var validatedConfig = await ValidateDeploymentPrerequisitesAsync(
config.FullName, configService, azureValidator, executor, logger);
config.FullName, configService, authValidator, executor, logger);
if (validatedConfig == null) return;

await DeployApplicationAsync(validatedConfig, deploymentService, verbose, inspect, restart, logger);
Expand All @@ -96,12 +99,20 @@ public static Command CreateCommand(
return command;
}

/// <summary>
/// Requirement checks for deploy: Azure CLI auth and App Service token validity.
/// AppServiceAuthRequirementCheck probes the App Service scope explicitly to catch
/// revoked/expired grants before build and upload begin.
/// </summary>
public static List<IRequirementCheck> GetChecks(AzureAuthValidator auth)
=> [new AzureAuthRequirementCheck(auth), new AppServiceAuthRequirementCheck(auth)];

private static Command CreateAppSubcommand(
ILogger<DeployCommand> logger,
IConfigService configService,
CommandExecutor executor,
DeploymentService deploymentService,
IAzureValidator azureValidator)
AzureAuthValidator authValidator)
{
var command = new Command("app", "Deploy Microsoft Agent 365 application binaries to the configured Azure App Service");

Expand Down Expand Up @@ -157,7 +168,7 @@ private static Command CreateAppSubcommand(
}

var validatedConfig = await ValidateDeploymentPrerequisitesAsync(
config.FullName, configService, azureValidator, executor, logger);
config.FullName, configService, authValidator, executor, logger);
if (validatedConfig == null) return;

await DeployApplicationAsync(validatedConfig, deploymentService, verbose, inspect, restart, logger);
Expand Down Expand Up @@ -218,6 +229,23 @@ private static Command CreateMcpSubcommand(
var updateConfig = await configService.LoadAsync(config.FullName);
if (updateConfig == null) Environment.Exit(1);

// Early validation: fail before any network calls
if (string.IsNullOrWhiteSpace(updateConfig.AgentBlueprintId))
{
logger.LogError("agentBlueprintId is not configured. Run 'a365 setup all' to create the agent blueprint.");
ExceptionHandler.ExitWithCleanup(1);
}
if (string.IsNullOrWhiteSpace(updateConfig.AgenticAppId))
{
logger.LogError("agenticAppId is not configured. Run 'a365 setup all' to complete setup.");
ExceptionHandler.ExitWithCleanup(1);
}
if (string.IsNullOrWhiteSpace(updateConfig.TenantId))
{
logger.LogError("tenantId is not configured. Run 'a365 setup all' to complete setup.");
ExceptionHandler.ExitWithCleanup(1);
}

// Configure GraphApiService with custom client app ID if available
if (!string.IsNullOrWhiteSpace(updateConfig.ClientAppId))
{
Expand All @@ -241,26 +269,40 @@ private static Command CreateMcpSubcommand(
}

/// <summary>
/// Validates configuration, Azure authentication, and Web App existence
/// Validates configuration, Azure CLI authentication, and Web App existence
/// </summary>
private static async Task<Agent365Config?> ValidateDeploymentPrerequisitesAsync(
string configPath,
IConfigService configService,
IAzureValidator azureValidator,
AzureAuthValidator authValidator,
CommandExecutor executor,
ILogger logger)
{
// Load configuration
var configData = await configService.LoadAsync(configPath);
if (configData == null) return null;
if (configData == null)
{
Environment.ExitCode = 1;
return null;
}

// Validate Azure CLI authentication, subscription, and environment
if (!await azureValidator.ValidateAllAsync(configData.SubscriptionId))
// Validate required config fields before any network calls
var missingFields = new List<string>();
if (string.IsNullOrWhiteSpace(configData.ResourceGroup)) missingFields.Add("resourceGroup");
if (string.IsNullOrWhiteSpace(configData.WebAppName)) missingFields.Add("webAppName");
if (string.IsNullOrWhiteSpace(configData.SubscriptionId)) missingFields.Add("subscriptionId");
if (missingFields.Count > 0)
{
logger.LogError("Deployment cannot proceed without proper Azure CLI authentication and the correct subscription context");
logger.LogError("Missing required configuration fields: {Fields}. Update a365.config.json and retry.",
string.Join(", ", missingFields));
Environment.ExitCode = 1;
return null;
}

// Validate Azure CLI authentication and App Service token scope
await RequirementsSubcommand.RunChecksOrExitAsync(
GetChecks(authValidator), configData, logger, CancellationToken.None);

// Validate Azure Web App exists before starting deployment
logger.LogInformation("Validating Azure Web App exists...");
var checkResult = await executor.ExecuteAsync("az",
Expand All @@ -278,6 +320,7 @@ private static Command CreateMcpSubcommand(
logger.LogInformation(" 2. Or verify your a365.config.json has the correct WebAppName and ResourceGroup");
logger.LogInformation("");
logger.LogError("Deployment cannot proceed without a valid Azure Web App target");
Environment.ExitCode = 1;
return null;
}

Expand Down Expand Up @@ -482,9 +525,11 @@ private static void HandleDeploymentException(Exception ex, ILogger logger)
logger.LogInformation(" 3. Run 'a365 deploy' to perform a deployment");
logger.LogInformation("");
break;
case DeployAppException deployAppEx:
// Already a structured exception with clean message — let it propagate as-is
throw deployAppEx;
default:
logger.LogError("Deployment failed: {Message}", ex.Message);

throw new DeployAppException($"Deployment failed: {ex.Message}", ex);
}
}
Expand Down
Loading
Loading