diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d41185c..0421584f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ ### Breaking changes +- On Entra ID (Azure Resource Manager) connections, `mkdb`, `mkcon`, `create database`, and `create container` now return `created: null` with `outcome: "createdOrUpdated"` instead of `created: true`, because the ARM create-or-update API cannot distinguish a new resource from an existing one. Scripts that treat `created` as a boolean should check `outcome` instead. ([#261](https://github.com/Azure/CosmosDBShell/issues/261)) + ### New features ### Fixes - Preserve `?` in unquoted command arguments so scalar indexing paths such as `index add /city/?` and `index remove /city/?` reach Cosmos DB unchanged. ([#260](https://github.com/Azure/CosmosDBShell/issues/260)) +- Database and container creation on key, emulator, and static-token connections now report `created: false` and `outcome: "existing"` when an existing resource is reused, instead of always reporting `created: true`. Human-readable output says "Using existing database/container" in that case. ([#261](https://github.com/Azure/CosmosDBShell/issues/261)) ### Improvements diff --git a/CosmosDBShell.Tests/CommandTests/ResourceCreationResultTests.cs b/CosmosDBShell.Tests/CommandTests/ResourceCreationResultTests.cs new file mode 100644 index 00000000..009481e6 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/ResourceCreationResultTests.cs @@ -0,0 +1,208 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.Net; +using System.Text.Json; +using Azure; +using Azure.Core; +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Azure.ResourceManager; +using Azure.ResourceManager.CosmosDB; +using Azure.ResourceManager.CosmosDB.Models; +using Microsoft.Azure.Cosmos; +using NSubstitute; +using Spectre.Console; + +[Collection(ConsoleOutputTestCollection.Name)] +public class ResourceCreationResultTests +{ + public static IEnumerable CreationCases() + { + foreach (var container in new[] { false, true }) + { + foreach (var longForm in new[] { false, true }) + { + foreach (bool? created in new bool?[] { true, false, null }) + { + foreach (var format in new[] { "user", "json" }) + { + yield return [container, longForm, created, format]; + } + } + } + } + } + + [Theory] + [MemberData(nameof(CreationCases))] + public async Task Commands_ReportBackendOutcomeInStructuredAndUserOutput(bool container, bool longForm, bool? created, string format) + { + using var shell = ShellInterpreter.CreateInstance(); + var client = Substitute.For(); + shell.Options = new Program.CosmosShellOptions { Output = format }; + if (created.HasValue) + { + ConfigureDataPlane(client, created.Value); + shell.State = new ConnectedState(client); + } + else + { + shell.State = new ConnectedState(client, ConfigureArm()); + } + + CosmosCommand command = (container, longForm) switch + { + (false, false) => new MakeDbCommand { Name = "db" }, + (false, true) => new CreateCommand { Item = "database", Name = "db" }, + (true, false) => new MakeContainerCommand { Name = "Items", PartitionKey = "/pk", Database = "db" }, + (true, true) => new CreateCommand { Item = "container", Name = "Items", PartitionKey = "/pk", Database = "db" }, + }; + var state = await command.ExecuteAsync(shell, new CommandState(), string.Empty, TestContext.Current.CancellationToken); + var result = Assert.IsType(state.Result).Value; + var outcome = !created.HasValue ? "createdOrUpdated" : created.Value ? "created" : "existing"; + Assert.Equal(container ? "container" : "database", result.GetProperty("type").GetString()); + Assert.Equal(container ? "Items" : "db", result.GetProperty("id").GetString()); + Assert.Equal(outcome, result.GetProperty("outcome").GetString()); + if (created.HasValue) + { + Assert.Equal(created.Value, result.GetProperty("created").GetBoolean()); + } + else + { + Assert.Equal(JsonValueKind.Null, result.GetProperty("created").ValueKind); + await client.DidNotReceiveWithAnyArgs().CreateDatabaseIfNotExistsAsync(default!, default(ThroughputProperties), default, TestContext.Current.CancellationToken); + } + + var originalOut = Console.Out; + var originalConsole = AnsiConsole.Console; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(output), + }); + shell.PrintState(state); + if (format == "json") + { + using var document = JsonDocument.Parse(output.ToString()); + Assert.True(JsonElement.DeepEquals(result, document.RootElement)); + } + else + { + var key = (container, created) switch + { + (false, true) => "command-mkdb-database_created", + (false, false) => "command-mkdb-database_existing", + (false, null) => "command-mkdb-database_created_or_updated", + (true, true) => "command-mkcon-CreatedContainer", + (true, false) => "command-mkcon-container_existing", + (true, null) => "command-mkcon-container_created_or_updated", + }; + var expected = MessageService.GetArgsString(key, container ? "container" : "db", container ? "Items" : "db"); + Assert.Equal(expected + Environment.NewLine, output.ToString()); + } + } + finally + { + Console.SetOut(originalOut); + AnsiConsole.Console = originalConsole; + } + } + + [Theory] + [InlineData(false, 200)] + [InlineData(false, 202)] + [InlineData(true, 200)] + [InlineData(true, 202)] + public async Task ArmCreation_DoesNotInferCreationFromResponseOrExistence(bool container, int status) + { + var context = ConfigureArm(status); + var operations = new ArmCosmosResourceOperations(context); + for (var attempt = 0; attempt < 2; attempt++) + { + var result = container + ? await operations.CreateContainerAsync("db", "Items", ["/pk"], null, null, null, null, TestContext.Current.CancellationToken) + : await operations.CreateDatabaseAsync("db", null, null, TestContext.Current.CancellationToken); + Assert.Equal(container ? "Items" : "db", result.Id); + Assert.Equal(ResourceCreationOutcome.CreatedOrUpdated, result.Outcome); + Assert.Null(result.Created); + Assert.Equal("createdOrUpdated", result.OutcomeName); + } + + var databases = context.Account.GetCosmosDBSqlDatabases(); + if (container) + { + var database = await databases.GetIfExistsAsync("db", TestContext.Current.CancellationToken); + await database.Value!.GetCosmosDBSqlContainers().DidNotReceiveWithAnyArgs() + .GetIfExistsAsync(default!, TestContext.Current.CancellationToken); + } + else + { + await databases.DidNotReceiveWithAnyArgs().GetIfExistsAsync(default!, TestContext.Current.CancellationToken); + } + } + + private static void ConfigureDataPlane(CosmosClient client, bool created) + { + var database = Substitute.For(); + database.Id.Returns("db"); + client.GetDatabase("db").Returns(database); + var existing = Substitute.For(); + existing.StatusCode.Returns(HttpStatusCode.OK); + database.ReadAsync(Arg.Any(), Arg.Any()).Returns(existing); + var databaseResponse = Substitute.For(); + databaseResponse.Database.Returns(database); + databaseResponse.StatusCode.Returns(created ? HttpStatusCode.Created : HttpStatusCode.OK); + client.CreateDatabaseIfNotExistsAsync("db", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(databaseResponse); + var containerResponse = Substitute.For(); + containerResponse.Container.Id.Returns("Items"); + containerResponse.StatusCode.Returns(created ? HttpStatusCode.Created : HttpStatusCode.OK); + database.CreateContainerIfNotExistsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(containerResponse); + } + + private static ArmCosmosContext ConfigureArm(int status = 200) + { + var account = Substitute.For(); + account.Data.Returns(System.ClientModel.Primitives.ModelReaderWriter.Read( + BinaryData.FromString("""{"location":"westus"}"""))!); + var databases = Substitute.For(); + account.GetCosmosDBSqlDatabases().Returns(databases); + var database = Substitute.For(); + database.Data.Returns(System.ClientModel.Primitives.ModelReaderWriter.Read( + BinaryData.FromString("""{"properties":{"resource":{"id":"db"}}}"""))!); + var databaseOperation = Substitute.For>(); + databaseOperation.Value.Returns(database); + var response = Substitute.For(); + response.Status.Returns(status); + databaseOperation.GetRawResponse().Returns(response); + databases.CreateOrUpdateAsync(WaitUntil.Completed, "db", Arg.Any(), Arg.Any()) + .Returns(databaseOperation); + databases.GetIfExistsAsync("db", Arg.Any()) + .Returns(Task.FromResult>(Response.FromValue(database, Substitute.For()))); + var containers = Substitute.For(); + database.GetCosmosDBSqlContainers().Returns(containers); + var container = Substitute.For(); + container.Data.Returns(System.ClientModel.Primitives.ModelReaderWriter.Read( + BinaryData.FromString("""{"properties":{"resource":{"id":"Items"}}}"""))!); + var containerOperation = Substitute.For>(); + containerOperation.Value.Returns(container); + containerOperation.GetRawResponse().Returns(response); + containers.CreateOrUpdateAsync(WaitUntil.Completed, "Items", Arg.Any(), Arg.Any()) + .Returns(containerOperation); + var id = new ResourceIdentifier("/subscriptions/s/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/a"); + return new ArmCosmosContext(Substitute.For(), id, "s", "rg", "a", new Uri("https://a.documents.azure.com"), account); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs b/CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs index 8c1ccb36..66862b6d 100644 --- a/CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs @@ -343,7 +343,8 @@ public async Task ExistingResource_ProvisionedWithExplicitThroughput_PreservesSu var result = await CreateResourceAsync(operations, isContainer, "manual", 400); - Assert.Equal(isContainer ? "Items" : "db", result); + Assert.Equal(isContainer ? "Items" : "db", result.Id); + Assert.Equal(ResourceCreationOutcome.Existing, result.Outcome); } [Theory] @@ -362,7 +363,7 @@ public async Task ExistingResource_OfferReadFailure_OnlyMissingOfferPreservesSuc if (status == HttpStatusCode.NotFound) { - Assert.Equal(isContainer ? "Items" : "db", await CreateResourceAsync(operations, isContainer, null, 400)); + Assert.Equal(isContainer ? "Items" : "db", (await CreateResourceAsync(operations, isContainer, null, 400)).Id); } else { @@ -372,7 +373,7 @@ public async Task ExistingResource_OfferReadFailure_OnlyMissingOfferPreservesSuc } } - private static Task CreateResourceAsync(DataPlaneCosmosResourceOperations operations, bool isContainer, string? scale, int? ru) + private static Task CreateResourceAsync(DataPlaneCosmosResourceOperations operations, bool isContainer, string? scale, int? ru) => isContainer ? operations.CreateContainerAsync("db", "Items", ["/pk"], null, null, scale, ru, TestContext.Current.CancellationToken) : operations.CreateDatabaseAsync("db", scale, ru, TestContext.Current.CancellationToken); diff --git a/CosmosDBShell.Tests/Integration/ResourceCreationTests.cs b/CosmosDBShell.Tests/Integration/ResourceCreationTests.cs new file mode 100644 index 00000000..f5f40176 --- /dev/null +++ b/CosmosDBShell.Tests/Integration/ResourceCreationTests.cs @@ -0,0 +1,84 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Integration; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +public class ResourceCreationTests : ConnectedEmulatorTestBase +{ + [Theory] + [InlineData("mkdb")] + [InlineData("create database")] + public async Task DatabaseCreation_ReportsCreatedThenExisting(string command) + { + var name = $"Creation_{Guid.NewGuid():N}"; + CreatedDatabases.Add(name); + await AssertCreationAsync($"{command} {name}", "database", name, true); + var before = await CosmosClient.GetDatabase(name).ReadAsync(cancellationToken: TestContext.Current.CancellationToken); + await AssertCreationAsync($"{command} {name}", "database", name, false); + var after = await CosmosClient.GetDatabase(name).ReadAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(JsonConvert.SerializeObject(before.Resource), JsonConvert.SerializeObject(after.Resource)); + } + + [Theory] + [InlineData("mkcon", false)] + [InlineData("create container", false)] + [InlineData("mkcon", true)] + [InlineData("create container", true)] + public async Task ContainerCreation_PreservesExistingResourceAndData(string command, bool sdkCreated) + { + var name = $"Creation_{Guid.NewGuid():N}"; + CreatedDatabases.Add(name); + var database = await CosmosClient.CreateDatabaseAsync(name, cancellationToken: TestContext.Current.CancellationToken); + if (sdkCreated) + { + var properties = new ContainerProperties("Items", "/pk") + { + PartitionKeyDefinitionVersion = PartitionKeyDefinitionVersion.V2, + DefaultTimeToLive = 3600, + }; + properties.UniqueKeyPolicy.UniqueKeys.Add(new UniqueKey { Paths = { "/email" } }); + await database.Database.CreateContainerAsync(properties, cancellationToken: TestContext.Current.CancellationToken); + } + + var text = $"{command} Items /pk --database={name}"; + await AssertCreationAsync(text, "container", "Items", !sdkCreated); + var container = CosmosClient.GetContainer(name, "Items"); + var before = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken); + var item = new { id = "retained", pk = "test", email = "test@example.test" }; + await container.CreateItemAsync(item, new PartitionKey("test"), cancellationToken: TestContext.Current.CancellationToken); + await AssertCreationAsync(text, "container", "Items", false); + var after = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.True(JToken.DeepEquals(JObject.Parse(JsonConvert.SerializeObject(before.Resource)), JObject.Parse(JsonConvert.SerializeObject(after.Resource)))); + if (sdkCreated) + { + Assert.Equal(PartitionKeyDefinitionVersion.V2, after.Resource.PartitionKeyDefinitionVersion); + } + + var retained = await container.ReadItemAsync("retained", new PartitionKey("test"), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("test@example.test", (string?)retained.Resource["email"]); + + var mismatch = await Assert.ThrowsAsync(() => + Shell.RunCommandAsync(new CommandState(), $"{command} Items /different --database={name}", TestContext.Current.CancellationToken)); + Assert.Equal("PartitionKey", mismatch.ParamName); + var unchanged = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(JsonConvert.SerializeObject(after.Resource), JsonConvert.SerializeObject(unchanged.Resource)); + } + + private async Task AssertCreationAsync(string command, string type, string id, bool created) + { + var state = await Shell.RunCommandAsync(new CommandState(), command, TestContext.Current.CancellationToken); + Assert.False(state.IsError, FormatError(state)); + var result = Assert.IsType(state.Result).Value; + Assert.Equal(type, result.GetProperty("type").GetString()); + Assert.Equal(id, result.GetProperty("id").GetString()); + Assert.Equal(created, result.GetProperty("created").GetBoolean()); + Assert.Equal(created ? "created" : "existing", result.GetProperty("outcome").GetString()); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs index ef6a0074..b0ee4e0b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs @@ -4,6 +4,7 @@ namespace Azure.Data.Cosmos.Shell.Commands; +using System.Diagnostics; using System.Text.Json; using Azure.Data.Cosmos.Shell.Mcp; using Azure.Data.Cosmos.Shell.Parser; @@ -149,10 +150,10 @@ private async Task CreateContainerInDatabaseAsync(ConnectedState s await ValidateDatabaseExistsAsync(state, databaseName, "mkcon", token); var partitionKeyPaths = this.GetPartitionKeyPaths(); - string containerName; + ResourceCreationResult creation; try { - containerName = await CosmosResourceFacade.CreateContainerAsync( + creation = await CosmosResourceFacade.CreateContainerAsync( state, databaseName, this.Name ?? string.Empty, @@ -177,10 +178,18 @@ private async Task CreateContainerInDatabaseAsync(ConnectedState s commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "container", - id = containerName, - created = true, + id = creation.Id, + created = creation.Created, + outcome = creation.OutcomeName, })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary { { "container", containerName } })); + var messageKey = creation.Outcome switch + { + ResourceCreationOutcome.Created => "command-mkcon-CreatedContainer", + ResourceCreationOutcome.Existing => "command-mkcon-container_existing", + ResourceCreationOutcome.CreatedOrUpdated => "command-mkcon-container_created_or_updated", + _ => throw new UnreachableException(), + }; + commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString(messageKey, new Dictionary { { "container", creation.Id } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs index c972f376..a03da155 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs @@ -4,6 +4,7 @@ namespace Azure.Data.Cosmos.Shell.Commands; +using System.Diagnostics; using System.Text.Json; using Azure.Data.Cosmos.Shell.Parser; using Azure.Data.Cosmos.Shell.Util; @@ -42,10 +43,10 @@ Task IStateVisitor.VisitDisconnect async Task IStateVisitor.VisitConnectedStateAsync(ConnectedState state, ShellInterpreter shell, CancellationToken token) { - string databaseName; + ResourceCreationResult creation; try { - databaseName = await CosmosResourceFacade.CreateDatabaseAsync(state, this.Name ?? string.Empty, this.Scale, this.MaxRU, token); + creation = await CosmosResourceFacade.CreateDatabaseAsync(state, this.Name ?? string.Empty, this.Scale, this.MaxRU, token); } catch (ServerlessThroughputNotSupportedException ex) { @@ -58,10 +59,18 @@ async Task IStateVisitor.VisitConn commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "database", - id = databaseName, - created = true, + id = creation.Id, + created = creation.Created, + outcome = creation.OutcomeName, })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-mkdb-database_created", new Dictionary { { "db", databaseName } })); + var messageKey = creation.Outcome switch + { + ResourceCreationOutcome.Created => "command-mkdb-database_created", + ResourceCreationOutcome.Existing => "command-mkdb-database_existing", + ResourceCreationOutcome.CreatedOrUpdated => "command-mkdb-database_created_or_updated", + _ => throw new UnreachableException(), + }; + commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString(messageKey, new Dictionary { { "db", creation.Id } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ArmCosmosResourceOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ArmCosmosResourceOperations.cs index 6bd48f6b..988125cf 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ArmCosmosResourceOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ArmCosmosResourceOperations.cs @@ -56,13 +56,13 @@ public async Task ContainerExistsAsync(string databaseName, string contain } } - public async Task CreateDatabaseAsync(string databaseName, string? scale, int? maxRu, CancellationToken token) + public async Task CreateDatabaseAsync(string databaseName, string? scale, int? maxRu, CancellationToken token) { var resource = await CosmosArmResourceProvider.CreateDatabaseAsync(context, databaseName, scale, maxRu, token); - return resource.Data.Resource.DatabaseName; + return new ResourceCreationResult(resource.Data.Resource.DatabaseName, ResourceCreationOutcome.CreatedOrUpdated); } - public async Task CreateContainerAsync( + public async Task CreateContainerAsync( string databaseName, string containerName, IReadOnlyList partitionKeyPaths, @@ -86,7 +86,7 @@ public async Task CreateContainerAsync( } var resource = await CosmosArmResourceProvider.CreateContainerAsync(context, databaseName, containerName, partitionKeyPaths, uniqueKey, parsedIndex, scale, maxRu, token); - return resource.Data.Resource.ContainerName; + return new ResourceCreationResult(resource.Data.Resource.ContainerName, ResourceCreationOutcome.CreatedOrUpdated); } public Task DeleteDatabaseAsync(string databaseName, CancellationToken token) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosResourceFacade.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosResourceFacade.cs index 3758f266..14a5d4eb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosResourceFacade.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosResourceFacade.cs @@ -33,12 +33,12 @@ public static Task ContainerExistsAsync(ConnectedState state, string datab return For(state).ContainerExistsAsync(databaseName, containerName, token); } - public static Task CreateDatabaseAsync(ConnectedState state, string databaseName, string? scale, int? maxRu, CancellationToken token) + public static Task CreateDatabaseAsync(ConnectedState state, string databaseName, string? scale, int? maxRu, CancellationToken token) { return For(state).CreateDatabaseAsync(databaseName, scale, maxRu, token); } - public static Task CreateContainerAsync( + public static Task CreateContainerAsync( ConnectedState state, string databaseName, string containerName, diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs index 6f4840a4..eb5c8d78 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs @@ -71,7 +71,7 @@ public async Task ContainerExistsAsync(string databaseName, string contain } } - public async Task CreateDatabaseAsync(string databaseName, string? scale, int? maxRu, CancellationToken token) + public async Task CreateDatabaseAsync(string databaseName, string? scale, int? maxRu, CancellationToken token) { var response = await CreateWithServerlessFallbackAsync( scale, @@ -84,10 +84,10 @@ await ValidateExistingResourceThroughputAsync( () => response.Database.ReadThroughputAsync(new RequestOptions(), token)); } - return response.Database.Id; + return new ResourceCreationResult(response.Database.Id, ToCreationOutcome(response.StatusCode)); } - public async Task CreateContainerAsync( + public async Task CreateContainerAsync( string databaseName, string containerName, IReadOnlyList partitionKeyPaths, @@ -129,7 +129,7 @@ await ValidateExistingResourceThroughputAsync( () => response.Container.ReadThroughputAsync(new RequestOptions(), token)); } - return response.Container.Id; + return new ResourceCreationResult(response.Container.Id, ToCreationOutcome(response.StatusCode)); } public async Task DeleteDatabaseAsync(string databaseName, CancellationToken token) @@ -418,6 +418,9 @@ private static ContainerProperties GetContainerPropertiesOrThrow(ContainerRespon return response.Resource ?? throw new ShellException(MessageService.GetString("error-unable_to_read_container")); } + private static ResourceCreationOutcome ToCreationOutcome(HttpStatusCode status) => + status == HttpStatusCode.Created ? ResourceCreationOutcome.Created : ResourceCreationOutcome.Existing; + internal static IndexingPolicy ParseIndexingPolicy(string indexPolicyJson) { try diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ICosmosResourceOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ICosmosResourceOperations.cs index ff6ea44d..bd632d28 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ICosmosResourceOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ICosmosResourceOperations.cs @@ -14,9 +14,9 @@ internal interface ICosmosResourceOperations Task ContainerExistsAsync(string databaseName, string containerName, CancellationToken token); - Task CreateDatabaseAsync(string databaseName, string? scale, int? maxRu, CancellationToken token); + Task CreateDatabaseAsync(string databaseName, string? scale, int? maxRu, CancellationToken token); - Task CreateContainerAsync( + Task CreateContainerAsync( string databaseName, string containerName, IReadOnlyList partitionKeyPaths, diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ResourceCreationOutcome.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ResourceCreationOutcome.cs new file mode 100644 index 00000000..d7b3663f --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ResourceCreationOutcome.cs @@ -0,0 +1,14 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +internal enum ResourceCreationOutcome +{ + Created, + Existing, + + // ARM create-or-update responses do not distinguish creation from update. + CreatedOrUpdated, +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ResourceCreationResult.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ResourceCreationResult.cs new file mode 100644 index 00000000..fdc9fec5 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ResourceCreationResult.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using System.Diagnostics; +using System.Text.Json; + +internal sealed record ResourceCreationResult(string Id, ResourceCreationOutcome Outcome) +{ + public bool? Created => this.Outcome switch + { + ResourceCreationOutcome.Created => true, + ResourceCreationOutcome.Existing => false, + ResourceCreationOutcome.CreatedOrUpdated => null, + _ => throw new UnreachableException(), + }; + + public string OutcomeName => JsonNamingPolicy.CamelCase.ConvertName(this.Outcome.ToString()); +} diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 24647f75..1c0ca28c 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -621,6 +621,8 @@ command-mkdb-description-name = The database name to create command-mkdb-description-scale = Database throughput mode (manual or auto). Not supported on serverless accounts. command-mkdb-description-ru = Database max RU/s (default: 1000; omitted on serverless accounts) command-mkdb-database_created = Created database { $db } +command-mkdb-database_existing = Using existing database { $db } +command-mkdb-database_created_or_updated = Created or updated database { $db } command-mkdb-error-only_auto_or_manual_allowed = Only manual or autoscale are allowed. Not both. command-mkcon-description = Creates a new container in the current database. @@ -631,6 +633,8 @@ command-mkcon-description-scale = Container throughput mode (manual or auto). No command-mkcon-description-ru = Container max RU/s (default: 1000; omitted on serverless accounts) command-mkcon-description-database = The database where the container should be created command-mkcon-CreatedContainer = Created container { $container } +command-mkcon-container_existing = Using existing container { $container } +command-mkcon-container_created_or_updated = Created or updated container { $container } command-mkcon-error_partition_key_empty = Partition key path cannot be empty. Provide a path that starts with '/', for example: mkcon name /pk. command-mkcon-error_partition_key_slash = Partition key path must start with a forward slash (/), for example: mkcon name /pk. command-mkcon-error_invalid_index_policy = Invalid indexing policy JSON. Please provide a valid Cosmos DB indexing policy. diff --git a/docs/commands.md b/docs/commands.md index 32a35975..76ddafa3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1029,6 +1029,21 @@ The editor is resolved from `$VISUAL`, then `$EDITOR`, then a platform default ( Database and container management commands prefer Azure Resource Manager when an ARM context is attached (Entra ID connections, optionally specifying `--subscription` and `--resource-group` for explicit account targeting). The account name is inferred from the endpoint. 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` and `mkcon` / `create container` return `type`, `id`, +`created`, and `outcome` in structured results: + +| Backend result | `created` | `outcome` | Human-readable status | +| --- | --- | --- | --- | +| Data plane: newly created (HTTP 201) | `true` | `created` | Created database/container | +| Data plane: existing resource reused (HTTP 200) | `false` | `existing` | Using existing database/container | +| ARM: successful create-or-update | `null` | `createdOrUpdated` | Created or updated database/container | + +Data-plane replay preserves the existing resource rather than recreating it; incompatible +partition keys and other service errors still fail. ARM retains its create-or-update +behavior and can update an existing resource. Its API does not distinguish creation +from update, so `null` means unknown, not `false`. The shell does not infer creation +from a preflight existence check. + ### mkdb Create database. diff --git a/docs/mcp.md b/docs/mcp.md index 088acbd4..816f7a79 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,6 +2,13 @@ Model Context Protocol (MCP) allows AI clients to control CosmosDBShell programmatically. +Database/container creation tools expose `created: true` and `outcome: "created"` +for a new data-plane resource, or `created: false` and `outcome: "existing"` when +reusing one. ARM create-or-update returns `created: null` and +`outcome: "createdOrUpdated"` because the API cannot distinguish the two. +Do not count that unknown outcome as a confirmed creation. +See [management result semantics](commands.md#management). + ## Start MCP Server ```bash diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index 10d60fd8..901f2d3e 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -609,6 +609,8 @@ "command-ls-table-header-container": "Container", "command-ls-table-header-database": "Database", "command-mkcon-CreatedContainer": "Created container {0}", + "command-mkcon-container_created_or_updated": "Created or updated container {0}", + "command-mkcon-container_existing": "Using existing container {0}", "command-mkcon-description": "Creates a new container in the current database.", "command-mkcon-description-database": "The database where the container should be created", "command-mkcon-description-index_policy": "The indexing policy as a JSON string. Follows the Cosmos DB indexing policy schema.", @@ -626,6 +628,8 @@ "command-mkcon-example-4": "Create container in specific database", "command-mkcon-example-5": "Create container with custom indexing policy", "command-mkdb-database_created": "Created database {0}", + "command-mkdb-database_created_or_updated": "Created or updated database {0}", + "command-mkdb-database_existing": "Using existing database {0}", "command-mkdb-description": "Creates new database", "command-mkdb-description-name": "The database name to create", "command-mkdb-description-ru": "Database max RU/s (default: 1000; omitted on serverless accounts)",