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 "aValues, 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 := "aGroup{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 }