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..208deb62 --- /dev/null +++ b/docs/environment-lifecycle.md @@ -0,0 +1,165 @@ +# Environment Lifecycle + +`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 + +```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 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 | +|--------|-------|----------|---------|-------------| +| `--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. | +| `--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 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 + +```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 +txc env delete [--yes] [--wait] +``` + +**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 | +|--------|----------|---------|-------------| +| `` | **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. | +| `--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 + +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 + +All environment lifecycle 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` | +| `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/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..e93acfaa --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentManagementService.cs @@ -0,0 +1,131 @@ +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); + +/// +/// 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 +/// 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, 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 +{ + /// + /// 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); + + /// + /// 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 + /// 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 7b3fd0ba..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(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/EnvironmentCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs new file mode 100644 index 00000000..bf8bdcda --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCreateCliCommand.cs @@ -0,0 +1,119 @@ +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. +/// +// 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( + 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; } + + 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, + }; + + 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/EnvironmentDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs new file mode 100644 index 00000000..26398c8c --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentDeleteCliCommand.cs @@ -0,0 +1,113 @@ +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; } + + /// + /// 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() + { + var service = TxcServices.Get(); + var result = await service.DeleteAsync( + Profile, + EnvironmentId, + Wait, + TimeSpan.FromMinutes(60), + 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.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.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.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..f2f43a7e --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentManagementService.cs @@ -0,0 +1,124 @@ +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); + } + + 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, + 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 new file mode 100644 index 00000000..2884c6d0 --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentProvisioning.cs @@ -0,0 +1,129 @@ +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); + +/// +/// 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); + +/// +/// 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. +/// +public interface IPowerPlatformEnvironmentProvisioner +{ + Task CreateAsync( + Connection connection, + Credential credential, + EnvironmentCreateRequest request, + CancellationToken ct); + + Task UpdateAsync( + Connection connection, + Credential credential, + EnvironmentUpdateRequest 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/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..3497562a --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformEnvironmentProvisioner.cs @@ -0,0 +1,587 @@ +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, 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. + /// + 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, + Connection connection, + Credential credential, + EnvironmentCreateRequest request, + EnvironmentEnvelope initial, + CancellationToken ct) + { + var started = DateTimeOffset.UtcNow; + var interval = InitialPollInterval; + var latest = initial; + + 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). + 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); + } + } + + 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() + : 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)); + } +}