diff --git a/.changelog/11807.txt b/.changelog/11807.txt new file mode 100644 index 000000000..99b572a4c --- /dev/null +++ b/.changelog/11807.txt @@ -0,0 +1,3 @@ +```release-note:improvement +namespaces: Allow enabling/disabling allowed drivers per namespace. +``` diff --git a/api/namespace.go b/api/namespace.go index c5324fdb9..409a62bdd 100644 --- a/api/namespace.go +++ b/api/namespace.go @@ -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 diff --git a/command/namespace_apply.go b/command/namespace_apply.go index b7c311c20..118645963 100644 --- a/command/namespace_apply.go +++ b/command/namespace_apply.go @@ -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] +Usage: nomad namespace apply [options] - 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: ") + c.Ui.Error("This command takes one argument: ") 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 +} diff --git a/command/namespace_status.go b/command/namespace_status.go index 85612481f..4f0f58b7f 100644 --- a/command/namespace_status.go +++ b/command/namespace_status.go @@ -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) diff --git a/nomad/job_endpoint.go b/nomad/job_endpoint.go index 564fe4bf3..418cbe1af 100644 --- a/nomad/job_endpoint.go +++ b/nomad/job_endpoint.go @@ -70,6 +70,7 @@ func NewJobEndpoints(s *Server) *Job { validators: []jobValidator{ jobConnectHook{}, jobExposeCheckHook{}, + jobNamespaceConstraintCheckHook{srv: s}, jobValidate{}, &memoryOversubscriptionValidate{srv: s}, }, diff --git a/nomad/job_endpoint_validators.go b/nomad/job_endpoint_validators.go new file mode 100644 index 000000000..b11891b08 --- /dev/null +++ b/nomad/job_endpoint_validators.go @@ -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 +} diff --git a/nomad/job_endpoint_validators_test.go b/nomad/job_endpoint_validators_test.go new file mode 100644 index 000000000..f84fd90e2 --- /dev/null +++ b/nomad/job_endpoint_validators_test.go @@ -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\"") +} diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 08d0474be..9a30381cf 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -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 } diff --git a/website/content/docs/commands/namespace/apply.mdx b/website/content/docs/commands/namespace/apply.mdx index 97f84c666..d9f93453c 100644 --- a/website/content/docs/commands/namespace/apply.mdx +++ b/website/content/docs/commands/namespace/apply.mdx @@ -15,11 +15,15 @@ when introduced in Nomad 0.7. ## Usage ```plaintext -nomad namespace apply [options] +nomad namespace apply [options] ``` -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 +``` diff --git a/website/content/docs/commands/namespace/status.mdx b/website/content/docs/commands/namespace/status.mdx index 45362a840..570b065e6 100644 --- a/website/content/docs/commands/namespace/status.mdx +++ b/website/content/docs/commands/namespace/status.mdx @@ -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