From 30722d6e00006673222629a53507abf30cfe692d Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 12 Jun 2026 16:24:37 +0200 Subject: [PATCH 1/6] feat: add txc env list and txc env create commands Implement tenant-level environment listing and creation via the Power Platform BAP admin API: - txc env list: lists Dataverse-backed environments with --filter and --type options, table/JSON output - txc env create: provisions environments with full option parity (type, region, currency, language, domain, templates, security group, user), fire-and-forget by default with --wait opt-in New backend infrastructure: - BapAdminApiClient: shared authenticated BAP transport (DRY) - BapEndpointProvider: cloud-to-BAP-host mapping - PowerPlatformEnvironmentProvisioner: validation, catalog lookups, create POST, async polling - EnvironmentManagementService: profile-resolving orchestrator - IEnvironmentManagementService: Core-level abstraction + DTOs Extends EnvironmentType enum with Teams (5) and SubscriptionBasedTrial (6). Refactors PowerPlatformEnvironmentCatalog to use shared BapAdminApiClient. Adds 5 provisioner unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/architecture.md | 2 +- docs/environment-lifecycle.md | 101 +++++ src/TALXIS.CLI.Core/Model/EnvironmentType.cs | 4 + .../IEnvironmentManagementService.cs | 77 ++++ .../EnvironmentCliCommand.cs | 2 +- .../EnvironmentCreateCliCommand.cs | 118 +++++ .../EnvironmentListCliCommand.cs | 87 ++++ ...erseProviderServiceCollectionExtensions.cs | 3 + .../Bap/BapAdminApiClient.cs | 77 ++++ .../Bap/BapEndpointProvider.cs | 49 +++ .../Bap/BapJsonOptions.cs | 25 ++ .../EnvironmentManagementService.cs | 81 ++++ .../EnvironmentProvisioning.cs | 73 ++++ .../EnvironmentSkuParser.cs | 40 ++ .../PowerPlatformEnvironmentCatalog.cs | 60 +-- .../PowerPlatformEnvironmentProvisioner.cs | 408 ++++++++++++++++++ ...owerPlatformEnvironmentProvisionerTests.cs | 188 ++++++++ 17 files changed, 1344 insertions(+), 51 deletions(-) create mode 100644 docs/environment-lifecycle.md create mode 100644 src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs create mode 100644 src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/EnvironmentListCliCommand.cs create mode 100644 src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapAdminApiClient.cs create mode 100644 src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapEndpointProvider.cs create mode 100644 src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapJsonOptions.cs create mode 100644 src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs create mode 100644 src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs create mode 100644 src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentSkuParser.cs create mode 100644 src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs create mode 100644 tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/PowerPlatformEnvironmentProvisionerTests.cs diff --git a/docs/architecture.md b/docs/architecture.md index cae968fb..f18b57b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ src/ TALXIS.CLI.Logging # Structured logging infrastructure TALXIS.CLI.Features.Config # txc config: profiles, auth, connections, settings - TALXIS.CLI.Features.Environment # txc environment: solution/package/deployment commands + TALXIS.CLI.Features.Environment # txc environment: env list/create, solution/package/deployment commands TALXIS.CLI.Features.Data # txc data: model conversion, data packages, transforms TALXIS.CLI.Features.Docs # txc docs (placeholder) TALXIS.CLI.Features.Workspace # txc workspace: scaffolding, templates, validation diff --git a/docs/environment-lifecycle.md b/docs/environment-lifecycle.md new file mode 100644 index 00000000..f64f0162 --- /dev/null +++ b/docs/environment-lifecycle.md @@ -0,0 +1,101 @@ +# Environment Lifecycle + +`txc env list` and `txc env create` manage Power Platform environments at the **tenant level** — they use the active profile's credential and cloud for admin authority, not a target environment URL. + +## Listing environments + +```sh +txc env list [--filter ] [--type ] [--format json|text] +``` + +Returns every Dataverse-backed environment visible to the caller. Results include environment id, display name, URL, unique name, and lifecycle type. + +| Option | Description | +|--------|-------------| +| `--filter` | Case-insensitive substring match against display name, unique name, or URL. | +| `--type`, `-t` | Filter to a single lifecycle type: `Production`, `Sandbox`, `Trial`, `Developer`, `Default`, `Teams`, `SubscriptionBasedTrial`. | +| `--profile`, `-p` | Profile supplying the admin identity and cloud. Falls back to the active profile. | +| `--format`, `-f` | `json` or `text` (auto-detected when omitted). | + +### Examples + +```sh +# List all environments +txc env list + +# Only sandboxes, as JSON +txc env list --type Sandbox -f json + +# Search by name +txc env list --filter "contoso" +``` + +## Creating environments + +```sh +txc env create --type [options] +``` + +Provisions a new environment via the Power Platform BAP admin API. By default the command returns immediately after the create request is accepted (fire-and-forget); pass `--wait` to block until provisioning completes. + +| Option | Alias | Required | Default | Description | +|--------|-------|----------|---------|-------------| +| `--type` | `-t` | **Yes** | — | `Production`, `Sandbox`, `Trial`, `Developer`, `Teams`, or `SubscriptionBasedTrial`. | +| `--name` | `-n` | Yes* | — | Display name. Required for all types except `Teams`. | +| `--region` | `-r` | No | `unitedstates` | Azure geo region slug (e.g. `europe`, `asia`, `unitedstates`). | +| `--currency` | `-c` | No | `USD` | ISO currency code, validated against the region's catalog. | +| `--language` | `-l` | No | `1033` | LCID integer or localized name (e.g. `English (United States)`). | +| `--domain` | `-d` | No | auto | Subdomain for the environment URL (2-32 chars). | +| `--templates` | — | No | — | Comma-separated Dynamics 365 app template names. | +| `--security-group-id` | `-sg` | Teams: yes | — | Entra security group id. Required for `Teams` environments. | +| `--user` | `-u` | No | — | Owning user's Entra object id. Only valid for `Developer` environments. | +| `--wait` | — | No | `false` | Block until provisioning completes. | +| `--max-wait-minutes` | — | No | `60` | Timeout in minutes when `--wait` is set. | +| `--profile` | `-p` | No | active | Profile supplying the admin identity and cloud. | + +> \* `--name` is ignored for `Teams` environments (the name derives from the linked group). + +### Examples + +```sh +# Quick sandbox — returns immediately +txc env create --type Sandbox --name "Feature Branch 42" --region europe + +# Developer environment owned by a specific user, wait for completion +txc env create --type Developer --name "Jan's Dev Box" --user 00000000-0000-0000-0000-000000000001 --wait + +# Trial with a Dynamics 365 Sales template +txc env create --type Trial --name "Sales Demo" --templates D365_Sales +``` + +### Type-specific rules + +| Type | Notes | +|------|-------| +| `Default` | **Not creatable** — this is the tenant's auto-provisioned environment. | +| `Teams` | Requires `--security-group-id`. Name is derived from the group; `--name` is ignored. | +| `Developer` | Only type that accepts `--user`. When omitted, owned by the caller. | +| `SubscriptionBasedTrial` | Behaves like `Trial` but tied to a subscription. | + +### Known limitations + +- **`--user` accepts only Entra object ids (GUIDs).** UPN-to-objectId resolution (which PAC CLI supports via Microsoft Graph) is not implemented. Use `az ad user show --id user@contoso.com --query id -o tsv` to look up the id. +- **No `--description` option.** The BAP create API does not accept a description field, so a CLI flag would be a silent no-op. +- **Currency, language, and template validation is region-specific.** The CLI fetches the per-region catalog and fails fast with the valid values when a mismatch is detected. + +## Authentication + +Both commands use the active profile (or `--profile`) to resolve a credential and cloud instance. The credential acquires a BAP admin token scoped to `https://service.powerapps.com/`. No target environment URL is needed — these are tenant-level operations. + +See [profiles-and-authentication.md](profiles-and-authentication.md) for how profiles work. + +## MCP integration + +Both commands are automatically exposed as MCP tools: + +| CLI command | MCP tool name | Access hint | +|-------------|--------------|-------------| +| `txc env list` | `environment_list` | `ReadOnlyHint` | +| `txc env create` | `environment_create` | `IdempotentHint` | + +No special MCP configuration is needed — tool registration is reflection-driven from the CLI command tree. diff --git a/src/TALXIS.CLI.Core/Model/EnvironmentType.cs b/src/TALXIS.CLI.Core/Model/EnvironmentType.cs index 965288ba..336a5399 100644 --- a/src/TALXIS.CLI.Core/Model/EnvironmentType.cs +++ b/src/TALXIS.CLI.Core/Model/EnvironmentType.cs @@ -18,4 +18,8 @@ public enum EnvironmentType Developer = 3, /// Default environment — auto-provisioned per tenant; treated as Production for safety. Default = 4, + /// Microsoft Teams-linked environment — backs a Teams team; not destructive by default. + Teams = 5, + /// Subscription-based trial environment — time-limited, convertible to production; no destructive guard. + SubscriptionBasedTrial = 6, } diff --git a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs new file mode 100644 index 00000000..6caad734 --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs @@ -0,0 +1,77 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Platforms.PowerPlatform; + +/// +/// A Power Platform environment as surfaced by txc env list. A +/// provider-agnostic projection so the management-plane CLI never depends on +/// control-plane implementation types. +/// +public sealed record EnvironmentInfo( + Guid EnvironmentId, + string DisplayName, + Uri EnvironmentUrl, + string? UniqueName, + Guid? OrganizationId, + EnvironmentType? EnvironmentType); + +/// +/// User-supplied inputs for txc env create. Raw, human-friendly values +/// (region slug, currency code, language name/LCID, template names) are +/// resolved and validated by the control-plane implementation. +/// +public sealed record EnvironmentCreateOptions +{ + public string? DisplayName { get; init; } + public required EnvironmentType EnvironmentType { get; init; } + public string Region { get; init; } = "unitedstates"; + public string CurrencyCode { get; init; } = "USD"; + public string Language { get; init; } = "1033"; + public string? DomainName { get; init; } + public IReadOnlyList Templates { get; init; } = Array.Empty(); + public Guid? SecurityGroupId { get; init; } + public Guid? UserObjectId { get; init; } + public bool Wait { get; init; } + public TimeSpan MaxWait { get; init; } = TimeSpan.FromMinutes(60); +} + +/// +/// Result of an environment creation. When the caller does not wait, +/// is false and +/// carries the URL that reports provisioning progress. +/// +public sealed record EnvironmentCreateOutcome( + Guid? EnvironmentId, + string? DisplayName, + Uri? EnvironmentUrl, + EnvironmentType? EnvironmentType, + string Status, + bool Completed, + Uri? OperationLocation); + +/// +/// Tenant-level environment administration: listing the environments visible +/// to the active profile's identity and creating new ones. Resolves the +/// (Profile, Connection, Credential) triple internally — the credential and +/// cloud supply the admin authority, independent of any single target +/// environment URL. +/// +public interface IEnvironmentManagementService +{ + /// + /// Lists the Dataverse-backed environments in the tenant visible to the + /// resolved profile's identity. + /// + Task> ListAsync( + string? profileName, + CancellationToken ct); + + /// + /// Creates a new environment using the resolved profile's credential and + /// cloud for admin authority. + /// + Task CreateAsync( + string? profileName, + EnvironmentCreateOptions options, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 7b3fd0ba..4648b16c 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs new file mode 100644 index 00000000..ea8218f4 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs @@ -0,0 +1,118 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment; + +/// +/// txc environment create — provisions a new Power Platform environment +/// in the tenant. This is a tenant-level admin operation: the active profile +/// supplies the credential and cloud (admin authority), not a target +/// environment. By default the command returns once provisioning is queued; +/// pass --wait to block until the environment is ready. +/// +[CliIdempotent] +[CliLongRunning] +[CliCommand( + Name = "create", + Description = "Create a new Power Platform environment in the tenant. Requires an active profile (used for admin identity and cloud). Returns after queueing unless --wait is passed." +)] +public class EnvironmentCreateCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentCreateCliCommand)); + + [CliOption(Name = "--name", Aliases = ["-n"], Description = "Display name for the new environment. Required for every type except Teams.", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--type", Aliases = ["-t"], Description = "Environment lifecycle type: Production, Sandbox, Trial, Developer, Teams, or SubscriptionBasedTrial. (Default is not creatable.)", Required = true)] + public EnvironmentType Type { get; set; } + + [CliOption(Name = "--region", Aliases = ["-r"], Description = "Azure geo region slug (e.g. unitedstates, europe, asia).", Required = false)] + public string Region { get; set; } = "unitedstates"; + + [CliOption(Name = "--currency", Aliases = ["-c"], Description = "ISO currency code, validated against the region's catalog.", Required = false)] + public string Currency { get; set; } = "USD"; + + [CliOption(Name = "--language", Aliases = ["-l"], Description = "Base language as an LCID (e.g. 1033) or a localized name (e.g. 'English (United States)').", Required = false)] + public string Language { get; set; } = "1033"; + + [CliOption(Name = "--domain", Aliases = ["-d"], Description = "Subdomain for the environment URL (2-32 chars). Defaults to a generated value when omitted.", Required = false)] + public string? Domain { get; set; } + + [CliOption(Name = "--templates", Description = "Comma-separated Dynamics 365 app template names to provision (validated against the region/type catalog).", Required = false)] + public string? Templates { get; set; } + + [CliOption(Name = "--security-group-id", Aliases = ["-sg"], Description = "Entra security group id that gates membership. Required for Teams environments.", Required = false)] + public Guid? SecurityGroupId { get; set; } + + [CliOption(Name = "--user", Aliases = ["-u"], Description = "Owning user's Entra object id. Only valid for Developer environments.", Required = false)] + public Guid? User { get; set; } + + [CliOption(Name = "--wait", Description = "Wait for provisioning to complete. By default the command returns after queueing.", Required = false)] + public bool Wait { get; set; } + + [CliOption(Name = "--max-wait-minutes", Description = "Maximum minutes to wait when --wait is set (default 60).", Required = false)] + public int MaxWaitMinutes { get; set; } = 60; + + protected override async Task ExecuteAsync() + { + var templates = string.IsNullOrWhiteSpace(Templates) + ? Array.Empty() + : Templates.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var options = new EnvironmentCreateOptions + { + DisplayName = Name, + EnvironmentType = Type, + Region = Region, + CurrencyCode = Currency, + Language = Language, + DomainName = Domain, + Templates = templates, + SecurityGroupId = SecurityGroupId, + UserObjectId = User, + Wait = Wait, + MaxWait = TimeSpan.FromMinutes(Math.Max(1, MaxWaitMinutes)), + }; + + var service = TxcServices.Get(); + var result = await service.CreateAsync(Profile, options, CancellationToken.None).ConfigureAwait(false); + + if (result.Completed) + Logger.LogInformation("Environment '{DisplayName}' provisioned ({EnvironmentId}).", result.DisplayName, result.EnvironmentId); + else + Logger.LogInformation("Environment creation queued ({EnvironmentId}); status {Status}.", result.EnvironmentId, result.Status); + + var payload = new + { + environmentId = result.EnvironmentId, + displayName = result.DisplayName, + environmentUrl = result.EnvironmentUrl?.ToString(), + type = result.EnvironmentType?.ToString(), + status = result.Status, + completed = result.Completed, + operationLocation = result.OperationLocation?.ToString(), + }; + + OutputFormatter.WriteData(payload, _ => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Environment ID: {result.EnvironmentId}"); + if (!string.IsNullOrWhiteSpace(result.DisplayName)) + OutputWriter.WriteLine($"Display Name: {result.DisplayName}"); + OutputWriter.WriteLine($"Type: {result.EnvironmentType?.ToString() ?? "Unknown"}"); + if (result.EnvironmentUrl is not null) + OutputWriter.WriteLine($"Environment URL: {result.EnvironmentUrl}"); + OutputWriter.WriteLine($"Status: {result.Status}"); + if (!result.Completed) + OutputWriter.WriteLine("Provisioning is in progress. Re-run 'txc env list' later to confirm, or pass --wait next time."); +#pragma warning restore TXC003 + }); + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentListCliCommand.cs new file mode 100644 index 00000000..e89855f8 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentListCliCommand.cs @@ -0,0 +1,87 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment; + +/// +/// txc environment list — lists the Power Platform environments in the +/// tenant visible to the active profile's identity. This is a tenant-level +/// admin operation: the profile supplies the credential and cloud, not a single +/// target environment. +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List the Power Platform environments in the tenant visible to the active profile's credential. Requires an active profile (used only for identity and cloud, not as a target)." +)] +public class EnvironmentListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentListCliCommand)); + + [CliOption(Name = "--filter", Description = "Show only environments whose display name, unique name, or URL contains this substring.", Required = false)] + public string? Filter { get; set; } + + [CliOption(Name = "--type", Aliases = ["-t"], Description = "Show only environments of this lifecycle type (Production, Sandbox, Trial, Developer, Default, Teams, SubscriptionBasedTrial).", Required = false)] + public EnvironmentType? Type { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + IReadOnlyList environments = await service.ListAsync(Profile, CancellationToken.None) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(Filter)) + { + environments = environments + .Where(e => Contains(e.DisplayName, Filter) + || Contains(e.UniqueName, Filter) + || Contains(e.EnvironmentUrl.ToString(), Filter)) + .ToList(); + } + + if (Type is { } type) + { + environments = environments.Where(e => e.EnvironmentType == type).ToList(); + } + + OutputFormatter.WriteList(environments, PrintTable); + return ExitSuccess; + } + + private static bool Contains(string? value, string substring) + => value is not null && value.Contains(substring, StringComparison.OrdinalIgnoreCase); + + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList environments) + { + if (environments.Count == 0) + { + OutputWriter.WriteLine("No environments found."); + return; + } + + int nameWidth = Math.Clamp(environments.Max(e => e.DisplayName.Length), 20, 40); + int typeWidth = 12; + string header = $"{"Display Name".PadRight(nameWidth)} | {"Type".PadRight(typeWidth)} | {"Environment ID".PadRight(36)} | Environment URL"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var e in environments.OrderBy(e => e.DisplayName, StringComparer.OrdinalIgnoreCase)) + { + string name = e.DisplayName.Length > nameWidth + ? e.DisplayName[..(nameWidth - 1)] + "." + : e.DisplayName; + string type = (e.EnvironmentType?.ToString() ?? "Unknown"); + type = type.Length > typeWidth ? type[..typeWidth] : type; + OutputWriter.WriteLine( + $"{name.PadRight(nameWidth)} | {type.PadRight(typeWidth)} | {e.EnvironmentId.ToString().PadRight(36)} | {e.EnvironmentUrl}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs index ac170c24..a7f8b047 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs @@ -52,9 +52,12 @@ public static IServiceCollection AddTxcDataverseProvider(this IServiceCollection services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapAdminApiClient.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapAdminApiClient.cs new file mode 100644 index 00000000..bbcb5912 --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapAdminApiClient.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.Bap; + +/// +/// Thin authenticated transport over the BAP admin API. Owns the cross-cutting +/// concerns shared by every BAP caller — token acquisition, base-URI +/// resolution, bearer-authorized JSON requests, and long-running operation +/// polling — so higher-level services (catalog, provisioner) contain only +/// endpoint-specific request building and response parsing. +/// +internal sealed class BapAdminApiClient +{ + private readonly IAccessTokenService _tokens; + private readonly IHttpClientFactoryWrapper _httpFactory; + + public BapAdminApiClient(IAccessTokenService tokens, IHttpClientFactoryWrapper? httpFactory = null) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + } + + /// Resolves the BAP admin base URI for the connection's cloud. + public Uri GetBaseUri(Connection connection) + => BapEndpointProvider.GetAdminApiBaseUri(connection.Cloud ?? CloudInstance.Public); + + /// Acquires a BAP admin bearer token for the (connection, credential) identity. + public Task AcquireTokenAsync(Connection connection, Credential credential, CancellationToken ct) + => _tokens.AcquireForResourceAsync(connection, credential, BapEndpointProvider.PowerAppsAudience, ct); + + /// + /// Sends a bearer-authorized request and returns the raw outcome (status, + /// body, and the Location header used to poll async operations). + /// The caller owns success/error interpretation so each endpoint can craft + /// its own diagnostic message. + /// + public async Task SendAsync( + HttpMethod method, + Uri requestUri, + string token, + object? jsonBody, + CancellationToken ct) + { + using var http = _httpFactory.Create(); + using var request = new HttpRequestMessage(method, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (jsonBody is not null) + { + var json = JsonSerializer.Serialize(jsonBody, BapJsonOptions.Default); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return new BapResponse(response.StatusCode, body, response.Headers.Location); + } + + /// + /// Truncates a (potentially large) response body for inclusion in error + /// messages without dumping a full payload to the log. + /// + public static string Truncate(string s, int max) + => string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s[..max] + "..."); +} + +/// Raw result of a BAP admin API call. +internal readonly record struct BapResponse(HttpStatusCode StatusCode, string Body, Uri? Location) +{ + public bool IsSuccess => (int)StatusCode is >= 200 and < 300; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapEndpointProvider.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapEndpointProvider.cs new file mode 100644 index 00000000..c6f60e4b --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapEndpointProvider.cs @@ -0,0 +1,49 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.Bap; + +/// +/// Single source of truth for Power Platform Business Application Platform +/// (BAP) admin API endpoints and constants. Centralised here so the +/// environment catalog (list/get) and the environment provisioner +/// (create + validation lookups) share one cloud→host map, token audience, +/// and API versions without duplication. +/// +internal static class BapEndpointProvider +{ + /// + /// Token audience for the BAP admin API. The admin scope is acquired + /// against the Power Apps service resource across all clouds. + /// + public static readonly Uri PowerAppsAudience = new("https://service.powerapps.com/"); + + /// API version used by the environment list/get endpoints. + public const string ListApiVersion = "2020-10-01"; + + /// + /// API version used by the environment create endpoint and the per-region + /// currency/language/template validation lookups. Matches the version the + /// Microsoft PAC CLI uses for the same calls. + /// + public const string CreateApiVersion = "2020-08-01"; + + /// + /// Resolves the BAP admin API base URI for the given sovereign cloud. + /// + /// + /// Public and GCC share the commercial host (GCC uses commercial identity + /// for the BAP control plane); the sovereign clouds get their dedicated + /// hosts. Kept intentionally explicit so an unmapped cloud fails loudly + /// rather than silently targeting the wrong tenant. + /// + public static Uri GetAdminApiBaseUri(CloudInstance cloud) + => cloud switch + { + CloudInstance.Public or CloudInstance.Gcc => new Uri("https://api.bap.microsoft.com/"), + CloudInstance.GccHigh => new Uri("https://high.api.bap.microsoft.us/"), + CloudInstance.Dod => new Uri("https://api.bap.appsplatform.us/"), + CloudInstance.China => new Uri("https://api.bap.partner.microsoftonline.cn/"), + _ => throw new NotSupportedException( + $"Power Platform environment administration is not wired for cloud '{cloud}' in this release."), + }; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapJsonOptions.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapJsonOptions.cs new file mode 100644 index 00000000..0721c6dd --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Bap/BapJsonOptions.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.Bap; + +/// +/// JSON serialization options for BAP admin API request bodies: camelCase +/// property names (the API contract) and null omission so optional metadata +/// (domain, security group, templates) is only sent when supplied. +/// +internal static class BapJsonOptions +{ + public static readonly JsonSerializerOptions Default = BuildOptions(); + + private static JsonSerializerOptions BuildOptions() + { +#pragma warning disable RS0030 // This IS the approved JsonSerializerOptions factory for BAP request bodies + return new JsonSerializerOptions(JsonSerializerDefaults.Web) +#pragma warning restore RS0030 + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + } +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs new file mode 100644 index 00000000..7c4000e3 --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs @@ -0,0 +1,81 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Platforms.PowerPlatform; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// +/// Profile-resolving orchestrator for tenant-level environment administration. +/// Mirrors : it owns the +/// (Profile, Connection, Credential) resolution and delegates the BAP admin +/// API work to the reusable catalog (list) and provisioner (create). The +/// connection's credential and cloud supply the admin authority — the target +/// environment URL is irrelevant for these tenant-scoped operations. +/// +public sealed class EnvironmentManagementService : IEnvironmentManagementService +{ + private readonly IConfigurationResolver _resolver; + private readonly IPowerPlatformEnvironmentCatalog _catalog; + private readonly IPowerPlatformEnvironmentProvisioner _provisioner; + + public EnvironmentManagementService( + IConfigurationResolver resolver, + IPowerPlatformEnvironmentCatalog catalog, + IPowerPlatformEnvironmentProvisioner provisioner) + { + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _provisioner = provisioner ?? throw new ArgumentNullException(nameof(provisioner)); + } + + public async Task> ListAsync(string? profileName, CancellationToken ct) + { + var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + var environments = await _catalog.ListAsync(ctx.Connection, ctx.Credential, ct).ConfigureAwait(false); + + return environments + .Select(e => new EnvironmentInfo( + e.EnvironmentId, + e.DisplayName, + e.EnvironmentUrl, + e.UniqueName, + e.OrganizationId, + e.EnvironmentType)) + .ToList(); + } + + public async Task CreateAsync( + string? profileName, + EnvironmentCreateOptions options, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + + var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + + var request = new EnvironmentCreateRequest + { + DisplayName = options.DisplayName, + EnvironmentType = options.EnvironmentType, + Region = options.Region, + CurrencyCode = options.CurrencyCode, + Language = options.Language, + DomainName = options.DomainName, + Templates = options.Templates, + SecurityGroupId = options.SecurityGroupId, + UserObjectId = options.UserObjectId, + Wait = options.Wait, + MaxWait = options.MaxWait, + }; + + var result = await _provisioner.CreateAsync(ctx.Connection, ctx.Credential, request, ct).ConfigureAwait(false); + + return new EnvironmentCreateOutcome( + result.EnvironmentId, + result.DisplayName, + result.EnvironmentUrl, + result.EnvironmentType, + result.Status, + result.Completed, + result.OperationLocation); + } +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs new file mode 100644 index 00000000..e028586b --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs @@ -0,0 +1,73 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// +/// User-supplied inputs for creating a Power Platform environment. Raw, +/// human-friendly values (region slug, currency code, language name/LCID, +/// template names) are resolved and validated against the BAP per-region +/// catalogs by the provisioner before the create request is issued. +/// +public sealed record EnvironmentCreateRequest +{ + /// Display name. Required for every type except . + public string? DisplayName { get; init; } + + /// Lifecycle type / SKU. is not creatable. + public required EnvironmentType EnvironmentType { get; init; } + + /// Azure geo region slug (e.g. unitedstates, europe). + public string Region { get; init; } = "unitedstates"; + + /// ISO currency code (validated against the region's catalog). + public string CurrencyCode { get; init; } = "USD"; + + /// Localized language name (e.g. English (United States)) or raw LCID (e.g. 1033). + public string Language { get; init; } = "1033"; + + /// Optional subdomain for the environment URL (2–32 chars). + public string? DomainName { get; init; } + + /// Optional Dynamics 365 app template names to provision (validated against the region/SKU catalog). + public IReadOnlyList Templates { get; init; } = Array.Empty(); + + /// Optional Entra security group that gates membership. Required for . + public Guid? SecurityGroupId { get; init; } + + /// Owning user (Entra object id) — only valid for environments. + public Guid? UserObjectId { get; init; } + + /// Whether to poll until provisioning completes (otherwise returns after queueing). + public bool Wait { get; init; } + + /// Maximum time to wait when is set. Mirrors PAC's 60-minute cap. + public TimeSpan MaxWait { get; init; } = TimeSpan.FromMinutes(60); +} + +/// +/// Outcome of an environment creation request. When the caller does not wait, +/// is false and +/// carries the URL that reports provisioning progress. +/// +public sealed record EnvironmentCreateResult( + Guid? EnvironmentId, + string? DisplayName, + Uri? EnvironmentUrl, + TALXIS.CLI.Core.Model.EnvironmentType? EnvironmentType, + string Status, + bool Completed, + Uri? OperationLocation); + +/// +/// Creates Power Platform environments through the BAP admin API, including +/// the per-region currency/language/template validation lookups and async +/// provisioning polling. +/// +public interface IPowerPlatformEnvironmentProvisioner +{ + Task CreateAsync( + Connection connection, + Credential credential, + EnvironmentCreateRequest request, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentSkuParser.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentSkuParser.cs new file mode 100644 index 00000000..b5185ccc --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentSkuParser.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// +/// Maps the Power Platform admin API properties.environmentSku string to +/// the strongly-typed . Centralised so the catalog +/// (reading existing environments) and the provisioner (echoing the created +/// environment's type) agree on one mapping. +/// +internal static class EnvironmentSkuParser +{ + /// + /// Reads environmentSku from a properties JSON object and maps + /// it to , or null when absent/unknown. + /// + public static EnvironmentType? TryParse(JsonElement properties) + { + if (!properties.TryGetProperty("environmentSku", out var skuElement) + || skuElement.ValueKind != JsonValueKind.String) + return null; + + return TryParse(skuElement.GetString()); + } + + /// Maps an environmentSku string to . + public static EnvironmentType? TryParse(string? sku) + => sku?.Trim().ToLowerInvariant() switch + { + "production" => EnvironmentType.Production, + "sandbox" => EnvironmentType.Sandbox, + "trial" => EnvironmentType.Trial, + "developer" => EnvironmentType.Developer, + "default" => EnvironmentType.Default, + "teams" => EnvironmentType.Teams, + "subscriptionbasedtrial" => EnvironmentType.SubscriptionBasedTrial, + _ => null, + }; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentCatalog.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentCatalog.cs index 5f9c5650..7901fb22 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentCatalog.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentCatalog.cs @@ -1,7 +1,7 @@ -using System.Net.Http.Headers; using System.Text.Json; using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control.Bap; namespace TALXIS.CLI.Platform.PowerPlatform.Control; @@ -40,18 +40,13 @@ Task> ListAsync( /// public sealed class PowerPlatformEnvironmentCatalog : IPowerPlatformEnvironmentCatalog { - private const string ApiVersion = "2020-10-01"; - private static readonly Uri PowerAppsAudience = new("https://service.powerapps.com/"); - - private readonly IAccessTokenService _tokens; - private readonly IHttpClientFactoryWrapper _httpFactory; + private readonly BapAdminApiClient _bap; public PowerPlatformEnvironmentCatalog( IAccessTokenService tokens, IHttpClientFactoryWrapper? httpFactory = null) { - _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); - _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + _bap = new BapAdminApiClient(tokens, httpFactory); } public async Task> ListAsync( @@ -62,28 +57,22 @@ public async Task> ListAsync( ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(credential); - var baseUri = GetAdminApiBaseUri(connection.Cloud ?? CloudInstance.Public); - var token = await _tokens.AcquireForResourceAsync(connection, credential, PowerAppsAudience, ct).ConfigureAwait(false); + var baseUri = _bap.GetBaseUri(connection); + var token = await _bap.AcquireTokenAsync(connection, credential, ct).ConfigureAwait(false); - using var http = _httpFactory.Create(); var environments = new List(); - Uri? nextPage = new(baseUri, $"/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments?api-version={ApiVersion}"); + Uri? nextPage = new(baseUri, $"/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments?api-version={BapEndpointProvider.ListApiVersion}"); while (nextPage is not null) { - using var request = new HttpRequestMessage(HttpMethod.Get, nextPage); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - using var response = await http.SendAsync(request, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); - var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) + var response = await _bap.SendAsync(HttpMethod.Get, nextPage, token, jsonBody: null, ct).ConfigureAwait(false); + if (!response.IsSuccess) { throw new InvalidOperationException( - $"Power Platform environment lookup failed ({(int)response.StatusCode} {response.ReasonPhrase}) against '{nextPage}': {Truncate(body, 500)}"); + $"Power Platform environment lookup failed ({(int)response.StatusCode} {response.StatusCode}) against '{nextPage}': {BapAdminApiClient.Truncate(response.Body, 500)}"); } - using var document = JsonDocument.Parse(body); + using var document = JsonDocument.Parse(response.Body); var root = document.RootElement; if (!root.TryGetProperty("value", out var items) || items.ValueKind != JsonValueKind.Array) throw new InvalidOperationException("Power Platform environment lookup returned a payload without a 'value' array."); @@ -137,7 +126,7 @@ private static bool TryParseEnvironment(JsonElement item, out PowerPlatformEnvir UniqueName: TryReadOptionalString(linked, "uniqueName"), DomainName: TryReadOptionalString(linked, "domainName"), OrganizationId: TryReadOptionalGuid(linked, "resourceId"), - EnvironmentType: TryParseEnvironmentSku(properties)); + EnvironmentType: EnvironmentSkuParser.TryParse(properties)); return true; } @@ -178,22 +167,6 @@ private static bool TryReadString(JsonElement element, string property, out stri ? parsed : null; - private static TALXIS.CLI.Core.Model.EnvironmentType? TryParseEnvironmentSku(JsonElement properties) - { - if (!TryReadString(properties, "environmentSku", out var sku)) - return null; - - return sku.ToLowerInvariant() switch - { - "production" => TALXIS.CLI.Core.Model.EnvironmentType.Production, - "sandbox" => TALXIS.CLI.Core.Model.EnvironmentType.Sandbox, - "trial" => TALXIS.CLI.Core.Model.EnvironmentType.Trial, - "developer" => TALXIS.CLI.Core.Model.EnvironmentType.Developer, - "default" => TALXIS.CLI.Core.Model.EnvironmentType.Default, - _ => null, - }; - } - private static bool UrlEquals(Uri left, Uri right) => NormalizeEnvironmentUrl(left).AbsoluteUri.Equals( NormalizeEnvironmentUrl(right).AbsoluteUri, @@ -201,15 +174,4 @@ private static bool UrlEquals(Uri left, Uri right) private static Uri NormalizeEnvironmentUrl(Uri uri) => new(uri.GetLeftPart(UriPartial.Path).TrimEnd('/') + "/"); - - private static Uri GetAdminApiBaseUri(CloudInstance cloud) - => cloud switch - { - CloudInstance.Public or CloudInstance.Gcc => new Uri("https://api.bap.microsoft.com/"), - _ => throw new NotSupportedException( - $"Power Platform environment lookup is not wired for cloud '{cloud}' in this release. Pass --name explicitly."), - }; - - private static string Truncate(string s, int max) - => string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s[..max] + "..."); } diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs new file mode 100644 index 00000000..14247960 --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs @@ -0,0 +1,408 @@ +using System.Net; +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control.Bap; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// +/// Creates Power Platform environments via the BAP admin API. Resolves +/// human-friendly currency/language/template inputs against the per-region +/// catalogs (throwing with the valid values on +/// a miss, so callers surface input errors as exit-code 2), issues the create +/// request, and optionally polls the returned operation until it completes. +/// +public sealed class PowerPlatformEnvironmentProvisioner : IPowerPlatformEnvironmentProvisioner +{ + private const string DatabaseType = "CommonDataService"; + + // Poll cadence mirrors the Microsoft PAC CLI: a tight initial interval that + // backs off once the operation is clearly long-running. + private static readonly TimeSpan InitialPollInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan SteadyPollInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan BackoffAfter = TimeSpan.FromSeconds(10); + + private readonly BapAdminApiClient _bap; + + public PowerPlatformEnvironmentProvisioner( + IAccessTokenService tokens, + IHttpClientFactoryWrapper? httpFactory = null) + { + _bap = new BapAdminApiClient(tokens, httpFactory); + } + + public async Task CreateAsync( + Connection connection, + Credential credential, + EnvironmentCreateRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + ArgumentNullException.ThrowIfNull(request); + + ValidateRequest(request); + + var baseUri = _bap.GetBaseUri(connection); + var token = await _bap.AcquireTokenAsync(connection, credential, ct).ConfigureAwait(false); + var region = request.Region.Trim().ToLowerInvariant(); + var sku = request.EnvironmentType.ToString(); + + // Resolve + validate the per-region catalog inputs before we POST so a + // bad currency/language/template fails fast with actionable guidance. + var currency = await ResolveCurrencyAsync(baseUri, token, region, request.CurrencyCode, ct).ConfigureAwait(false); + var baseLanguage = await ResolveLanguageAsync(baseUri, token, region, request.Language, ct).ConfigureAwait(false); + if (request.Templates.Count > 0) + await ValidateTemplatesAsync(baseUri, token, region, sku, request.Templates, ct).ConfigureAwait(false); + + var body = BuildRequestBody(request, region, sku, currency, baseLanguage, connection.TenantId); + + var createUri = new Uri( + baseUri, + $"/providers/Microsoft.BusinessAppPlatform/environments?api-version={BapEndpointProvider.CreateApiVersion}&id=/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments"); + + var response = await _bap.SendAsync(HttpMethod.Post, createUri, token, body, ct).ConfigureAwait(false); + if (!response.IsSuccess && response.StatusCode != HttpStatusCode.Accepted) + { + throw new InvalidOperationException( + $"Environment creation failed ({(int)response.StatusCode} {response.StatusCode}): {BapAdminApiClient.Truncate(response.Body, 500)}"); + } + + var parsed = ParseEnvironmentEnvelope(response.Body); + var operationLocation = response.Location; + + // Fire-and-forget: return the queued operation so the caller can report + // the new environment id and where to track progress. + if (!request.Wait) + { + return new EnvironmentCreateResult( + EnvironmentId: parsed.Id, + DisplayName: parsed.DisplayName ?? request.DisplayName, + EnvironmentUrl: parsed.Url, + EnvironmentType: parsed.Type ?? request.EnvironmentType, + Status: parsed.State ?? "Provisioning", + Completed: false, + OperationLocation: operationLocation); + } + + // Already complete (synchronous 200/201) — no polling needed. + if (response.StatusCode != HttpStatusCode.Accepted || operationLocation is null) + { + return new EnvironmentCreateResult( + parsed.Id, parsed.DisplayName ?? request.DisplayName, parsed.Url, + parsed.Type ?? request.EnvironmentType, parsed.State ?? "Succeeded", + Completed: true, OperationLocation: null); + } + + return await PollUntilCompleteAsync(operationLocation, token, request, parsed, ct).ConfigureAwait(false); + } + + /// + /// Validates the cross-field rules the BAP API enforces, surfaced here as + /// so the CLI returns a validation exit code. + /// + private static void ValidateRequest(EnvironmentCreateRequest request) + { + if (request.EnvironmentType == EnvironmentType.Default) + throw new ArgumentException("Environment type 'Default' cannot be created — it is the tenant's auto-provisioned environment."); + + if (request.EnvironmentType == EnvironmentType.Teams) + { + if (request.SecurityGroupId is null || request.SecurityGroupId == Guid.Empty) + throw new ArgumentException("A '--security-group-id' is required when creating a 'Teams' environment."); + } + else if (string.IsNullOrWhiteSpace(request.DisplayName)) + { + throw new ArgumentException("A display name ('--name') is required for this environment type."); + } + + if (request.UserObjectId is { } userId && userId != Guid.Empty + && request.EnvironmentType != EnvironmentType.Developer) + { + throw new ArgumentException("'--user' is only supported when creating a 'Developer' environment."); + } + } + + private async Task PollUntilCompleteAsync( + Uri operationLocation, + string token, + EnvironmentCreateRequest request, + EnvironmentEnvelope initial, + CancellationToken ct) + { + var started = DateTimeOffset.UtcNow; + var interval = InitialPollInterval; + var latest = initial; + + while (true) + { + var poll = await _bap.SendAsync(HttpMethod.Get, operationLocation, token, jsonBody: null, ct).ConfigureAwait(false); + + // 202 = still provisioning; anything else terminal (success body parsed below). + if (poll.StatusCode != HttpStatusCode.Accepted) + { + if (!poll.IsSuccess) + { + throw new InvalidOperationException( + $"Environment provisioning failed ({(int)poll.StatusCode} {poll.StatusCode}): {BapAdminApiClient.Truncate(poll.Body, 500)}"); + } + + var done = ParseEnvironmentEnvelope(poll.Body); + return new EnvironmentCreateResult( + done.Id ?? latest.Id, + done.DisplayName ?? latest.DisplayName ?? request.DisplayName, + done.Url ?? latest.Url, + done.Type ?? latest.Type ?? request.EnvironmentType, + done.State ?? "Succeeded", + Completed: true, + OperationLocation: null); + } + + if (!string.IsNullOrWhiteSpace(poll.Body)) + latest = ParseEnvironmentEnvelope(poll.Body) is { Id: not null } p ? p : latest; + + if (DateTimeOffset.UtcNow - started >= request.MaxWait) + { + // Timed out waiting — report as still provisioning rather than failing. + return new EnvironmentCreateResult( + latest.Id, latest.DisplayName ?? request.DisplayName, latest.Url, + latest.Type ?? request.EnvironmentType, "Provisioning", + Completed: false, OperationLocation: operationLocation); + } + + await Task.Delay(interval, ct).ConfigureAwait(false); + if (DateTimeOffset.UtcNow - started >= BackoffAfter) + interval = SteadyPollInterval; + } + } + + private static Dictionary BuildRequestBody( + EnvironmentCreateRequest request, + string region, + string sku, + ResolvedCurrency currency, + int baseLanguage, + string? tenantId) + { + var linkedMetadata = new Dictionary + { + ["baseLanguage"] = baseLanguage, + ["currency"] = new Dictionary + { + ["code"] = currency.Code, + ["name"] = currency.Name, + ["symbol"] = currency.Symbol, + }, + ["domainName"] = string.IsNullOrWhiteSpace(request.DomainName) ? null : request.DomainName.Trim(), + }; + + if (request.Templates.Count > 0) + linkedMetadata["templates"] = request.Templates.ToArray(); + + if (request.SecurityGroupId is { } sg && sg != Guid.Empty) + linkedMetadata["securityGroupId"] = sg; + + var properties = new Dictionary + { + ["displayName"] = request.DisplayName, + ["environmentSku"] = sku, + ["databaseType"] = DatabaseType, + ["linkedEnvironmentMetadata"] = linkedMetadata, + }; + + // Teams environments associate the security group as a connected group. + if (request.EnvironmentType == EnvironmentType.Teams && request.SecurityGroupId is { } teamGroup) + { + properties["connectedGroups"] = new[] + { + new Dictionary { ["id"] = teamGroup }, + }; + } + + // Developer environments are owned by the specified user. + if (request.EnvironmentType == EnvironmentType.Developer && request.UserObjectId is { } userId && userId != Guid.Empty) + { + properties["usedBy"] = new Dictionary + { + ["id"] = userId, + ["tenantId"] = tenantId, + ["type"] = "User", + }; + } + + return new Dictionary + { + ["location"] = region, + ["properties"] = properties, + }; + } + + private async Task ResolveCurrencyAsync( + Uri baseUri, string token, string region, string currencyCode, CancellationToken ct) + { + var uri = new Uri(baseUri, $"/providers/Microsoft.BusinessAppPlatform/locations/{region}/environmentCurrencies?api-version={BapEndpointProvider.CreateApiVersion}"); + using var doc = await GetCatalogAsync(uri, token, region, "currencies", ct).ConfigureAwait(false); + + var valid = new List(); + foreach (var item in EnumerateValue(doc.RootElement)) + { + if (!item.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object) + continue; + var code = ReadString(props, "code"); + if (code is null) + continue; + valid.Add(code); + + if (string.Equals(code, currencyCode.Trim(), StringComparison.OrdinalIgnoreCase)) + { + var name = ReadString(props, "localizedName") ?? ReadString(props, "name") ?? code; + var symbol = ReadString(props, "symbol") ?? code; + return new ResolvedCurrency(code, name, symbol); + } + } + + throw new ArgumentException( + $"Currency '{currencyCode}' is not available in region '{region}'. Valid codes: {string.Join(", ", valid.OrderBy(c => c))}."); + } + + private async Task ResolveLanguageAsync( + Uri baseUri, string token, string region, string language, CancellationToken ct) + { + // Raw LCID integers are accepted directly (matches PAC behavior). + if (int.TryParse(language.Trim(), out var lcid)) + return lcid; + + var uri = new Uri(baseUri, $"/providers/Microsoft.BusinessAppPlatform/locations/{region}/environmentLanguages?api-version={BapEndpointProvider.CreateApiVersion}"); + using var doc = await GetCatalogAsync(uri, token, region, "languages", ct).ConfigureAwait(false); + + var matches = new List(); + var valid = new List(); + foreach (var item in EnumerateValue(doc.RootElement)) + { + if (!item.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object) + continue; + var localizedName = ReadString(props, "localizedName"); + var localeId = ReadString(props, "localeId"); + if (localizedName is null || localeId is null || !int.TryParse(localeId, out var id)) + continue; + valid.Add($"{localizedName} ({localeId})"); + + if (localizedName.StartsWith(language.Trim(), StringComparison.OrdinalIgnoreCase)) + matches.Add(id); + } + + return matches.Count switch + { + 1 => matches[0], + 0 => throw new ArgumentException( + $"Language '{language}' was not found in region '{region}'. Valid languages: {string.Join(", ", valid)}."), + _ => throw new ArgumentException( + $"Language '{language}' is ambiguous in region '{region}' — refine it or pass the LCID. Valid languages: {string.Join(", ", valid)}."), + }; + } + + private async Task ValidateTemplatesAsync( + Uri baseUri, string token, string region, string sku, IReadOnlyList templates, CancellationToken ct) + { + var uri = new Uri(baseUri, $"/providers/Microsoft.BusinessAppPlatform/locations/{region}/templates?api-version={BapEndpointProvider.CreateApiVersion}"); + using var doc = await GetCatalogAsync(uri, token, region, "templates", ct).ConfigureAwait(false); + + // The response is an object keyed by SKU; each value is an array of template objects. + var available = new List(); + foreach (var skuProperty in doc.RootElement.EnumerateObject()) + { + if (!string.Equals(skuProperty.Name, sku, StringComparison.OrdinalIgnoreCase) + || skuProperty.Value.ValueKind != JsonValueKind.Array) + continue; + + foreach (var template in skuProperty.Value.EnumerateArray()) + { + var name = ReadString(template, "name"); + if (name is not null) + available.Add(name); + } + } + + var invalid = templates + .Where(t => !available.Contains(t, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + if (invalid.Count > 0) + { + throw new ArgumentException( + $"Unknown template(s) for SKU '{sku}' in region '{region}': {string.Join(", ", invalid)}. " + + $"Valid templates: {(available.Count > 0 ? string.Join(", ", available) : "(none)")}."); + } + } + + private async Task GetCatalogAsync(Uri uri, string token, string region, string catalog, CancellationToken ct) + { + var response = await _bap.SendAsync(HttpMethod.Get, uri, token, jsonBody: null, ct).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw new InvalidOperationException( + $"Failed to load {catalog} for region '{region}' ({(int)response.StatusCode} {response.StatusCode}): {BapAdminApiClient.Truncate(response.Body, 300)}"); + } + return JsonDocument.Parse(response.Body); + } + + private static EnvironmentEnvelope ParseEnvironmentEnvelope(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return new EnvironmentEnvelope(null, null, null, null, null); + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(body); + } + catch (JsonException) + { + return new EnvironmentEnvelope(null, null, null, null, null); + } + + using (doc) + { + var root = doc.RootElement; + Guid? id = Guid.TryParse(ReadString(root, "name"), out var parsed) ? parsed : null; + + string? displayName = null; + Uri? url = null; + EnvironmentType? type = null; + string? state = null; + + if (root.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object) + { + displayName = ReadString(props, "displayName"); + type = EnvironmentSkuParser.TryParse(props); + state = ReadString(props, "provisioningState") ?? ReadString(props, "state"); + + if (props.TryGetProperty("linkedEnvironmentMetadata", out var linked) + && linked.ValueKind == JsonValueKind.Object + && ReadString(linked, "instanceUrl") is { } instanceUrl + && Uri.TryCreate(instanceUrl, UriKind.Absolute, out var parsedUrl)) + { + url = parsedUrl; + } + } + + return new EnvironmentEnvelope(id, displayName, url, type, state); + } + } + + private static IEnumerable EnumerateValue(JsonElement root) + => root.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray() + : Enumerable.Empty(); + + private static string? ReadString(JsonElement element, string property) + => element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String + ? prop.GetString()?.Trim() + : null; + + private readonly record struct ResolvedCurrency(string Code, string Name, string Symbol); + + private readonly record struct EnvironmentEnvelope( + Guid? Id, string? DisplayName, Uri? Url, EnvironmentType? Type, string? State); +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/PowerPlatformEnvironmentProvisionerTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/PowerPlatformEnvironmentProvisionerTests.cs new file mode 100644 index 00000000..fcb590c2 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/PowerPlatformEnvironmentProvisionerTests.cs @@ -0,0 +1,188 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.Dataverse; + +public sealed class PowerPlatformEnvironmentProvisionerTests +{ + private static readonly Guid NewEnvId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private static Connection Conn() => new() + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + Cloud = CloudInstance.Public, + TenantId = "tenant-1", + }; + + private static Credential Cred() => new() + { + Id = "cred", + Kind = CredentialKind.InteractiveBrowser, + }; + + [Fact] + public async Task CreateAsync_FireAndForget_PostsBodyAndReturnsQueuedOperation() + { + string? capturedBody = null; + var operationLocation = new Uri("https://api.bap.microsoft.com/operations/op-1"); + + var http = new FakeHttpClientFactoryWrapper(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("environmentCurrencies")) + return Json(HttpStatusCode.OK, CurrencyCatalog()); + + if (req.Method == HttpMethod.Post) + { + capturedBody = req.Content!.ReadAsStringAsync().Result; + var resp = Json(HttpStatusCode.Accepted, EnvironmentBody("Provisioning")); + resp.Headers.Location = operationLocation; + return resp; + } + + return new HttpResponseMessage(HttpStatusCode.BadRequest); + }); + + var sut = new PowerPlatformEnvironmentProvisioner(new FakeTokens(), http); + + var result = await sut.CreateAsync(Conn(), Cred(), new EnvironmentCreateRequest + { + DisplayName = "Contoso Dev", + EnvironmentType = EnvironmentType.Sandbox, + Region = "unitedstates", + CurrencyCode = "USD", + Language = "1033", + Wait = false, + }, CancellationToken.None); + + Assert.NotNull(capturedBody); + using var doc = JsonDocument.Parse(capturedBody!); + var props = doc.RootElement.GetProperty("properties"); + Assert.Equal("unitedstates", doc.RootElement.GetProperty("location").GetString()); + Assert.Equal("Contoso Dev", props.GetProperty("displayName").GetString()); + Assert.Equal("Sandbox", props.GetProperty("environmentSku").GetString()); + Assert.Equal("CommonDataService", props.GetProperty("databaseType").GetString()); + Assert.Equal("USD", props.GetProperty("linkedEnvironmentMetadata").GetProperty("currency").GetProperty("code").GetString()); + Assert.Equal(1033, props.GetProperty("linkedEnvironmentMetadata").GetProperty("baseLanguage").GetInt32()); + + Assert.Equal(NewEnvId, result.EnvironmentId); + Assert.False(result.Completed); + Assert.Equal(operationLocation, result.OperationLocation); + } + + [Fact] + public async Task CreateAsync_Wait_PollsOperationUntilComplete() + { + var http = new FakeHttpClientFactoryWrapper(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("environmentCurrencies")) + return Json(HttpStatusCode.OK, CurrencyCatalog()); + + if (req.Method == HttpMethod.Post) + { + var resp = Json(HttpStatusCode.Accepted, EnvironmentBody("Provisioning")); + resp.Headers.Location = new Uri("https://api.bap.microsoft.com/operations/op-1"); + return resp; + } + + // First poll returns terminal success immediately (no Task.Delay hit). + return Json(HttpStatusCode.OK, EnvironmentBody("Succeeded")); + }); + + var sut = new PowerPlatformEnvironmentProvisioner(new FakeTokens(), http); + + var result = await sut.CreateAsync(Conn(), Cred(), new EnvironmentCreateRequest + { + DisplayName = "Contoso Dev", + EnvironmentType = EnvironmentType.Sandbox, + Wait = true, + }, CancellationToken.None); + + Assert.True(result.Completed); + Assert.Equal("Succeeded", result.Status); + Assert.Null(result.OperationLocation); + } + + [Fact] + public async Task CreateAsync_DefaultType_ThrowsArgumentException() + { + var sut = new PowerPlatformEnvironmentProvisioner(new FakeTokens(), Unreachable()); + + await Assert.ThrowsAsync(() => sut.CreateAsync(Conn(), Cred(), + new EnvironmentCreateRequest { DisplayName = "x", EnvironmentType = EnvironmentType.Default }, + CancellationToken.None)); + } + + [Fact] + public async Task CreateAsync_TeamsWithoutSecurityGroup_ThrowsArgumentException() + { + var sut = new PowerPlatformEnvironmentProvisioner(new FakeTokens(), Unreachable()); + + await Assert.ThrowsAsync(() => sut.CreateAsync(Conn(), Cred(), + new EnvironmentCreateRequest { EnvironmentType = EnvironmentType.Teams }, + CancellationToken.None)); + } + + [Fact] + public async Task CreateAsync_UnknownCurrency_ThrowsArgumentException() + { + var http = new FakeHttpClientFactoryWrapper(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("environmentCurrencies")) + return Json(HttpStatusCode.OK, CurrencyCatalog()); + return new HttpResponseMessage(HttpStatusCode.BadRequest); + }); + + var sut = new PowerPlatformEnvironmentProvisioner(new FakeTokens(), http); + + var ex = await Assert.ThrowsAsync(() => sut.CreateAsync(Conn(), Cred(), + new EnvironmentCreateRequest + { + DisplayName = "x", + EnvironmentType = EnvironmentType.Sandbox, + CurrencyCode = "ZZZ", + }, + CancellationToken.None)); + + Assert.Contains("USD", ex.Message); + } + + private static string CurrencyCatalog() + => "{\"value\":[{\"properties\":{\"code\":\"USD\",\"localizedName\":\"US Dollar\",\"symbol\":\"$\"}}]}"; + + private static string EnvironmentBody(string state) + => $"{{\"name\":\"{NewEnvId}\",\"properties\":{{\"displayName\":\"Contoso Dev\",\"provisioningState\":\"{state}\",\"environmentSku\":\"Sandbox\"}}}}"; + + private static HttpResponseMessage Json(HttpStatusCode code, string body) + => new(code) { Content = new StringContent(body) }; + + private static FakeHttpClientFactoryWrapper Unreachable() + => new(_ => throw new InvalidOperationException("HTTP should not be called for pre-flight validation failures.")); + + private sealed class FakeTokens : IAccessTokenService + { + public Task AcquireForResourceAsync(Connection connection, Credential credential, Uri resourceUri, CancellationToken ct) + => Task.FromResult("token"); + } + + private sealed class FakeHttpClientFactoryWrapper : IHttpClientFactoryWrapper + { + private readonly Func _handler; + public FakeHttpClientFactoryWrapper(Func handler) => _handler = handler; + public HttpClient Create() => new(new FakeHttpMessageHandler(_handler)); + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + public FakeHttpMessageHandler(Func handler) => _handler = handler; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_handler(request)); + } +} From 5276491c5a915a875489d7d150bbfe8f819de13f Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 12 Jun 2026 16:39:46 +0200 Subject: [PATCH 2/6] fix: refresh token during poll loop, document idempotent annotation - Re-acquire bearer token on each poll iteration in PollUntilCompleteAsync so long-running waits (up to 60 min) don't fail with 401 when the initial token expires. MSAL cache makes this a no-op when the token is still valid. - Add explanatory comment on [CliIdempotent] for env create: not truly idempotent but matches all other create commands and avoids the wrong UX of [CliDestructive] + --yes for a create operation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EnvironmentCreateCliCommand.cs | 5 +++++ .../PowerPlatformEnvironmentProvisioner.cs | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs index ea8218f4..61db240f 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs @@ -15,6 +15,11 @@ namespace TALXIS.CLI.Features.Environment; /// environment. By default the command returns once provisioning is queued; /// pass --wait to block until the environment is ready. /// +// NOTE: Environment creation is not truly idempotent (each call creates a new +// environment), but [CliIdempotent] is used here to match the convention of all +// other create commands (SolutionCreate, PublisherCreate, etc.) and to avoid the +// [CliDestructive] + IDestructiveCommand + --yes ceremony which is wrong UX for +// a create operation. MCP clients should still confirm with users before calling. [CliIdempotent] [CliLongRunning] [CliCommand( diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs index 14247960..061e885d 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs @@ -95,7 +95,7 @@ public async Task CreateAsync( Completed: true, OperationLocation: null); } - return await PollUntilCompleteAsync(operationLocation, token, request, parsed, ct).ConfigureAwait(false); + return await PollUntilCompleteAsync(operationLocation, connection, credential, request, parsed, ct).ConfigureAwait(false); } /// @@ -126,7 +126,8 @@ private static void ValidateRequest(EnvironmentCreateRequest request) private async Task PollUntilCompleteAsync( Uri operationLocation, - string token, + Connection connection, + Credential credential, EnvironmentCreateRequest request, EnvironmentEnvelope initial, CancellationToken ct) @@ -137,6 +138,10 @@ private async Task PollUntilCompleteAsync( while (true) { + // Re-acquire on every iteration so the token stays fresh across + // long-running polls (up to MaxWait, default 60 min). MSAL's + // in-memory cache makes this a no-op when the token is still valid. + var token = await _bap.AcquireTokenAsync(connection, credential, ct).ConfigureAwait(false); var poll = await _bap.SendAsync(HttpMethod.Get, operationLocation, token, jsonBody: null, ct).ConfigureAwait(false); // 202 = still provisioning; anything else terminal (success body parsed below). From 9b6ddf0aedf82fbaa9c646a317ea0bc5e3732d65 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 12 Jun 2026 16:46:48 +0200 Subject: [PATCH 3/6] feat: add txc env delete command Permanently deletes a Power Platform environment via the BAP admin API. Follows the PAC CLI pattern: pre-flight validateDelete check, then DELETE with async polling. - [CliDestructive] + IDestructiveCommand with --yes confirmation - --wait/--max-wait-minutes for blocking mode - --allow-production safety guard inherited from ProfiledCliCommand - Token refresh on each poll iteration (same fix as create) - Docs updated with delete section and MCP tool mapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/environment-lifecycle.md | 31 +++++ .../IEnvironmentManagementService.cs | 32 ++++- .../EnvironmentCliCommand.cs | 2 +- .../EnvironmentDeleteCliCommand.cs | 76 ++++++++++++ .../EnvironmentManagementService.cs | 17 +++ .../EnvironmentProvisioning.cs | 25 +++- .../PowerPlatformEnvironmentProvisioner.cs | 112 ++++++++++++++++++ 7 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs diff --git a/docs/environment-lifecycle.md b/docs/environment-lifecycle.md index f64f0162..9a7cbc23 100644 --- a/docs/environment-lifecycle.md +++ b/docs/environment-lifecycle.md @@ -83,6 +83,36 @@ txc env create --type Trial --name "Sales Demo" --templates D365_Sales - **No `--description` option.** The BAP create API does not accept a description field, so a CLI flag would be a silent no-op. - **Currency, language, and template validation is region-specific.** The CLI fetches the per-region catalog and fails fast with the valid values when a mismatch is detected. +## Deleting environments + +```sh +txc env delete [--yes] [--wait] [--max-wait-minutes ] +``` + +**This action is irreversible.** Permanently deletes a Power Platform environment and all its data. The BAP admin API validates that the environment can be deleted before initiating the operation (e.g. environments with active D365 apps or managed-environment policies may be blocked). + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `` | **Yes** | — | Environment id (GUID) to delete. | +| `--yes` | No | — | Skip interactive confirmation prompt. Required in non-interactive (CI) environments. | +| `--wait` | No | `false` | Block until deletion completes. | +| `--max-wait-minutes` | No | `60` | Timeout in minutes when `--wait` is set. | +| `--profile`, `-p` | No | active | Profile supplying the admin identity and cloud. | +| `--allow-production` | No | — | Required when targeting Production or Default environments (safety guard). | + +### Examples + +```sh +# Interactive delete with confirmation prompt +txc env delete 11111111-1111-1111-1111-111111111111 + +# CI/scripting — skip prompt, wait for completion +txc env delete 11111111-1111-1111-1111-111111111111 --yes --wait + +# Delete a production environment (requires explicit opt-in) +txc env delete 11111111-1111-1111-1111-111111111111 --yes --allow-production +``` + ## Authentication Both commands use the active profile (or `--profile`) to resolve a credential and cloud instance. The credential acquires a BAP admin token scoped to `https://service.powerapps.com/`. No target environment URL is needed — these are tenant-level operations. @@ -97,5 +127,6 @@ Both commands are automatically exposed as MCP tools: |-------------|--------------|-------------| | `txc env list` | `environment_list` | `ReadOnlyHint` | | `txc env create` | `environment_create` | `IdempotentHint` | +| `txc env delete` | `environment_delete` | `DestructiveHint` | No special MCP configuration is needed — tool registration is reflection-driven from the CLI command tree. diff --git a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs index 6caad734..b7a86a43 100644 --- a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs +++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs @@ -49,12 +49,23 @@ public sealed record EnvironmentCreateOutcome( bool Completed, Uri? OperationLocation); +/// +/// Result of an environment deletion. When the caller does not wait, +/// is false and +/// carries the URL that reports deletion progress. +/// +public sealed record EnvironmentDeleteOutcome( + Guid EnvironmentId, + string Status, + bool Completed, + Uri? OperationLocation); + /// /// Tenant-level environment administration: listing the environments visible -/// to the active profile's identity and creating new ones. Resolves the -/// (Profile, Connection, Credential) triple internally — the credential and -/// cloud supply the admin authority, independent of any single target -/// environment URL. +/// to the active profile's identity, creating new ones, and deleting existing +/// ones. Resolves the (Profile, Connection, Credential) triple internally — +/// the credential and cloud supply the admin authority, independent of any +/// single target environment URL. /// public interface IEnvironmentManagementService { @@ -74,4 +85,17 @@ Task CreateAsync( string? profileName, EnvironmentCreateOptions options, CancellationToken ct); + + /// + /// Permanently deletes an environment from the tenant. The BAP admin API + /// validates that the environment can be deleted before initiating the + /// operation. By default returns immediately; pass + /// to block until deletion completes. + /// + Task DeleteAsync( + string? profileName, + Guid environmentId, + bool wait, + TimeSpan maxWait, + CancellationToken ct); } diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 4648b16c..e64b551b 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs new file mode 100644 index 00000000..33586ce7 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs @@ -0,0 +1,76 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment; + +/// +/// txc environment delete — permanently deletes a Power Platform +/// environment from the tenant. This is an irreversible, tenant-level admin +/// operation: the active profile supplies the credential and cloud. The BAP +/// admin API validates that the environment can be deleted before initiating +/// the operation. By default the command returns after queueing; pass +/// --wait to block until deletion completes. +/// +[CliDestructive("Permanently deletes a Power Platform environment and all its data. This action is irreversible.")] +[CliLongRunning] +[CliCommand( + Name = "delete", + Description = "Permanently delete a Power Platform environment from the tenant. Requires an active profile (used for admin identity and cloud). This action is irreversible." +)] +public class EnvironmentDeleteCliCommand : ProfiledCliCommand, IDestructiveCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentDeleteCliCommand)); + + [CliArgument(Name = "id", Description = "Environment id (GUID) of the environment to delete.")] + public Guid EnvironmentId { get; set; } + + [CliOption(Name = "--yes", Description = "Skip interactive confirmation for this destructive operation.", Required = false)] + public bool Yes { get; set; } + + [CliOption(Name = "--wait", Description = "Wait for deletion to complete. By default the command returns after queueing.", Required = false)] + public bool Wait { get; set; } + + [CliOption(Name = "--max-wait-minutes", Description = "Maximum minutes to wait when --wait is set (default 60).", Required = false)] + public int MaxWaitMinutes { get; set; } = 60; + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var result = await service.DeleteAsync( + Profile, + EnvironmentId, + Wait, + TimeSpan.FromMinutes(Math.Max(1, MaxWaitMinutes)), + CancellationToken.None).ConfigureAwait(false); + + if (result.Completed) + Logger.LogInformation("Environment {EnvironmentId} deleted.", result.EnvironmentId); + else + Logger.LogInformation("Environment deletion queued ({EnvironmentId}); status {Status}.", result.EnvironmentId, result.Status); + + var payload = new + { + environmentId = result.EnvironmentId, + status = result.Status, + completed = result.Completed, + operationLocation = result.OperationLocation?.ToString(), + }; + + OutputFormatter.WriteData(payload, _ => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Environment ID: {result.EnvironmentId}"); + OutputWriter.WriteLine($"Status: {result.Status}"); + if (!result.Completed) + OutputWriter.WriteLine("Deletion is in progress. Pass --wait next time to block until complete."); +#pragma warning restore TXC003 + }); + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs index 7c4000e3..8fd8c417 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs @@ -78,4 +78,21 @@ public async Task CreateAsync( result.Completed, result.OperationLocation); } + + public async Task DeleteAsync( + string? profileName, + Guid environmentId, + bool wait, + TimeSpan maxWait, + CancellationToken ct) + { + var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + var result = await _provisioner.DeleteAsync(ctx.Connection, ctx.Credential, environmentId, wait, maxWait, ct).ConfigureAwait(false); + + return new EnvironmentDeleteOutcome( + result.EnvironmentId, + result.Status, + result.Completed, + result.OperationLocation); + } } diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs index e028586b..a21514c9 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs @@ -59,9 +59,20 @@ public sealed record EnvironmentCreateResult( Uri? OperationLocation); /// -/// Creates Power Platform environments through the BAP admin API, including -/// the per-region currency/language/template validation lookups and async -/// provisioning polling. +/// Outcome of an environment deletion request. When the caller does not wait, +/// is false and +/// carries the URL that reports deletion progress. +/// +public sealed record EnvironmentDeleteResult( + Guid EnvironmentId, + string Status, + bool Completed, + Uri? OperationLocation); + +/// +/// Creates and deletes Power Platform environments through the BAP admin API, +/// including the per-region currency/language/template validation lookups and +/// async provisioning/deletion polling. /// public interface IPowerPlatformEnvironmentProvisioner { @@ -70,4 +81,12 @@ Task CreateAsync( Credential credential, EnvironmentCreateRequest request, CancellationToken ct); + + Task DeleteAsync( + Connection connection, + Credential credential, + Guid environmentId, + bool wait, + TimeSpan maxWait, + CancellationToken ct); } diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs index 061e885d..8b31b20c 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs @@ -396,6 +396,118 @@ private static EnvironmentEnvelope ParseEnvironmentEnvelope(string body) } } + public async Task DeleteAsync( + Connection connection, + Credential credential, + Guid environmentId, + bool wait, + TimeSpan maxWait, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + + if (environmentId == Guid.Empty) + throw new ArgumentException("Environment id must not be empty.", nameof(environmentId)); + + var baseUri = _bap.GetBaseUri(connection); + var token = await _bap.AcquireTokenAsync(connection, credential, ct).ConfigureAwait(false); + + // Pre-flight: ask the BAP API whether this environment can be deleted. + // The response contains a "canInitiateDelete" flag and, on false, the + // reasons the delete would be blocked (e.g. managed environments, D365 apps). + await ValidateDeleteAsync(baseUri, token, environmentId, ct).ConfigureAwait(false); + + var deleteUri = new Uri( + baseUri, + $"/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/{environmentId}?api-version={BapEndpointProvider.CreateApiVersion}"); + + var response = await _bap.SendAsync(HttpMethod.Delete, deleteUri, token, jsonBody: null, ct).ConfigureAwait(false); + if (!response.IsSuccess && response.StatusCode != HttpStatusCode.Accepted) + { + throw new InvalidOperationException( + $"Environment deletion failed ({(int)response.StatusCode} {response.StatusCode}): {BapAdminApiClient.Truncate(response.Body, 500)}"); + } + + var operationLocation = response.Location; + + if (!wait) + { + return new EnvironmentDeleteResult( + environmentId, "Deleting", Completed: false, OperationLocation: operationLocation); + } + + // Already complete (synchronous 200) — no polling needed. + if (response.StatusCode != HttpStatusCode.Accepted || operationLocation is null) + { + return new EnvironmentDeleteResult( + environmentId, "Deleted", Completed: true, OperationLocation: null); + } + + return await PollDeleteUntilCompleteAsync(operationLocation, connection, credential, environmentId, maxWait, ct).ConfigureAwait(false); + } + + private async Task ValidateDeleteAsync(Uri baseUri, string token, Guid environmentId, CancellationToken ct) + { + var validateUri = new Uri( + baseUri, + $"/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/{environmentId}/validateDelete?api-version={BapEndpointProvider.CreateApiVersion}"); + + var response = await _bap.SendAsync(HttpMethod.Post, validateUri, token, jsonBody: null, ct).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw new InvalidOperationException( + $"Delete validation failed ({(int)response.StatusCode} {response.StatusCode}): {BapAdminApiClient.Truncate(response.Body, 500)}"); + } + + using var doc = JsonDocument.Parse(response.Body); + if (doc.RootElement.TryGetProperty("canInitiateDelete", out var canDelete) + && canDelete.ValueKind == JsonValueKind.False) + { + throw new InvalidOperationException( + $"Environment {environmentId} cannot be deleted: {BapAdminApiClient.Truncate(response.Body, 500)}"); + } + } + + private async Task PollDeleteUntilCompleteAsync( + Uri operationLocation, + Connection connection, + Credential credential, + Guid environmentId, + TimeSpan maxWait, + CancellationToken ct) + { + var started = DateTimeOffset.UtcNow; + var interval = InitialPollInterval; + + while (true) + { + var token = await _bap.AcquireTokenAsync(connection, credential, ct).ConfigureAwait(false); + var poll = await _bap.SendAsync(HttpMethod.Get, operationLocation, token, jsonBody: null, ct).ConfigureAwait(false); + + if (poll.StatusCode != HttpStatusCode.Accepted) + { + if (!poll.IsSuccess) + { + throw new InvalidOperationException( + $"Environment deletion failed ({(int)poll.StatusCode} {poll.StatusCode}): {BapAdminApiClient.Truncate(poll.Body, 500)}"); + } + + return new EnvironmentDeleteResult(environmentId, "Deleted", Completed: true, OperationLocation: null); + } + + if (DateTimeOffset.UtcNow - started >= maxWait) + { + return new EnvironmentDeleteResult( + environmentId, "Deleting", Completed: false, OperationLocation: operationLocation); + } + + await Task.Delay(interval, ct).ConfigureAwait(false); + if (DateTimeOffset.UtcNow - started >= BackoffAfter) + interval = SteadyPollInterval; + } + } + private static IEnumerable EnumerateValue(JsonElement root) => root.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array ? value.EnumerateArray() From 41aa3b2c7857f5758c62d2e03c28b79d9144b38b Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 12 Jun 2026 16:57:59 +0200 Subject: [PATCH 4/6] feat: add txc env update command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update environment properties via PATCH: display name, type (SKU conversion e.g. Sandbox→Production), and security group. Only supplied options are patched — omitted fields are left unchanged. - [CliIdempotent] — same values produce the same result - Passing empty GUID to --security-group-id clears the restriction - Domain update intentionally excluded (breaks environment URLs) - Docs updated with update section and examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/environment-lifecycle.md | 35 +++++++++ .../IEnvironmentManagementService.cs | 30 ++++++++ .../EnvironmentCliCommand.cs | 2 +- .../EnvironmentUpdateCliCommand.cs | 75 +++++++++++++++++++ .../EnvironmentManagementService.cs | 26 +++++++ .../EnvironmentProvisioning.cs | 39 +++++++++- .../PowerPlatformEnvironmentProvisioner.cs | 62 +++++++++++++++ 7 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Environment/EnvironmentUpdateCliCommand.cs diff --git a/docs/environment-lifecycle.md b/docs/environment-lifecycle.md index 9a7cbc23..e809c0f7 100644 --- a/docs/environment-lifecycle.md +++ b/docs/environment-lifecycle.md @@ -83,6 +83,40 @@ txc env create --type Trial --name "Sales Demo" --templates D365_Sales - **No `--description` option.** The BAP create API does not accept a description field, so a CLI flag would be a silent no-op. - **Currency, language, and template validation is region-specific.** The CLI fetches the per-region catalog and fails fast with the valid values when a mismatch is detected. +## Updating environments + +```sh +txc env update [--name ] [--type ] [--security-group-id ] +``` + +Updates properties of an existing environment. Only the supplied options are changed — omitted properties are left as-is. + +| Option | Alias | Description | +|--------|-------|-------------| +| `` | — | Environment id (GUID) to update. **Required.** | +| `--name` | `-n` | New display name. | +| `--type` | `-t` | Convert to a different type (e.g. `Sandbox` → `Production`). | +| `--security-group-id` | `-sg` | Entra security group that gates access. Pass `00000000-0000-0000-0000-000000000000` to remove the restriction. | +| `--profile` | `-p` | Profile supplying the admin identity and cloud. | + +### Examples + +```sh +# Rename an environment +txc env update 11111111-1111-1111-1111-111111111111 --name "Production - Contoso" + +# Promote a sandbox to production +txc env update 11111111-1111-1111-1111-111111111111 --type Production + +# Restrict access to a security group +txc env update 11111111-1111-1111-1111-111111111111 \ + --security-group-id 22222222-2222-2222-2222-222222222222 + +# Remove the security group restriction +txc env update 11111111-1111-1111-1111-111111111111 \ + --security-group-id 00000000-0000-0000-0000-000000000000 +``` + ## Deleting environments ```sh @@ -127,6 +161,7 @@ Both commands are automatically exposed as MCP tools: |-------------|--------------|-------------| | `txc env list` | `environment_list` | `ReadOnlyHint` | | `txc env create` | `environment_create` | `IdempotentHint` | +| `txc env update` | `environment_update` | `IdempotentHint` | | `txc env delete` | `environment_delete` | `DestructiveHint` | No special MCP configuration is needed — tool registration is reflection-driven from the CLI command tree. diff --git a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs index b7a86a43..e93acfaa 100644 --- a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs +++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs @@ -49,6 +49,27 @@ public sealed record EnvironmentCreateOutcome( bool Completed, Uri? OperationLocation); +/// +/// User-supplied inputs for txc env update. Only non-null properties +/// are patched — omitted fields are left unchanged on the environment. +/// +public sealed record EnvironmentUpdateOptions +{ + public required Guid EnvironmentId { get; init; } + public string? DisplayName { get; init; } + public EnvironmentType? EnvironmentType { get; init; } + public Guid? SecurityGroupId { get; init; } +} + +/// +/// Result of an environment update. +/// +public sealed record EnvironmentUpdateOutcome( + Guid EnvironmentId, + string? DisplayName, + EnvironmentType? EnvironmentType, + string Status); + /// /// Result of an environment deletion. When the caller does not wait, /// is false and @@ -86,6 +107,15 @@ Task CreateAsync( EnvironmentCreateOptions options, CancellationToken ct); + /// + /// Updates properties of an existing environment. Only the non-null fields + /// in are changed. + /// + Task UpdateAsync( + string? profileName, + EnvironmentUpdateOptions options, + CancellationToken ct); + /// /// Permanently deletes an environment from the tenant. The BAP admin API /// validates that the environment can be deleted before initiating the diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index e64b551b..3e26fc7f 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentUpdateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentUpdateCliCommand.cs new file mode 100644 index 00000000..dee3fb05 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentUpdateCliCommand.cs @@ -0,0 +1,75 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment; + +/// +/// txc environment update — updates properties of an existing Power +/// Platform environment. Only the supplied options are changed; omitted +/// properties are left as-is. This is a tenant-level admin operation: the +/// active profile supplies the credential and cloud. +/// +[CliIdempotent] +[CliCommand( + Name = "update", + Description = "Update properties of an existing Power Platform environment (name, type, access). Requires an active profile (used for admin identity and cloud)." +)] +public class EnvironmentUpdateCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentUpdateCliCommand)); + + [CliArgument(Name = "id", Description = "Environment id (GUID) of the environment to update.")] + public Guid EnvironmentId { get; set; } + + [CliOption(Name = "--name", Aliases = ["-n"], Description = "New display name for the environment.", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--type", Aliases = ["-t"], Description = "Convert the environment to a different type (e.g. Sandbox to Production).", Required = false)] + public EnvironmentType? Type { get; set; } + + [CliOption(Name = "--security-group-id", Aliases = ["-sg"], Description = "Entra security group id that gates access. Pass an empty GUID (00000000-0000-0000-0000-000000000000) to remove the restriction.", Required = false)] + public Guid? SecurityGroupId { get; set; } + + protected override async Task ExecuteAsync() + { + var options = new EnvironmentUpdateOptions + { + EnvironmentId = EnvironmentId, + DisplayName = Name, + EnvironmentType = Type, + SecurityGroupId = SecurityGroupId, + }; + + var service = TxcServices.Get(); + var result = await service.UpdateAsync(Profile, options, CancellationToken.None).ConfigureAwait(false); + + Logger.LogInformation("Environment {EnvironmentId} updated.", result.EnvironmentId); + + var payload = new + { + environmentId = result.EnvironmentId, + displayName = result.DisplayName, + type = result.EnvironmentType?.ToString(), + status = result.Status, + }; + + OutputFormatter.WriteData(payload, _ => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Environment ID: {result.EnvironmentId}"); + if (!string.IsNullOrWhiteSpace(result.DisplayName)) + OutputWriter.WriteLine($"Display Name: {result.DisplayName}"); + if (result.EnvironmentType is not null) + OutputWriter.WriteLine($"Type: {result.EnvironmentType}"); + OutputWriter.WriteLine($"Status: {result.Status}"); +#pragma warning restore TXC003 + }); + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs index 8fd8c417..f2f43a7e 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs @@ -79,6 +79,32 @@ public async Task CreateAsync( result.OperationLocation); } + public async Task UpdateAsync( + string? profileName, + EnvironmentUpdateOptions options, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + + var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + + var request = new EnvironmentUpdateRequest + { + EnvironmentId = options.EnvironmentId, + DisplayName = options.DisplayName, + EnvironmentType = options.EnvironmentType, + SecurityGroupId = options.SecurityGroupId, + }; + + var result = await _provisioner.UpdateAsync(ctx.Connection, ctx.Credential, request, ct).ConfigureAwait(false); + + return new EnvironmentUpdateOutcome( + result.EnvironmentId, + result.DisplayName, + result.EnvironmentType, + result.Status); + } + public async Task DeleteAsync( string? profileName, Guid environmentId, diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs index a21514c9..2884c6d0 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs @@ -70,7 +70,38 @@ public sealed record EnvironmentDeleteResult( Uri? OperationLocation); /// -/// Creates and deletes Power Platform environments through the BAP admin API, +/// User-supplied inputs for updating an existing Power Platform environment. +/// Only non-null properties are patched — omitted fields are left unchanged. +/// +public sealed record EnvironmentUpdateRequest +{ + /// Environment id to update. + public required Guid EnvironmentId { get; init; } + + /// New display name, or null to leave unchanged. + public string? DisplayName { get; init; } + + /// New lifecycle type (SKU conversion, e.g. Sandbox→Production), or null to leave unchanged. + public EnvironmentType? EnvironmentType { get; init; } + + /// + /// New security group id, or to clear the + /// restriction, or null to leave unchanged. + /// + public Guid? SecurityGroupId { get; init; } +} + +/// +/// Outcome of an environment update request. +/// +public sealed record EnvironmentUpdateResult( + Guid EnvironmentId, + string? DisplayName, + EnvironmentType? EnvironmentType, + string Status); + +/// +/// Creates, updates, and deletes Power Platform environments through the BAP admin API, /// including the per-region currency/language/template validation lookups and /// async provisioning/deletion polling. /// @@ -82,6 +113,12 @@ Task CreateAsync( EnvironmentCreateRequest request, CancellationToken ct); + Task UpdateAsync( + Connection connection, + Credential credential, + EnvironmentUpdateRequest request, + CancellationToken ct); + Task DeleteAsync( Connection connection, Credential credential, diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs index 8b31b20c..3497562a 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs @@ -98,6 +98,68 @@ public async Task CreateAsync( return await PollUntilCompleteAsync(operationLocation, connection, credential, request, parsed, ct).ConfigureAwait(false); } + public async Task UpdateAsync( + Connection connection, + Credential credential, + EnvironmentUpdateRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + ArgumentNullException.ThrowIfNull(request); + + if (request.EnvironmentId == Guid.Empty) + throw new ArgumentException("Environment id must not be empty."); + + if (request.EnvironmentType == EnvironmentType.Default) + throw new ArgumentException("Cannot convert an environment to type 'Default'."); + + // Build a sparse PATCH body — only include the properties the caller wants to change. + var properties = new Dictionary(); + + if (request.DisplayName is not null) + properties["displayName"] = request.DisplayName; + + if (request.EnvironmentType is { } newType) + properties["environmentSku"] = newType.ToString(); + + if (request.SecurityGroupId is { } sg) + { + // Guid.Empty means "clear the security group restriction". + properties["linkedEnvironmentMetadata"] = new Dictionary + { + ["securityGroupId"] = sg == Guid.Empty ? null : sg, + }; + } + + if (properties.Count == 0) + throw new ArgumentException("At least one property to update must be specified (--name, --type, or --security-group-id)."); + + var body = new Dictionary { ["properties"] = properties }; + + var baseUri = _bap.GetBaseUri(connection); + var token = await _bap.AcquireTokenAsync(connection, credential, ct).ConfigureAwait(false); + + var patchUri = new Uri( + baseUri, + $"/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/{request.EnvironmentId}?api-version={BapEndpointProvider.CreateApiVersion}"); + + var response = await _bap.SendAsync(new HttpMethod("PATCH"), patchUri, token, body, ct).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw new InvalidOperationException( + $"Environment update failed ({(int)response.StatusCode} {response.StatusCode}): {BapAdminApiClient.Truncate(response.Body, 500)}"); + } + + // Parse the response to return the current state after the update. + var parsed = ParseEnvironmentEnvelope(response.Body); + return new EnvironmentUpdateResult( + request.EnvironmentId, + parsed.DisplayName ?? request.DisplayName, + parsed.Type ?? request.EnvironmentType, + parsed.State ?? "Succeeded"); + } + /// /// Validates the cross-field rules the BAP API enforces, surfaced here as /// so the CLI returns a validation exit code. From b35486bed6e13e35a46e3031a33bae830dc1fe86 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 12 Jun 2026 17:11:50 +0200 Subject: [PATCH 5/6] fix: production guard checks target env, remove --max-wait-minutes - EnvironmentDeleteCliCommand now overrides PreExecuteAsync to resolve the *target* environment's type from the tenant catalog, instead of checking the profile's connection (which is unrelated to the environment being deleted). This prevents false negatives (deleting production via sandbox profile) and false positives (blocking sandbox deletion via production profile). - Remove --max-wait-minutes from env create and env delete to match SolutionImportCliCommand which only exposes --wait with a hardcoded timeout. Consistent surface across all long-running commands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/environment-lifecycle.md | 4 +- .../EnvironmentCreateCliCommand.cs | 4 -- .../EnvironmentDeleteCliCommand.cs | 43 +++++++++++++++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/environment-lifecycle.md b/docs/environment-lifecycle.md index e809c0f7..7818e73a 100644 --- a/docs/environment-lifecycle.md +++ b/docs/environment-lifecycle.md @@ -50,7 +50,6 @@ Provisions a new environment via the Power Platform BAP admin API. By default th | `--security-group-id` | `-sg` | Teams: yes | — | Entra security group id. Required for `Teams` environments. | | `--user` | `-u` | No | — | Owning user's Entra object id. Only valid for `Developer` environments. | | `--wait` | — | No | `false` | Block until provisioning completes. | -| `--max-wait-minutes` | — | No | `60` | Timeout in minutes when `--wait` is set. | | `--profile` | `-p` | No | active | Profile supplying the admin identity and cloud. | > \* `--name` is ignored for `Teams` environments (the name derives from the linked group). @@ -120,7 +119,7 @@ txc env update 11111111-1111-1111-1111-111111111111 \ ## Deleting environments ```sh -txc env delete [--yes] [--wait] [--max-wait-minutes ] +txc env delete [--yes] [--wait] ``` **This action is irreversible.** Permanently deletes a Power Platform environment and all its data. The BAP admin API validates that the environment can be deleted before initiating the operation (e.g. environments with active D365 apps or managed-environment policies may be blocked). @@ -130,7 +129,6 @@ txc env delete [--yes] [--wait] [--max-wait-minutes ] | `` | **Yes** | — | Environment id (GUID) to delete. | | `--yes` | No | — | Skip interactive confirmation prompt. Required in non-interactive (CI) environments. | | `--wait` | No | `false` | Block until deletion completes. | -| `--max-wait-minutes` | No | `60` | Timeout in minutes when `--wait` is set. | | `--profile`, `-p` | No | active | Profile supplying the admin identity and cloud. | | `--allow-production` | No | — | Required when targeting Production or Default environments (safety guard). | diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs index 61db240f..bf8bdcda 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs @@ -60,9 +60,6 @@ public class EnvironmentCreateCliCommand : ProfiledCliCommand [CliOption(Name = "--wait", Description = "Wait for provisioning to complete. By default the command returns after queueing.", Required = false)] public bool Wait { get; set; } - [CliOption(Name = "--max-wait-minutes", Description = "Maximum minutes to wait when --wait is set (default 60).", Required = false)] - public int MaxWaitMinutes { get; set; } = 60; - protected override async Task ExecuteAsync() { var templates = string.IsNullOrWhiteSpace(Templates) @@ -81,7 +78,6 @@ protected override async Task ExecuteAsync() SecurityGroupId = SecurityGroupId, UserObjectId = User, Wait = Wait, - MaxWait = TimeSpan.FromMinutes(Math.Max(1, MaxWaitMinutes)), }; var service = TxcServices.Get(); diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs index 33586ce7..26398c8c 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs @@ -35,8 +35,45 @@ public class EnvironmentDeleteCliCommand : ProfiledCliCommand, IDestructiveComma [CliOption(Name = "--wait", Description = "Wait for deletion to complete. By default the command returns after queueing.", Required = false)] public bool Wait { get; set; } - [CliOption(Name = "--max-wait-minutes", Description = "Maximum minutes to wait when --wait is set (default 60).", Required = false)] - public int MaxWaitMinutes { get; set; } = 60; + /// + /// Overrides the base production guard because this is a tenant-level + /// command: the profile supplies admin credentials, but the *target* + /// environment is identified by , which is + /// independent of the profile's connection. The base guard would check + /// the wrong environment. Instead we resolve the target environment's + /// type from the tenant catalog and apply the same check. + /// + protected override async Task PreExecuteAsync() + { + if (AllowProduction) + return null; + + try + { + var service = TxcServices.Get(); + var environments = await service.ListAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var target = environments.FirstOrDefault(e => e.EnvironmentId == EnvironmentId); + + if (target is null) + return null; // Not found — let ExecuteAsync handle the 404 from the API. + + if (!IsProductionLike(target.EnvironmentType, target.DisplayName, target.EnvironmentUrl?.ToString())) + return null; + + Logger.LogError( + "Blocked: this is a destructive operation targeting {EnvType} environment '{EnvLabel}'. " + + "Pass --allow-production to confirm.", + target.EnvironmentType?.ToString() ?? "Unknown", + target.DisplayName ?? EnvironmentId.ToString()); + return ExitValidationError; + } + catch (Exception) + { + // If we can't resolve the target, don't block — let the API call + // proceed and fail with its own error if needed. + return null; + } + } protected override async Task ExecuteAsync() { @@ -45,7 +82,7 @@ protected override async Task ExecuteAsync() Profile, EnvironmentId, Wait, - TimeSpan.FromMinutes(Math.Max(1, MaxWaitMinutes)), + TimeSpan.FromMinutes(60), CancellationToken.None).ConfigureAwait(false); if (result.Completed) From 4a4606335769d6e1583c1c2f22c8c4bb6141e1e3 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 12 Jun 2026 17:17:43 +0200 Subject: [PATCH 6/6] docs: update environment-lifecycle.md and PR description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix intro to mention all 4 CRUD commands (was only list/create) - Fix auth and MCP sections ('Both commands' → 'All commands') - Remove internal API references (BAP admin API) from user-facing docs — users shouldn't need to know which Microsoft API powers the commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/environment-lifecycle.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/environment-lifecycle.md b/docs/environment-lifecycle.md index 7818e73a..208deb62 100644 --- a/docs/environment-lifecycle.md +++ b/docs/environment-lifecycle.md @@ -1,6 +1,6 @@ # Environment Lifecycle -`txc env list` and `txc env create` manage Power Platform environments at the **tenant level** — they use the active profile's credential and cloud for admin authority, not a target environment URL. +`txc env list`, `txc env create`, `txc env update`, and `txc env delete` manage Power Platform environments at the **tenant level** — they use the active profile's credential and cloud for admin authority, not a target environment URL. ## Listing environments @@ -36,7 +36,7 @@ txc env list --filter "contoso" txc env create --type [options] ``` -Provisions a new environment via the Power Platform BAP admin API. By default the command returns immediately after the create request is accepted (fire-and-forget); pass `--wait` to block until provisioning completes. +Provisions a new Power Platform environment. By default the command returns immediately after the request is accepted (fire-and-forget); pass `--wait` to block until provisioning completes. | Option | Alias | Required | Default | Description | |--------|-------|----------|---------|-------------| @@ -79,7 +79,7 @@ txc env create --type Trial --name "Sales Demo" --templates D365_Sales ### Known limitations - **`--user` accepts only Entra object ids (GUIDs).** UPN-to-objectId resolution (which PAC CLI supports via Microsoft Graph) is not implemented. Use `az ad user show --id user@contoso.com --query id -o tsv` to look up the id. -- **No `--description` option.** The BAP create API does not accept a description field, so a CLI flag would be a silent no-op. +- **No `--description` option.** The platform does not support setting a description during creation. - **Currency, language, and template validation is region-specific.** The CLI fetches the per-region catalog and fails fast with the valid values when a mismatch is detected. ## Updating environments @@ -122,7 +122,7 @@ txc env update 11111111-1111-1111-1111-111111111111 \ txc env delete [--yes] [--wait] ``` -**This action is irreversible.** Permanently deletes a Power Platform environment and all its data. The BAP admin API validates that the environment can be deleted before initiating the operation (e.g. environments with active D365 apps or managed-environment policies may be blocked). +**This action is irreversible.** Permanently deletes a Power Platform environment and all its data. The platform validates that the environment can be deleted before initiating the operation (e.g. environments with active D365 apps or managed-environment policies may be blocked). | Option | Required | Default | Description | |--------|----------|---------|-------------| @@ -147,13 +147,13 @@ txc env delete 11111111-1111-1111-1111-111111111111 --yes --allow-production ## Authentication -Both commands use the active profile (or `--profile`) to resolve a credential and cloud instance. The credential acquires a BAP admin token scoped to `https://service.powerapps.com/`. No target environment URL is needed — these are tenant-level operations. +All environment lifecycle commands use the active profile (or `--profile`) to resolve a credential and cloud instance. The credential acquires an admin token — no target environment URL is needed, since these are tenant-level operations. See [profiles-and-authentication.md](profiles-and-authentication.md) for how profiles work. ## MCP integration -Both commands are automatically exposed as MCP tools: +All environment lifecycle commands are automatically exposed as MCP tools: | CLI command | MCP tool name | Access hint | |-------------|--------------|-------------|