namespaces: allow enabling/disabling allowed drivers per namespace
This commit is contained in:
parent
57b9c64b8f
commit
3bced8f558
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
namespaces: Allow enabling/disabling allowed drivers per namespace.
|
||||
```
|
|
@ -67,11 +67,17 @@ func (n *Namespaces) Delete(namespace string, q *WriteOptions) (*WriteMeta, erro
|
|||
|
||||
// Namespace is used to serialize a namespace.
|
||||
type Namespace struct {
|
||||
Name string
|
||||
Description string
|
||||
Quota string
|
||||
CreateIndex uint64
|
||||
ModifyIndex uint64
|
||||
Name string
|
||||
Description string
|
||||
Quota string
|
||||
Capabilities *NamespaceCapabilities `hcl:"capabilities,block"`
|
||||
CreateIndex uint64
|
||||
ModifyIndex uint64
|
||||
}
|
||||
|
||||
type NamespaceCapabilities struct {
|
||||
EnabledTaskDrivers []string `hcl:"enabled_task_drivers"`
|
||||
DisabledTaskDrivers []string `hcl:"disabled_task_drivers"`
|
||||
}
|
||||
|
||||
// NamespaceIndexSort is a wrapper to sort Namespaces by CreateIndex. We
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/hcl"
|
||||
"github.com/hashicorp/hcl/hcl/ast"
|
||||
"github.com/hashicorp/nomad/api"
|
||||
flaghelper "github.com/hashicorp/nomad/helper/flags"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/posener/complete"
|
||||
)
|
||||
|
||||
|
@ -15,10 +22,14 @@ type NamespaceApplyCommand struct {
|
|||
|
||||
func (c *NamespaceApplyCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: nomad namespace apply [options] <namespace>
|
||||
Usage: nomad namespace apply [options] <input>
|
||||
|
||||
Apply is used to create or update a namespace. It takes the namespace name to
|
||||
create or update as its only argument.
|
||||
Apply is used to create or update a namespace. The specification file
|
||||
will be read from stdin by specifying "-", otherwise a path to the file is
|
||||
expected.
|
||||
|
||||
Instead of a file, you may instead pass the namespace name to create
|
||||
or update as the only argument.
|
||||
|
||||
If ACLs are enabled, this command requires a management ACL token.
|
||||
|
||||
|
@ -33,6 +44,9 @@ Apply Options:
|
|||
|
||||
-description
|
||||
An optional description for the namespace.
|
||||
|
||||
-json
|
||||
Parse the input as a JSON namespace specification.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
@ -42,6 +56,7 @@ func (c *NamespaceApplyCommand) AutocompleteFlags() complete.Flags {
|
|||
complete.Flags{
|
||||
"-description": complete.PredictAnything,
|
||||
"-quota": QuotaPredictor(c.Meta.Client),
|
||||
"-json": complete.PredictNothing,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -56,6 +71,7 @@ func (c *NamespaceApplyCommand) Synopsis() string {
|
|||
func (c *NamespaceApplyCommand) Name() string { return "namespace apply" }
|
||||
|
||||
func (c *NamespaceApplyCommand) Run(args []string) int {
|
||||
var jsonInput bool
|
||||
var description, quota *string
|
||||
|
||||
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
|
||||
|
@ -68,6 +84,7 @@ func (c *NamespaceApplyCommand) Run(args []string) int {
|
|||
quota = &s
|
||||
return nil
|
||||
}), "quota", "")
|
||||
flags.BoolVar(&jsonInput, "json", false, "")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
|
@ -76,18 +93,15 @@ func (c *NamespaceApplyCommand) Run(args []string) int {
|
|||
// Check that we get exactly one argument
|
||||
args = flags.Args()
|
||||
if l := len(args); l != 1 {
|
||||
c.Ui.Error("This command takes one argument: <namespace>")
|
||||
c.Ui.Error("This command takes one argument: <input>")
|
||||
c.Ui.Error(commandErrorText(c))
|
||||
return 1
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
|
||||
// Validate we have at-least a name
|
||||
if name == "" {
|
||||
c.Ui.Error("Namespace name required")
|
||||
return 1
|
||||
}
|
||||
file := args[0]
|
||||
var rawNamespace []byte
|
||||
var err error
|
||||
var namespace *api.Namespace
|
||||
|
||||
// Get the HTTP client
|
||||
client, err := c.Meta.Client()
|
||||
|
@ -96,33 +110,133 @@ func (c *NamespaceApplyCommand) Run(args []string) int {
|
|||
return 1
|
||||
}
|
||||
|
||||
// Lookup the given namespace
|
||||
ns, _, err := client.Namespaces().Info(name, nil)
|
||||
if err != nil && !strings.Contains(err.Error(), "404") {
|
||||
c.Ui.Error(fmt.Sprintf("Error looking up namespace: %s", err))
|
||||
return 1
|
||||
}
|
||||
if _, err = os.Stat(file); file == "-" || err == nil {
|
||||
if quota != nil || description != nil {
|
||||
c.Ui.Warn("Flags are ignored when a file is specified!")
|
||||
}
|
||||
|
||||
if ns == nil {
|
||||
ns = &api.Namespace{
|
||||
Name: name,
|
||||
if file == "-" {
|
||||
rawNamespace, err = ioutil.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Failed to read stdin: %v", err))
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
rawNamespace, err = ioutil.ReadFile(file)
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Failed to read file: %v", err))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if jsonInput {
|
||||
var jsonSpec api.Namespace
|
||||
dec := json.NewDecoder(bytes.NewBuffer(rawNamespace))
|
||||
if err := dec.Decode(&jsonSpec); err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Failed to parse quota: %v", err))
|
||||
return 1
|
||||
}
|
||||
namespace = &jsonSpec
|
||||
} else {
|
||||
hclSpec, err := parseNamespaceSpec(rawNamespace)
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error parsing quota specification: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
namespace = hclSpec
|
||||
}
|
||||
} else {
|
||||
name := args[0]
|
||||
|
||||
// Validate we have at-least a name
|
||||
if name == "" {
|
||||
c.Ui.Error("Namespace name required")
|
||||
return 1
|
||||
}
|
||||
|
||||
// Lookup the given namespace
|
||||
namespace, _, err = client.Namespaces().Info(name, nil)
|
||||
if err != nil && !strings.Contains(err.Error(), "404") {
|
||||
c.Ui.Error(fmt.Sprintf("Error looking up namespace: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
if namespace == nil {
|
||||
namespace = &api.Namespace{
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// Add what is set
|
||||
if description != nil {
|
||||
namespace.Description = *description
|
||||
}
|
||||
if quota != nil {
|
||||
namespace.Quota = *quota
|
||||
}
|
||||
}
|
||||
|
||||
// Add what is set
|
||||
if description != nil {
|
||||
ns.Description = *description
|
||||
}
|
||||
if quota != nil {
|
||||
ns.Quota = *quota
|
||||
}
|
||||
|
||||
_, err = client.Namespaces().Register(ns, nil)
|
||||
_, err = client.Namespaces().Register(namespace, nil)
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error applying namespace: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
c.Ui.Output(fmt.Sprintf("Successfully applied namespace %q!", name))
|
||||
c.Ui.Output(fmt.Sprintf("Successfully applied namespace %q!", namespace.Name))
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseNamespaceSpec is used to parse the namespace specification from HCL
|
||||
func parseNamespaceSpec(input []byte) (*api.Namespace, error) {
|
||||
root, err := hcl.ParseBytes(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Top-level item should be a list
|
||||
list, ok := root.Node.(*ast.ObjectList)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("error parsing: root should be an object")
|
||||
}
|
||||
|
||||
var spec api.Namespace
|
||||
if err := parseNamespaceSpecImpl(&spec, list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
// parseNamespaceSpec parses the quota namespace taking as input the AST tree
|
||||
func parseNamespaceSpecImpl(result *api.Namespace, list *ast.ObjectList) error {
|
||||
// Decode the full thing into a map[string]interface for ease
|
||||
var m map[string]interface{}
|
||||
if err := hcl.DecodeObject(&m, list); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(m, "capabilities")
|
||||
|
||||
// Decode the rest
|
||||
if err := mapstructure.WeakDecode(m, result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cObj := list.Filter("capabilities")
|
||||
if len(cObj.Items) > 0 {
|
||||
for _, o := range cObj.Elem().Items {
|
||||
ot, ok := o.Val.(*ast.ObjectType)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
var opts *api.NamespaceCapabilities
|
||||
if err := hcl.DecodeObject(&opts, ot.List); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Capabilities = opts
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -111,10 +111,22 @@ func (c *NamespaceStatusCommand) Run(args []string) int {
|
|||
|
||||
// formatNamespaceBasics formats the basic information of the namespace
|
||||
func formatNamespaceBasics(ns *api.Namespace) string {
|
||||
enabled_drivers := "*"
|
||||
disabled_drivers := ""
|
||||
if ns.Capabilities != nil {
|
||||
if len(ns.Capabilities.EnabledTaskDrivers) != 0 {
|
||||
enabled_drivers = strings.Join(ns.Capabilities.EnabledTaskDrivers, ",")
|
||||
}
|
||||
if len(ns.Capabilities.DisabledTaskDrivers) != 0 {
|
||||
disabled_drivers = strings.Join(ns.Capabilities.DisabledTaskDrivers, ",")
|
||||
}
|
||||
}
|
||||
basic := []string{
|
||||
fmt.Sprintf("Name|%s", ns.Name),
|
||||
fmt.Sprintf("Description|%s", ns.Description),
|
||||
fmt.Sprintf("Quota|%s", ns.Quota),
|
||||
fmt.Sprintf("EnabledDrivers|%s", enabled_drivers),
|
||||
fmt.Sprintf("DisabledDrivers|%s", disabled_drivers),
|
||||
}
|
||||
|
||||
return formatKV(basic)
|
||||
|
|
|
@ -70,6 +70,7 @@ func NewJobEndpoints(s *Server) *Job {
|
|||
validators: []jobValidator{
|
||||
jobConnectHook{},
|
||||
jobExposeCheckHook{},
|
||||
jobNamespaceConstraintCheckHook{srv: s},
|
||||
jobValidate{},
|
||||
&memoryOversubscriptionValidate{srv: s},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
package nomad
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type jobNamespaceConstraintCheckHook struct {
|
||||
srv *Server
|
||||
}
|
||||
|
||||
func (jobNamespaceConstraintCheckHook) Name() string {
|
||||
return "namespace-constraint-check"
|
||||
}
|
||||
|
||||
func (c jobNamespaceConstraintCheckHook) Validate(job *structs.Job) (warnings []error, err error) {
|
||||
// This was validated before and matches the WriteRequest namespace
|
||||
ns, err := c.srv.State().NamespaceByName(nil, job.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ns == nil {
|
||||
return nil, errors.Errorf("job %q is in nonexistent namespace %q", job.ID, job.Namespace)
|
||||
}
|
||||
|
||||
var disallowedDrivers []string
|
||||
for _, tg := range job.TaskGroups {
|
||||
for _, t := range tg.Tasks {
|
||||
if !taskValidateDriver(t, ns) {
|
||||
disallowedDrivers = append(disallowedDrivers, t.Driver)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(disallowedDrivers) > 0 {
|
||||
if len(disallowedDrivers) == 1 {
|
||||
return nil, errors.Errorf(
|
||||
"used task driver %q is not allowed in namespace %q", disallowedDrivers[0], ns.Name)
|
||||
|
||||
} else {
|
||||
return nil, errors.Errorf(
|
||||
"used task drivers %q are not allowed in namespace %q", disallowedDrivers, ns.Name)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func taskValidateDriver(task *structs.Task, ns *structs.Namespace) bool {
|
||||
if ns.Capabilities == nil {
|
||||
return true
|
||||
}
|
||||
allow := len(ns.Capabilities.EnabledTaskDrivers) == 0
|
||||
for _, d := range ns.Capabilities.EnabledTaskDrivers {
|
||||
if task.Driver == d {
|
||||
allow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, d := range ns.Capabilities.DisabledTaskDrivers {
|
||||
if task.Driver == d {
|
||||
allow = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return allow
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
package nomad
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJobNamespaceConstraintCheckHook_Name(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, "namespace-constraint-check", new(jobNamespaceConstraintCheckHook).Name())
|
||||
}
|
||||
|
||||
func TestJobNamespaceConstraintCheckHook_taskValidateDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
description string
|
||||
driver string
|
||||
ns *structs.Namespace
|
||||
result bool
|
||||
}{
|
||||
{
|
||||
"No drivers enabled/disabled, allow all",
|
||||
"docker",
|
||||
&structs.Namespace{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Only exec and docker are allowed 1/2",
|
||||
"docker",
|
||||
&structs.Namespace{
|
||||
Capabilities: &structs.NamespaceCapabilities{
|
||||
EnabledTaskDrivers: []string{"docker", "exec"}},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Only exec and docker are allowed 2/2",
|
||||
"raw_exec",
|
||||
&structs.Namespace{
|
||||
Capabilities: &structs.NamespaceCapabilities{
|
||||
EnabledTaskDrivers: []string{"docker", "exec"}},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"disable takes precedence over enable",
|
||||
"docker",
|
||||
&structs.Namespace{
|
||||
Capabilities: &structs.NamespaceCapabilities{
|
||||
EnabledTaskDrivers: []string{"docker"},
|
||||
DisabledTaskDrivers: []string{"docker"}},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"All drivers but docker are allowed 1/2",
|
||||
"docker",
|
||||
&structs.Namespace{
|
||||
Capabilities: &structs.NamespaceCapabilities{
|
||||
DisabledTaskDrivers: []string{"docker"}},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"All drivers but docker are allowed 2/2",
|
||||
"raw_exec",
|
||||
&structs.Namespace{
|
||||
Capabilities: &structs.NamespaceCapabilities{
|
||||
DisabledTaskDrivers: []string{"docker"}},
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
var task = &structs.Task{Driver: c.driver}
|
||||
require.Equal(t, c.result, taskValidateDriver(task, c.ns), c.description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobNamespaceConstraintCheckHook_validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
s1, cleanupS1 := TestServer(t, nil)
|
||||
defer cleanupS1()
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
|
||||
// Create a namespace
|
||||
ns := mock.Namespace()
|
||||
ns.Name = "default" // fix the name
|
||||
ns.Capabilities = &structs.NamespaceCapabilities{
|
||||
EnabledTaskDrivers: []string{"docker", "qemu"},
|
||||
DisabledTaskDrivers: []string{"exec", "raw_exec"},
|
||||
}
|
||||
s1.fsm.State().UpsertNamespaces(1000, []*structs.Namespace{ns})
|
||||
|
||||
hook := jobNamespaceConstraintCheckHook{srv: s1}
|
||||
job := mock.LifecycleJob()
|
||||
job.TaskGroups[0].Tasks[0].Driver = "docker"
|
||||
job.TaskGroups[0].Tasks[1].Driver = "qemu"
|
||||
job.TaskGroups[0].Tasks[2].Driver = "docker"
|
||||
_, err := hook.Validate(job)
|
||||
require.Nil(t, err)
|
||||
|
||||
job.TaskGroups[0].Tasks[2].Driver = "raw_exec"
|
||||
_, err = hook.Validate(job)
|
||||
require.Equal(t, err.Error(), "used task driver \"raw_exec\" is not allowed in namespace \"default\"")
|
||||
|
||||
job.TaskGroups[0].Tasks[1].Driver = "exec"
|
||||
_, err = hook.Validate(job)
|
||||
require.Equal(t, err.Error(), "used task drivers [\"exec\" \"raw_exec\"] are not allowed in namespace \"default\"")
|
||||
}
|
|
@ -4960,6 +4960,9 @@ type Namespace struct {
|
|||
// against.
|
||||
Quota string
|
||||
|
||||
// Capabilities is the set of capabilities allowed for this namespace
|
||||
Capabilities *NamespaceCapabilities
|
||||
|
||||
// Hash is the hash of the namespace which is used to efficiently replicate
|
||||
// cross-regions.
|
||||
Hash []byte
|
||||
|
@ -4969,6 +4972,13 @@ type Namespace struct {
|
|||
ModifyIndex uint64
|
||||
}
|
||||
|
||||
// NamespaceCapabilities represents a set of capabilities allowed for this
|
||||
// namespace, to be checked at job submission time.
|
||||
type NamespaceCapabilities struct {
|
||||
EnabledTaskDrivers []string
|
||||
DisabledTaskDrivers []string
|
||||
}
|
||||
|
||||
func (n *Namespace) Validate() error {
|
||||
var mErr multierror.Error
|
||||
|
||||
|
@ -4997,6 +5007,14 @@ func (n *Namespace) SetHash() []byte {
|
|||
_, _ = hash.Write([]byte(n.Name))
|
||||
_, _ = hash.Write([]byte(n.Description))
|
||||
_, _ = hash.Write([]byte(n.Quota))
|
||||
if n.Capabilities != nil {
|
||||
for _, driver := range n.Capabilities.EnabledTaskDrivers {
|
||||
_, _ = hash.Write([]byte(driver))
|
||||
}
|
||||
for _, driver := range n.Capabilities.DisabledTaskDrivers {
|
||||
_, _ = hash.Write([]byte(driver))
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the hash
|
||||
hashVal := hash.Sum(nil)
|
||||
|
@ -5010,6 +5028,13 @@ func (n *Namespace) Copy() *Namespace {
|
|||
nc := new(Namespace)
|
||||
*nc = *n
|
||||
nc.Hash = make([]byte, len(n.Hash))
|
||||
if n.Capabilities != nil {
|
||||
c := new(NamespaceCapabilities)
|
||||
*c = *n.Capabilities
|
||||
c.EnabledTaskDrivers = helper.CopySliceString(n.Capabilities.EnabledTaskDrivers)
|
||||
c.DisabledTaskDrivers = helper.CopySliceString(n.Capabilities.DisabledTaskDrivers)
|
||||
nc.Capabilities = c
|
||||
}
|
||||
copy(nc.Hash, n.Hash)
|
||||
return nc
|
||||
}
|
||||
|
|
|
@ -15,11 +15,15 @@ when introduced in Nomad 0.7.
|
|||
## Usage
|
||||
|
||||
```plaintext
|
||||
nomad namespace apply [options] <namespace>
|
||||
nomad namespace apply [options] <input>
|
||||
```
|
||||
|
||||
The `namespace apply` command requires the name of the namespace to be created
|
||||
or updated.
|
||||
Apply is used to create or update a namespace. The specification file
|
||||
will be read from stdin by specifying "-", otherwise a path to the file is
|
||||
expected.
|
||||
|
||||
Instead of a file, you may instead pass the namespace name to create
|
||||
or update as the only argument.
|
||||
|
||||
If ACLs are enabled, this command requires a management ACL token.
|
||||
|
||||
|
@ -33,6 +37,8 @@ If ACLs are enabled, this command requires a management ACL token.
|
|||
|
||||
- `-description` : An optional human readable description for the namespace.
|
||||
|
||||
- `json` : Parse the input as a JSON namespace specification.
|
||||
|
||||
## Examples
|
||||
|
||||
Create a namespace with a quota:
|
||||
|
@ -47,3 +53,16 @@ Remove a quota from a namespace:
|
|||
```shell-session
|
||||
$ nomad namespace apply -quota= api-prod
|
||||
```
|
||||
|
||||
Create a namespace from a file:
|
||||
```shell-session
|
||||
$ cat namespace.json
|
||||
name = "dev"
|
||||
description = "Namespace for developers"
|
||||
|
||||
capabilities {
|
||||
enabled_task_drivers = ["docker", "exec"]
|
||||
disabled_task_drivers = ["raw_exec"]
|
||||
}
|
||||
$ nomad namespace apply namespace.json
|
||||
```
|
||||
|
|
|
@ -33,9 +33,11 @@ View the status of a namespace:
|
|||
|
||||
```shell-session
|
||||
$ nomad namespace status default
|
||||
Name = default
|
||||
Description = Default shared namespace
|
||||
Quota = shared-default-quota
|
||||
Name = default
|
||||
Description = Default shared namespace
|
||||
Quota = shared-default-quota
|
||||
EnabledDrivers = docker,exec
|
||||
DisabledDrivers = raw_exec
|
||||
|
||||
Quota Limits
|
||||
Region CPU Usage Memory Usage
|
||||
|
|
Loading…
Reference in New Issue