gitee.com/mysnapcore/mysnapd@v0.1.0/cmd/snap/cmd_quota.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2021 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  package main
    21  
    22  import (
    23  	"fmt"
    24  	"regexp"
    25  	"sort"
    26  	"strconv"
    27  	"strings"
    28  	"time"
    29  
    30  	"github.com/jessevdk/go-flags"
    31  
    32  	"gitee.com/mysnapcore/mysnapd/client"
    33  	"gitee.com/mysnapcore/mysnapd/gadget/quantity"
    34  	"gitee.com/mysnapcore/mysnapd/i18n"
    35  	"gitee.com/mysnapcore/mysnapd/strutil"
    36  )
    37  
    38  var shortQuotaHelp = i18n.G("Show quota group for a set of snaps")
    39  var longQuotaHelp = i18n.G(`
    40  The quota command shows information about a quota group, including the set of 
    41  snaps and any sub-groups it contains, as well as its resource constraints and 
    42  the current usage of those constrained resources.
    43  `)
    44  
    45  var shortQuotasHelp = i18n.G("Show quota groups")
    46  var longQuotasHelp = i18n.G(`
    47  The quotas command shows all quota groups.
    48  `)
    49  
    50  var shortRemoveQuotaHelp = i18n.G("Remove quota group")
    51  var longRemoveQuotaHelp = i18n.G(`
    52  The remove-quota command removes the given quota group. 
    53  
    54  Currently, only quota groups with no sub-groups can be removed. In order to 
    55  remove a quota group with sub-groups, the sub-groups must first be removed until
    56  there are no sub-groups for the group, then the group itself can be removed.
    57  `)
    58  
    59  var shortSetQuotaHelp = i18n.G(`Create or update a quota group.`)
    60  var longSetQuotaHelp = i18n.G(`
    61  The set-quota command updates or creates a quota group with the specified set of
    62  snaps.
    63  
    64  A quota group sets resource limits on the set of snaps it contains. Snaps can 
    65  be at most in one quota group but quota groups can be nested. Nested quota 
    66  groups are subject to the restriction that the total sum of each existing quota
    67  in sub-groups cannot exceed that of the parent group the nested groups are part of.
    68  
    69  All provided snaps are appended to the group; to remove a snap from a
    70  quota group, the entire group must be removed with remove-quota and recreated 
    71  without the snap. To remove a sub-group from the quota group, the 
    72  sub-group must be removed directly with the remove-quota command.
    73  
    74  The memory limit for a quota group can be increased but not decreased. To
    75  decrease the memory limit for a quota group, the entire group must be removed
    76  with the remove-quota command and recreated with a lower limit. Increasing the
    77  memory limit for a quota group does not restart any services associated with 
    78  snaps in the quota group.
    79  
    80  The CPU limit for a quota group can be both increased and decreased after being
    81  set on a quota group. The CPU limit can be specified as a single percentage which
    82  means that the quota group is allowed an overall percentage of the CPU resources. Setting
    83  it to 50% means that the quota group is allowed to use up to 50% of all CPU cores
    84  in the allowed CPU set. Setting the percentage to 2x100% means that the quota group 
    85  is allowed up to 100% on two cpu cores.
    86  
    87  The CPU set limit for a quota group can be modified to include new cpus, or to remove
    88  existing cpus from the quota already set.
    89  
    90  The threads limit for a quota group can be increased but not decreased. To
    91  decrease the threads limit for a quota group, the entire group must be removed
    92  with the remove-quota command and recreated with a lower limit.
    93  
    94  The journal limits can be increased and decreased after being set on a group.
    95  Setting a journal limit will cause the snaps in the group to be put into the same
    96  journal namespace. This will affect the behaviour of the log command.
    97  
    98  New quotas can be set on existing quota groups, but existing quotas cannot be removed
    99  from a quota group, without removing and recreating the entire group.
   100  
   101  Adding new snaps to a quota group will result in all non-disabled services in 
   102  that snap being restarted.
   103  
   104  An existing sub group cannot be moved from one parent to another.
   105  `)
   106  
   107  func init() {
   108  	// TODO: unhide the commands when non-experimental
   109  	cmd := addCommand("set-quota", shortSetQuotaHelp, longSetQuotaHelp,
   110  		func() flags.Commander { return &cmdSetQuota{} },
   111  		waitDescs.also(map[string]string{
   112  			"memory":             i18n.G("Memory quota"),
   113  			"cpu":                i18n.G("CPU quota"),
   114  			"cpu-set":            i18n.G("CPU set quota"),
   115  			"threads":            i18n.G("Threads quota"),
   116  			"journal-size":       i18n.G("Journal size quota"),
   117  			"journal-rate-limit": i18n.G("Journal rate limit as <message count>/<message period>"),
   118  			"parent":             i18n.G("Parent quota group"),
   119  		}), nil)
   120  	cmd.hidden = true
   121  
   122  	cmd = addCommand("quota", shortQuotaHelp, longQuotaHelp, func() flags.Commander { return &cmdQuota{} }, nil, nil)
   123  	cmd.hidden = true
   124  
   125  	cmd = addCommand("quotas", shortQuotasHelp, longQuotasHelp, func() flags.Commander { return &cmdQuotas{} }, nil, nil)
   126  	cmd.hidden = true
   127  
   128  	cmd = addCommand("remove-quota", shortRemoveQuotaHelp, longRemoveQuotaHelp, func() flags.Commander { return &cmdRemoveQuota{} }, nil, nil)
   129  	cmd.hidden = true
   130  }
   131  
   132  type cmdSetQuota struct {
   133  	waitMixin
   134  
   135  	MemoryMax        string `long:"memory" optional:"true"`
   136  	CPUMax           string `long:"cpu" optional:"true"`
   137  	CPUSet           string `long:"cpu-set" optional:"true"`
   138  	ThreadsMax       string `long:"threads" optional:"true"`
   139  	JournalSizeMax   string `long:"journal-size" optional:"true"`
   140  	JournalRateLimit string `long:"journal-rate-limit" optional:"true"`
   141  	Parent           string `long:"parent" optional:"true"`
   142  	Positional       struct {
   143  		GroupName string              `positional-arg-name:"<group-name>" required:"true"`
   144  		Snaps     []installedSnapName `positional-arg-name:"<snap>" optional:"true"`
   145  	} `positional-args:"yes"`
   146  }
   147  
   148  // example cpu quota string: "2x50%", "90%"
   149  var cpuValueMatcher = regexp.MustCompile(`([0-9]+x)?([0-9]+)%`)
   150  
   151  func parseCpuQuota(cpuMax string) (count int, percentage int, err error) {
   152  	parseError := func(input string) error {
   153  		return fmt.Errorf("cannot parse cpu quota string %q", input)
   154  	}
   155  
   156  	match := cpuValueMatcher.FindStringSubmatch(cpuMax)
   157  	if match == nil {
   158  		return 0, 0, parseError(cpuMax)
   159  	}
   160  
   161  	// Detect whether format was NxM% or M%
   162  	if len(match[1]) > 0 {
   163  		// Assume format was NxM%
   164  		count, err = strconv.Atoi(match[1][:len(match[1])-1])
   165  		if err != nil || count == 0 {
   166  			return 0, 0, parseError(cpuMax)
   167  		}
   168  	}
   169  
   170  	percentage, err = strconv.Atoi(match[2])
   171  	if err != nil || percentage == 0 {
   172  		return 0, 0, parseError(cpuMax)
   173  	}
   174  	return count, percentage, nil
   175  }
   176  
   177  func parseJournalRateQuota(journalRateLimit string) (count int, period time.Duration, err error) {
   178  	// the rate limit is a string of the form N/P, where N is the number of
   179  	// messages and P is the period as a time string (e.g 5s)
   180  	parts := strings.Split(journalRateLimit, "/")
   181  	if len(parts) != 2 {
   182  		return 0, 0, fmt.Errorf("rate limit must be of the form <number of messages>/<period duration>")
   183  	}
   184  
   185  	count, err = strconv.Atoi(parts[0])
   186  	if err != nil {
   187  		return 0, 0, fmt.Errorf("cannot parse message count: %v", err)
   188  	}
   189  
   190  	period, err = time.ParseDuration(parts[1])
   191  	if err != nil {
   192  		return 0, 0, fmt.Errorf("cannot parse period: %v", err)
   193  	}
   194  	return count, period, nil
   195  }
   196  
   197  func (x *cmdSetQuota) parseQuotas() (*client.QuotaValues, error) {
   198  	var quotaValues client.QuotaValues
   199  
   200  	if x.MemoryMax != "" {
   201  		value, err := strutil.ParseByteSize(x.MemoryMax)
   202  		if err != nil {
   203  			return nil, err
   204  		}
   205  		quotaValues.Memory = quantity.Size(value)
   206  	}
   207  
   208  	if x.CPUMax != "" {
   209  		countValue, percentageValue, err := parseCpuQuota(x.CPUMax)
   210  		if err != nil {
   211  			return nil, err
   212  		}
   213  		if percentageValue > 100 || percentageValue <= 0 {
   214  			return nil, fmt.Errorf("cannot use value %v: cpu quota percentage must be between 1 and 100", percentageValue)
   215  		}
   216  
   217  		quotaValues.CPU = &client.QuotaCPUValues{
   218  			Count:      countValue,
   219  			Percentage: percentageValue,
   220  		}
   221  	}
   222  
   223  	if x.CPUSet != "" {
   224  		var cpus []int
   225  		cpuTokens := strutil.CommaSeparatedList(x.CPUSet)
   226  		for _, cpuToken := range cpuTokens {
   227  			cpu, err := strconv.ParseUint(cpuToken, 10, 32)
   228  			if err != nil {
   229  				return nil, fmt.Errorf("cannot parse CPU set value %q", cpuToken)
   230  			}
   231  			cpus = append(cpus, int(cpu))
   232  		}
   233  
   234  		quotaValues.CPUSet = &client.QuotaCPUSetValues{
   235  			CPUs: cpus,
   236  		}
   237  	}
   238  
   239  	if x.ThreadsMax != "" {
   240  		value, err := strconv.ParseUint(x.ThreadsMax, 10, 32)
   241  		if err != nil {
   242  			return nil, fmt.Errorf("cannot use threads value %q", x.ThreadsMax)
   243  		}
   244  		quotaValues.Threads = int(value)
   245  	}
   246  
   247  	if x.JournalSizeMax != "" || x.JournalRateLimit != "" {
   248  		quotaValues.Journal = &client.QuotaJournalValues{}
   249  		if x.JournalSizeMax != "" {
   250  			value, err := strutil.ParseByteSize(x.JournalSizeMax)
   251  			if err != nil {
   252  				return nil, fmt.Errorf("cannot parse journal size %q: %v", x.JournalSizeMax, err)
   253  			}
   254  			quotaValues.Journal.Size = quantity.Size(value)
   255  		}
   256  
   257  		if x.JournalRateLimit != "" {
   258  			count, period, err := parseJournalRateQuota(x.JournalRateLimit)
   259  			if err != nil {
   260  				return nil, fmt.Errorf("cannot parse journal rate limit %q: %v", x.JournalRateLimit, err)
   261  			}
   262  			quotaValues.Journal.QuotaJournalRate = &client.QuotaJournalRate{
   263  				RateCount:  count,
   264  				RatePeriod: period,
   265  			}
   266  		}
   267  	}
   268  
   269  	return &quotaValues, nil
   270  }
   271  
   272  func (x *cmdSetQuota) hasQuotaSet() bool {
   273  	return x.MemoryMax != "" || x.CPUMax != "" || x.CPUSet != "" ||
   274  		x.ThreadsMax != "" || x.JournalSizeMax != "" || x.JournalRateLimit != ""
   275  }
   276  
   277  func (x *cmdSetQuota) Execute(args []string) (err error) {
   278  	quotaProvided := x.hasQuotaSet()
   279  
   280  	names := installedSnapNames(x.Positional.Snaps)
   281  
   282  	// figure out if the group exists or not to make error messages more useful
   283  	groupExists := false
   284  	if _, err = x.client.GetQuotaGroup(x.Positional.GroupName); err == nil {
   285  		groupExists = true
   286  	}
   287  
   288  	var chgID string
   289  
   290  	switch {
   291  	case !quotaProvided && x.Parent == "" && len(x.Positional.Snaps) == 0:
   292  		// no snaps were specified, no memory limit was specified, and no parent
   293  		// was specified, so just the group name was provided - this is not
   294  		// supported since there is nothing to change/create
   295  
   296  		if groupExists {
   297  			return fmt.Errorf("no options set to change quota group")
   298  		}
   299  		return fmt.Errorf("cannot create quota group without any limit")
   300  
   301  	case !quotaProvided && x.Parent != "" && len(x.Positional.Snaps) == 0:
   302  		// this is either trying to create a new group with a parent and forgot
   303  		// to specify the limits for the new group, or the user is trying
   304  		// to re-parent a group, i.e. move it from the current parent to a
   305  		// different one, which is currently unsupported
   306  
   307  		if groupExists {
   308  			// TODO: or this could be setting the parent to the existing parent,
   309  			// which is effectively no change or update but maybe we allow since
   310  			// it's a noop?
   311  			return fmt.Errorf("cannot move a quota group to a new parent")
   312  		}
   313  		return fmt.Errorf("cannot create quota group without any limits")
   314  
   315  	case quotaProvided:
   316  		// we have a limits to set for this group, so specify that along
   317  		// with whatever snaps may have been provided and whatever parent may
   318  		// have been specified
   319  		quotaValues, err := x.parseQuotas()
   320  		if err != nil {
   321  			return err
   322  		}
   323  
   324  		// note that the group could currently exist with a parent, and we could
   325  		// be specifying x.Parent as "" here - in the future that may mean to
   326  		// orphan a sub-group to no longer have a parent, but currently it just
   327  		// means leave the group with whatever parent it has, or if it doesn't
   328  		// currently exist, create the group without a parent group
   329  		chgID, err = x.client.EnsureQuota(x.Positional.GroupName, x.Parent, names, quotaValues)
   330  		if err != nil {
   331  			return err
   332  		}
   333  	case len(x.Positional.Snaps) != 0:
   334  		// there are snaps specified for this group but no limits, so the
   335  		// group must already exist and we must be adding the specified snaps to
   336  		// the group
   337  
   338  		// TODO: this case may someday also imply overwriting the current set of
   339  		// snaps with whatever was specified with some option, but we don't
   340  		// currently support that, so currently all snaps specified here are
   341  		// just added to the group
   342  
   343  		chgID, err = x.client.EnsureQuota(x.Positional.GroupName, x.Parent, names, nil)
   344  		if err != nil {
   345  			return err
   346  		}
   347  	default:
   348  		// should be logically impossible to reach here
   349  		panic("impossible set of options")
   350  	}
   351  
   352  	if _, err := x.wait(chgID); err != nil {
   353  		if err == noWait {
   354  			return nil
   355  		}
   356  		return err
   357  	}
   358  
   359  	return nil
   360  }
   361  
   362  type cmdQuota struct {
   363  	clientMixin
   364  
   365  	Positional struct {
   366  		GroupName string `positional-arg-name:"<group-name>" required:"true"`
   367  	} `positional-args:"yes"`
   368  }
   369  
   370  func (x *cmdQuota) Execute(args []string) (err error) {
   371  	if len(args) != 0 {
   372  		return fmt.Errorf("too many arguments provided")
   373  	}
   374  
   375  	group, err := x.client.GetQuotaGroup(x.Positional.GroupName)
   376  	if err != nil {
   377  		return err
   378  	}
   379  
   380  	w := tabWriter()
   381  	defer w.Flush()
   382  
   383  	fmt.Fprintf(w, "name:\t%s\n", group.GroupName)
   384  	if group.Parent != "" {
   385  		fmt.Fprintf(w, "parent:\t%s\n", group.Parent)
   386  	}
   387  
   388  	// Constraints should always be non-nil, since a quota group always needs to
   389  	// have at least one limit set
   390  	if group.Constraints == nil {
   391  		return fmt.Errorf("internal error: constraints is missing from daemon response")
   392  	}
   393  
   394  	fmt.Fprintf(w, "constraints:\n")
   395  
   396  	if group.Constraints.Memory != 0 {
   397  		val := strings.TrimSpace(fmtSize(int64(group.Constraints.Memory)))
   398  		fmt.Fprintf(w, "  memory:\t%s\n", val)
   399  	}
   400  	if group.Constraints.CPU != nil {
   401  		fmt.Fprintf(w, "  cpu-count:\t%d\n", group.Constraints.CPU.Count)
   402  		fmt.Fprintf(w, "  cpu-percentage:\t%d\n", group.Constraints.CPU.Percentage)
   403  	}
   404  	if group.Constraints.CPUSet != nil && len(group.Constraints.CPUSet.CPUs) > 0 {
   405  		cpus := strutil.IntsToCommaSeparated(group.Constraints.CPUSet.CPUs)
   406  		fmt.Fprintf(w, "  cpu-set:\t%s\n", cpus)
   407  	}
   408  	if group.Constraints.Threads != 0 {
   409  		fmt.Fprintf(w, "  threads:\t%d\n", group.Constraints.Threads)
   410  	}
   411  	if group.Constraints.Journal != nil {
   412  		if group.Constraints.Journal.Size != 0 {
   413  			val := strings.TrimSpace(fmtSize(int64(group.Constraints.Journal.Size)))
   414  			fmt.Fprintf(w, "  journal-size:\t%s\n", val)
   415  		}
   416  		if group.Constraints.Journal.QuotaJournalRate != nil {
   417  			fmt.Fprintf(w, "  journal-rate:\t%d/%s\n",
   418  				group.Constraints.Journal.RateCount,
   419  				group.Constraints.Journal.RatePeriod)
   420  		}
   421  	}
   422  
   423  	memoryUsage := "0B"
   424  	currentThreads := 0
   425  	if group.Current != nil {
   426  		memoryUsage = strings.TrimSpace(fmtSize(int64(group.Current.Memory)))
   427  		currentThreads = group.Current.Threads
   428  	}
   429  
   430  	fmt.Fprintf(w, "current:\n")
   431  	if group.Constraints.Memory != 0 {
   432  		fmt.Fprintf(w, "  memory:\t%s\n", memoryUsage)
   433  	}
   434  	if group.Constraints.Threads != 0 {
   435  		fmt.Fprintf(w, "  threads:\t%d\n", currentThreads)
   436  	}
   437  
   438  	if len(group.Subgroups) > 0 {
   439  		fmt.Fprint(w, "subgroups:\n")
   440  		for _, name := range group.Subgroups {
   441  			fmt.Fprintf(w, "  - %s\n", name)
   442  		}
   443  	}
   444  	if len(group.Snaps) > 0 {
   445  		fmt.Fprint(w, "snaps:\n")
   446  		for _, snapName := range group.Snaps {
   447  			fmt.Fprintf(w, "  - %s\n", snapName)
   448  		}
   449  	}
   450  
   451  	return nil
   452  }
   453  
   454  type cmdRemoveQuota struct {
   455  	waitMixin
   456  
   457  	Positional struct {
   458  		GroupName string `positional-arg-name:"<group-name>" required:"true"`
   459  	} `positional-args:"yes"`
   460  }
   461  
   462  func (x *cmdRemoveQuota) Execute(args []string) (err error) {
   463  	chgID, err := x.client.RemoveQuotaGroup(x.Positional.GroupName)
   464  	if err != nil {
   465  		return err
   466  	}
   467  
   468  	if _, err := x.wait(chgID); err != nil {
   469  		if err == noWait {
   470  			return nil
   471  		}
   472  		return err
   473  	}
   474  
   475  	return nil
   476  }
   477  
   478  type cmdQuotas struct {
   479  	clientMixin
   480  }
   481  
   482  func (x *cmdQuotas) Execute(args []string) (err error) {
   483  	res, err := x.client.Quotas()
   484  	if err != nil {
   485  		return err
   486  	}
   487  	if len(res) == 0 {
   488  		fmt.Fprintln(Stdout, i18n.G("No quota groups defined."))
   489  		return nil
   490  	}
   491  
   492  	w := tabWriter()
   493  	fmt.Fprintf(w, "Quota\tParent\tConstraints\tCurrent\n")
   494  	err = processQuotaGroupsTree(res, func(q *client.QuotaGroupResult) error {
   495  		if q.Constraints == nil {
   496  			return fmt.Errorf("internal error: constraints is missing from daemon response")
   497  		}
   498  
   499  		var grpConstraints []string
   500  
   501  		// format memory constraint as memory=N
   502  		if q.Constraints.Memory != 0 {
   503  			grpConstraints = append(grpConstraints, "memory="+strings.TrimSpace(fmtSize(int64(q.Constraints.Memory))))
   504  		}
   505  
   506  		// format cpu constraint as cpu=NxM%,cpu-set=x,y,z
   507  		if q.Constraints.CPU != nil {
   508  			if q.Constraints.CPU.Count != 0 {
   509  				grpConstraints = append(grpConstraints, fmt.Sprintf("cpu=%dx%d%%", q.Constraints.CPU.Count, q.Constraints.CPU.Percentage))
   510  			} else {
   511  				grpConstraints = append(grpConstraints, fmt.Sprintf("cpu=%d%%", q.Constraints.CPU.Percentage))
   512  			}
   513  		}
   514  
   515  		if q.Constraints.CPUSet != nil && len(q.Constraints.CPUSet.CPUs) > 0 {
   516  			cpus := strutil.IntsToCommaSeparated(q.Constraints.CPUSet.CPUs)
   517  			grpConstraints = append(grpConstraints, "cpu-set="+cpus)
   518  		}
   519  
   520  		// format threads constraint as threads=N
   521  		if q.Constraints.Threads != 0 {
   522  			grpConstraints = append(grpConstraints, "threads="+strconv.Itoa(q.Constraints.Threads))
   523  		}
   524  
   525  		// format journal constraint as journal-size=xMB,journal-rate=x/y
   526  		if q.Constraints.Journal != nil {
   527  			if q.Constraints.Journal.Size != 0 {
   528  				grpConstraints = append(grpConstraints, "journal-size="+strings.TrimSpace(fmtSize(int64(q.Constraints.Journal.Size))))
   529  			}
   530  
   531  			if q.Constraints.Journal.QuotaJournalRate != nil {
   532  				grpConstraints = append(grpConstraints,
   533  					fmt.Sprintf("journal-rate=%d/%s",
   534  						q.Constraints.Journal.RateCount, q.Constraints.Journal.RatePeriod))
   535  			}
   536  		}
   537  
   538  		// format current resource values as memory=N,threads=N
   539  		var grpCurrent []string
   540  		if q.Current != nil {
   541  			if q.Constraints.Memory != 0 && q.Current.Memory != 0 {
   542  				grpCurrent = append(grpCurrent, "memory="+strings.TrimSpace(fmtSize(int64(q.Current.Memory))))
   543  			}
   544  			if q.Constraints.Threads != 0 && q.Current.Threads != 0 {
   545  				grpCurrent = append(grpCurrent, "threads="+fmt.Sprintf("%d", q.Current.Threads))
   546  			}
   547  		}
   548  
   549  		fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", q.GroupName, q.Parent, strings.Join(grpConstraints, ","), strings.Join(grpCurrent, ","))
   550  
   551  		return nil
   552  	})
   553  	if err != nil {
   554  		return err
   555  	}
   556  	w.Flush()
   557  	return nil
   558  }
   559  
   560  type quotaGroup struct {
   561  	res       *client.QuotaGroupResult
   562  	subGroups []*quotaGroup
   563  }
   564  
   565  type byQuotaName []*quotaGroup
   566  
   567  func (q byQuotaName) Len() int           { return len(q) }
   568  func (q byQuotaName) Swap(i, j int)      { q[i], q[j] = q[j], q[i] }
   569  func (q byQuotaName) Less(i, j int) bool { return q[i].res.GroupName < q[j].res.GroupName }
   570  
   571  // processQuotaGroupsTree recreates the hierarchy of quotas and then visits it
   572  // recursively following the hierarchy first, then naming order.
   573  func processQuotaGroupsTree(quotas []*client.QuotaGroupResult, handleGroup func(q *client.QuotaGroupResult) error) error {
   574  	var roots []*quotaGroup
   575  	groupLookup := make(map[string]*quotaGroup, len(quotas))
   576  
   577  	for _, q := range quotas {
   578  		grp := &quotaGroup{res: q}
   579  		groupLookup[q.GroupName] = grp
   580  
   581  		if q.Parent == "" {
   582  			roots = append(roots, grp)
   583  		}
   584  	}
   585  
   586  	sort.Sort(byQuotaName(roots))
   587  
   588  	// populate sub-groups
   589  	for _, g := range groupLookup {
   590  		sort.Strings(g.res.Subgroups)
   591  		for _, subgrpName := range g.res.Subgroups {
   592  			subGroup, ok := groupLookup[subgrpName]
   593  			if !ok {
   594  				return fmt.Errorf("internal error: inconsistent groups received, unknown subgroup %q", subgrpName)
   595  			}
   596  			g.subGroups = append(g.subGroups, subGroup)
   597  		}
   598  	}
   599  
   600  	var processGroups func(groups []*quotaGroup) error
   601  	processGroups = func(groups []*quotaGroup) error {
   602  		for _, g := range groups {
   603  			if err := handleGroup(g.res); err != nil {
   604  				return err
   605  			}
   606  			if len(g.subGroups) > 0 {
   607  				if err := processGroups(g.subGroups); err != nil {
   608  					return err
   609  				}
   610  			}
   611  		}
   612  		return nil
   613  	}
   614  	return processGroups(roots)
   615  }