diff --git a/README.md b/README.md index c79092cd..2693fa65 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,10 @@ txc env pkg uninstall TALXIS.Controls.FileExplorer.Package --yes # Import from a folder or .cdsproj project — auto-packs via SolutionPackager txc env sln import ./src/MySolution/ +# Import and pre-populate connection references + environment variables for the +# target environment from a pac-style deployment settings file +txc env sln import ./out/MySolution_managed.zip --settings-file ./DEV-deployment-settings.json + # Publish customizations after import (makes forms, views, sitemaps visible) txc env sln publish diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionImportService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionImportService.cs index 3b4ea800..e01df16a 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionImportService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionImportService.cs @@ -1,3 +1,5 @@ +using TALXIS.CLI.Core.Deployment; + namespace TALXIS.CLI.Core.Contracts.Dataverse; /// @@ -22,7 +24,8 @@ public sealed record SolutionImportOptions( bool PublishWorkflows, bool SkipDependencyCheck, bool SkipLowerVersion, - bool Async); + bool Async, + DeploymentSettings? Settings = null); public sealed record SolutionImportResult( SolutionImportPath Path, diff --git a/src/TALXIS.CLI.Core/Deployment/DeploymentSettings.cs b/src/TALXIS.CLI.Core/Deployment/DeploymentSettings.cs new file mode 100644 index 00000000..4638f0af --- /dev/null +++ b/src/TALXIS.CLI.Core/Deployment/DeploymentSettings.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace TALXIS.CLI.Core.Deployment; + +/// +/// Deployment settings in the pac / Power Platform Build Tools JSON format, used to +/// pre-populate connection references and environment variable values during a +/// solution import. Mirrors the file produced by pac solution create-settings. +/// +public sealed record DeploymentSettings +{ + [JsonPropertyName("ConnectionReferences")] + public IReadOnlyList ConnectionReferences { get; init; } = []; + + [JsonPropertyName("EnvironmentVariables")] + public IReadOnlyList EnvironmentVariables { get; init; } = []; + + /// True when the file carries neither a connection reference nor an environment variable. + public bool IsEmpty => ConnectionReferences.Count == 0 && EnvironmentVariables.Count == 0; +} + +/// A single connection reference entry from a deployment settings file. +public sealed record ConnectionReferenceSetting +{ + [JsonPropertyName("LogicalName")] + public string? LogicalName { get; init; } + + [JsonPropertyName("ConnectionId")] + public string? ConnectionId { get; init; } + + [JsonPropertyName("ConnectorId")] + public string? ConnectorId { get; init; } +} + +/// A single environment variable entry from a deployment settings file. +public sealed record EnvironmentVariableSetting +{ + [JsonPropertyName("SchemaName")] + public string? SchemaName { get; init; } + + [JsonPropertyName("Value")] + public string? Value { get; init; } +} diff --git a/src/TALXIS.CLI.Core/Deployment/DeploymentSettingsFile.cs b/src/TALXIS.CLI.Core/Deployment/DeploymentSettingsFile.cs new file mode 100644 index 00000000..a57d9a13 --- /dev/null +++ b/src/TALXIS.CLI.Core/Deployment/DeploymentSettingsFile.cs @@ -0,0 +1,89 @@ +using System.Text.Json; + +namespace TALXIS.CLI.Core.Deployment; + +/// +/// Loads and parses deployment settings JSON files. +/// +public static class DeploymentSettingsFile +{ + // The deployment settings format is fixed PascalCase (pac CLI), so the shared + // camelCase TxcJsonOptions don't apply here - a dedicated case-insensitive reader + // is intentional and accepts both PascalCase and camelCase files. +#pragma warning disable RS0030 // Bespoke options required: case-insensitive, not camelCase + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; +#pragma warning restore RS0030 + + /// Reads and parses a file; returns false with an error message instead of throwing. + public static bool TryLoad(string path, out DeploymentSettings? settings, out string? error) + { + try + { + settings = Load(path); + error = null; + + return true; + } + catch (Exception ex) when (ex is FileNotFoundException or InvalidDataException or ArgumentException) + { + settings = null; + error = ex.Message; + + return false; + } + } + + /// + /// Reads and parses a deployment settings file from disk. + /// + public static DeploymentSettings Load(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!File.Exists(path)) + throw new FileNotFoundException($"Deployment settings file not found: {path}", path); + + return Parse(File.ReadAllText(path)); + } + + /// + /// Parses deployment settings from a JSON string. + /// + public static DeploymentSettings Parse(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + + DeploymentSettings? settings; + try + { + settings = JsonSerializer.Deserialize(json, Options); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Deployment settings file is not valid JSON: {ex.Message}", ex); + } + + if (settings is null) + throw new InvalidDataException("Deployment settings file is empty or contains only 'null'."); + + // Reject entries missing their key identifier early — these are almost always a + // malformed file and otherwise surface as a cryptic failure at import time. + foreach (var connection in settings.ConnectionReferences) + { + if (string.IsNullOrWhiteSpace(connection.LogicalName)) + throw new InvalidDataException("A 'ConnectionReferences' entry is missing its 'LogicalName'."); + } + + foreach (var variable in settings.EnvironmentVariables) + { + if (string.IsNullOrWhiteSpace(variable.SchemaName)) + throw new InvalidDataException("An 'EnvironmentVariables' entry is missing its 'SchemaName'."); + } + + return settings; + } +} diff --git a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectionValidator.cs b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectionValidator.cs new file mode 100644 index 00000000..91f5f936 --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectionValidator.cs @@ -0,0 +1,20 @@ +using TALXIS.CLI.Core.Deployment; + +namespace TALXIS.CLI.Core.Platforms.PowerPlatform; + +/// +/// Pre-flight result. is false when the check couldn't run (caller should proceed); +/// lists connection ids not found in the target environment. +/// +public sealed record ConnectionValidationResult( + bool Validated, + IReadOnlyList MissingConnections); + +/// Checks the deployment settings' connection references exist in the target environment before import. +public interface IConnectionValidator +{ + Task ValidateAsync( + string? profileName, + DeploymentSettings settings, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Docs/Skills/deployment-workflow.md b/src/TALXIS.CLI.Features.Docs/Skills/deployment-workflow.md index 10c7d9f9..01748ef2 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/deployment-workflow.md +++ b/src/TALXIS.CLI.Features.Docs/Skills/deployment-workflow.md @@ -28,6 +28,8 @@ Tool: environment_solution_import ``` Uploads the solution package to the target Dataverse environment. By default returns immediately with an `asyncOperationId`. **Do NOT use `--wait`** — solution imports take minutes and will time out the MCP request. Instead, monitor progress with `environment_deployment_show --solution-name ` until status is `Completed` or `Failed`. +For solutions with connection references or environment variables, pass `--settings-file ` (a pac / Power Platform Build Tools deployment settings JSON file) to pre-populate them on import. Entries are reconciled against the staged solution — anything not present in the solution, or missing a value, is reported as a warning and skipped. Before importing, the referenced connections are checked against the target environment via the Power Platform connections API; a connection id that doesn't exist there fails the command with a clear message. Use `--skip-settings-validation` to bypass that pre-flight check. + ### 5. Publish ``` Tool: environment_solution_publish diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs index 0d7d9096..d6712e66 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs @@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.Deployment; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.PowerPlatform; using TALXIS.CLI.Core.Resolution; using TALXIS.CLI.Logging; using TALXIS.Platform.Metadata.Packaging; @@ -47,8 +49,46 @@ public class SolutionImportCliCommand : ProfiledCliCommand [CliOption(Name = "--managed", Description = "When importing from a folder, pack as managed solution.", Required = false)] public bool Managed { get; set; } + [CliOption(Name = "--settings-file", Description = "Path to a deployment settings JSON file (pac / Power Platform Build Tools format) used to pre-populate connection references and environment variable values during import.", Required = false)] + public string? SettingsFile { get; set; } + + [CliOption(Name = "--skip-settings-validation", Description = "Skip the pre-flight check that connections referenced by --settings-file exist in the target environment.", Required = false)] + public bool SkipSettingsValidation { get; set; } + protected override async Task ExecuteAsync() { + DeploymentSettings? deploymentSettings = null; + if (!string.IsNullOrWhiteSpace(SettingsFile)) + { + var settingsPath = Path.GetFullPath(SettingsFile); + + if (!DeploymentSettingsFile.TryLoad(settingsPath, out deploymentSettings, out var settingsError)) + { + Logger.LogError("Could not read deployment settings file: {Message}", settingsError); + + return ExitValidationError; + } + + Logger.LogInformation( + "Loaded deployment settings: {ConnectionCount} connection reference(s), {VariableCount} environment variable(s).", + deploymentSettings!.ConnectionReferences.Count, + deploymentSettings.EnvironmentVariables.Count); + + if (!SkipSettingsValidation && deploymentSettings.ConnectionReferences.Count > 0) + { + var validator = TxcServices.Get(); + var validation = await validator.ValidateAsync(Profile, deploymentSettings, CancellationToken.None).ConfigureAwait(false); + if (validation.MissingConnections.Count > 0) + { + Logger.LogError( + "Deployment settings reference connections that do not exist in the target environment: {Missing}. Create or share these connections first, or re-run with --skip-settings-validation.", + string.Join("; ", validation.MissingConnections)); + + return ExitValidationError; + } + } + } + string solutionPath = Path.GetFullPath(SolutionZip); string? tempZipPath = null; @@ -122,7 +162,8 @@ protected override async Task ExecuteAsync() PublishWorkflows: PublishWorkflows, SkipDependencyCheck: SkipDependencyCheck, SkipLowerVersion: SkipLowerVersion, - Async: !Wait); + Async: !Wait, + Settings: deploymentSettings); var service = TxcServices.Get(); var result = await service.ImportAsync(Profile, solutionPath, options, CancellationToken.None).ConfigureAwait(false); diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/ComponentParameterPlanner.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/ComponentParameterPlanner.cs new file mode 100644 index 00000000..487ef062 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/ComponentParameterPlanner.cs @@ -0,0 +1,100 @@ +using TALXIS.CLI.Core.Deployment; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Sdk; + +/// A connection reference declared by the solution being imported. +public sealed record SolutionConnectionReference(string LogicalName, string? ConnectorId); + +/// An environment variable (definition or value) declared by the solution being imported. +public sealed record SolutionEnvironmentVariable(string SchemaName, string? ValueId); + +/// Connection reference for ComponentParameters. ConnectionId is a string (the column isn't a GUID). +public sealed record PlannedConnectionReference(string LogicalName, string ConnectionId, string? ConnectorId); + +/// Environment variable value for ComponentParameters. +public sealed record PlannedEnvironmentVariable(string SchemaName, string Value, string? ValueId); + +/// Values to apply, plus warnings about skipped entries. +public sealed record ComponentParameterPlan( + IReadOnlyList ConnectionReferences, + IReadOnlyList EnvironmentVariables, + IReadOnlyList Warnings) +{ + public bool IsEmpty => ConnectionReferences.Count == 0 && EnvironmentVariables.Count == 0; +} + +/// Reconciles a deployment settings file with the solution's declared components. Pure, no Dataverse calls. +public static class ComponentParameterPlanner +{ + public static ComponentParameterPlan Plan( + DeploymentSettings settings, + IReadOnlyList solutionConnectionReferences, + IReadOnlyList solutionEnvironmentVariables) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(solutionConnectionReferences); + ArgumentNullException.ThrowIfNull(solutionEnvironmentVariables); + + var warnings = new List(); + var connectionReferences = new List(); + var environmentVariables = new List(); + + var declaredReferences = solutionConnectionReferences + .GroupBy(r => r.LogicalName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + // A schema name can appear both as a definition and a value component — prefer + // the value component so we carry its id and update in place rather than duplicate. + var declaredVariables = solutionEnvironmentVariables + .GroupBy(v => v.SchemaName, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(v => v.ValueId is not null).First(), + StringComparer.OrdinalIgnoreCase); + + foreach (var setting in settings.ConnectionReferences) + { + // LogicalName is guaranteed non-empty by DeploymentSettingsFile. + var logicalName = setting.LogicalName!; + + if (!declaredReferences.TryGetValue(logicalName, out var declared)) + { + warnings.Add($"Connection reference '{logicalName}' from the settings file is not part of the solution; skipping."); + continue; + } + + if (string.IsNullOrWhiteSpace(setting.ConnectionId)) + { + warnings.Add($"Connection reference '{logicalName}' has no ConnectionId in the settings file; skipping."); + continue; + } + + var connectorId = !string.IsNullOrWhiteSpace(setting.ConnectorId) ? setting.ConnectorId : declared.ConnectorId; + connectionReferences.Add(new PlannedConnectionReference(logicalName, setting.ConnectionId.Trim(), connectorId)); + } + + foreach (var setting in settings.EnvironmentVariables) + { + // SchemaName is guaranteed non-empty by DeploymentSettingsFile. + var schemaName = setting.SchemaName!; + + if (!declaredVariables.TryGetValue(schemaName, out var declared)) + { + warnings.Add($"Environment variable '{schemaName}' from the settings file is not part of the solution; skipping."); + continue; + } + + // pac create-settings emits empty placeholders for unfilled values; treat + // those as "not provided" rather than overwriting with a blank value. + if (string.IsNullOrWhiteSpace(setting.Value)) + { + warnings.Add($"Environment variable '{schemaName}' has no value in the settings file; skipping."); + continue; + } + + environmentVariables.Add(new PlannedEnvironmentVariable(schemaName, setting.Value, declared.ValueId)); + } + + return new ComponentParameterPlan(connectionReferences, environmentVariables, warnings); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionImporter.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionImporter.cs index 66a78c95..2d4137cd 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionImporter.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionImporter.cs @@ -183,6 +183,8 @@ public async Task ImportAsync( $"Solution staging failed ({stageResults.StageSolutionStatus}). {messages}"); } + EntityCollection? componentParameters = BuildComponentParameters(stageResults, options); + DateTime startedAtUtc = DateTime.UtcNow; Guid importJobId = Guid.NewGuid(); Guid? asyncOperationId = null; @@ -199,6 +201,9 @@ public async Task ImportAsync( ImportJobId = importJobId }; + if (componentParameters is not null) + stageAndUpgrade.ComponentParameters = componentParameters; + var response = (StageAndUpgradeAsyncResponse)await _service .ExecuteAsync(stageAndUpgrade, cancellationToken) .ConfigureAwait(false); @@ -219,6 +224,9 @@ public async Task ImportAsync( ImportJobId = importJobId }; + if (componentParameters is not null) + import.ComponentParameters = componentParameters; + var response = (ImportSolutionAsyncResponse)await _service .ExecuteAsync(import, cancellationToken) .ConfigureAwait(false); @@ -243,6 +251,94 @@ public async Task ImportAsync( status); } + /// Builds the ComponentParameters from the settings file, or null if there's nothing to apply. + private EntityCollection? BuildComponentParameters( + StageSolutionResults stageResults, + SolutionImportOptions options) + { + if (options.Settings is not { IsEmpty: false } settings) + return null; + + var (connectionReferences, environmentVariables) = ExtractSolutionComponents(stageResults); + var plan = ComponentParameterPlanner.Plan(settings, connectionReferences, environmentVariables); + + foreach (var warning in plan.Warnings) + _logger?.LogWarning("{Warning}", warning); + + if (plan.IsEmpty) + return null; + + _logger?.LogInformation( + "Applying deployment settings: {ConnectionCount} connection reference(s), {VariableCount} environment variable(s).", + plan.ConnectionReferences.Count, + plan.EnvironmentVariables.Count); + + return MaterializeComponentParameters(plan); + } + + /// Reads the connection references and environment variables a staged solution declares. + internal static (List ConnectionReferences, List EnvironmentVariables) + ExtractSolutionComponents(StageSolutionResults stageResults) + { + var connectionReferences = new List(); + var environmentVariables = new List(); + + foreach (var component in stageResults.SolutionComponentsDetails ?? new List()) + { + var attributes = component.Attributes ?? new Dictionary(); + + if (string.Equals(component.ComponentTypeName, "connectionreference", StringComparison.OrdinalIgnoreCase)) + { + attributes.TryGetValue("connectionreferencelogicalname", out var logicalName); + attributes.TryGetValue("connectorid", out var connectorId); + if (!string.IsNullOrWhiteSpace(logicalName)) + connectionReferences.Add(new SolutionConnectionReference(logicalName, connectorId)); + } + else if (string.Equals(component.ComponentTypeName, "environmentvariabledefinition", StringComparison.OrdinalIgnoreCase) + || string.Equals(component.ComponentTypeName, "environmentvariablevalue", StringComparison.OrdinalIgnoreCase)) + { + attributes.TryGetValue("schemaname", out var schemaName); + attributes.TryGetValue("environmentvariablevalueid", out var valueId); + if (!string.IsNullOrWhiteSpace(schemaName)) + environmentVariables.Add(new SolutionEnvironmentVariable(schemaName, string.IsNullOrWhiteSpace(valueId) ? null : valueId)); + } + } + + return (connectionReferences, environmentVariables); + } + + /// Turns the plan into the EntityCollection passed as ComponentParameters. + private static EntityCollection MaterializeComponentParameters(ComponentParameterPlan plan) + { + var collection = new EntityCollection(); + + foreach (var reference in plan.ConnectionReferences) + { + var entity = new Entity("connectionreference") + { + ["connectionreferencelogicalname"] = reference.LogicalName, + ["connectionid"] = reference.ConnectionId + }; + if (!string.IsNullOrWhiteSpace(reference.ConnectorId)) + entity["connectorid"] = reference.ConnectorId; + collection.Entities.Add(entity); + } + + foreach (var variable in plan.EnvironmentVariables) + { + var entity = new Entity("environmentvariablevalue") + { + ["schemaname"] = variable.SchemaName, + ["value"] = variable.Value + }; + if (!string.IsNullOrWhiteSpace(variable.ValueId)) + entity["environmentvariablevalueid"] = variable.ValueId; + collection.Entities.Add(entity); + } + + return collection; + } + private async Task PollAsyncOperationAsync(Guid asyncOperationId, CancellationToken cancellationToken) { // Poll asyncoperation row until state transitions to Completed. StateCode values: diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs index ac170c24..560aef5e 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs @@ -55,6 +55,9 @@ public static IServiceCollection AddTxcDataverseProvider(this IServiceCollection services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/ConnectionMatcher.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/ConnectionMatcher.cs new file mode 100644 index 00000000..d715876c --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/ConnectionMatcher.cs @@ -0,0 +1,33 @@ +using TALXIS.CLI.Core.Deployment; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// Pure matching of settings-file connection references against a target environment's connection ids. +public static class ConnectionMatcher +{ + /// Normalizes a connection id for comparison (ids come with or without hyphens): strip hyphens, lowercase. + public static string Normalize(string id) => (id ?? string.Empty).Trim().Replace("-", string.Empty).ToLowerInvariant(); + + /// Returns a descriptor for each reference whose connection id is absent. References without an id are ignored. + public static IReadOnlyList FindMissing( + IReadOnlyList references, + IEnumerable existingConnectionIds) + { + ArgumentNullException.ThrowIfNull(references); + ArgumentNullException.ThrowIfNull(existingConnectionIds); + + var existing = existingConnectionIds.Select(Normalize).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var missing = new List(); + foreach (var reference in references) + { + if (string.IsNullOrWhiteSpace(reference.ConnectionId)) + continue; + + if (!existing.Contains(Normalize(reference.ConnectionId))) + missing.Add($"'{reference.LogicalName}' -> connection {reference.ConnectionId}"); + } + + return missing; + } +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/PowerPlatformConnectionCatalog.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/PowerPlatformConnectionCatalog.cs new file mode 100644 index 00000000..1280d081 --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/PowerPlatformConnectionCatalog.cs @@ -0,0 +1,149 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// A connector connection from the Power Platform API. is its name (the connectionid). +public sealed record PowerPlatformConnection( + string Id, + string? ConnectorId, + string? DisplayName, + string? Status); + +public interface IPowerPlatformConnectionCatalog +{ + /// Lists the connector connections in the given environment. + Task> ListAsync( + Connection connection, + Credential credential, + Guid environmentId, + CancellationToken ct); +} + +/// Lists connector connections in an environment via the Power Platform connections API (admin scope). +public sealed class PowerPlatformConnectionCatalog : IPowerPlatformConnectionCatalog +{ + private const string ApiVersion = "2016-11-01"; + private static readonly Uri PowerAppsAudience = new("https://service.powerapps.com/"); + + private readonly IAccessTokenService _tokens; + private readonly IHttpClientFactoryWrapper _httpFactory; + + public PowerPlatformConnectionCatalog( + IAccessTokenService tokens, + IHttpClientFactoryWrapper? httpFactory = null) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + } + + public async Task> ListAsync( + Connection connection, + Credential credential, + Guid environmentId, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + + var baseUri = GetApiBaseUri(connection.Cloud ?? CloudInstance.Public); + var token = await _tokens.AcquireForResourceAsync(connection, credential, PowerAppsAudience, ct).ConfigureAwait(false); + + using var http = _httpFactory.Create(); + var connections = new List(); + Uri? nextPage = new(baseUri, + $"/providers/Microsoft.PowerApps/scopes/admin/environments/{environmentId}/connections?api-version={ApiVersion}"); + + 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) + { + throw new InvalidOperationException( + $"Power Platform connection lookup failed ({(int)response.StatusCode} {response.ReasonPhrase}) against '{nextPage}': {Truncate(body, 500)}"); + } + + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + if (!root.TryGetProperty("value", out var items) || items.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException("Power Platform connection lookup returned a payload without a 'value' array."); + + foreach (var item in items.EnumerateArray()) + { + if (TryParseConnection(item, out var parsed)) + connections.Add(parsed); + } + + nextPage = TryReadNextLink(root, baseUri); + } + + return connections; + } + + private static bool TryParseConnection(JsonElement item, out PowerPlatformConnection connection) + { + connection = null!; + + if (!item.TryGetProperty("name", out var nameElement) + || nameElement.ValueKind != JsonValueKind.String) + return false; + + var id = nameElement.GetString(); + if (string.IsNullOrWhiteSpace(id)) + return false; + + string? connectorId = null; + string? displayName = null; + string? status = null; + if (item.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object) + { + connectorId = TryReadString(properties, "apiId"); + displayName = TryReadString(properties, "displayName"); + if (properties.TryGetProperty("statuses", out var statuses) + && statuses.ValueKind == JsonValueKind.Array + && statuses.GetArrayLength() > 0 + && statuses[0].ValueKind == JsonValueKind.Object) + { + status = TryReadString(statuses[0], "status"); + } + } + + connection = new PowerPlatformConnection(id.Trim(), connectorId, displayName, status); + return true; + } + + private static Uri? TryReadNextLink(JsonElement root, Uri baseUri) + { + var nextLink = TryReadString(root, "nextLink"); + if (string.IsNullOrWhiteSpace(nextLink)) + return null; + + if (Uri.TryCreate(nextLink, UriKind.Absolute, out var absolute)) + return absolute; + + return Uri.TryCreate(baseUri, nextLink, out var relative) ? relative : null; + } + + private static string? TryReadString(JsonElement element, string property) + => element.TryGetProperty(property, out var propertyElement) && propertyElement.ValueKind == JsonValueKind.String + ? propertyElement.GetString()?.Trim() + : null; + + private static Uri GetApiBaseUri(CloudInstance cloud) + => cloud switch + { + CloudInstance.Public or CloudInstance.Gcc => new Uri("https://api.powerapps.com/"), + _ => throw new NotSupportedException( + $"Power Platform connection lookup is not wired for cloud '{cloud}' in this release."), + }; + + 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/Connections/PowerPlatformConnectionValidator.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/PowerPlatformConnectionValidator.cs new file mode 100644 index 00000000..d6a95b0f --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Connections/PowerPlatformConnectionValidator.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Deployment; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Platforms.PowerPlatform; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// Validates deployment-settings connection references against the connections in the target environment. +public sealed class PowerPlatformConnectionValidator : IConnectionValidator +{ + private readonly IConfigurationResolver _resolver; + private readonly IPowerPlatformEnvironmentCatalog _environments; + private readonly IPowerPlatformConnectionCatalog _connections; + private readonly ILogger _logger; + + public PowerPlatformConnectionValidator( + IConfigurationResolver resolver, + IPowerPlatformEnvironmentCatalog environments, + IPowerPlatformConnectionCatalog connections, + ILoggerFactory? loggerFactory = null) + { + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _environments = environments ?? throw new ArgumentNullException(nameof(environments)); + _connections = connections ?? throw new ArgumentNullException(nameof(connections)); + _logger = loggerFactory?.CreateLogger() + ?? NullLogger.Instance; + } + + public async Task ValidateAsync( + string? profileName, + DeploymentSettings settings, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(settings); + + var references = settings.ConnectionReferences + .Where(r => !string.IsNullOrWhiteSpace(r.ConnectionId)) + .ToList(); + + if (references.Count == 0) + return new ConnectionValidationResult(Validated: true, MissingConnections: Array.Empty()); + + try + { + var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + var environmentId = await ResolveEnvironmentIdAsync(ctx, ct).ConfigureAwait(false); + + var existing = await _connections + .ListAsync(ctx.Connection, ctx.Credential, environmentId, ct) + .ConfigureAwait(false); + + var missing = ConnectionMatcher.FindMissing(references, existing.Select(c => c.Id)); + return new ConnectionValidationResult(Validated: true, MissingConnections: missing); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not validate connections against the target environment; skipping pre-flight check."); + + return new ConnectionValidationResult(Validated: false, MissingConnections: Array.Empty()); + } + } + + private async Task ResolveEnvironmentIdAsync(ResolvedProfileContext ctx, CancellationToken ct) + { + if (ctx.Connection.EnvironmentId.HasValue) + return ctx.Connection.EnvironmentId.Value; + + if (string.IsNullOrWhiteSpace(ctx.Connection.EnvironmentUrl) + || !Uri.TryCreate(ctx.Connection.EnvironmentUrl, UriKind.Absolute, out var envUri)) + throw new InvalidOperationException( + $"Connection '{ctx.Connection.Id}' has no EnvironmentUrl or EnvironmentId."); + + var environment = await _environments + .TryGetByEnvironmentUrlAsync(ctx.Connection, ctx.Credential, envUri, ct) + .ConfigureAwait(false); + + if (environment is null) + throw new InvalidOperationException( + $"Could not resolve Power Platform environment for URL '{ctx.Connection.EnvironmentUrl}'."); + + return environment.EnvironmentId; + } +} diff --git a/tests/TALXIS.CLI.Tests/Deployment/DeploymentSettingsFileTests.cs b/tests/TALXIS.CLI.Tests/Deployment/DeploymentSettingsFileTests.cs new file mode 100644 index 00000000..b65db777 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Deployment/DeploymentSettingsFileTests.cs @@ -0,0 +1,97 @@ +using System.IO; +using TALXIS.CLI.Core.Deployment; +using Xunit; + +namespace TALXIS.CLI.Tests.Deployment; + +public class DeploymentSettingsFileTests +{ + [Fact] + public void Parse_ReadsPascalCaseFile() + { + const string json = """ + { + "EnvironmentVariables": [ + { "SchemaName": "tst_env", "Value": "UAT" } + ], + "ConnectionReferences": [ + { "LogicalName": "tst_sp", "ConnectionId": "abc", "ConnectorId": "/providers/x/shared_sharepointonline" } + ] + } + """; + + var settings = DeploymentSettingsFile.Parse(json); + + var variable = Assert.Single(settings.EnvironmentVariables); + Assert.Equal("tst_env", variable.SchemaName); + Assert.Equal("UAT", variable.Value); + + var connection = Assert.Single(settings.ConnectionReferences); + Assert.Equal("tst_sp", connection.LogicalName); + Assert.Equal("abc", connection.ConnectionId); + Assert.Equal("/providers/x/shared_sharepointonline", connection.ConnectorId); + } + + [Fact] + public void Parse_AlsoAcceptsCamelCase() + { + const string json = """ + { + "environmentVariables": [ { "schemaName": "tst_env", "value": "v" } ], + "connectionReferences": [ { "logicalName": "tst_sp", "connectionId": "abc" } ] + } + """; + + var settings = DeploymentSettingsFile.Parse(json); + + Assert.Equal("tst_env", Assert.Single(settings.EnvironmentVariables).SchemaName); + Assert.Equal("tst_sp", Assert.Single(settings.ConnectionReferences).LogicalName); + } + + [Fact] + public void Parse_AllowsMissingSections() + { + var settings = DeploymentSettingsFile.Parse("{}"); + + Assert.Empty(settings.ConnectionReferences); + Assert.Empty(settings.EnvironmentVariables); + Assert.True(settings.IsEmpty); + } + + [Fact] + public void Parse_ThrowsOnMalformedJson() + { + Assert.Throws(() => DeploymentSettingsFile.Parse("{ not json")); + } + + [Fact] + public void Parse_ThrowsWhenConnectionMissesLogicalName() + { + const string json = """{ "ConnectionReferences": [ { "ConnectionId": "abc" } ] }"""; + + var ex = Assert.Throws(() => DeploymentSettingsFile.Parse(json)); + Assert.Contains("LogicalName", ex.Message); + } + + [Fact] + public void Parse_ThrowsWhenVariableMissesSchemaName() + { + const string json = """{ "EnvironmentVariables": [ { "Value": "v" } ] }"""; + + var ex = Assert.Throws(() => DeploymentSettingsFile.Parse(json)); + Assert.Contains("SchemaName", ex.Message); + } + + [Fact] + public void TryLoad_ReturnsFalseWithErrorWhenFileMissing() + { + var ok = DeploymentSettingsFile.TryLoad( + Path.Combine(Path.GetTempPath(), "txc-does-not-exist-" + Guid.NewGuid().ToString("N") + ".json"), + out var settings, + out var error); + + Assert.False(ok); + Assert.Null(settings); + Assert.False(string.IsNullOrEmpty(error)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/ComponentParameterPlannerTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/ComponentParameterPlannerTests.cs new file mode 100644 index 00000000..06f28e1e --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/ComponentParameterPlannerTests.cs @@ -0,0 +1,172 @@ +using TALXIS.CLI.Core.Deployment; +using TALXIS.CLI.Platform.Dataverse.Application.Sdk; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +public class ComponentParameterPlannerTests +{ + private const string SampleConnectionId = "4445162937b84457a3465d2f0c2cab7e"; + + private static DeploymentSettings Settings( + IEnumerable? connections = null, + IEnumerable? variables = null) => new() + { + ConnectionReferences = (connections ?? []).ToList(), + EnvironmentVariables = (variables ?? []).ToList(), + }; + + [Fact] + public void Plan_MapsConnectionReferenceFromSettings() + { + var settings = Settings(connections: [ + new ConnectionReferenceSetting { LogicalName = "tst_sp", ConnectionId = SampleConnectionId, ConnectorId = "/providers/x/file" } + ]); + + var plan = ComponentParameterPlanner.Plan( + settings, + [new SolutionConnectionReference("tst_sp", "/providers/x/solution")], + []); + + var planned = Assert.Single(plan.ConnectionReferences); + Assert.Equal("tst_sp", planned.LogicalName); + Assert.Equal(SampleConnectionId, planned.ConnectionId); + Assert.Equal("/providers/x/file", planned.ConnectorId); // file value wins over solution's + Assert.Empty(plan.Warnings); + } + + [Fact] + public void Plan_FallsBackToSolutionConnectorIdWhenFileOmitsIt() + { + var settings = Settings(connections: [ + new ConnectionReferenceSetting { LogicalName = "tst_sp", ConnectionId = SampleConnectionId } + ]); + + var plan = ComponentParameterPlanner.Plan( + settings, + [new SolutionConnectionReference("tst_sp", "/providers/x/solution")], + []); + + Assert.Equal("/providers/x/solution", Assert.Single(plan.ConnectionReferences).ConnectorId); + } + + [Fact] + public void Plan_WarnsAndSkipsConnectionNotInSolution() + { + var settings = Settings(connections: [ + new ConnectionReferenceSetting { LogicalName = "tst_ghost", ConnectionId = SampleConnectionId } + ]); + + var plan = ComponentParameterPlanner.Plan(settings, [], []); + + Assert.Empty(plan.ConnectionReferences); + Assert.Contains(plan.Warnings, w => w.Contains("tst_ghost") && w.Contains("not part of the solution")); + } + + [Fact] + public void Plan_WarnsAndSkipsConnectionWithBlankId() + { + var settings = Settings(connections: [ + new ConnectionReferenceSetting { LogicalName = "tst_sp", ConnectionId = "" } + ]); + + var plan = ComponentParameterPlanner.Plan( + settings, + [new SolutionConnectionReference("tst_sp", null)], + []); + + Assert.Empty(plan.ConnectionReferences); + Assert.Contains(plan.Warnings, w => w.Contains("no ConnectionId")); + } + + [Fact] + public void Plan_PassesConnectionIdThroughVerbatim() + { + // connectionreference.connectionid is a string column — the id must be applied + // exactly as supplied (no GUID round-trip that would re-introduce hyphens). + var settings = Settings(connections: [ + new ConnectionReferenceSetting { LogicalName = "tst_sp", ConnectionId = " f3d887a13d0d4faba017870352e3efce " } + ]); + + var plan = ComponentParameterPlanner.Plan( + settings, + [new SolutionConnectionReference("tst_sp", null)], + []); + + Assert.Equal("f3d887a13d0d4faba017870352e3efce", Assert.Single(plan.ConnectionReferences).ConnectionId); + } + + [Fact] + public void Plan_MapsEnvironmentVariableAndCarriesValueId() + { + var settings = Settings(variables: [ + new EnvironmentVariableSetting { SchemaName = "tst_env", Value = "UAT" } + ]); + + var plan = ComponentParameterPlanner.Plan( + settings, + [], + [new SolutionEnvironmentVariable("tst_env", "value-id-1")]); + + var planned = Assert.Single(plan.EnvironmentVariables); + Assert.Equal("tst_env", planned.SchemaName); + Assert.Equal("UAT", planned.Value); + Assert.Equal("value-id-1", planned.ValueId); + } + + [Fact] + public void Plan_PrefersValueComponentOverDefinitionForSameSchema() + { + var settings = Settings(variables: [ + new EnvironmentVariableSetting { SchemaName = "tst_env", Value = "UAT" } + ]); + + var plan = ComponentParameterPlanner.Plan( + settings, + [], + [ + new SolutionEnvironmentVariable("tst_env", null), // definition, no id + new SolutionEnvironmentVariable("tst_env", "value-id"), // value, has id + ]); + + Assert.Equal("value-id", Assert.Single(plan.EnvironmentVariables).ValueId); + } + + [Fact] + public void Plan_WarnsAndSkipsVariableNotInSolution() + { + var settings = Settings(variables: [ + new EnvironmentVariableSetting { SchemaName = "tst_ghost", Value = "x" } + ]); + + var plan = ComponentParameterPlanner.Plan(settings, [], []); + + Assert.Empty(plan.EnvironmentVariables); + Assert.Contains(plan.Warnings, w => w.Contains("tst_ghost") && w.Contains("not part of the solution")); + } + + [Fact] + public void Plan_WarnsAndSkipsVariableWithBlankValue() + { + var settings = Settings(variables: [ + new EnvironmentVariableSetting { SchemaName = "tst_env", Value = "" } + ]); + + var plan = ComponentParameterPlanner.Plan( + settings, + [], + [new SolutionEnvironmentVariable("tst_env", null)]); + + Assert.Empty(plan.EnvironmentVariables); + Assert.Contains(plan.Warnings, w => w.Contains("no value")); + } + + [Fact] + public void Plan_IsEmpty_WhenNothingApplies() + { + var plan = ComponentParameterPlanner.Plan(Settings(), [], []); + + Assert.True(plan.IsEmpty); + Assert.Empty(plan.Warnings); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/PowerPlatform/ConnectionMatcherTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/PowerPlatform/ConnectionMatcherTests.cs new file mode 100644 index 00000000..5d8094b6 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/PowerPlatform/ConnectionMatcherTests.cs @@ -0,0 +1,65 @@ +using TALXIS.CLI.Core.Deployment; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.PowerPlatform; + +public class ConnectionMatcherTests +{ + private static ConnectionReferenceSetting Ref(string logicalName, string? connectionId) => + new() { LogicalName = logicalName, ConnectionId = connectionId }; + + [Fact] + public void FindMissing_ReturnsEmpty_WhenConnectionExists() + { + var missing = ConnectionMatcher.FindMissing( + [Ref("new_o365", "f3d887a13d0d4faba017870352e3efce")], + ["f3d887a13d0d4faba017870352e3efce"]); + + Assert.Empty(missing); + } + + [Fact] + public void FindMissing_MatchesRegardlessOfHyphensAndCase() + { + // settings file: no hyphens; environment returns dashed + different case + var missing = ConnectionMatcher.FindMissing( + [Ref("new_o365", "f3d887a13d0d4faba017870352e3efce")], + ["F3D887A1-3D0D-4FAB-A017-870352E3EFCE"]); + + Assert.Empty(missing); + } + + [Fact] + public void FindMissing_FlagsConnectionAbsentFromEnvironment() + { + var missing = ConnectionMatcher.FindMissing( + [Ref("new_o365", "00000000000000000000000000000000")], + ["f3d887a13d0d4faba017870352e3efce"]); + + var entry = Assert.Single(missing); + Assert.Contains("new_o365", entry); + Assert.Contains("00000000000000000000000000000000", entry); + } + + [Fact] + public void FindMissing_IgnoresReferencesWithoutConnectionId() + { + var missing = ConnectionMatcher.FindMissing( + [Ref("new_o365", ""), Ref("new_sp", null)], + []); + + Assert.Empty(missing); + } + + [Fact] + public void FindMissing_FlagsEachMissingReference() + { + var missing = ConnectionMatcher.FindMissing( + [Ref("new_a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), Ref("new_b", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")], + ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]); + + var entry = Assert.Single(missing); + Assert.Contains("new_b", entry); + } +}