namespaces: allow enabling/disabling allowed drivers per namespace

This commit is contained in:
Florian Apolloner 2022-02-24 15:27:32 +01:00 committed by GitHub
parent 57b9c64b8f
commit 3bced8f558
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 406 additions and 42 deletions

3
.changelog/11807.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
namespaces: Allow enabling/disabling allowed drivers per namespace.
```

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -70,6 +70,7 @@ func NewJobEndpoints(s *Server) *Job {
validators: []jobValidator{
jobConnectHook{},
jobExposeCheckHook{},
jobNamespaceConstraintCheckHook{srv: s},
jobValidate{},
&memoryOversubscriptionValidate{srv: s},
},

View File

@ -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
}

View File

@ -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\"")
}

View File

@ -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
}

View File

@ -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
```

View File

@ -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