Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 219 additions & 3 deletions internal/cmd/base/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"log"
"maps"
"slices"
"strings"
"sync"

"github.com/spf13/cobra"

Expand All @@ -15,6 +17,20 @@ import (
"github.com/hetznercloud/cli/internal/state"
)

// labelBatchSize is the batch size when processing multiple resources in parallel.
const labelBatchSize = 10

// BatchLabelResult represents the result of a batch label operation for a single resource.
type BatchLabelResult struct {
IDOrName string
Resource interface{}
Success bool
Error error
LabelsAdded []string
LabelsRemoved []string
AllLabelsRemoved bool
}

// LabelCmds allows defining commands for adding labels to resources.
type LabelCmds[T any] struct {
ResourceNameSingular string
Expand Down Expand Up @@ -48,6 +64,10 @@ type LabelCmds[T any] struct {

// Experimental is a function that will be used to mark the command as experimental.
Experimental func(state.State, *cobra.Command) *cobra.Command

// Batch operation support
FetchBatch func(s state.State, idOrNames []string) ([]T, []error)
SetLabelsBatch func(s state.State, resources []T, labels map[string]string) []error
}

// AddCobraCommand creates a command that can be registered with cobra.
Expand All @@ -65,15 +85,15 @@ func (lc *LabelCmds[T]) AddCobraCommand(s state.State) *cobra.Command {
}

cmd := &cobra.Command{
Use: fmt.Sprintf("add-label [--overwrite] %s <label>...", positionalArguments(lc.ResourceNameSingular, lc.PositionalArgumentOverride)),
Use: fmt.Sprintf("add-label [--overwrite] %s... <label>...", positionalArguments(lc.ResourceNameSingular, lc.PositionalArgumentOverride)),
Short: lc.ShortDescriptionAdd,
Args: util.Validate,
ValidArgsFunction: cmpl.SuggestArgs(suggestArgs...),
TraverseChildren: true,
DisableFlagsInUseLine: true,
PreRunE: util.ChainRunE(lc.validateAddLabel, s.EnsureToken),
PreRunE: util.ChainRunE(lc.validateAddLabelBatch, s.EnsureToken),
RunE: func(cmd *cobra.Command, args []string) error {
return lc.RunAdd(s, cmd, args)
return lc.RunAddBatch(s, cmd, args)
},
}

Expand Down Expand Up @@ -130,6 +150,170 @@ func (lc *LabelCmds[T]) RunAdd(s state.State, cmd *cobra.Command, args []string)
return nil
}

// RunAddBatch executes a batch add label command
func (lc *LabelCmds[T]) RunAddBatch(s state.State, cmd *cobra.Command, args []string) error {
overwrite, _ := cmd.Flags().GetBool("overwrite")

// Parse arguments to separate resources from labels
var resourceArgs []string
var labelArgs []string

// Separate arguments: those with '=' are labels, others are resources
for _, arg := range args {
if strings.Contains(arg, "=") {
labelArgs = append(labelArgs, arg)
} else {
resourceArgs = append(resourceArgs, arg)
}
}

if len(resourceArgs) == 0 {
return fmt.Errorf("must specify at least one %s", strings.ToLower(lc.ResourceNameSingular))
}

if len(labelArgs) == 0 {
return fmt.Errorf("must specify at least one label")
}

// Parse labels to add
labelsToAdd := make(map[string]string)
var labelKeys []string
for _, label := range labelArgs {
key, val := util.SplitLabelVars(label)
labelsToAdd[key] = val
labelKeys = append(labelKeys, key)
}

// Process resources in batches
errs := make([]error, 0, len(resourceArgs))
results := make([]BatchLabelResult, 0, len(resourceArgs))

for batch := range slices.Chunk(resourceArgs, labelBatchSize) {
batchResults := lc.processAddBatch(s, batch, labelsToAdd, overwrite)
results = append(results, batchResults...)

// Collect errors
for _, result := range batchResults {
if result.Error != nil {
errs = append(errs, result.Error)
}
}
}

// Display results
lc.displayAddResults(cmd, results, labelKeys)

return errors.Join(errs...)
}

// processAddBatch processes a batch of resources for adding labels
func (lc *LabelCmds[T]) processAddBatch(s state.State, idOrNames []string, labelsToAdd map[string]string, overwrite bool) []BatchLabelResult {
results := make([]BatchLabelResult, len(idOrNames))

// Fetch all resources in batch
resources, fetchErrors := lc.fetchResourcesBatch(s, idOrNames)

for i, idOrName := range idOrNames {
results[i] = BatchLabelResult{IDOrName: idOrName}

if fetchErrors[i] != nil {
results[i].Error = fetchErrors[i]
continue
}

resource := resources[i]
if util.IsNil(resource) {
results[i].Error = fmt.Errorf("%s not found: %s", lc.ResourceNameSingular, idOrName)
continue
}

// Get current labels
existingLabels := lc.GetLabels(resource)
if existingLabels == nil {
existingLabels = make(map[string]string)
}

// Check for conflicts
var conflictKeys []string
for key := range labelsToAdd {
if _, exists := existingLabels[key]; exists && !overwrite {
conflictKeys = append(conflictKeys, key)
}
}

if len(conflictKeys) > 0 {
results[i].Error = fmt.Errorf("label(s) %s on %s %s already exist",
strings.Join(conflictKeys, ", "), lc.ResourceNameSingular, idOrName)
continue
}

// Merge new labels
for key, value := range labelsToAdd {
existingLabels[key] = value
}

// Apply labels
if err := lc.SetLabels(s, resource, existingLabels); err != nil {
results[i].Error = err
continue
}

results[i].Success = true
results[i].Resource = resource
results[i].LabelsAdded = slices.Collect(maps.Keys(labelsToAdd))
}

return results
}

// fetchResourcesBatch fetches multiple resources in parallel
func (lc *LabelCmds[T]) fetchResourcesBatch(s state.State, idOrNames []string) ([]T, []error) {
if lc.FetchBatch != nil {
return lc.FetchBatch(s, idOrNames)
}

// Fallback to individual fetching with goroutines
resources := make([]T, len(idOrNames))
errors := make([]error, len(idOrNames))

var wg sync.WaitGroup
for i, idOrName := range idOrNames {
wg.Add(1)
go func(idx int, id string) {
defer wg.Done()
resource, err := lc.Fetch(s, id)
resources[idx] = resource
errors[idx] = err
}(i, idOrName)
}
wg.Wait()

return resources, errors
}

// displayAddResults displays the results of a batch add operation
func (lc *LabelCmds[T]) displayAddResults(cmd *cobra.Command, results []BatchLabelResult, labelKeys []string) {
successful := 0
failed := 0

for _, result := range results {
if result.Success {
successful++
cmd.Printf("✓ %s: Labels %s added\n",
result.IDOrName,
strings.Join(labelKeys, ", "))
} else {
failed++
cmd.Printf("✗ %s: %v\n", result.IDOrName, result.Error)
}
}

// Summary for multiple resources
if len(results) > 1 {
cmd.Printf("\nSummary: %d successful, %d failed\n", successful, failed)
}
}

func (lc *LabelCmds[T]) validateAddLabel(_ *cobra.Command, args []string) error {
numPosArgs := max(1, len(lc.PositionalArgumentOverride))

Expand All @@ -142,6 +326,38 @@ func (lc *LabelCmds[T]) validateAddLabel(_ *cobra.Command, args []string) error
return nil
}

// validateAddLabelBatch validates arguments for batch add label operations
func (lc *LabelCmds[T]) validateAddLabelBatch(_ *cobra.Command, args []string) error {
var resourceArgs []string
var labelArgs []string

// Separate arguments: those with '=' are labels, others are resources
for _, arg := range args {
if strings.Contains(arg, "=") {
labelArgs = append(labelArgs, arg)
} else {
resourceArgs = append(resourceArgs, arg)
}
}

if len(resourceArgs) == 0 {
return fmt.Errorf("must specify at least one %s", strings.ToLower(lc.ResourceNameSingular))
}

if len(labelArgs) == 0 {
return fmt.Errorf("must specify at least one label")
}

// Validate label format for all labels
for _, label := range labelArgs {
if len(util.SplitLabel(label)) != 2 {
return fmt.Errorf("invalid label: %s", label)
}
}

return nil
}

// RemoveCobraCommand creates a command that can be registered with cobra.
func (lc *LabelCmds[T]) RemoveCobraCommand(s state.State) *cobra.Command {
var suggestArgs []cobra.CompletionFunc
Expand Down
Loading
Loading