diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs index ab0c65b..bbb8b8a 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs @@ -80,7 +80,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co // Handle "cd" with no arguments - go to root if (targetDatabase == null && targetContainer == null) { - SetState(shell, new ConnectedState(connectedState.Client)); + SetState(shell, new ConnectedState(connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_connected_state")); @@ -93,11 +93,11 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co // Validate and navigate to database if (targetDatabase != null) { - await ValidateDatabaseExistsAsync(connectedState.Client, targetDatabase, "cd", token); + await ValidateDatabaseExistsAsync(connectedState, targetDatabase, "cd", token); if (targetContainer == null) { - SetState(shell, new DatabaseState(targetDatabase, connectedState.Client)); + SetState(shell, new DatabaseState(targetDatabase, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_db", new Dictionary { { "db", targetDatabase } })); @@ -108,8 +108,8 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co } // Continue to navigate to container - await ValidateContainerExistsAsync(connectedState.Client, targetDatabase, targetContainer, "cd", token); - SetState(shell, new ContainerState(targetContainer, targetDatabase, connectedState.Client)); + await ValidateContainerExistsAsync(connectedState, targetDatabase, targetContainer, "cd", token); + SetState(shell, new ContainerState(targetContainer, targetDatabase, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); @@ -128,8 +128,8 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co throw new NotInDatabaseException("cd"); } - await ValidateContainerExistsAsync(connectedState.Client, dbName, targetContainer, "cd", token); - SetState(shell, new ContainerState(targetContainer, dbName, connectedState.Client)); + await ValidateContainerExistsAsync(connectedState, dbName, targetContainer, "cd", token); + SetState(shell, new ContainerState(targetContainer, dbName, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs index 692eb62..ae420f3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs @@ -43,6 +43,15 @@ internal partial class ConnectCommand : CosmosCommand [CosmosOption("managed-identity")] public string? ManagedIdentityClientId { get; set; } + [CosmosOption("subscription")] + public string? SubscriptionId { get; set; } + + [CosmosOption("resource-group")] + public string? ResourceGroupName { get; set; } + + [CosmosOption("account")] + public string? AccountName { get; set; } + [CosmosOption("vscode-credential", "connect-vscode-credential", Hidden = true)] public bool UseVSCodeCredential { get; init; } @@ -85,7 +94,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co try { - await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, token: token); + await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, subscriptionId: this.SubscriptionId, resourceGroupName: this.ResourceGroupName, accountName: this.AccountName, token: token); var returnState = new CommandState { IsPrinted = true, @@ -162,6 +171,11 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete table.AddRow(MessageService.GetString("command-connect-info-account"), $"[white]{acc.Id}[/]"); table.AddRow(MessageService.GetString("command-connect-info-endpoint"), $"[white]{client.Endpoint}[/]"); + if (connectedState.ArmContext != null) + { + table.AddRow(MessageService.GetString("command-connect-info-arm-account"), $"[white]{connectedState.ArmContext.AccountResourceId}[/]"); + } + // Display the connection mode var connectionMode = client.ClientOptions.ConnectionMode; table.AddRow(MessageService.GetString("command-connect-info-mode"), $"[white]{connectionMode}[/]"); @@ -183,6 +197,7 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete ["connected"] = true, ["accountId"] = acc.Id, ["endpoint"] = client.Endpoint.ToString(), + ["armAccountId"] = connectedState.ArmContext?.AccountResourceId.ToString(), ["connectionMode"] = connectionMode.ToString(), ["readRegions"] = acc.ReadableRegions.Select(r => r.Name).ToArray(), ["writeRegions"] = acc.WritableRegions.Select(r => r.Name).ToArray(), diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexPolicyCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexPolicyCommand.cs index eebee69..a4d0183 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexPolicyCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexPolicyCommand.cs @@ -57,7 +57,7 @@ async Task IStateVisitor.VisitConn { if (!string.IsNullOrEmpty(this.Database) && !string.IsNullOrEmpty(this.Container)) { - return await this.ExecuteOnContainerAsync(state.Client, this.Database, this.Container, token); + return await this.ExecuteOnContainerAsync(state, this.Database, this.Container, token); } throw new NotInContainerException("indexpolicy"); @@ -69,7 +69,7 @@ async Task IStateVisitor.VisitData if (!string.IsNullOrEmpty(this.Container)) { - return await this.ExecuteOnContainerAsync(state.Client, databaseName, this.Container, token); + return await this.ExecuteOnContainerAsync(state, databaseName, this.Container, token); } throw new NotInContainerException("indexpolicy"); @@ -80,20 +80,12 @@ async Task IStateVisitor.VisitCont string databaseName = this.Database ?? state.DatabaseName; string containerName = this.Container ?? state.ContainerName; - return await this.ExecuteOnContainerAsync(state.Client, databaseName, containerName, token); + return await this.ExecuteOnContainerAsync(state, databaseName, containerName, token); } - private static async Task ReadIndexPolicyAsync(Container container, CancellationToken token) + private static async Task ReadIndexPolicyAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) { - var containerResponse = await container.ReadContainerAsync(cancellationToken: token); - var resource = containerResponse.Resource; - if (resource == null) - { - throw new CommandException("indexpolicy", MessageService.GetString("error-unable_to_read_container")); - } - - var indexingPolicy = resource.IndexingPolicy; - var json = Newtonsoft.Json.JsonConvert.SerializeObject(indexingPolicy, Newtonsoft.Json.Formatting.Indented); + var json = await CosmosResourceFacade.GetIndexingPolicyJsonAsync(state, databaseName, containerName, token); ShellInterpreter.WriteLine(json); @@ -106,50 +98,34 @@ private static async Task ReadIndexPolicyAsync(Container container return commandState; } - private async Task ExecuteOnContainerAsync(CosmosClient client, string databaseName, string containerName, CancellationToken token) + private async Task ExecuteOnContainerAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) { - await ValidateContainerExistsAsync(client, databaseName, containerName, "indexpolicy", token); - - var container = client.GetDatabase(databaseName).GetContainer(containerName); + await ValidateContainerExistsAsync(state, databaseName, containerName, "indexpolicy", token); if (string.IsNullOrEmpty(this.Policy)) { - return await ReadIndexPolicyAsync(container, token); + return await ReadIndexPolicyAsync(state, databaseName, containerName, token); } - return await this.WriteIndexPolicyAsync(container, token); + return await this.WriteIndexPolicyAsync(state, databaseName, containerName, token); } - private async Task WriteIndexPolicyAsync(Container container, CancellationToken token) + private async Task WriteIndexPolicyAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) { - IndexingPolicy indexingPolicy; + string updatedJson; try { - indexingPolicy = Newtonsoft.Json.JsonConvert.DeserializeObject(this.Policy!) - ?? throw new CommandException("indexpolicy", MessageService.GetString("command-indexpolicy-error_invalid_policy")); + updatedJson = await CosmosResourceFacade.ReplaceIndexingPolicyAsync(state, databaseName, containerName, this.Policy!, token); } - catch (Newtonsoft.Json.JsonException ex) + catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException) { throw new CommandException("indexpolicy", MessageService.GetString("command-indexpolicy-error_invalid_policy"), ex); } - var containerResponse = await container.ReadContainerAsync(cancellationToken: token); - var resource = containerResponse.Resource; - if (resource == null) - { - throw new CommandException("indexpolicy", MessageService.GetString("error-unable_to_read_container")); - } - - resource.IndexingPolicy = indexingPolicy; - var replaceResponse = await container.ReplaceContainerAsync(resource, cancellationToken: token); - - var updatedPolicy = replaceResponse.Resource.IndexingPolicy; - var json = Newtonsoft.Json.JsonConvert.SerializeObject(updatedPolicy, Newtonsoft.Json.Formatting.Indented); - ShellInterpreter.WriteLine(MessageService.GetString("command-indexpolicy-updated")); - ShellInterpreter.WriteLine(json); + ShellInterpreter.WriteLine(updatedJson); - using var jsonDoc = JsonDocument.Parse(json); + using var jsonDoc = JsonDocument.Parse(updatedJson); var commandState = new CommandState { IsPrinted = true, diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs index c6003ad..903e4d9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs @@ -53,7 +53,7 @@ async Task IStateVisitor.VisitCont string databaseName = this.Database ?? state.DatabaseName; string containerName = this.Container ?? state.ContainerName; - return await this.ListContainerItemsAsync(state.Client, databaseName, containerName, token); + return await this.ListContainerItemsAsync(state, databaseName, containerName, token); } Task IStateVisitor.VisitDisconnectedStateAsync(DisconnectedState state, ShellInterpreter interpreter, CancellationToken token) @@ -67,26 +67,26 @@ async Task IStateVisitor.VisitConn { if (!string.IsNullOrEmpty(this.Container)) { - return await this.ListContainerItemsAsync(state.Client, this.Database, this.Container, token); + return await this.ListContainerItemsAsync(state, this.Database, this.Container, token); } - return await this.ListDatabaseContainersAsync(state.Client, this.Database, token); + return await this.ListDatabaseContainersAsync(state, this.Database, token); } // Default behavior: list databases var list = new List(); var completionList = new List(); - await foreach (var database in EnumerateDatabasesAsync(state.Client)) + await foreach (var databaseName in EnumerateDatabaseNamesAsync(state, "ls", token)) { - var databaseName = database.Id.Trim(); - completionList.Add(databaseName); + var trimmed = databaseName.Trim(); + completionList.Add(trimmed); - if (!this.IsMatch(database.Id)) + if (!this.IsMatch(trimmed)) { continue; } - var cn = Markup.Escape(databaseName); + var cn = Markup.Escape(trimmed); list.Add(cn); AnsiConsole.MarkupLine($"[green]{cn}[/]"); } @@ -108,36 +108,35 @@ async Task IStateVisitor.VisitData // If container is specified, list items in that container if (!string.IsNullOrEmpty(this.Container)) { - return await this.ListContainerItemsAsync(state.Client, databaseName, this.Container, token); + return await this.ListContainerItemsAsync(state, databaseName, this.Container, token); } // Default behavior: list containers in the database - return await this.ListDatabaseContainersAsync(state.Client, databaseName, token); + return await this.ListDatabaseContainersAsync(state, databaseName, token); } - private async Task ListDatabaseContainersAsync(CosmosClient client, string databaseName, CancellationToken token) + private async Task ListDatabaseContainersAsync(ConnectedState state, string databaseName, CancellationToken token) { // Validate database exists - await ValidateDatabaseExistsAsync(client, databaseName, "ls", token); - var db = client.GetDatabase(databaseName); + await ValidateDatabaseExistsAsync(state, databaseName, "ls", token); var list = new List(); var completionList = new List(); - await foreach (var container in EnumerateContainersAsync(db)) + await foreach (var containerName in EnumerateContainerNamesAsync(state, databaseName, "ls", token)) { - var containerName = container.Id.Trim(); - completionList.Add(containerName); + var trimmed = containerName.Trim(); + completionList.Add(trimmed); - if (!this.IsMatch(container.Id)) + if (!this.IsMatch(trimmed)) { continue; } - var cn = Markup.Escape(containerName); + var cn = Markup.Escape(trimmed); list.Add(cn); AnsiConsole.MarkupLine($"[magenta]{cn}[/]"); } - CosmosCompleteCommand.SetContainers(client, databaseName, completionList); + CosmosCompleteCommand.SetContainers(state.Client, databaseName, completionList); var result = new CommandState { @@ -147,11 +146,12 @@ private async Task ListDatabaseContainersAsync(CosmosClient client return result; } - private async Task ListContainerItemsAsync(CosmosClient client, string databaseName, string containerName, CancellationToken token) + private async Task ListContainerItemsAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) { // Validate database and container exist - await ValidateContainerExistsAsync(client, databaseName, containerName, "ls", token); + await ValidateContainerExistsAsync(state, databaseName, containerName, "ls", token); + var client = state.Client; var container = client.GetDatabase(databaseName).GetContainer(containerName); AnsiConsole.MarkupLine(MessageService.GetString("command-ls-container", new Dictionary { { "container", Theme.ContainerNamePromt(container.Id) } })); var opt = new QueryRequestOptions(); @@ -161,8 +161,8 @@ private async Task ListContainerItemsAsync(CosmosClient client, st opt.MaxItemCount = effectiveMaxItemCount.Value; } - var containerResponse = await container.ReadContainerAsync(cancellationToken: token); - var partitionKeyPropertyNames = GetPartitionKeyPropertyNames(containerResponse.Resource.PartitionKeyPaths); + var partitionKeyPaths = await CosmosResourceFacade.GetPartitionKeyPathsAsync(state, databaseName, containerName, token); + var partitionKeyPropertyNames = GetPartitionKeyPropertyNames(partitionKeyPaths); var matchKeyPropertyNames = string.IsNullOrEmpty(this.Key) ? partitionKeyPropertyNames : [this.Key]; var queryText = BuildItemQueryText(effectiveMaxItemCount, this.Filter); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs index 4452252..e54d5bd 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs @@ -66,7 +66,7 @@ async Task IStateVisitor.VisitConn { if (!string.IsNullOrEmpty(this.Database)) { - return await this.CreateContainerInDatabaseAsync(state.Client, this.Database, token); + return await this.CreateContainerInDatabaseAsync(state, this.Database, token); } throw new NotInDatabaseException("mkcon"); @@ -120,10 +120,10 @@ async Task IStateVisitor.VisitData { if (!string.IsNullOrEmpty(this.Database) && !string.Equals(this.Database, state.DatabaseName, StringComparison.OrdinalIgnoreCase)) { - return await this.CreateContainerInDatabaseAsync(state.Client, this.Database, token); + return await this.CreateContainerInDatabaseAsync(state, this.Database, token); } - return await this.CreateContainerInDatabaseAsync(state.Client, state.DatabaseName, token); + return await this.CreateContainerInDatabaseAsync(state, state.DatabaseName, token); } Task IStateVisitor.VisitContainerStateAsync(ContainerState state, ShellInterpreter shell, CancellationToken token) @@ -136,40 +136,49 @@ Task IStateVisitor.VisitContainerS throw new CommandException("mkcon", MessageService.GetString("error-not_allowed_in_container")); } - private async Task CreateContainerInDatabaseAsync(CosmosClient client, string databaseName, CancellationToken token) + private async Task CreateContainerInDatabaseAsync(ConnectedState state, string databaseName, CancellationToken token) { // Validate database exists - await ValidateDatabaseExistsAsync(client, databaseName, "mkcon", token); + await ValidateDatabaseExistsAsync(state, databaseName, "mkcon", token); + + var partitionKeyPaths = this.GetPartitionKeyPaths(); + var containerName = await CosmosResourceFacade.CreateContainerAsync( + state, + databaseName, + this.Name ?? string.Empty, + partitionKeyPaths, + this.UniqueKey, + this.IndexPolicy, + this.Scale, + this.MaxRU, + token); - var db = client.GetDatabase(databaseName); - var cp = this.CreateContainerProperties(db); - - if (!string.IsNullOrEmpty(this.IndexPolicy)) - { - try - { - var indexingPolicy = Newtonsoft.Json.JsonConvert.DeserializeObject(this.IndexPolicy) - ?? throw new CommandException("mkcon", MessageService.GetString("command-mkcon-error_invalid_index_policy")); - cp.IndexingPolicy = indexingPolicy; - } - catch (Newtonsoft.Json.JsonException ex) - { - throw new CommandException("mkcon", MessageService.GetString("command-mkcon-error_invalid_index_policy"), ex); - } - } - - var tp = MakeDbCommand.CreateThroughputProperties(this.Scale, this.MaxRU); - var container = await db.CreateContainerIfNotExistsAsync(cp, tp, cancellationToken: token); CosmosCompleteCommand.ClearContainers(); - ShellInterpreter.WriteLine(MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary { { "container", container.Container.Id } })); + ShellInterpreter.WriteLine(MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary { { "container", containerName } })); var commandState = new CommandState { IsPrinted = true, }; - var jsonObject = new { created_container = container.Container.Id }; + var jsonObject = new { created_container = containerName }; var jsonString = JsonSerializer.Serialize(jsonObject); using var jsonDoc = JsonDocument.Parse(jsonString); commandState.Result = new ShellJson(jsonDoc.RootElement.Clone()); return commandState; } + + private IReadOnlyList GetPartitionKeyPaths() + { + var keys = (this.PartitionKey ?? string.Empty).Split(",", StringSplitOptions.TrimEntries).ToList(); + if (string.IsNullOrEmpty(keys[0])) + { + throw new CommandException("mkcon", MessageService.GetString("command-mkcon-error_partition_key_empty")); + } + + if (keys.Any(key => !key.StartsWith("/"))) + { + throw new CommandException("mkcon", MessageService.GetString("command-mkcon-error_partition_key_slash")); + } + + return keys; + } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs index 1cfe175..a13bb02 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs @@ -48,12 +48,11 @@ Task IStateVisitor.VisitDisconnect async Task IStateVisitor.VisitConnectedStateAsync(ConnectedState state, ShellInterpreter shell, CancellationToken token) { - var tp = CreateThroughputProperties(this.Scale, this.MaxRU); - var result = await state.Client.CreateDatabaseIfNotExistsAsync(this.Name, tp, cancellationToken: token); + var databaseName = await CosmosResourceFacade.CreateDatabaseAsync(state, this.Name ?? string.Empty, this.Scale, this.MaxRU, token); CosmosCompleteCommand.ClearDatabases(); - ShellInterpreter.WriteLine(MessageService.GetString("command-mkdb-database_created", new Dictionary { { "db", result.Database.Id } })); - var jsonString = $"{{\"created_database\": \"{result.Database.Id}\"}}"; + ShellInterpreter.WriteLine(MessageService.GetString("command-mkdb-database_created", new Dictionary { { "db", databaseName } })); + var jsonString = $"{{\"created_database\": \"{databaseName}\"}}"; using var jsonDoc = JsonDocument.Parse(jsonString); var commandState = new CommandState(); commandState.Result = new ShellJson(jsonDoc.RootElement.Clone()); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs index a6577c4..33619bd 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -44,7 +44,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co throw new NotConnectedException("replace"); } - var (_, _, container) = await ResolveContainerAsync( + var (databaseName, containerName, container) = await ResolveContainerAsync( connectedState.Client, shell.State, this.Database, @@ -52,8 +52,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co "replace", token); - var containerResponse = await container.ReadContainerAsync(cancellationToken: token); - var partitionKeyPaths = GetPartitionKeyPaths(containerResponse.Resource); + var partitionKeyPaths = await CosmosResourceFacade.GetPartitionKeyPathsAsync(connectedState, databaseName ?? string.Empty, containerName ?? string.Empty, token); await ReplaceItemsAsync(container, partitionKeyPaths, jsonOpt, this.ETag, token); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs index 4afc188..e082c7d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs @@ -35,11 +35,6 @@ internal class RmCommand : CosmosCommand, IStateVisitor [CosmosOption("key", "k")] public string? Key { get; init; } - public static new IAsyncEnumerable EnumerateDatabasesAsync(CosmosClient client) - { - throw new NotInContainerException("rm"); - } - public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) { if (commandState.Result == null) @@ -66,7 +61,7 @@ async Task IStateVisitor.VisitConnectedStateAs // If both database and container are specified, allow removing items if (!string.IsNullOrEmpty(this.Database) && !string.IsNullOrEmpty(this.Container)) { - return await this.RemoveItemsFromContainerAsync(state.Client, this.Database, this.Container, commandState, token); + return await this.RemoveItemsFromContainerAsync(state, this.Database, this.Container, commandState, token); } throw new NotInContainerException("rm"); @@ -77,7 +72,7 @@ async Task IStateVisitor.VisitDatabaseStateAsy string databaseName = this.Database ?? state.DatabaseName; if (!string.IsNullOrEmpty(this.Container)) { - return await this.RemoveItemsFromContainerAsync(state.Client, databaseName, this.Container, commandState, token); + return await this.RemoveItemsFromContainerAsync(state, databaseName, this.Container, commandState, token); } throw new NotInContainerException("rm"); @@ -88,10 +83,10 @@ async Task IStateVisitor.VisitContainerStateAs string databaseName = this.Database ?? state.DatabaseName; string containerName = this.Container ?? state.ContainerName; - return await this.RemoveItemsFromContainerAsync(state.Client, databaseName, containerName, commandState, token); + return await this.RemoveItemsFromContainerAsync(state, databaseName, containerName, commandState, token); } - private async Task RemoveItemsFromContainerAsync(CosmosClient client, string databaseName, string containerName, CommandState commandState, CancellationToken token) + private async Task RemoveItemsFromContainerAsync(ConnectedState state, string databaseName, string containerName, CommandState commandState, CancellationToken token) { if (this.shell == null) { @@ -106,13 +101,14 @@ private async Task RemoveItemsFromContainerAsync(CosmosClient client, } // Validate database and container exist - await ValidateContainerExistsAsync(client, databaseName, containerName, "rm", token); + await ValidateContainerExistsAsync(state, databaseName, containerName, "rm", token); + var client = state.Client; var container = client.GetDatabase(databaseName).GetContainer(containerName); // Get container properties to find the partition key paths - var containerResponse = await container.ReadContainerAsync(cancellationToken: token); - var partitionKeyPropertyNames = GetPartitionKeyPropertyNames(containerResponse.Resource.PartitionKeyPaths); + var partitionKeyPaths = await CosmosResourceFacade.GetPartitionKeyPathsAsync(state, databaseName, containerName, token); + var partitionKeyPropertyNames = GetPartitionKeyPropertyNames(partitionKeyPaths); // Determine which key to match against (partition key by default, or custom key if specified) var matchKeyPropertyNames = string.IsNullOrEmpty(this.Key) ? partitionKeyPropertyNames : [this.Key]; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs index 0f625a1..ae810e0 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs @@ -41,7 +41,7 @@ async Task IStateVisitor.VisitConnectedSta { if (!string.IsNullOrEmpty(this.Database)) { - return await this.RemoveContainerAsync(state.Client, this.Database, token); + return await this.RemoveContainerAsync(state, this.Database, token); } throw new NotInDatabaseException("rmcon"); @@ -51,42 +51,41 @@ async Task IStateVisitor.VisitDatabaseStat { if (!string.IsNullOrEmpty(this.Database)) { - return await this.RemoveContainerAsync(state.Client, this.Database, token); + return await this.RemoveContainerAsync(state, this.Database, token); } - return await this.RemoveContainerAsync(state.Client, state.DatabaseName, token); + return await this.RemoveContainerAsync(state, state.DatabaseName, token); } async Task IStateVisitor.VisitContainerStateAsync(ContainerState state, ShellInterpreter shell, CancellationToken token) { if (!string.IsNullOrEmpty(this.Database)) { - return await this.RemoveContainerAsync(state.Client, this.Database, token); + return await this.RemoveContainerAsync(state, this.Database, token); } throw new NotInContainerException("rmcon"); } - private async Task RemoveContainerAsync(CosmosClient client, string databaseName, CancellationToken token) + private async Task RemoveContainerAsync(ConnectedState state, string databaseName, CancellationToken token) { // Validate database exists - await ValidateDatabaseExistsAsync(client, databaseName, "rmcon", token); + await ValidateDatabaseExistsAsync(state, databaseName, "rmcon", token); - await foreach (var containerProperty in EnumerateContainersAsync(client.GetDatabase(databaseName))) + await foreach (var containerName in EnumerateContainerNamesAsync(state, databaseName, "rmcon", token)) { if (token.IsCancellationRequested) { return -1; } - if (containerProperty.Id == this.Name) + if (containerName == this.Name) { - var c = client.GetContainer(databaseName, this.Name); if (this.Force == true || ShellInterpreter.Confirm("command-rmcon-confirm_container_deletion")) { - await c.DeleteContainerAsync(cancellationToken: token); + await CosmosResourceFacade.DeleteContainerAsync(state, databaseName, containerName, token); CosmosCompleteCommand.ClearContainers(); - AnsiConsole.MarkupLine(MessageService.GetString("command-rmcon-deleted_container", new Dictionary { { "container", containerProperty.Id } })); + AnsiConsole.MarkupLine(MessageService.GetString("command-rmcon-deleted_container", new Dictionary { { "container", containerName } })); } return 0; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs index 02d7d8f..52b7806 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs @@ -35,12 +35,12 @@ Task IStateVisitor.VisitDisconnectedStateA async Task IStateVisitor.VisitConnectedStateAsync(ConnectedState state, ShellInterpreter shell, CancellationToken token) { - return await this.RemoveDatabaseAsync(state.Client, shell, token); + return await this.RemoveDatabaseAsync(state, shell, token); } async Task IStateVisitor.VisitDatabaseStateAsync(DatabaseState state, ShellInterpreter shell, CancellationToken token) { - return await this.RemoveDatabaseAsync(state.Client, shell, token); + return await this.RemoveDatabaseAsync(state, shell, token); } Task IStateVisitor.VisitContainerStateAsync(ContainerState state, ShellInterpreter shell, CancellationToken token) @@ -48,35 +48,39 @@ Task IStateVisitor.VisitContainerStateAsyn throw new CommandException("rmdb", MessageService.GetString("command-rmdb-error-not_allowed_in_container")); } - internal static void UpdateStateAfterDelete(ShellInterpreter shell, CosmosClient client, string deletedDatabaseName) + internal static void UpdateStateAfterDelete(ShellInterpreter shell, CosmosClient client, ArmCosmosContext? armContext, string deletedDatabaseName) { if (shell.State is DatabaseState databaseState && databaseState.DatabaseName == deletedDatabaseName) { - var connectedState = new ConnectedState(client); + var connectedState = new ConnectedState(client, armContext); shell.State = connectedState; CosmosCompleteCommand.ClearContainers(); } } - private async Task RemoveDatabaseAsync(CosmosClient client, ShellInterpreter shell, CancellationToken token) + internal static void UpdateStateAfterDelete(ShellInterpreter shell, CosmosClient client, string deletedDatabaseName) + { + UpdateStateAfterDelete(shell, client, null, deletedDatabaseName); + } + + private async Task RemoveDatabaseAsync(ConnectedState state, ShellInterpreter shell, CancellationToken token) { - await foreach (var database in EnumerateDatabasesAsync(client)) + await foreach (var databaseName in EnumerateDatabaseNamesAsync(state, "rmdb", token)) { if (token.IsCancellationRequested) { return -1; } - if (database.Id == this.Name) + if (databaseName == this.Name) { - var db = client.GetDatabase(this.Name); if (this.Force is true || ShellInterpreter.Confirm("command-rmdb-confirm_db_deletion")) { - await db.DeleteAsync(cancellationToken: token); - UpdateStateAfterDelete(shell, client, database.Id); + await CosmosResourceFacade.DeleteDatabaseAsync(state, databaseName, token); + UpdateStateAfterDelete(shell, state.Client, state.ArmContext, databaseName); CosmosCompleteCommand.ClearDatabases(); - var messageArguments = new Dictionary { { "db", database.Id } }; + var messageArguments = new Dictionary { { "db", databaseName } }; AnsiConsole.MarkupLine(MessageService.GetString("command-rmdb-deleted_db", messageArguments)); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SettingsCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SettingsCommand.cs index 24ca3a9..31bb4b9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SettingsCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SettingsCommand.cs @@ -19,11 +19,6 @@ namespace Azure.Data.Cosmos.Shell.Commands; [CosmosExample("settings --database=MyDB --container=Products", Description = "Display container settings for a specific database and container")] internal class SettingsCommand : CosmosCommand { - /// - /// Cosmos DB substatus returned when no dedicated throughput offer exists for the current container. - /// - private const int ThroughputNotConfiguredSubStatusCode = 1003; - private static readonly Regex PrincipalIdRegex = new("Request for (.*) is blocked because principal \\[(.*)\\] does not have required RBAC permissions to perform action \\[(.*)\\]"); [CosmosOption("database", "db")] @@ -58,7 +53,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co // If both database and container are resolved, show container settings if (!string.IsNullOrEmpty(databaseName) && !string.IsNullOrEmpty(containerName)) { - return await ShowContainerSettingsAsync(connectedState.Client, databaseName, containerName, commandState, token); + return await ShowContainerSettingsAsync(connectedState, databaseName, containerName, commandState, token); } // Otherwise show account overview @@ -76,83 +71,45 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co } } - private static async Task ShowContainerSettingsAsync(CosmosClient client, string databaseName, string containerName, CommandState commandState, CancellationToken token) + private static async Task ShowContainerSettingsAsync(ConnectedState state, string databaseName, string containerName, CommandState commandState, CancellationToken token) { - var database = client.GetDatabase(databaseName); - var container = database.GetContainer(containerName); + var view = await CosmosResourceFacade.GetContainerSettingsAsync(state, databaseName, containerName, token); var mcpTable = new Dictionary(); // Scale section - fail gracefully if it cannot be read AnsiConsole.MarkupLine($"[bold]{MessageService.GetString("command-settings-scale-heading")}[/]"); - try - { - var throughputResponse = await container.ReadThroughputAsync(null, cancellationToken: token); - var min = throughputResponse.MinThroughput ?? 0; - var max = throughputResponse.Resource.AutoscaleMaxThroughput.HasValue ? throughputResponse.Resource.AutoscaleMaxThroughput.Value : throughputResponse.Resource.Throughput; - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-settings-scale-usage", "min", min, "max", max ?? min)); - mcpTable["minThroughput"] = min; - mcpTable["maxThroughput"] = max ?? min; - } - catch (Exception e) + AnsiConsole.Markup("\t"); + switch (view.Throughput) { - AnsiConsole.Markup("\t"); - if (TryGetPrincipialIdFromRbacException(e, out var id, out var request, out var permission)) - { - AskForRBacPermissions(id ?? string.Empty, request ?? string.Empty, permission ?? string.Empty); - } - else if (IsThroughputNotConfiguredException(e)) - { - // No dedicated throughput is configured on this container - show N/A - AnsiConsole.MarkupLine($"[grey]{MessageService.GetString("command-settings-na")}[/]"); - } - else - { - AnsiConsole.MarkupLine($"[red]{Markup.Escape(CommandException.GetDisplayMessage(e))}[/]"); - } - } - - AnsiConsole.MarkupLine(string.Empty); + case ThroughputAvailability.Available: + var minDisplay = view.MinThroughput?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? MessageService.GetString("command-settings-na"); + var maxDisplay = view.MaxThroughput?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? MessageService.GetString("command-settings-na"); + AnsiConsole.MarkupLine(MessageService.GetArgsString("command-settings-scale-usage", "min", minDisplay, "max", maxDisplay)); + if (view.MinThroughput.HasValue) + { + mcpTable["minThroughput"] = view.MinThroughput.Value; + } - // Container settings section - fail gracefully if it cannot be read - ContainerProperties? resource = null; - try - { - var containerResponse = await container.ReadContainerAsync(cancellationToken: token); - resource = containerResponse.Resource; - } - catch (Exception e) - { - AnsiConsole.MarkupLine($"[bold]{MessageService.GetString("command-settings-title")}[/]"); - AnsiConsole.Markup("\t"); - if (TryGetPrincipialIdFromRbacException(e, out var id, out var request, out var permission)) - { - AskForRBacPermissions(id ?? string.Empty, request ?? string.Empty, permission ?? string.Empty); - } - else - { - AnsiConsole.MarkupLine($"[red]{Markup.Escape(CommandException.GetDisplayMessage(e))}[/]"); - } + if (view.MaxThroughput.HasValue) + { + mcpTable["maxThroughput"] = view.MaxThroughput.Value; + } - commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(mcpTable)); - commandState.IsPrinted = true; - return commandState; + break; + case ThroughputAvailability.NotConfigured: + AnsiConsole.MarkupLine($"[grey]{MessageService.GetString("command-settings-na")}[/]"); + break; + default: + AnsiConsole.MarkupLine($"[red]{Markup.Escape(view.ThroughputErrorMessage ?? string.Empty)}[/]"); + break; } - if (resource == null) - { - AnsiConsole.MarkupLine($"[bold]{MessageService.GetString("command-settings-title")}[/]"); - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine($"[yellow]{MessageService.GetString("command-settings-not-available")}[/]"); - commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(mcpTable)); - commandState.IsPrinted = true; - return commandState; - } + AnsiConsole.MarkupLine(string.Empty); - mcpTable["id"] = resource.Id; - mcpTable["partitionKey"] = resource.PartitionKeyPaths; - mcpTable["analyticalTTL"] = resource.AnalyticalStoreTimeToLiveInSeconds; + mcpTable["id"] = view.ContainerName; + mcpTable["partitionKey"] = view.PartitionKeyPaths; + mcpTable["analyticalTTL"] = view.AnalyticalStorageTtl; AnsiConsole.MarkupLine($"[bold]{MessageService.GetString("command-settings-title")}[/]"); @@ -160,12 +117,12 @@ private static async Task ShowContainerSettingsAsync(CosmosClient table.AddColumns(string.Empty, string.Empty); string ttl; - if (resource.AnalyticalStoreTimeToLiveInSeconds == null || - resource.AnalyticalStoreTimeToLiveInSeconds == 0) + if (view.AnalyticalStorageTtl == null || + view.AnalyticalStorageTtl == 0) { ttl = MessageService.GetString("command-settings-Off"); } - else if (resource.AnalyticalStoreTimeToLiveInSeconds == -1) + else if (view.AnalyticalStorageTtl == -1) { ttl = MessageService.GetString("command-settings-On"); } @@ -174,64 +131,54 @@ private static async Task ShowContainerSettingsAsync(CosmosClient ttl = MessageService.GetArgsString( "command-settings-ttl-seconds", "seconds", - resource.AnalyticalStoreTimeToLiveInSeconds); + view.AnalyticalStorageTtl); } table.AddRow(MessageService.GetString("command-settings-ttl-label"), $"[white]{ttl}[/]"); - string label; - switch (resource.GeospatialConfig.GeospatialType) + if (view.GeospatialType is { } geospatialType) { - case GeospatialType.Geography: - label = MessageService.GetString("command-settings-geospatial-geography"); - mcpTable["geospatialType"] = "Geography"; - break; - default: - mcpTable["geospatialType"] = "Geometry"; - label = MessageService.GetString("command-settings-geospatial-geometry"); - break; + string geospatialLabel = string.Equals(geospatialType, "Geography", StringComparison.OrdinalIgnoreCase) + ? MessageService.GetString("command-settings-geospatial-geography") + : MessageService.GetString("command-settings-geospatial-geometry"); + table.AddRow(MessageService.GetString("command-settings-geospatial-label"), $"[white]{geospatialLabel}[/]"); + mcpTable["geospatialType"] = geospatialType; } - table.AddRow(MessageService.GetString("command-settings-geospatial-label"), $"[white]{label}[/]"); - table.AddRow(MessageService.GetString("command-settings-partition-key-label"), $"[white]{string.Join(',', resource.PartitionKeyPaths)}[/]"); + table.AddRow(MessageService.GetString("command-settings-partition-key-label"), $"[white]{string.Join(',', view.PartitionKeyPaths)}[/]"); table.HideHeaders(); AnsiConsole.Write(table); - // Full Text Policy section - show N/A if unset - AnsiConsole.MarkupLine($"[bold]{MessageService.GetString("command-settings-fulltext-title")}[/]"); - - if (resource.FullTextPolicy != null) + // Full Text Policy section - only emitted when known + if (view.FullTextPolicy is { } fullText) { - table = new Table(); - table.AddColumns(string.Empty, string.Empty); + AnsiConsole.MarkupLine($"[bold]{MessageService.GetString("command-settings-fulltext-title")}[/]"); - string defaultLanguage = resource.FullTextPolicy.DefaultLanguage; - table.AddRow( - MessageService.GetString("command-settings-fulltext-default-language-label"), - $"[white]{(string.IsNullOrEmpty(defaultLanguage) ? MessageService.GetString("command-settings-na") : defaultLanguage)}[/]"); + var fullTextTable = new Table(); + fullTextTable.AddColumns(string.Empty, string.Empty); - var fullTextPaths = new List>(); + var defaultLanguage = string.IsNullOrEmpty(fullText.DefaultLanguage) + ? MessageService.GetString("command-settings-na") + : fullText.DefaultLanguage; + fullTextTable.AddRow( + MessageService.GetString("command-settings-fulltext-default-language-label"), + $"[white]{defaultLanguage}[/]"); - foreach (var path in resource.FullTextPolicy.FullTextPaths) + var mcpPaths = new List>(); + foreach (var path in fullText.Paths) { - table.AddRow(MessageService.GetString("command-settings-fulltext-path-label"), $"[white]{path.Path}[/]"); - table.AddRow(MessageService.GetString("command-settings-fulltext-language-label"), $"[white]{path.Language}[/]"); - - fullTextPaths.Add(new Dictionary + fullTextTable.AddRow(MessageService.GetString("command-settings-fulltext-path-label"), $"[white]{path.Path}[/]"); + fullTextTable.AddRow(MessageService.GetString("command-settings-fulltext-language-label"), $"[white]{path.Language}[/]"); + mcpPaths.Add(new Dictionary { { "path", path.Path }, { "language", path.Language }, }); } - mcpTable["fullTextPolicy"] = fullTextPaths; - table.HideHeaders(); - AnsiConsole.Write(table); - } - else - { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine($"[grey]{MessageService.GetString("command-settings-na")}[/]"); + mcpTable["fullTextPolicy"] = mcpPaths; + fullTextTable.HideHeaders(); + AnsiConsole.Write(fullTextTable); } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(mcpTable)); @@ -262,27 +209,6 @@ private static void AskForRBacPermissions(string principalId, string request, st ShellInterpreter.WriteLine(MessageService.GetArgsString("command-settings-rbac-error", "id", principalId, "request", request, "permission", permission)); } - /// - /// Checks if the exception indicates that no dedicated throughput is configured on the container - /// (e.g., Serverless accounts, Emulators, or containers using shared database throughput). - /// - private static bool IsThroughputNotConfiguredException(Exception e) - { - if (e is not CosmosException cosmosEx || - cosmosEx.StatusCode != System.Net.HttpStatusCode.NotFound) - { - return false; - } - - if ((int)cosmosEx.SubStatusCode == ThroughputNotConfiguredSubStatusCode) - { - // Cosmos DB uses this substatus when the container has no dedicated throughput offer. - return true; - } - - return cosmosEx.Message.Contains("Throughput is not configured", StringComparison.OrdinalIgnoreCase); - } - private static async Task PrintOverviewAsync(CosmosClient client, CommandState commandState, CancellationToken token) { var acc = await client.ReadAccountAsync(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ArmCosmosContext.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ArmCosmosContext.cs new file mode 100644 index 0000000..a11d0bf --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ArmCosmosContext.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using global::Azure.Core; +using global::Azure.ResourceManager; +using global::Azure.ResourceManager.CosmosDB; + +internal sealed class ArmCosmosContext( + ArmClient armClient, + ResourceIdentifier accountResourceId, + string subscriptionId, + string resourceGroupName, + string accountName, + Uri accountEndpoint, + CosmosDBAccountResource accountResource) +{ + public ArmClient ArmClient { get; } = armClient; + + public ResourceIdentifier AccountResourceId { get; } = accountResourceId; + + public string SubscriptionId { get; } = subscriptionId; + + public string ResourceGroupName { get; } = resourceGroupName; + + public string AccountName { get; } = accountName; + + public Uri AccountEndpoint { get; } = accountEndpoint; + + public CosmosDBAccountResource Account { get; } = accountResource; +} \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerFullTextPathView.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerFullTextPathView.cs new file mode 100644 index 0000000..93e9b86 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerFullTextPathView.cs @@ -0,0 +1,7 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +internal sealed record ContainerFullTextPathView(string Path, string? Language); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerFullTextPolicyView.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerFullTextPolicyView.cs new file mode 100644 index 0000000..0a3470b --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerFullTextPolicyView.cs @@ -0,0 +1,9 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +internal sealed record ContainerFullTextPolicyView( + string? DefaultLanguage, + IReadOnlyList Paths); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerSettingsView.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerSettingsView.cs new file mode 100644 index 0000000..bd41e7f --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ContainerSettingsView.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +internal sealed record ContainerSettingsView( + string ContainerName, + IReadOnlyList PartitionKeyPaths, + long? AnalyticalStorageTtl, + int? MinThroughput, + int? MaxThroughput, + ThroughputAvailability Throughput, + string? ThroughputErrorMessage, + string? GeospatialType, + ContainerFullTextPolicyView? FullTextPolicy); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosArmResourceProvider.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosArmResourceProvider.cs new file mode 100644 index 0000000..34f41ab --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosArmResourceProvider.cs @@ -0,0 +1,304 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using System.ClientModel.Primitives; +using System.Net; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Util; +using global::Azure; +using global::Azure.Core; +using global::Azure.Core.Pipeline; +using global::Azure.Identity; +using global::Azure.ResourceManager; +using global::Azure.ResourceManager.CosmosDB; +using global::Azure.ResourceManager.CosmosDB.Models; + +internal static class CosmosArmResourceProvider +{ + public static async Task TryCreateContextAsync( + TokenCredential? credential, + Uri dataPlaneEndpoint, + string? subscriptionId, + string? resourceGroupName, + string? accountName, + CancellationToken token) + { + if (credential == null) + { + return null; + } + + var armClient = new ArmClient(credential); + + var hasSubscription = !string.IsNullOrWhiteSpace(subscriptionId); + var hasResourceGroup = !string.IsNullOrWhiteSpace(resourceGroupName); + var hasAccount = !string.IsNullOrWhiteSpace(accountName); + + if (hasSubscription || hasResourceGroup || hasAccount) + { + if (!hasSubscription || !hasResourceGroup || !hasAccount) + { + throw new ShellException(MessageService.GetString("error-arm-context-incomplete")); + } + + return await CreateExplicitContextAsync(armClient, dataPlaneEndpoint, subscriptionId!, resourceGroupName!, accountName!, token); + } + + return await DiscoverContextAsync(armClient, dataPlaneEndpoint, token); + } + + public static ArmCosmosContext RequireContext(ArmCosmosContext? context, string commandName) + { + if (context == null) + { + throw new CommandException( + commandName, + MessageService.GetString("error-arm-context-required")); + } + + return context; + } + + public static async Task GetDatabaseAsync(ArmCosmosContext context, string databaseName, CancellationToken token) + { + var response = await context.Account.GetCosmosDBSqlDatabases().GetIfExistsAsync(databaseName, token); + if (!response.HasValue || response.Value is null) + { + throw new RequestFailedException((int)HttpStatusCode.NotFound, $"Database '{databaseName}' was not found."); + } + + return response.Value; + } + + public static async Task GetContainerAsync(ArmCosmosContext context, string databaseName, string containerName, CancellationToken token) + { + var database = await GetDatabaseAsync(context, databaseName, token); + var response = await database.GetCosmosDBSqlContainers().GetIfExistsAsync(containerName, token); + if (!response.HasValue || response.Value is null) + { + throw new RequestFailedException((int)HttpStatusCode.NotFound, $"Container '{containerName}' was not found in database '{databaseName}'."); + } + + return response.Value; + } + + public static async IAsyncEnumerable GetDatabasesAsync(ArmCosmosContext context, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default) + { + await foreach (var database in context.Account.GetCosmosDBSqlDatabases().GetAllAsync(token)) + { + yield return database; + } + } + + public static async IAsyncEnumerable GetContainersAsync(ArmCosmosContext context, string databaseName, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default) + { + var database = await GetDatabaseAsync(context, databaseName, token); + await foreach (var container in database.GetCosmosDBSqlContainers().GetAllAsync(cancellationToken: token)) + { + yield return container; + } + } + + public static async Task CreateDatabaseAsync(ArmCosmosContext context, string databaseName, string? scale, int? maxRu, CancellationToken token) + { + var content = new CosmosDBSqlDatabaseCreateOrUpdateContent( + context.Account.Data.Location, + new CosmosDBSqlDatabaseResourceInfo(databaseName)) + { + Options = CreateUpdateConfig(scale, maxRu), + }; + + var operation = await context.Account.GetCosmosDBSqlDatabases().CreateOrUpdateAsync(WaitUntil.Completed, databaseName, content, token); + return operation.Value; + } + + public static async Task CreateContainerAsync( + ArmCosmosContext context, + string databaseName, + string containerName, + IReadOnlyList partitionKeyPaths, + string? uniqueKey, + string? indexPolicyJson, + string? scale, + int? maxRu, + CancellationToken token) + { + var database = await GetDatabaseAsync(context, databaseName, token); + var resource = new CosmosDBSqlContainerResourceInfo(containerName) + { + PartitionKey = new CosmosDBContainerPartitionKey + { + Kind = partitionKeyPaths.Count > 1 ? CosmosDBPartitionKind.MultiHash : CosmosDBPartitionKind.Hash, + Version = partitionKeyPaths.Count > 1 ? 2 : 1, + }, + }; + + foreach (var path in partitionKeyPaths) + { + resource.PartitionKey.Paths.Add(path); + } + + if (!string.IsNullOrWhiteSpace(uniqueKey)) + { + var armUniqueKey = new CosmosDBUniqueKey(); + foreach (var path in uniqueKey.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + armUniqueKey.Paths.Add(path); + } + + resource.UniqueKeys.Add(armUniqueKey); + } + + if (!string.IsNullOrWhiteSpace(indexPolicyJson)) + { + resource.IndexingPolicy = ReadArmModel(indexPolicyJson); + } + + var content = new CosmosDBSqlContainerCreateOrUpdateContent(context.Account.Data.Location, resource) + { + Options = CreateUpdateConfig(scale, maxRu), + }; + + var operation = await database.GetCosmosDBSqlContainers().CreateOrUpdateAsync(WaitUntil.Completed, containerName, content, token); + return operation.Value; + } + + public static async Task DeleteDatabaseAsync(ArmCosmosContext context, string databaseName, CancellationToken token) + { + var database = await GetDatabaseAsync(context, databaseName, token); + await database.DeleteAsync(WaitUntil.Completed, token); + } + + public static async Task DeleteContainerAsync(ArmCosmosContext context, string databaseName, string containerName, CancellationToken token) + { + var container = await GetContainerAsync(context, databaseName, containerName, token); + await container.DeleteAsync(WaitUntil.Completed, token); + } + + public static IReadOnlyList GetPartitionKeyPaths(CosmosDBSqlContainerResource container) + { + return container.Data.Resource.PartitionKey?.Paths?.ToArray() ?? []; + } + + public static CosmosDBCreateUpdateConfig CreateUpdateConfig(string? scale, int? maxRu) + { + var ru = maxRu ?? 1000; + if (string.Equals(scale, "manual", StringComparison.InvariantCultureIgnoreCase) || + string.Equals(scale, "m", StringComparison.InvariantCultureIgnoreCase)) + { + return new CosmosDBCreateUpdateConfig + { + Throughput = ru, + }; + } + + return new CosmosDBCreateUpdateConfig + { + AutoscaleMaxThroughput = ru, + }; + } + + public static string WriteArmModel(T model) + where T : IPersistableModel + { + return ModelReaderWriter.Write(model, ModelReaderWriterOptions.Json).ToString(); + } + + public static T ReadArmModel(string json) + where T : IPersistableModel + { + var model = ModelReaderWriter.Read(BinaryData.FromString(json), ModelReaderWriterOptions.Json); + if (model is null) + { + throw new InvalidOperationException("Unable to read ARM model JSON."); + } + + return model; + } + + private static async Task CreateExplicitContextAsync( + ArmClient armClient, + Uri dataPlaneEndpoint, + string subscriptionId, + string resourceGroupName, + string accountName, + CancellationToken token) + { + var accountResourceId = CosmosDBAccountResource.CreateResourceIdentifier(subscriptionId, resourceGroupName, accountName); + var account = armClient.GetCosmosDBAccountResource(accountResourceId); + var response = await account.GetAsync(token); + var endpoint = new Uri(response.Value.Data.DocumentEndpoint); + ValidateEndpoint(dataPlaneEndpoint, endpoint); + + return new ArmCosmosContext(armClient, accountResourceId, subscriptionId, resourceGroupName, accountName, endpoint, response.Value); + } + + private static async Task DiscoverContextAsync(ArmClient armClient, Uri dataPlaneEndpoint, CancellationToken token) + { + CosmosDBAccountResource? singleMatch = null; + bool multipleMatches = false; + + await foreach (var subscription in armClient.GetSubscriptions().GetAllAsync(token)) + { + await foreach (var account in subscription.GetCosmosDBAccountsAsync(token)) + { + if (!EndpointEquals(dataPlaneEndpoint, new Uri(account.Data.DocumentEndpoint))) + { + continue; + } + + if (singleMatch is null) + { + singleMatch = account; + continue; + } + + // We already have one match; finding a second is enough to know the discovery + // is ambiguous, so stop scanning further subscriptions/accounts. + multipleMatches = true; + break; + } + + if (multipleMatches) + { + break; + } + } + + if (multipleMatches) + { + throw new ShellException(MessageService.GetString("error-arm-context-ambiguous")); + } + + if (singleMatch is null) + { + return null; + } + + var id = singleMatch.Id; + return new ArmCosmosContext( + armClient, + id, + id.SubscriptionId ?? string.Empty, + id.ResourceGroupName ?? string.Empty, + singleMatch.Data.Name, + new Uri(singleMatch.Data.DocumentEndpoint), + singleMatch); + } + + private static void ValidateEndpoint(Uri dataPlaneEndpoint, Uri armEndpoint) + { + if (!EndpointEquals(dataPlaneEndpoint, armEndpoint)) + { + throw new InvalidOperationException($"The ARM account endpoint '{armEndpoint}' does not match the connected Cosmos DB endpoint '{dataPlaneEndpoint}'."); + } + } + + private static bool EndpointEquals(Uri left, Uri right) + { + return Uri.Compare(left, right, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0; + } +} \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs index 053a25d..b12be49 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs @@ -73,7 +73,7 @@ internal abstract class CosmosCommand } // Validate that database and container exist - await ValidateContainerExistsAsync(client, databaseName, containerName, commandName, token); + await ValidateContainerExistsAsync(RequireConnectedState(state, commandName), databaseName, containerName, commandName, token); var container = client.GetDatabase(databaseName).GetContainer(containerName); return (databaseName, containerName, container); @@ -125,7 +125,7 @@ internal abstract class CosmosCommand } // Validate that database exists - await ValidateDatabaseExistsAsync(client, databaseName, commandName, token); + await ValidateDatabaseExistsAsync(RequireConnectedState(state, commandName), databaseName, commandName, token); var database = client.GetDatabase(databaseName); return (databaseName ?? string.Empty, database); @@ -139,19 +139,14 @@ internal abstract class CosmosCommand /// The name of the command requesting validation (for error reporting). /// Cancellation token. /// Thrown when the database does not exist. - protected static async Task ValidateDatabaseExistsAsync(CosmosClient client, string? databaseName, string commandName, CancellationToken token) + protected static async Task ValidateDatabaseExistsAsync(ConnectedState state, string? databaseName, string commandName, CancellationToken token) { if (databaseName == null) { return; } - try - { - var database = client.GetDatabase(databaseName); - await database.ReadAsync(cancellationToken: token); - } - catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + if (!await CosmosResourceFacade.DatabaseExistsAsync(state, databaseName, token)) { throw new CommandException( commandName, @@ -168,7 +163,7 @@ protected static async Task ValidateDatabaseExistsAsync(CosmosClient client, str /// The name of the command requesting validation (for error reporting). /// Cancellation token. /// Thrown when the database or container does not exist. - protected static async Task ValidateContainerExistsAsync(CosmosClient client, string? databaseName, string? containerName, string commandName, CancellationToken token) + protected static async Task ValidateContainerExistsAsync(ConnectedState state, string? databaseName, string? containerName, string commandName, CancellationToken token) { if (databaseName == null) { @@ -176,20 +171,14 @@ protected static async Task ValidateContainerExistsAsync(CosmosClient client, st } // First validate the database exists - await ValidateDatabaseExistsAsync(client, databaseName, commandName, token); + await ValidateDatabaseExistsAsync(state, databaseName, commandName, token); if (containerName == null) { return; } - // Then validate the container exists - try - { - var container = client.GetDatabase(databaseName).GetContainer(containerName); - await container.ReadContainerAsync(cancellationToken: token); - } - catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + if (!await CosmosResourceFacade.ContainerExistsAsync(state, databaseName, containerName, token)) { throw new CommandException( commandName, @@ -202,23 +191,27 @@ protected static async Task ValidateContainerExistsAsync(CosmosClient client, st } } - protected static async IAsyncEnumerable EnumerateDatabasesAsync(CosmosClient client) + protected static IAsyncEnumerable EnumerateDatabaseNamesAsync(ConnectedState state, string commandName, CancellationToken token) { - using var feedIterator = client.GetDatabaseQueryIterator(); - while (feedIterator.HasMoreResults) - { - var response = await feedIterator.ReadNextAsync(); - foreach (var database in response) - { - yield return database; - } - } + _ = commandName; + return CosmosResourceFacade.GetDatabaseNamesAsync(state, token); + } + + protected static IAsyncEnumerable EnumerateContainerNamesAsync(ConnectedState state, string databaseName, string commandName, CancellationToken token) + { + _ = commandName; + return CosmosResourceFacade.GetContainerNamesAsync(state, databaseName, token); } - protected static IAsyncEnumerable EnumerateContainersAsync(Database database) + private static ConnectedState RequireConnectedState(State state, string commandName) { - using var feedIterator = database.GetContainerQueryIterator(); - return EnumerateFeedAsync(feedIterator); + if (state is ConnectedState connectedState) + { + return connectedState; + } + + ThrowNotConnected(commandName); + throw new InvalidOperationException(); } protected static async IAsyncEnumerable EnumerateFeedAsync(FeedIterator feedIterator) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompleteCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompleteCommand.cs index 661ac1e..1ed3782 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompleteCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompleteCommand.cs @@ -267,9 +267,9 @@ private static string GetContainerCacheKey(CosmosClient client, string databaseN private static async Task GetDatabasesAsync(ConnectedState state) { var result = new List(); - await foreach (var database in EnumerateDatabasesAsync(state.Client)) + await foreach (var name in CosmosResourceFacade.GetDatabaseNamesAsync(state, CancellationToken.None)) { - result.Add(database.Id); + result.Add(name); } return [.. result]; @@ -278,43 +278,13 @@ private static async Task GetDatabasesAsync(ConnectedState state) private static async Task GetContainersAsync(DatabaseState state) { var result = new List(); - await foreach (var container in EnumerateContainersAsync(state.Client.GetDatabase(state.DatabaseName))) + await foreach (var name in CosmosResourceFacade.GetContainerNamesAsync(state, state.DatabaseName, CancellationToken.None)) { - result.Add(container.Id); + result.Add(name); } return [.. result]; } - private static async IAsyncEnumerable EnumerateDatabasesAsync(CosmosClient client) - { - using var feedIterator = client.GetDatabaseQueryIterator("SELECT * FROM c"); - await foreach (var item in EnumerateFeedAsync(feedIterator)) - { - yield return item; - } - } - - private static async IAsyncEnumerable EnumerateContainersAsync(Database database) - { - using var feedIterator = database.GetContainerQueryIterator("SELECT * FROM c"); - await foreach (var item in EnumerateFeedAsync(feedIterator)) - { - yield return item; - } - } - - private static async IAsyncEnumerable EnumerateFeedAsync(FeedIterator feedIterator) - { - while (feedIterator.HasMoreResults) - { - var response = await feedIterator.ReadNextAsync(); - foreach (var container in response) - { - yield return container; - } - } - } - private sealed record CompletionCacheEntry(string[] Items, DateTimeOffset RefreshedAt); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosResourceFacade.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosResourceFacade.cs new file mode 100644 index 0000000..cdde513 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosResourceFacade.cs @@ -0,0 +1,367 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using System.Net; +using System.Runtime.CompilerServices; +using Azure.Data.Cosmos.Shell.States; +using global::Azure; +using global::Azure.ResourceManager.CosmosDB.Models; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; + +/// +/// Hybrid resource access for databases and containers. Prefers Azure Resource Manager +/// when an is attached to the connected state, and otherwise +/// falls back to the Cosmos data plane. The fallback is required for connections that have +/// no ARM equivalent (account key, static token, emulator). +/// +internal static class CosmosResourceFacade +{ + public static async IAsyncEnumerable GetDatabaseNamesAsync(ConnectedState state, [EnumeratorCancellation] CancellationToken token) + { + if (state.ArmContext is { } arm) + { + await foreach (var database in CosmosArmResourceProvider.GetDatabasesAsync(arm, token)) + { + token.ThrowIfCancellationRequested(); + yield return database.Data.Resource.DatabaseName; + } + + yield break; + } + + using var iterator = state.Client.GetDatabaseQueryIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(token); + foreach (var database in page) + { + yield return database.Id; + } + } + } + + public static async IAsyncEnumerable GetContainerNamesAsync(ConnectedState state, string databaseName, [EnumeratorCancellation] CancellationToken token) + { + if (state.ArmContext is { } arm) + { + await foreach (var container in CosmosArmResourceProvider.GetContainersAsync(arm, databaseName, token)) + { + token.ThrowIfCancellationRequested(); + yield return container.Data.Resource.ContainerName; + } + + yield break; + } + + var database = state.Client.GetDatabase(databaseName); + using var iterator = database.GetContainerQueryIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(token); + foreach (var container in page) + { + yield return container.Id; + } + } + } + + public static async Task DatabaseExistsAsync(ConnectedState state, string databaseName, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + try + { + await CosmosArmResourceProvider.GetDatabaseAsync(arm, databaseName, token); + return true; + } + catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.NotFound) + { + return false; + } + } + + try + { + var response = await state.Client.GetDatabase(databaseName).ReadAsync(cancellationToken: token); + return response.StatusCode == HttpStatusCode.OK; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + } + + public static async Task ContainerExistsAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + try + { + await CosmosArmResourceProvider.GetContainerAsync(arm, databaseName, containerName, token); + return true; + } + catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.NotFound) + { + return false; + } + } + + try + { + var response = await state.Client.GetDatabase(databaseName).GetContainer(containerName).ReadContainerAsync(cancellationToken: token); + return response.StatusCode == HttpStatusCode.OK; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + } + + public static async Task CreateDatabaseAsync(ConnectedState state, string databaseName, string? scale, int? maxRu, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + var resource = await CosmosArmResourceProvider.CreateDatabaseAsync(arm, databaseName, scale, maxRu, token); + return resource.Data.Resource.DatabaseName; + } + + var throughput = CreateThroughputProperties(scale, maxRu); + var response = await state.Client.CreateDatabaseIfNotExistsAsync(databaseName, throughput, cancellationToken: token); + return response.Database.Id; + } + + public static async Task CreateContainerAsync( + ConnectedState state, + string databaseName, + string containerName, + IReadOnlyList partitionKeyPaths, + string? uniqueKey, + string? indexPolicyJson, + string? scale, + int? maxRu, + CancellationToken token) + { + if (state.ArmContext is { } arm) + { + var resource = await CosmosArmResourceProvider.CreateContainerAsync(arm, databaseName, containerName, partitionKeyPaths, uniqueKey, indexPolicyJson, scale, maxRu, token); + return resource.Data.Resource.ContainerName; + } + + var props = partitionKeyPaths.Count > 1 + ? new ContainerProperties(containerName, partitionKeyPaths.ToList()) + : new ContainerProperties(containerName, partitionKeyPaths[0]); + + if (!string.IsNullOrWhiteSpace(uniqueKey)) + { + var key = new UniqueKey(); + foreach (var path in uniqueKey.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + key.Paths.Add(path); + } + + props.UniqueKeyPolicy.UniqueKeys.Add(key); + } + + if (!string.IsNullOrWhiteSpace(indexPolicyJson)) + { + var policy = JsonConvert.DeserializeObject(indexPolicyJson) + ?? throw new InvalidOperationException("Unable to parse indexing policy JSON."); + props.IndexingPolicy = policy; + } + + var throughput = CreateThroughputProperties(scale, maxRu); + var database = state.Client.GetDatabase(databaseName); + var response = await database.CreateContainerIfNotExistsAsync(props, throughput, cancellationToken: token); + return response.Container.Id; + } + + public static async Task DeleteDatabaseAsync(ConnectedState state, string databaseName, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + await CosmosArmResourceProvider.DeleteDatabaseAsync(arm, databaseName, token); + return; + } + + await state.Client.GetDatabase(databaseName).DeleteAsync(cancellationToken: token); + } + + public static async Task DeleteContainerAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + await CosmosArmResourceProvider.DeleteContainerAsync(arm, databaseName, containerName, token); + return; + } + + await state.Client.GetDatabase(databaseName).GetContainer(containerName).DeleteContainerAsync(cancellationToken: token); + } + + public static async Task> GetPartitionKeyPathsAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + var resource = await CosmosArmResourceProvider.GetContainerAsync(arm, databaseName, containerName, token); + return CosmosArmResourceProvider.GetPartitionKeyPaths(resource); + } + + var response = await state.Client.GetDatabase(databaseName).GetContainer(containerName).ReadContainerAsync(cancellationToken: token); + var properties = response.Resource; + if (properties == null) + { + return []; + } + + if (properties.PartitionKeyPaths is { Count: > 0 } paths) + { + return [.. paths]; + } + + return string.IsNullOrEmpty(properties.PartitionKeyPath) ? [] : [properties.PartitionKeyPath]; + } + + public static async Task GetContainerSettingsAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + var resource = await CosmosArmResourceProvider.GetContainerAsync(arm, databaseName, containerName, token); + int? min = null; + int? max = null; + ThroughputAvailability throughputAvailability = ThroughputAvailability.Available; + string? throughputError = null; + try + { + var throughputResponse = await resource.GetCosmosDBSqlContainerThroughputSetting().GetAsync(token); + var throughput = throughputResponse.Value.Data.Resource; + min = int.TryParse(throughput.MinimumThroughput, out var parsedMin) ? parsedMin : null; + max = throughput.AutoscaleSettings?.MaxThroughput ?? throughput.Throughput ?? min; + } + catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.NotFound) + { + throughputAvailability = ThroughputAvailability.NotConfigured; + } + catch (Exception ex) + { + throughputAvailability = ThroughputAvailability.Unavailable; + throughputError = ex.Message; + } + + var armResource = resource.Data.Resource; + return new ContainerSettingsView( + armResource.ContainerName, + armResource.PartitionKey?.Paths?.ToArray() ?? [], + armResource.AnalyticalStorageTtl, + min, + max, + throughputAvailability, + throughputError, + GeospatialType: null, + FullTextPolicy: null); + } + + var dpResponse = await state.Client.GetDatabase(databaseName).GetContainer(containerName).ReadContainerAsync(cancellationToken: token); + var properties = dpResponse.Resource; + int? dpMin = null; + int? dpMax = null; + ThroughputAvailability dpAvailability = ThroughputAvailability.Available; + string? dpError = null; + try + { + var throughputResponse = await state.Client.GetDatabase(databaseName).GetContainer(containerName).ReadThroughputAsync(new RequestOptions(), token); + dpMin = throughputResponse.MinThroughput; + dpMax = throughputResponse.Resource?.AutoscaleMaxThroughput ?? throughputResponse.Resource?.Throughput ?? dpMin; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + dpAvailability = ThroughputAvailability.NotConfigured; + } + catch (Exception ex) + { + dpAvailability = ThroughputAvailability.Unavailable; + dpError = ex.Message; + } + + string? geospatialType = properties.GeospatialConfig?.GeospatialType switch + { + GeospatialType.Geography => "Geography", + GeospatialType.Geometry => "Geometry", + _ => null, + }; + + ContainerFullTextPolicyView? fullTextView = null; + if (properties.FullTextPolicy is { } fullTextPolicy) + { + var paths = fullTextPolicy.FullTextPaths is null + ? Array.Empty() + : fullTextPolicy.FullTextPaths.Select(p => new ContainerFullTextPathView(p.Path, p.Language)).ToArray(); + fullTextView = new ContainerFullTextPolicyView(fullTextPolicy.DefaultLanguage, paths); + } + + return new ContainerSettingsView( + properties.Id, + properties.PartitionKeyPaths?.ToArray() ?? (properties.PartitionKeyPath != null ? [properties.PartitionKeyPath] : []), + properties.AnalyticalStoreTimeToLiveInSeconds, + dpMin, + dpMax, + dpAvailability, + dpError, + geospatialType, + fullTextView); + } + + public static async Task GetIndexingPolicyJsonAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + var resource = await CosmosArmResourceProvider.GetContainerAsync(arm, databaseName, containerName, token); + var indexingPolicy = resource.Data.Resource.IndexingPolicy + ?? throw new InvalidOperationException("Container has no indexing policy."); + return CosmosArmResourceProvider.WriteArmModel(indexingPolicy); + } + + var response = await state.Client.GetDatabase(databaseName).GetContainer(containerName).ReadContainerAsync(cancellationToken: token); + var policy = response.Resource?.IndexingPolicy + ?? throw new InvalidOperationException("Container has no indexing policy."); + return JsonConvert.SerializeObject(policy); + } + + public static async Task ReplaceIndexingPolicyAsync(ConnectedState state, string databaseName, string containerName, string indexPolicyJson, CancellationToken token) + { + if (state.ArmContext is { } arm) + { + var resource = await CosmosArmResourceProvider.GetContainerAsync(arm, databaseName, containerName, token); + var indexingPolicy = CosmosArmResourceProvider.ReadArmModel(indexPolicyJson); + var data = resource.Data.Resource; + data.IndexingPolicy = indexingPolicy; + var content = new CosmosDBSqlContainerCreateOrUpdateContent(resource.Data.Location, data); + var response = await resource.UpdateAsync(WaitUntil.Completed, content, token); + var updated = response.Value.Data.Resource.IndexingPolicy; + return CosmosArmResourceProvider.WriteArmModel(updated); + } + + var container = state.Client.GetDatabase(databaseName).GetContainer(containerName); + var current = await container.ReadContainerAsync(cancellationToken: token); + var props = current.Resource; + var policy = JsonConvert.DeserializeObject(indexPolicyJson) + ?? throw new InvalidOperationException("Unable to parse indexing policy JSON."); + props.IndexingPolicy = policy; + var replaced = await container.ReplaceContainerAsync(props, cancellationToken: token); + return JsonConvert.SerializeObject(replaced.Resource?.IndexingPolicy ?? policy); + } + + private static ThroughputProperties CreateThroughputProperties(string? scale, int? maxRu) + { + var ru = maxRu ?? 1000; + if (string.Equals(scale, "manual", StringComparison.OrdinalIgnoreCase) || + string.Equals(scale, "m", StringComparison.OrdinalIgnoreCase)) + { + return ThroughputProperties.CreateManualThroughput(ru); + } + + return ThroughputProperties.CreateAutoscaleThroughput(ru); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 7460313..945a750 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -567,7 +567,7 @@ internal async Task RunCommandAsync(CommandState currentState, str return currentState; } - internal async Task ConnectAsync(string connectionString, string? loginHint = null, ConnectionMode? mode = null, string? tenantId = null, string? authorityHost = null, string? managedIdentityClientId = null, bool useVSCodeCredential = false, CancellationToken token = default) + internal async Task ConnectAsync(string connectionString, string? loginHint = null, ConnectionMode? mode = null, string? tenantId = null, string? authorityHost = null, string? managedIdentityClientId = null, bool useVSCodeCredential = false, string? subscriptionId = null, string? resourceGroupName = null, string? accountName = null, CancellationToken token = default) { token.ThrowIfCancellationRequested(); @@ -677,7 +677,8 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu { var vscProps = await ReadAccountAsync(client, token); WriteLine(MessageService.GetArgsString("command-connect-connected", "account", vscProps.Id)); - this.Connect(client); + var armContext = await CosmosArmResourceProvider.TryCreateContextAsync(vscCredential, client.Endpoint, subscriptionId, resourceGroupName, accountName, token); + this.Connect(client, armContext); return; } catch (OperationCanceledException) when (token.IsCancellationRequested) @@ -767,7 +768,8 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu } WriteLine(MessageService.GetArgsString("command-connect-connected", "account", miProps.Id)); - this.Connect(client); + var armContext = await CosmosArmResourceProvider.TryCreateContextAsync(credential, client.Endpoint, subscriptionId, resourceGroupName, accountName, token); + this.Connect(client, armContext); return; } @@ -803,7 +805,8 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu { var entraProps = await ReadAccountAsync(client, token); WriteLine(MessageService.GetArgsString("command-connect-connected", "account", entraProps.Id)); - this.Connect(client); + var armContext = await CosmosArmResourceProvider.TryCreateContextAsync(browserCredential, client.Endpoint, subscriptionId, resourceGroupName, accountName, token); + this.Connect(client, armContext); return; } catch (OperationCanceledException) when (token.IsCancellationRequested) @@ -855,7 +858,8 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu } WriteLine(MessageService.GetArgsString("command-connect-connected", "account", dcProps.Id)); - this.Connect(client); + var armContext = await CosmosArmResourceProvider.TryCreateContextAsync(deviceCodeCredential, client.Endpoint, subscriptionId, resourceGroupName, accountName, token); + this.Connect(client, armContext); return; } } @@ -881,7 +885,8 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu { var dacProps = await ReadAccountAsync(client, token); WriteLine(MessageService.GetArgsString("command-connect-connected", "account", dacProps.Id)); - this.Connect(client); + var armContext = await CosmosArmResourceProvider.TryCreateContextAsync(dacCredential, client.Endpoint, subscriptionId, resourceGroupName, accountName, token); + this.Connect(client, armContext); return; } catch (OperationCanceledException) when (token.IsCancellationRequested) @@ -906,10 +911,10 @@ private static async Task ReadAccountAsync(CosmosClient clien /// /// Connects to a client & disposes old state. /// - internal void Connect(CosmosClient client) + internal void Connect(CosmosClient client, ArmCosmosContext? armContext = null) { this.State?.Dispose(); - this.State = new ConnectedState(client); + this.State = new ConnectedState(client, armContext); CosmosCompleteCommand.ClearDatabases(); CosmosCompleteCommand.ClearContainers(); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ThroughputAvailability.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ThroughputAvailability.cs new file mode 100644 index 0000000..f10315a --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ThroughputAvailability.cs @@ -0,0 +1,12 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +internal enum ThroughputAvailability +{ + Available, + NotConfigured, + Unavailable, +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ConnectedState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ConnectedState.cs index c7bd4e3..4cf832f 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ConnectedState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ConnectedState.cs @@ -4,10 +4,14 @@ namespace Azure.Data.Cosmos.Shell.States; -internal class ConnectedState(CosmosClient cosmosClient) : State +using Azure.Data.Cosmos.Shell.Core; + +internal class ConnectedState(CosmosClient cosmosClient, ArmCosmosContext? armContext = null) : State { public CosmosClient Client { get; init; } = cosmosClient; + public ArmCosmosContext? ArmContext { get; init; } = armContext; + public override Task AcceptAsync(IStateVisitor visitor, T data, CancellationToken token) where TR : class { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ContainerState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ContainerState.cs index 5a759d8..46d31e2 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ContainerState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.States/ContainerState.cs @@ -4,8 +4,10 @@ namespace Azure.Data.Cosmos.Shell.States; -internal class ContainerState(string containerName, string databaseName, CosmosClient cosmosClient) - : DatabaseState(databaseName, cosmosClient) +using Azure.Data.Cosmos.Shell.Core; + +internal class ContainerState(string containerName, string databaseName, CosmosClient cosmosClient, ArmCosmosContext? armContext = null) + : DatabaseState(databaseName, cosmosClient, armContext) { public string ContainerName { get; init; } = containerName; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.States/DatabaseState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.States/DatabaseState.cs index 6fb42b0..0b9f7d6 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.States/DatabaseState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.States/DatabaseState.cs @@ -4,7 +4,9 @@ namespace Azure.Data.Cosmos.Shell.States; -internal class DatabaseState(string databaseName, CosmosClient cosmosClient) : ConnectedState(cosmosClient) +using Azure.Data.Cosmos.Shell.Core; + +internal class DatabaseState(string databaseName, CosmosClient cosmosClient, ArmCosmosContext? armContext = null) : ConnectedState(cosmosClient, armContext) { public string DatabaseName { get; init; } = databaseName; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs index cdccd53..49688f1 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs @@ -31,6 +31,12 @@ public class LocalizableSentenceBuilder : SentenceBuilder public static string ConnectManagedIdentity => MessageService.GetString("help-ConnectManagedIdentity"); + public static string ConnectSubscription => MessageService.GetString("help-ConnectSubscription"); + + public static string ConnectResourceGroup => MessageService.GetString("help-ConnectResourceGroup"); + + public static string ConnectAccount => MessageService.GetString("help-ConnectAccount"); + public static string ConnectVSCodeCredential => MessageService.GetString("help-ConnectVSCodeCredential"); public static string Command => MessageService.GetString("help-cmd"); diff --git a/CosmosDBShell/CosmosDBShell.csproj b/CosmosDBShell/CosmosDBShell.csproj index a4e4e21..c8e549b 100644 --- a/CosmosDBShell/CosmosDBShell.csproj +++ b/CosmosDBShell/CosmosDBShell.csproj @@ -86,6 +86,8 @@ + + @@ -96,6 +98,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CosmosDBShell/Program.cs b/CosmosDBShell/Program.cs index e2d2132..c75d695 100644 --- a/CosmosDBShell/Program.cs +++ b/CosmosDBShell/Program.cs @@ -127,6 +127,9 @@ await ShellInterpreter.Instance.ConnectAsync( authorityHost: o.ConnectAuthorityHost, managedIdentityClientId: o.ConnectManagedIdentity, useVSCodeCredential: o.ConnectVSCodeCredential, + subscriptionId: o.ConnectSubscription, + resourceGroupName: o.ConnectResourceGroup, + accountName: o.ConnectAccount, token: connectToken); } catch (OperationCanceledException) when (connectToken.IsCancellationRequested) @@ -349,6 +352,15 @@ public class CosmosShellOptions [Option("connect-managed-identity", Required = false, HelpText = "ConnectManagedIdentity", ResourceType = typeof(LocalizableSentenceBuilder))] public string? ConnectManagedIdentity { get; set; } + [Option("connect-subscription", Required = false, HelpText = "ConnectSubscription", ResourceType = typeof(LocalizableSentenceBuilder))] + public string? ConnectSubscription { get; set; } + + [Option("connect-resource-group", Required = false, HelpText = "ConnectResourceGroup", ResourceType = typeof(LocalizableSentenceBuilder))] + public string? ConnectResourceGroup { get; set; } + + [Option("connect-account", Required = false, HelpText = "ConnectAccount", ResourceType = typeof(LocalizableSentenceBuilder))] + public string? ConnectAccount { get; set; } + [Option("connect-vscode-credential", Required = false, HelpText = "ConnectVSCodeCredential", ResourceType = typeof(LocalizableSentenceBuilder), Hidden = true)] public bool ConnectVSCodeCredential { get; set; } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 50c60b8..168c6eb 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -51,6 +51,9 @@ error-variable_not_set = Variable '{ $name }' is not set. error-mutually-exclusive-options = Options '-c' and '-k' cannot be used together. error-shell-not-initialized = Shell is not initialized error-unable_to_read_container = Unable to read container. +error-arm-context-required = Database and container resource operations require Azure Resource Manager context. Reconnect with Entra ID and provide --subscription, --resource-group, and --account, or use an identity that can discover the Cosmos DB account through ARM. +error-arm-context-incomplete = Provide subscription, resource group, and account name together to use an explicit Azure Resource Manager account context. +error-arm-context-ambiguous = Multiple Cosmos DB Azure Resource Manager accounts match the connected endpoint. Reconnect and provide subscription, resource group, and account name explicitly. help-usage = Usage: { $command } help-usage-heading = Usage @@ -319,6 +322,9 @@ command-connect-description-mode = Connection mode: 'direct' (default) or 'gatew command-connect-description-tenant = The Entra ID tenant ID to authenticate against. command-connect-description-authority-host = The authority host URL (The default is https://login.microsoftonline.com/). command-connect-description-managed-identity = The client ID of a user-assigned managed identity to authenticate with. +command-connect-description-subscription = Azure subscription ID for ARM database and container operations. +command-connect-description-resource-group = Azure resource group name for ARM database and container operations. +command-connect-description-account = Cosmos DB account name for ARM database and container operations. command-connect-error-no_endpoint = An account endpoint or connection string must be specified. command-connect-connected = Connected to account '{ $account }' command-connect-emulator-detected = Emulator endpoint detected, using well-known account key and gateway mode. @@ -326,6 +332,7 @@ command-connect-switching = Disconnecting from '{ $endpoint }'... command-connect-not_connected = Not connected to any Cosmos DB account. command-connect-info-title = Connection Information command-connect-info-account = Account +command-connect-info-arm-account = ARM Account command-connect-info-endpoint = Endpoint command-connect-info-mode = Connection Mode command-connect-info-read-regions = Read Regions @@ -473,6 +480,9 @@ help-ConnectTenant = The Entra ID tenant ID to authenticate against at startup. help-ConnectHint = Login hint for browser authentication at startup. help-ConnectAuthorityHost = The authority host URL at startup (default: https://login.microsoftonline.com/). help-ConnectManagedIdentity = The client ID of a user-assigned managed identity at startup. +help-ConnectSubscription = Azure subscription ID for ARM database and container operations at startup. +help-ConnectResourceGroup = Azure resource group name for ARM database and container operations at startup. +help-ConnectAccount = Cosmos DB account name for ARM database and container operations at startup. help-ConnectVSCodeCredential = Use Visual Studio Code credential for authentication at startup. help-EnableMcpServer = Enable MCP server for programmatic control of the shell help-EnableLspServer = Enable Language Server Protocol (LSP) server for editor integration diff --git a/Directory.Packages.props b/Directory.Packages.props index 38f24b1..d6ace88 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,8 @@ + + @@ -23,6 +25,7 @@ + diff --git a/README.md b/README.md index 2aeccfe..ea4e5a9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Lightweight CLI for Azure Cosmos DB. - Navigate with `ls` and `cd` (Account -> Databases -> Containers -> Items) - Inspect the current location with `pwd` - Create, query, replace, patch, delete: `mkdb`, `mkcon`, `mkitem`, `query`, `replace`, `patch`, `rm` +- Database and container management commands use Azure Resource Manager when connected with Entra ID - Pipelines and scripting with variables, loops, functions - MCP server for AI/tool integration @@ -121,6 +122,9 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | `--connect-hint ` | Login hint for interactive login | | `--connect-authority-host ` | Authority host (e.g. sovereign clouds) | | `--connect-managed-identity ` | Use a user-assigned managed identity | +| `--connect-subscription ` | Azure subscription ID for database/container ARM operations | +| `--connect-resource-group ` | Azure resource group name for database/container ARM operations | +| `--connect-account ` | Cosmos DB account name for database/container ARM operations | | `--mcp [port]` | Enable MCP server on the given port, or `6128` by default | | `--verbose` | Print full exception details | | `--cs ` | Colors: 0=off, 1=standard, 2=truecolor | diff --git a/docs/commands.md b/docs/commands.md index 7e20874..cdee250 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -286,6 +286,8 @@ for $file in (dir "*.csh") { exec $file.path } ## Management +Database and container management commands prefer Azure Resource Manager when an ARM context is attached (Entra ID connections, optionally with `--subscription`, `--resource-group`, and `--account`). Account-key, emulator, and static-token connections do not attach ARM context, so these commands automatically fall back to the Cosmos DB data plane and use the connection's existing credentials. + ### mkdb Create database. diff --git a/docs/connect.md b/docs/connect.md index b30d594..ec9defa 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -20,6 +20,21 @@ The credential type is determined by the first matching rule (top-to-bottom): The `--authority-host` option is passed through to whichever credential is created (priorities 3-6). It does not affect which credential type is selected. +## Azure Resource Manager Context + +Database and container resource operations (listing, navigating to, creating, deleting, and reading settings for databases and containers) prefer Azure Resource Manager (ARM) when an ARM context is attached. Item operations always use the Cosmos DB data plane. + +ARM context is attached only for Entra ID credential flows: `VisualStudioCodeCredential`, `ManagedIdentityCredential`, `InteractiveBrowserCredential`, `DeviceCodeCredential`, and `DefaultAzureCredential`. Account-key connections, emulator connections, and `COSMOSDB_SHELL_TOKEN` connections do not attach ARM context, so resource operations fall back to the Cosmos DB data plane. + +When ARM context is attached, the shell can discover the ARM account by matching the connected data-plane endpoint across accessible subscriptions. For deterministic startup, especially in CI/CD or multi-subscription environments, provide the coordinates explicitly: + +```bash +connect https://myaccount.documents.azure.com:443/ --tenant= --subscription= --resource-group= --account= +cosmosdbshell --connect https://myaccount.documents.azure.com:443/ --connect-tenant= --connect-subscription= --connect-resource-group= --connect-account= +``` + +When ARM is in use, the identity needs data-plane RBAC for item operations and Azure management-plane permissions for database/container resources, such as Cosmos DB Operator or equivalent scoped permissions on the account. When falling back to the data plane (account-key, emulator, static token), the connection's existing data-plane authority is used for all commands. + ## Examples ### Account Key @@ -118,6 +133,7 @@ All connect options are also available as CLI startup arguments: ```bash cosmosdbshell --connect https://myaccount.documents.azure.com:443/ --connect-tenant= cosmosdbshell --connect https://myaccount.documents.azure.com:443/ --connect-managed-identity= +cosmosdbshell --connect https://myaccount.documents.azure.com:443/ --connect-subscription= --connect-resource-group= --connect-account= cosmosdbshell --connect https://localhost:8081 ``` diff --git a/docs/mcp.md b/docs/mcp.md index d1546b3..9e7d4d3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -40,6 +40,8 @@ The MCP server runs locally with your user permissions. Connected clients can ex - Query and retrieve documents - Create, update, and delete resources +Database and container resource actions are executed through Azure Resource Manager when an ARM context is attached (Entra ID connections). MCP sessions connected with account keys, emulator credentials, or static data-plane tokens fall back to the Cosmos DB data plane for these actions. + ### Data Exposure Your MCP client may use a remote LLM. Command outputs, query results, and file contents could be transmitted to external services. **Treat all shell output as potentially shared.** @@ -52,6 +54,7 @@ Your MCP client may use a remote LLM. Command outputs, query results, and file c | Unauthorized access | Bind to localhost only, don't expose port publicly | | Credential leakage | Use Azure AD instead of connection strings/keys | | Excessive permissions | Apply least-privilege RBAC, narrow scopes | +| Missing management-plane scope | For ARM-routed actions, connect with Entra ID and grant Cosmos DB Operator or equivalent scoped permissions; otherwise the shell falls back to the data plane | | Accidental destruction | Review tool requests, don't auto-approve deletes | | Unnecessary exposure | Disable `--mcp` when not needed | diff --git a/docs/navigation.md b/docs/navigation.md index 59c4976..790564c 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -215,6 +215,9 @@ Start the shell with options to customize behavior: | `--connect-hint ` | Login hint for browser auth at startup | | `--connect-authority-host ` | Authority host URL at startup | | `--connect-managed-identity ` | User-assigned managed identity client ID at startup | +| `--connect-subscription ` | Azure subscription ID for database/container ARM operations at startup | +| `--connect-resource-group ` | Azure resource group name for database/container ARM operations at startup | +| `--connect-account ` | Cosmos DB account name for database/container ARM operations at startup | | `--mcp [port]` | Enable MCP (Model Context Protocol) server on the given port, or `6128` by default | | `--cs ` | Color scheme: 0=off, 1=standard, 2=truecolor | | `--clearhistory` | Clear command history on start | @@ -239,6 +242,9 @@ cosmosdbshell -c "connect $CONN; cd mydb/mycont; ls -m 5" # Start connected to a specific account cosmosdbshell --connect "AccountEndpoint=...;AccountKey=..." +# Start connected with explicit ARM account context +cosmosdbshell --connect https://myaccount.documents.azure.com:443/ --connect-subscription --connect-resource-group --connect-account + # Start with MCP server enabled on the default port (6128) cosmosdbshell --mcp