diff --git a/examples.md b/examples.md index 4bf5872f..78e9594e 100644 --- a/examples.md +++ b/examples.md @@ -97,12 +97,31 @@ fingerprint=$(ssh-keygen -E md5 -lf /etc/ssh/ssh_host_rsa_key.pub | cut -d' ' -f monoExists=$(command -v mono) if [ $monoExists ] then - octopus deployment-target ssh create --account "TheAccount" --name "MySshTargetName" --host $localIp --fingerprint $fingerprint--role linux --runtime mono --no-prompt + octopus deployment-target ssh create --account "TheAccount" --name "MySshTargetName" --host $localIp --fingerprint $fingerprint --role linux --runtime mono --no-prompt else - octopus deployment-target ssh create --account "TheAccount" --name "MySshTargetName" --host $localIp --fingerprint $fingerprint--role linux --runtime self-contained --platform linux-x64 --no-prompt + octopus deployment-target ssh create --account "TheAccount" --name "MySshTargetName" --host $localIp --fingerprint $fingerprint --role linux --runtime self-contained --platform linux-x64 --no-prompt fi ``` +# Create deployment target with target tags + +Target tag sets provide organized tagging with validation. Use `--tag` with canonical format (TagSetName/TagName): + +``` +octopus deployment-target ssh create \ + --account "TheAccount" \ + --name "MySshTargetName" \ + --host $localIp \ + --fingerprint $fingerprint \ + --environment Production \ + --tag "[System] Target Tags/web-server" \ + --tag Region/US-East \ + --runtime mono \ + --no-prompt +``` + +Note: The `--role` flag continues to work for backwards compatibility but will be deprecated in favor of `--tag` once target tag sets are widely adopted. + # Bulk deleting releases by created date This example will delete all releases created before 2AM 6 Dec 2022 UTC diff --git a/go.mod b/go.mod index 45461615..e68e0469 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.89.1 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.101.0 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 40ee8852..fe6ee82e 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4 github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.89.1 h1:EDo4CdA7jYZBHiaKu8nZRf9ndonJXC70OlU29+6KXKc= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.89.1/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.101.0 h1:AhFBHMVWvt4++0BaL3dUtMVIqikVmg3hAaor7tVquJQ= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.101.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/target/azure-web-app/create/create.go b/pkg/cmd/target/azure-web-app/create/create.go index a4a48fbd..b297e2d6 100644 --- a/pkg/cmd/target/azure-web-app/create/create.go +++ b/pkg/cmd/target/azure-web-app/create/create.go @@ -140,6 +140,11 @@ func createRun(opts *CreateOptions) error { } environmentIds := util.SliceTransform(envs, func(e *environments.Environment) string { return e.ID }) + combinedRoles, err := shared.CombineRolesAndTags(opts.Client, opts.Roles.Value, opts.Tags.Value) + if err != nil { + return err + } + account, err := getAzureAccount(opts) if err != nil { return err @@ -154,7 +159,7 @@ func createRun(opts *CreateOptions) error { endpoint.WebAppName = opts.WebApp.Value endpoint.ResourceGroupName = opts.ResourceGroup.Value endpoint.WebAppSlotName = opts.Slot.Value - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(combinedRoles)) err = shared.ConfigureTenant(deploymentTarget, opts.CreateTargetTenantFlags, opts.CreateTargetTenantOptions) if err != nil { @@ -168,7 +173,7 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.Out, "Successfully created Azure web app '%s'.\n", deploymentTarget.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.Account, opts.WebApp, opts.ResourceGroup, opts.Slot, opts.Environments, opts.Roles, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.Account, opts.WebApp, opts.ResourceGroup, opts.Slot, opts.Environments, opts.Roles, opts.Tags, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/cloud-region/create/create.go b/pkg/cmd/target/cloud-region/create/create.go index e358ecd0..70cac811 100644 --- a/pkg/cmd/target/cloud-region/create/create.go +++ b/pkg/cmd/target/cloud-region/create/create.go @@ -102,6 +102,11 @@ func createRun(opts *CreateOptions) error { } environmentIds := util.SliceTransform(envs, func(e *environments.Environment) string { return e.ID }) + combinedRoles, err := shared.CombineRolesAndTags(opts.Client, opts.Roles.Value, opts.Tags.Value) + if err != nil { + return err + } + endpoint := machines.NewCloudRegionEndpoint() if opts.WorkerPool.Value != "" { workerPoolId, err := shared.FindWorkerPoolId(opts.GetAllWorkerPoolsCallback, opts.WorkerPool.Value) @@ -111,7 +116,7 @@ func createRun(opts *CreateOptions) error { endpoint.DefaultWorkerPoolID = workerPoolId } - target := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) + target := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(combinedRoles)) err = shared.ConfigureTenant(target, opts.CreateTargetTenantFlags, opts.CreateTargetTenantOptions) if err != nil { return err @@ -123,7 +128,7 @@ func createRun(opts *CreateOptions) error { } fmt.Fprintf(opts.Out, "Successfully created cloud region '%s'.\n", target.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.WorkerPool, opts.Environments, opts.Roles, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.WorkerPool, opts.Environments, opts.Roles, opts.Tags, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/kubernetes/create/create.go b/pkg/cmd/target/kubernetes/create/create.go index 008a7c85..4bb2f904 100644 --- a/pkg/cmd/target/kubernetes/create/create.go +++ b/pkg/cmd/target/kubernetes/create/create.go @@ -329,6 +329,11 @@ func (opts *CreateOptions) Commit() error { } environmentIds := util.SliceTransform(envs, func(e *environments.Environment) string { return e.ID }) + combinedRoles, err := shared.CombineRolesAndTags(opts.Client, opts.Roles.Value, opts.Tags.Value) + if err != nil { + return err + } + kubernetesUrl, err := url.Parse(opts.KubernetesClusterURL.Value) if err != nil { return err @@ -446,7 +451,7 @@ func (opts *CreateOptions) Commit() error { endpoint.Authentication = auth } - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(combinedRoles)) machinePolicy, err := machinescommon.FindDefaultMachinePolicy(opts.GetAllMachinePoliciesCallback) if err != nil { @@ -511,6 +516,7 @@ func (opts *CreateOptions) Commit() error { opts.Environments, opts.Roles, + opts.Tags, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags, diff --git a/pkg/cmd/target/listening-tentacle/create/create.go b/pkg/cmd/target/listening-tentacle/create/create.go index 3745fec1..a43f3787 100644 --- a/pkg/cmd/target/listening-tentacle/create/create.go +++ b/pkg/cmd/target/listening-tentacle/create/create.go @@ -122,6 +122,11 @@ func createRun(opts *CreateOptions) error { } environmentIds := util.SliceTransform(envs, func(e *environments.Environment) string { return e.ID }) + combinedRoles, err := shared.CombineRolesAndTags(opts.Client, opts.Roles.Value, opts.Tags.Value) + if err != nil { + return err + } + endpoint := machines.NewListeningTentacleEndpoint(url, opts.Thumbprint.Value) if opts.Proxy.Value != "" { proxy, err := machinescommon.FindProxy(opts.CreateTargetProxyOptions, opts.CreateTargetProxyFlags) @@ -131,7 +136,7 @@ func createRun(opts *CreateOptions) error { endpoint.ProxyID = proxy.GetID() } - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(combinedRoles)) machinePolicy, err := machinescommon.FindMachinePolicy(opts.GetAllMachinePoliciesCallback, opts.MachinePolicy.Value) if err != nil { return err @@ -149,7 +154,7 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.Out, "Successfully created listening tenatcle '%s'.\n", deploymentTarget.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.URL, opts.Thumbprint, opts.Environments, opts.Roles, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.URL, opts.Thumbprint, opts.Environments, opts.Roles, opts.Tags, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/shared/role.go b/pkg/cmd/target/shared/role.go index f46af898..c9ce7249 100644 --- a/pkg/cmd/target/shared/role.go +++ b/pkg/cmd/target/shared/role.go @@ -1,22 +1,28 @@ package shared import ( + "strings" + "github.com/OctopusDeploy/cli/pkg/cmd" "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" "github.com/OctopusDeploy/cli/pkg/util" "github.com/OctopusDeploy/cli/pkg/util/flag" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tagsets" "github.com/spf13/cobra" ) const ( FlagRole = "role" + FlagTag = "tag" ) type GetAllRolesCallback func() ([]string, error) type CreateTargetRoleFlags struct { Roles *flag.Flag[[]string] + Tags *flag.Flag[[]string] } type CreateTargetRoleOptions struct { @@ -37,26 +43,39 @@ func NewCreateTargetRoleOptions(dependencies *cmd.Dependencies) *CreateTargetRol func NewCreateTargetRoleFlags() *CreateTargetRoleFlags { return &CreateTargetRoleFlags{ Roles: flag.New[[]string](FlagRole, false), + Tags: flag.New[[]string](FlagTag, false), } } func RegisterCreateTargetRoleFlags(cmd *cobra.Command, commonFlags *CreateTargetRoleFlags) { - cmd.Flags().StringSliceVar(&commonFlags.Roles.Value, FlagRole, []string{}, "Choose at least one role that this deployment target will provide.") + cmd.Flags().StringSliceVar(&commonFlags.Roles.Value, FlagRole, []string{}, "Choose at least one role that this deployment target will provide (use --tag for tag sets with validation).") + cmd.Flags().StringSliceVar(&commonFlags.Tags.Value, FlagTag, []string{}, "Target tags in canonical format (TagSetName/TagName).") } func PromptForRoles(opts *CreateTargetRoleOptions, flags *CreateTargetRoleFlags) error { - - if util.Empty(flags.Roles.Value) { - availableRoles, err := opts.GetAllRolesCallback() + if util.Empty(flags.Roles.Value) && util.Empty(flags.Tags.Value) { + tagSets, err := getTargetTagSets(opts.Client) if err != nil { return err } - roles, err := question.MultiSelectWithAddMap(opts.Ask, "Choose at least one role for the deployment target.\n", availableRoles, true) - if err != nil { - return err + if len(tagSets) > 0 { + tags, err := selectors.Tags(opts.Ask, []string{}, []string{}, tagSets) + if err != nil { + return err + } + flags.Tags.Value = tags + } else { + availableRoles, err := opts.GetAllRolesCallback() + if err != nil { + return err + } + roles, err := question.MultiSelectWithAddMap(opts.Ask, "Choose at least one role for the deployment target.\n", availableRoles, true) + if err != nil { + return err + } + flags.Roles.Value = roles } - flags.Roles.Value = roles } return nil } @@ -73,3 +92,56 @@ func getAllMachineRoles(client client.Client) ([]string, error) { } return roles, nil } + +func getTargetTagSets(client *client.Client) ([]*tagsets.TagSet, error) { + if client == nil { + return []*tagsets.TagSet{}, nil + } + + query := tagsets.TagSetsQuery{ + Scopes: []string{string(tagsets.TagSetScopeTarget)}, + } + result, err := tagsets.Get(client, client.GetSpaceID(), query) + if err != nil { + return nil, err + } + return result.Items, nil +} + +// ValidateTags validates tags in canonical format (TagSetName/TagName) against target scoped tag sets +// Returns plain tag names to send to the API +func ValidateTags(client *client.Client, tags []string) ([]string, error) { + if len(tags) == 0 { + return []string{}, nil + } + + tagSets, err := getTargetTagSets(client) + if err != nil { + return nil, err + } + + if err := selectors.ValidateTags(tags, tagSets); err != nil { + return nil, err + } + + plainNames := make([]string, 0, len(tags)) + for _, tag := range tags { + parts := strings.SplitN(tag, "/", 2) + plainNames = append(plainNames, parts[1]) + } + + return plainNames, nil +} + +func CombineRolesAndTags(client *client.Client, roles []string, tags []string) ([]string, error) { + combined := make([]string, 0, len(roles)+len(tags)) + combined = append(combined, roles...) + + validatedTags, err := ValidateTags(client, tags) + if err != nil { + return nil, err + } + combined = append(combined, validatedTags...) + + return combined, nil +} diff --git a/pkg/cmd/target/shared/role_test.go b/pkg/cmd/target/shared/role_test.go index 01c842ac..e067bbdc 100644 --- a/pkg/cmd/target/shared/role_test.go +++ b/pkg/cmd/target/shared/role_test.go @@ -54,3 +54,39 @@ func TestPromptRolesAndEnvironments_ShouldPrompt(t *testing.T) { assert.Equal(t, []string{"Ninja #3"}, flags.Roles.Value) } + +func TestValidateTags_EmptyList(t *testing.T) { + result, err := shared.ValidateTags(nil, []string{}) + assert.NoError(t, err) + assert.Empty(t, result) +} + +func TestValidateTags_InvalidFormat_NoSlash(t *testing.T) { + _, err := shared.ValidateTags(nil, []string{"PlainName"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "is not in the correct format") +} + +func TestValidateTags_InvalidFormat_MultipleSlashes(t *testing.T) { + _, err := shared.ValidateTags(nil, []string{"Set/Tag/Extra"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "does not belong to any tag set") +} + +func TestCombineRolesAndTags_RolesOnly(t *testing.T) { + result, err := shared.CombineRolesAndTags(nil, []string{"web-server", "db-server"}, []string{}) + assert.NoError(t, err) + assert.Equal(t, []string{"web-server", "db-server"}, result) +} + +func TestCombineRolesAndTags_EmptyBoth(t *testing.T) { + result, err := shared.CombineRolesAndTags(nil, []string{}, []string{}) + assert.NoError(t, err) + assert.Empty(t, result) +} + +func TestCombineRolesAndTags_TagValidationError(t *testing.T) { + _, err := shared.CombineRolesAndTags(nil, []string{"web-server"}, []string{"InvalidFormat"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "is not in the correct format") +} diff --git a/pkg/cmd/target/ssh/create/create.go b/pkg/cmd/target/ssh/create/create.go index 9ea153d4..d88c2f94 100644 --- a/pkg/cmd/target/ssh/create/create.go +++ b/pkg/cmd/target/ssh/create/create.go @@ -117,6 +117,11 @@ func createRun(opts *CreateOptions) error { } environmentIds := util.SliceTransform(envs, func(e *environments.Environment) string { return e.ID }) + combinedRoles, err := shared.CombineRolesAndTags(opts.Client, opts.Roles.Value, opts.Tags.Value) + if err != nil { + return err + } + account, err := machinescommon.GetSshAccount(opts.SshCommonOptions, opts.SshCommonFlags) if err != nil { return err @@ -141,7 +146,7 @@ func createRun(opts *CreateOptions) error { endpoint.ProxyID = proxy.GetID() } - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(combinedRoles)) machinePolicy, err := machinescommon.FindMachinePolicy(opts.GetAllMachinePoliciesCallback, opts.MachinePolicy.Value) if err != nil { return err @@ -160,7 +165,7 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.Out, "Successfully created SSH deployment target '%s'.\n", deploymentTarget.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.HostName, opts.Port, opts.Fingerprint, opts.Runtime, opts.Platform, opts.Environments, opts.Roles, opts.Account, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.HostName, opts.Port, opts.Fingerprint, opts.Runtime, opts.Platform, opts.Environments, opts.Roles, opts.Tags, opts.Account, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) }