diff --git a/src/Eryph.ComputeClient.Commands/Catlets/CatletCmdLet.cs b/src/Eryph.ComputeClient.Commands/Catlets/CatletCmdLet.cs index 8eb9e36..1d806f9 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/CatletCmdLet.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/CatletCmdLet.cs @@ -1,4 +1,7 @@ -using Eryph.ComputeClient.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using Eryph.ComputeClient.Models; using JetBrains.Annotations; namespace Eryph.ComputeClient.Commands.Catlets @@ -6,11 +9,45 @@ namespace Eryph.ComputeClient.Commands.Catlets [PublicAPI] public abstract class CatletCmdLet : ComputeCmdLet { + /// Resource kind used in catlet not-found / ambiguity messages. + protected const string CatletResourceKind = "catlet"; + + /// + /// Hint shown when a catlet name is ambiguous, naming the parameter that + /// disambiguates it (a catlet name is unique only per project + environment). + /// + protected const string EnvironmentAmbiguityHint = "-Environment"; + + /// + /// The , but only when + /// was not already supplied. Once -Environment has been applied it did not resolve the + /// ambiguity, so suggesting it again would be misleading; return null in that case. + /// + protected static string EnvironmentHintIfUnset(string environment) => + string.IsNullOrWhiteSpace(environment) ? EnvironmentAmbiguityHint : null; + protected Catlet GetSingleCatlet(string id) { return Factory.CreateCatletsClient().Get(id); } + /// + /// Lists the catlets of a project, optionally narrowed to a single environment. + /// A catlet's name is unique only per project + environment, so a lookup by name + /// can match several catlets across environments; passing an environment restricts + /// the listing to that environment (case-insensitive exact match). An empty/null + /// environment applies no filter and lists every environment. As the server has no + /// environment filter, this is applied client-side, mirroring Get-VNetwork and + /// Get-CatletDisk. An explicit id (a GUID) bypasses this listing entirely. + /// + protected IEnumerable ListCatlets(string projectId, string environment) + { + IEnumerable catlets = Factory.CreateCatletsClient().List(projectId: projectId); + if (!string.IsNullOrWhiteSpace(environment)) + catlets = catlets.Where(c => string.Equals(c.Environment, environment, StringComparison.OrdinalIgnoreCase)); + return catlets; + } + protected void WaitForOperation( Operation operation, bool noWait, diff --git a/src/Eryph.ComputeClient.Commands/Catlets/CatletGuestServiceGetCmdlet.cs b/src/Eryph.ComputeClient.Commands/Catlets/CatletGuestServiceGetCmdlet.cs index 389f571..9a56c19 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/CatletGuestServiceGetCmdlet.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/CatletGuestServiceGetCmdlet.cs @@ -33,6 +33,10 @@ public abstract class CatletGuestServiceGetCmdlet : CatletCmdLet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter(ParameterSetName = "list")] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + protected override void BeginProcessing() { base.BeginProcessing(); @@ -46,7 +50,7 @@ protected override void ProcessRecord() foreach (var id in Id) { if (Stopping) break; - if (TryGetById(id, GetSingleCatlet, "catlet", out var catlet)) + if (TryGetById(id, GetSingleCatlet, CatletResourceKind, out var catlet)) EmitCatlet(catlet); } @@ -59,9 +63,9 @@ protected override void ProcessRecord() WriteByNameOrId( Name, GetSingleCatlet, - () => Factory.CreateCatletsClient().List(projectId: projectId), + () => ListCatlets(projectId, Environment), catlet => catlet.Name, - "catlet", + CatletResourceKind, EmitCatlet); } diff --git a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletCommand.cs index 390edb4..ee87f4e 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletCommand.cs @@ -46,6 +46,10 @@ public class GetCatletCommand : CatletCmdLet [ValidateNotNullOrEmpty] public string Name { get; set; } + [Parameter(ParameterSetName = "list")] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + protected override void ProcessRecord() { @@ -57,12 +61,12 @@ protected override void ProcessRecord() if (Config.IsPresent) { - if (TryGetById(id, i => Factory.CreateCatletsClient().GetConfig(i), "catlet", out var config)) + if (TryGetById(id, i => Factory.CreateCatletsClient().GetConfig(i), CatletResourceKind, out var config)) WriteConfig(config); } else { - if (TryGetById(id, GetSingleCatlet, "catlet", out var catlet)) + if (TryGetById(id, GetSingleCatlet, CatletResourceKind, out var catlet)) WriteObject(catlet); } } @@ -90,9 +94,9 @@ protected override void ProcessRecord() WriteByNameOrId( Name, GetSingleCatlet, - () => Factory.CreateCatletsClient().List(projectId: projectId), + () => ListCatlets(projectId, Environment), catlet => catlet.Name, - "catlet"); + CatletResourceKind); } private void WriteConfig(CatletConfiguration config) diff --git a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletIpCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletIpCommand.cs index 8c427c9..ee8b481 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletIpCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletIpCommand.cs @@ -29,6 +29,10 @@ public class GetCatletIpCommand : CatletCmdLet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter(ParameterSetName = "list")] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + [Parameter] public SwitchParameter InternalIp { get; set; } @@ -42,7 +46,7 @@ protected override void ProcessRecord() foreach (var id in Id) { if (Stopping) break; - if (TryGetById(id, GetSingleCatlet, "catlet", out var catlet)) + if (TryGetById(id, GetSingleCatlet, CatletResourceKind, out var catlet)) WriteIp(catlet); } @@ -55,9 +59,9 @@ protected override void ProcessRecord() WriteByNameOrId( Name, GetSingleCatlet, - () => Factory.CreateCatletsClient().List(projectId: projectId), + () => ListCatlets(projectId, Environment), catlet => catlet.Name, - "catlet", + CatletResourceKind, WriteIp); } diff --git a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningLogCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningLogCommand.cs index 2156d16..edf259a 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningLogCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningLogCommand.cs @@ -38,6 +38,10 @@ public class GetCatletProvisioningLogCommand : CatletCmdLet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter(ParameterSetName = "list")] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + /// /// Emit the rendered, human-readable text log instead of the structured events. /// @@ -57,7 +61,7 @@ protected override void ProcessRecord() foreach (var id in Id) { if (Stopping) break; - if (TryGetById(id, GetSingleCatlet, "catlet", out var catlet)) + if (TryGetById(id, GetSingleCatlet, CatletResourceKind, out var catlet)) EmitLog(catlet); } @@ -70,9 +74,9 @@ protected override void ProcessRecord() WriteByNameOrId( Name, GetSingleCatlet, - () => Factory.CreateCatletsClient().List(projectId: projectId), + () => ListCatlets(projectId, Environment), catlet => catlet.Name, - "catlet", + CatletResourceKind, EmitLog); } @@ -92,7 +96,7 @@ private void EmitLog(Catlet catlet) // Prefix a header so the text blocks stay attributable to their catlet // when several catlets are targeted in one invocation. var header = $"# Catlet {catlet.Name} ({catlet.Id})"; - WriteObject(header + Environment.NewLine + result.RenderedLog); + WriteObject(header + System.Environment.NewLine + result.RenderedLog); return; } diff --git a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningStatusCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningStatusCommand.cs index 482b21a..c8b14a2 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningStatusCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/GetCatletProvisioningStatusCommand.cs @@ -35,6 +35,10 @@ public class GetCatletProvisioningStatusCommand : CatletCmdLet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter(ParameterSetName = "list")] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + protected override void BeginProcessing() { base.BeginProcessing(); @@ -48,7 +52,7 @@ protected override void ProcessRecord() foreach (var id in Id) { if (Stopping) break; - if (TryGetById(id, GetSingleCatlet, "catlet", out var catlet)) + if (TryGetById(id, GetSingleCatlet, CatletResourceKind, out var catlet)) WriteStatus(catlet); } @@ -61,9 +65,9 @@ protected override void ProcessRecord() WriteByNameOrId( Name, GetSingleCatlet, - () => Factory.CreateCatletsClient().List(projectId: projectId), + () => ListCatlets(projectId, Environment), catlet => catlet.Name, - "catlet", + CatletResourceKind, WriteStatus); } diff --git a/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletCommand.cs index 5c36433..9b2d307 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletCommand.cs @@ -55,6 +55,10 @@ public SwitchParameter NoWait [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + private bool _force; private bool _nowait; private bool _passThru; @@ -68,7 +72,7 @@ protected override void ProcessRecord() foreach (var nameOrId in Id) { foreach (var catlet in ResolveActionTargets(nameOrId, ProjectName, GetSingleCatlet, - projectId => Factory.CreateCatletsClient().List(projectId: projectId), c => c.Name, "catlet")) + projectId => ListCatlets(projectId, Environment), c => c.Name, CatletResourceKind, EnvironmentHintIfUnset(Environment))) { if (Stopping) break; diff --git a/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletGuestServiceAccessKeyCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletGuestServiceAccessKeyCommand.cs index 628acc5..900de8f 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletGuestServiceAccessKeyCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/RemoveCatletGuestServiceAccessKeyCommand.cs @@ -23,6 +23,10 @@ public class RemoveCatletGuestServiceAccessKeyCommand : CatletCmdLet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + [Parameter] public SwitchParameter Force { @@ -64,7 +68,7 @@ protected override void ProcessRecord() foreach (var nameOrId in Id) { foreach (var catlet in ResolveActionTargets(nameOrId, ProjectName, GetSingleCatlet, - projectId => Factory.CreateCatletsClient().List(projectId: projectId), c => c.Name, "catlet")) + projectId => ListCatlets(projectId, Environment), c => c.Name, CatletResourceKind, EnvironmentHintIfUnset(Environment))) { if (Stopping) break; diff --git a/src/Eryph.ComputeClient.Commands/Catlets/SetCatletGuestServiceConfigCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/SetCatletGuestServiceConfigCommand.cs index 0e9201d..3dc76e2 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/SetCatletGuestServiceConfigCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/SetCatletGuestServiceConfigCommand.cs @@ -23,6 +23,10 @@ public class SetCatletGuestServiceConfigCommand : CatletCmdLet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + /// /// Shell command for interactive SSH sessions. An empty string clears the /// override; omitting the parameter leaves the current value unchanged. @@ -84,7 +88,7 @@ protected override void ProcessRecord() foreach (var nameOrId in Id) { foreach (var catlet in ResolveActionTargets(nameOrId, ProjectName, GetSingleCatlet, - projectId => Factory.CreateCatletsClient().List(projectId: projectId), c => c.Name, "catlet")) + projectId => ListCatlets(projectId, Environment), c => c.Name, CatletResourceKind, EnvironmentHintIfUnset(Environment))) { if (Stopping) break; diff --git a/src/Eryph.ComputeClient.Commands/Catlets/StartCatletCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/StartCatletCommand.cs index 579392b..1d2180a 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/StartCatletCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/StartCatletCommand.cs @@ -44,6 +44,10 @@ public SwitchParameter NoWait [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + private bool _force; private bool _nowait; private bool _yesToAll, _noToAll; @@ -54,7 +58,7 @@ protected override void ProcessRecord() foreach (var nameOrId in Id) { foreach (var catlet in ResolveActionTargets(nameOrId, ProjectName, GetSingleCatlet, - projectId => Factory.CreateCatletsClient().List(projectId: projectId), c => c.Name, "catlet")) + projectId => ListCatlets(projectId, Environment), c => c.Name, CatletResourceKind, EnvironmentHintIfUnset(Environment))) { if (Stopping) break; diff --git a/src/Eryph.ComputeClient.Commands/Catlets/StopCatletCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/StopCatletCommand.cs index 434735c..5a4e7c9 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/StopCatletCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/StopCatletCommand.cs @@ -37,6 +37,10 @@ public class StopCatletCommand : CatletCmdLet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + private bool _yesToAll; private bool _noToAll; private bool _yesToKillAll; @@ -55,7 +59,7 @@ protected override void ProcessRecord() foreach (var nameOrId in Id) { foreach (var catlet in ResolveActionTargets(nameOrId, ProjectName, GetSingleCatlet, - projectId => Factory.CreateCatletsClient().List(projectId: projectId), c => c.Name, "catlet")) + projectId => ListCatlets(projectId, Environment), c => c.Name, CatletResourceKind, EnvironmentHintIfUnset(Environment))) { if (Stopping) break; diff --git a/src/Eryph.ComputeClient.Commands/Catlets/UpdateCatletCommand.cs b/src/Eryph.ComputeClient.Commands/Catlets/UpdateCatletCommand.cs index 481d433..6fb1d28 100644 --- a/src/Eryph.ComputeClient.Commands/Catlets/UpdateCatletCommand.cs +++ b/src/Eryph.ComputeClient.Commands/Catlets/UpdateCatletCommand.cs @@ -31,6 +31,10 @@ public class UpdateCatletCommand : CatletConfigCmdlet [ValidateNotNullOrEmpty] public string ProjectName { get; set; } + [Parameter] + [ValidateNotNullOrEmpty] + public string Environment { get; set; } + protected override void ProcessRecord() { var client = Factory.CreateCatletsClient(); @@ -38,7 +42,7 @@ protected override void ProcessRecord() foreach (var nameOrId in Id) { foreach (var catlet in ResolveActionTargets(nameOrId, ProjectName, GetSingleCatlet, - projectId => client.List(projectId: projectId), c => c.Name, "catlet")) + projectId => ListCatlets(projectId, Environment), c => c.Name, CatletResourceKind, EnvironmentHintIfUnset(Environment))) { if (Stopping) break; diff --git a/src/Eryph.ComputeClient.Commands/ComputeCmdLet.cs b/src/Eryph.ComputeClient.Commands/ComputeCmdLet.cs index 9cba64b..efbc084 100644 --- a/src/Eryph.ComputeClient.Commands/ComputeCmdLet.cs +++ b/src/Eryph.ComputeClient.Commands/ComputeCmdLet.cs @@ -236,11 +236,13 @@ protected IEnumerable ResolveByNameOrId( /// /// Resolves the target(s) of a mutating cmdlet from a value that may be a resource - /// id (a GUID) or a name. An id is used directly. A name must be scoped to a - /// project ( is required) so that it cannot fan out - /// across projects; within the project a wildcard may still match several - /// resources. This keeps destructive operations from acting on same-named - /// resources in other projects. + /// id (a GUID) or a name. An id is used directly. An exact name is resolved across + /// every project the caller can access; (and filters + /// such as -Environment) only narrow the search. Because some names are unique only + /// per project + environment, an exact name that resolves to more than one resource + /// is rejected as ambiguous rather than mutated, so a mutation never silently fans + /// out. A wildcard, which matches many by design, still requires + /// so it cannot fan out across projects. /// /// /// Like this is a deferred iterator that calls @@ -255,51 +257,77 @@ protected IEnumerable ResolveActionTargets( string resourceKind, string ambiguityHint = null) { - if (IsResourceId(nameOrId)) - { - if (TryGetById(nameOrId, getById, resourceKind, out var item)) - yield return item; - yield break; - } - - if (string.IsNullOrWhiteSpace(projectName)) + // An empty/whitespace target must never be treated as a name: as an empty + // filter it would match every resource, and since an exact name now resolves + // across all accessible projects, a stray empty array element (e.g. piped from + // an object with a blank name) could silently act on an unrelated resource. + if (string.IsNullOrWhiteSpace(nameOrId)) { WriteError(new ErrorRecord( - new PSArgumentException( - $"When a {resourceKind} is identified by name, -ProjectName is required " - + "so the name can be resolved within a single project. Specify -ProjectName, " - + "or pass the id instead."), - "ProjectNameRequiredForNameLookup", + new PSArgumentException($"A {resourceKind} name or id must be specified; it must not be empty."), + "EmptyTarget", ErrorCategory.InvalidArgument, nameOrId)); yield break; } - if (!TryGetProjectId(projectName, out var projectId)) + if (IsResourceId(nameOrId)) + { + if (TryGetById(nameOrId, getById, resourceKind, out var item)) + yield return item; yield break; + } - // A wildcard may intentionally match several resources. An exact name, however, - // must resolve to a single target before a mutation: some resource names are - // unique only per project + environment, so an exact name could otherwise match - // several resources and the command would act on all of them. + var hasProject = !string.IsNullOrWhiteSpace(projectName); + + // A wildcard matches many resources by design, so the ambiguity guard below + // cannot protect it. Without a project to scope it, it could fan out across + // every project the caller can access, so a wildcard still requires -ProjectName. if (WildcardPattern.ContainsWildcardCharacters(nameOrId)) { - foreach (var item in FilterByName(listInProject(projectId), nameOrId, nameSelector, resourceKind)) + if (!hasProject) + { + WriteError(new ErrorRecord( + new PSArgumentException( + $"A wildcard {resourceKind} name requires -ProjectName so it cannot fan out " + + "across projects. Specify -ProjectName, or use an exact name or the id."), + "ProjectNameRequiredForWildcard", + ErrorCategory.InvalidArgument, + nameOrId)); + yield break; + } + + if (!TryGetProjectId(projectName, out var wildcardProjectId)) + yield break; + + foreach (var item in FilterByName(listInProject(wildcardProjectId), nameOrId, nameSelector, resourceKind)) yield return item; yield break; } + // An exact name needs no project: a null projectId lists across all projects. + // -ProjectName only narrows the search when supplied. + string projectId = null; + if (hasProject && !TryGetProjectId(projectName, out projectId)) + yield break; + var matches = FilterByName(listInProject(projectId), nameOrId, nameSelector, resourceKind).ToList(); if (matches.Count > 1) { - var hint = string.IsNullOrWhiteSpace(ambiguityHint) - ? "Specify the id to select one." - : $"Narrow the selection with {ambiguityHint} or specify the id."; + var narrowers = new List(); + if (!hasProject) + narrowers.Add("-ProjectName"); + if (!string.IsNullOrWhiteSpace(ambiguityHint)) + narrowers.Add(ambiguityHint); + var hint = narrowers.Count > 0 + ? $"Narrow the selection with {string.Join(" / ", narrowers)} or specify the id." + : "Specify the id to select one."; + var scope = hasProject ? $" in project '{projectName}'" : ""; WriteError(new ErrorRecord( new PSArgumentException( - $"The {resourceKind} name '{nameOrId}' is ambiguous in project '{projectName}': " + $"The {resourceKind} name '{nameOrId}' is ambiguous{scope}: " + $"it matches {matches.Count} resources. {hint}"), - "AmbiguousNameInProject", + "AmbiguousName", ErrorCategory.InvalidArgument, nameOrId)); yield break; diff --git a/test/pester/ResourceNameLookup.Tests.ps1 b/test/pester/ResourceNameLookup.Tests.ps1 index 2cc996c..7f67923 100644 --- a/test/pester/ResourceNameLookup.Tests.ps1 +++ b/test/pester/ResourceNameLookup.Tests.ps1 @@ -39,6 +39,10 @@ Describe 'Get-* -Name parameter surface (no server required)' { @{ Cmd = 'Get-CatletIp' } @{ Cmd = 'Get-VNetwork' } @{ Cmd = 'Get-CatletDisk' } + @{ Cmd = 'Get-CatletGuestServiceStatus' } + @{ Cmd = 'Get-CatletGuestServiceConfig' } + @{ Cmd = 'Get-CatletProvisioningStatus' } + @{ Cmd = 'Get-CatletProvisioningLog' } ) { $p = (Get-Command $Cmd).Parameters['Name'] $p | Should -Not -BeNullOrEmpty @@ -53,6 +57,10 @@ Describe 'Get-* -Name parameter surface (no server required)' { @{ Cmd = 'Get-CatletIp' } @{ Cmd = 'Get-VNetwork' } @{ Cmd = 'Get-CatletDisk' } + @{ Cmd = 'Get-CatletGuestServiceStatus' } + @{ Cmd = 'Get-CatletGuestServiceConfig' } + @{ Cmd = 'Get-CatletProvisioningStatus' } + @{ Cmd = 'Get-CatletProvisioningLog' } ) { (Get-Command $Cmd).Parameters['Name'].ParameterSets['list'].Position | Should -Be 0 } @@ -74,6 +82,12 @@ Describe 'Get-* -Name parameter surface (no server required)' { It " exposes an -Environment filter in the 'list' set" -ForEach @( @{ Cmd = 'Get-VNetwork' } @{ Cmd = 'Get-CatletDisk' } + @{ Cmd = 'Get-Catlet' } + @{ Cmd = 'Get-CatletIp' } + @{ Cmd = 'Get-CatletGuestServiceStatus' } + @{ Cmd = 'Get-CatletGuestServiceConfig' } + @{ Cmd = 'Get-CatletProvisioningStatus' } + @{ Cmd = 'Get-CatletProvisioningLog' } ) { $p = (Get-Command $Cmd).Parameters['Environment'] $p | Should -Not -BeNullOrEmpty @@ -95,6 +109,10 @@ Describe 'Get-* -Name parameter surface (no server required)' { @{ Cmd = 'Get-CatletIp' } @{ Cmd = 'Get-VNetwork' } @{ Cmd = 'Get-CatletSpecification' } + @{ Cmd = 'Get-CatletGuestServiceStatus' } + @{ Cmd = 'Get-CatletGuestServiceConfig' } + @{ Cmd = 'Get-CatletProvisioningStatus' } + @{ Cmd = 'Get-CatletProvisioningLog' } ) { $p = (Get-Command $Cmd).Parameters['ProjectName'] $p | Should -Not -BeNullOrEmpty @@ -129,15 +147,32 @@ Describe 'Action cmdlets accept name-or-id (parameter surface)' { $positions | Should -Contain 0 } - It " exposes a -ProjectName parameter to scope name resolution" -ForEach @( + It " exposes an optional -ProjectName parameter to narrow name resolution" -ForEach @( @{ Cmd = 'Start-Catlet' } @{ Cmd = 'Stop-Catlet' } @{ Cmd = 'Remove-Catlet' } @{ Cmd = 'Update-Catlet' } + @{ Cmd = 'Set-CatletGuestServiceConfig' } + @{ Cmd = 'Remove-CatletGuestServiceAccessKey' } @{ Cmd = 'Remove-CatletSpecification' } @{ Cmd = 'Update-CatletSpecification' } ) { - (Get-Command $Cmd).Parameters['ProjectName'] | Should -Not -BeNullOrEmpty + $p = (Get-Command $Cmd).Parameters['ProjectName'] + $p | Should -Not -BeNullOrEmpty + # -ProjectName is a narrowing filter, not required: an exact name resolves across + # projects, so it must not be mandatory in any parameter set. + $p.ParameterSets.Values.IsMandatory | Should -Not -Contain $true + } + + It " exposes an -Environment parameter to narrow name resolution" -ForEach @( + @{ Cmd = 'Start-Catlet' } + @{ Cmd = 'Stop-Catlet' } + @{ Cmd = 'Remove-Catlet' } + @{ Cmd = 'Update-Catlet' } + @{ Cmd = 'Set-CatletGuestServiceConfig' } + @{ Cmd = 'Remove-CatletGuestServiceAccessKey' } + ) { + (Get-Command $Cmd).Parameters['Environment'] | Should -Not -BeNullOrEmpty } It 'Remove-CatletDisk is Id-only (Name+Location+DataStore is required to identify a disk; use Get-CatletDisk to resolve)' { @@ -228,6 +263,15 @@ Describe 'Get-Catlet name-or-id (integration, read-only)' -Skip:(-not $eryphAvai Get-Catlet -Name "zzzz-no-such-catlet-*" | Should -BeNullOrEmpty } + It 'filters catlets by environment (and excludes other environments)' { + if ($existing.Count -eq 0) { Set-ItResult -Skipped -Because 'no catlets present'; return } + $environment = $existing[0].Environment + $filtered = @(Get-Catlet -Environment $environment) + $filtered | ForEach-Object { $_.Environment | Should -Be $environment } + $expected = @($existing | Where-Object Environment -EQ $environment).Count + $filtered.Count | Should -Be $expected + } + It 'treats a whitespace-only name as a name (not "list all")' { # ' ' must not be interpreted as an omitted filter; it is an exact, # non-matching name and so produces a not-found error, not every catlet. @@ -273,9 +317,19 @@ Describe 'Get-CatletIp name-or-id (integration, read-only)' -Skip:(-not $eryphAv Describe 'Action cmdlets name resolution (integration, non-destructive)' -Skip:(-not $eryphAvailable) { - It 'requires -ProjectName when the target is given by name (no cross-project fan-out)' { - { Start-Catlet -Name 'some-catlet' -ErrorAction Stop } | - Should -Throw -ExpectedMessage '*ProjectName is required*' + BeforeAll { $existing = @(Get-Catlet) } + + It 'resolves an exact name without -ProjectName (searches across projects)' { + # An exact name no longer requires -ProjectName: it is resolved across every project + # the caller can access. A random, non-existent name must therefore fail with a + # not-found error, NOT a "ProjectName is required" error. + { Start-Catlet -Name "zzz-$([guid]::NewGuid().ToString('N'))" -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*Cannot find*' + } + + It 'requires -ProjectName for a WILDCARD name (no cross-project fan-out)' { + { Start-Catlet -Name 'zzz-no-such-*' -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*-ProjectName*' } It 'does not require -ProjectName when the target is given by id' { @@ -299,6 +353,22 @@ Describe 'Action cmdlets name resolution (integration, non-destructive)' -Skip:( { Remove-Catlet -Name 'zzzz-no-such-catlet-*' -ProjectName 'default' -Force -ErrorAction Stop } | Should -Not -Throw } + + It 'rejects an empty target instead of matching every catlet' { + # An empty name/id element must error, not fall through to an empty filter that + # would match (and act on) every catlet the caller can see. + { Start-Catlet -Id '' -ErrorAction Stop } | Should -Throw + } + + It 'errors as ambiguous (with a narrowing hint) when an exact name matches several catlets' { + # A catlet name is unique only per project + environment, so the same name can exist + # in several environments/projects. Use existing data if it contains a duplicate + # name; the suite does not create catlets, so skip when none is present. + $dup = $existing | Group-Object Name | Where-Object Count -GT 1 | Select-Object -First 1 + if (-not $dup) { Set-ItResult -Skipped -Because 'no duplicate catlet name present'; return } + { Start-Catlet -Name $dup.Name -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*ambiguous*' + } } Describe 'Get-VNetwork name-or-id / -Environment (integration, read-only)' -Skip:(-not $eryphAvailable) {