mirror of
https://github.com/kemko/nomad.git
synced 2026-01-02 00:15:43 +03:00
* exec2: add client support for unveil filesystem isolation mode This PR adds support for a new filesystem isolation mode, "Unveil". The mode introduces a "alloc_mounts" directory where tasks have user-owned directory structure which are bind mounts into the real alloc directory structure. This enables a task driver to use landlock (and maybe the real unveil on openbsd one day) to isolate a task to the task owned directory structure, providing sandboxing. * actually create alloc-mounts-dir directory * fix doc strings about alloc mount dir paths
628 lines
14 KiB
Go
628 lines
14 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package agent
|
|
|
|
import (
|
|
"math"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hashicorp/nomad/ci"
|
|
"github.com/hashicorp/nomad/helper/pointer"
|
|
"github.com/mitchellh/cli"
|
|
"github.com/shoenig/test/must"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/hashicorp/nomad/nomad/structs"
|
|
"github.com/hashicorp/nomad/nomad/structs/config"
|
|
"github.com/hashicorp/nomad/version"
|
|
)
|
|
|
|
func TestCommand_Implements(t *testing.T) {
|
|
ci.Parallel(t)
|
|
var _ cli.Command = &Command{}
|
|
}
|
|
|
|
func TestCommand_Args(t *testing.T) {
|
|
ci.Parallel(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
type tcase struct {
|
|
args []string
|
|
errOut string
|
|
}
|
|
tcases := []tcase{
|
|
{
|
|
[]string{},
|
|
"Must specify either server, client or dev mode for the agent.",
|
|
},
|
|
{
|
|
[]string{"-client", "-data-dir=" + tmpDir, "-bootstrap-expect=1"},
|
|
"Bootstrap requires server mode to be enabled",
|
|
},
|
|
{
|
|
[]string{"-data-dir=" + tmpDir, "-server", "-bootstrap-expect=1"},
|
|
"WARNING: Bootstrap mode enabled!",
|
|
},
|
|
{
|
|
[]string{"-data-dir=" + tmpDir, "-server", "-bootstrap-expect=2"},
|
|
"Number of bootstrap servers should ideally be set to an odd number",
|
|
},
|
|
{
|
|
[]string{"-server"},
|
|
"Must specify \"data_dir\" config option or \"data-dir\" CLI flag",
|
|
},
|
|
{
|
|
[]string{"-client", "-alloc-dir="},
|
|
"Must specify the state, alloc-dir, alloc-mounts-dir and plugin-dir if data-dir is omitted.",
|
|
},
|
|
{
|
|
[]string{"-client", "-data-dir=" + tmpDir, "-meta=invalid..key=inaccessible-value"},
|
|
"Invalid Client.Meta key: invalid..key",
|
|
},
|
|
{
|
|
[]string{"-client", "-data-dir=" + tmpDir, "-meta=.invalid=inaccessible-value"},
|
|
"Invalid Client.Meta key: .invalid",
|
|
},
|
|
{
|
|
[]string{"-client", "-data-dir=" + tmpDir, "-meta=invalid.=inaccessible-value"},
|
|
"Invalid Client.Meta key: invalid.",
|
|
},
|
|
{
|
|
[]string{"-client", "-node-pool=not@valid"},
|
|
"Invalid node pool",
|
|
},
|
|
}
|
|
for _, tc := range tcases {
|
|
// Make a new command. We preemptively close the shutdownCh
|
|
// so that the command exits immediately instead of blocking.
|
|
ui := cli.NewMockUi()
|
|
shutdownCh := make(chan struct{})
|
|
close(shutdownCh)
|
|
cmd := &Command{
|
|
Version: version.GetVersion(),
|
|
Ui: ui,
|
|
ShutdownCh: shutdownCh,
|
|
}
|
|
|
|
// To prevent test failures on hosts whose hostname resolves to
|
|
// a loopback address, we must append a bind address
|
|
tc.args = append(tc.args, "-bind=169.254.0.1")
|
|
if code := cmd.Run(tc.args); code != 1 {
|
|
t.Fatalf("args: %v\nexit: %d\n", tc.args, code)
|
|
}
|
|
|
|
if expect := tc.errOut; expect != "" {
|
|
out := ui.ErrorWriter.String()
|
|
if !strings.Contains(out, expect) {
|
|
t.Fatalf("expect to find %q\n\n%s", expect, out)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommand_MetaConfigValidation(t *testing.T) {
|
|
ci.Parallel(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
tcases := []string{
|
|
"foo..invalid",
|
|
".invalid",
|
|
"invalid.",
|
|
}
|
|
for _, tc := range tcases {
|
|
configFile := filepath.Join(tmpDir, "conf1.hcl")
|
|
err := os.WriteFile(configFile, []byte(`client{
|
|
enabled = true
|
|
meta = {
|
|
"valid" = "yes"
|
|
"`+tc+`" = "kaboom!"
|
|
"nested.var" = "is nested"
|
|
"deeply.nested.var" = "is deeply nested"
|
|
}
|
|
}`), 0600)
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
// Make a new command. We preemptively close the shutdownCh
|
|
// so that the command exits immediately instead of blocking.
|
|
ui := cli.NewMockUi()
|
|
shutdownCh := make(chan struct{})
|
|
close(shutdownCh)
|
|
cmd := &Command{
|
|
Version: version.GetVersion(),
|
|
Ui: ui,
|
|
ShutdownCh: shutdownCh,
|
|
}
|
|
|
|
// To prevent test failures on hosts whose hostname resolves to
|
|
// a loopback address, we must append a bind address
|
|
args := []string{"-client", "-data-dir=" + tmpDir, "-config=" + configFile, "-bind=169.254.0.1"}
|
|
if code := cmd.Run(args); code != 1 {
|
|
t.Fatalf("args: %v\nexit: %d\n", args, code)
|
|
}
|
|
|
|
expect := "Invalid Client.Meta key: " + tc
|
|
out := ui.ErrorWriter.String()
|
|
if !strings.Contains(out, expect) {
|
|
t.Fatalf("expect to find %q\n\n%s", expect, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommand_InvalidCharInDatacenter(t *testing.T) {
|
|
ci.Parallel(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
tcases := []string{
|
|
"char-\\000-in-the-middle",
|
|
"ends-with-\\000",
|
|
"\\000-at-the-beginning",
|
|
"char-*-in-the-middle",
|
|
"ends-with-*",
|
|
"*-at-the-beginning",
|
|
}
|
|
for _, tc := range tcases {
|
|
configFile := filepath.Join(tmpDir, "conf1.hcl")
|
|
err := os.WriteFile(configFile, []byte(`
|
|
datacenter = "`+tc+`"
|
|
client{
|
|
enabled = true
|
|
}`), 0600)
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
// Make a new command. We preemptively close the shutdownCh
|
|
// so that the command exits immediately instead of blocking.
|
|
ui := cli.NewMockUi()
|
|
shutdownCh := make(chan struct{})
|
|
close(shutdownCh)
|
|
cmd := &Command{
|
|
Version: version.GetVersion(),
|
|
Ui: ui,
|
|
ShutdownCh: shutdownCh,
|
|
}
|
|
|
|
// To prevent test failures on hosts whose hostname resolves to
|
|
// a loopback address, we must append a bind address
|
|
args := []string{"-client", "-data-dir=" + tmpDir, "-config=" + configFile, "-bind=169.254.0.1"}
|
|
if code := cmd.Run(args); code != 1 {
|
|
t.Fatalf("args: %v\nexit: %d\n", args, code)
|
|
}
|
|
|
|
out := ui.ErrorWriter.String()
|
|
exp := "Datacenter contains invalid characters (null or '*')"
|
|
if !strings.Contains(out, exp) {
|
|
t.Fatalf("expect to find %q\n\n%s", exp, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommand_NullCharInRegion(t *testing.T) {
|
|
ci.Parallel(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
tcases := []string{
|
|
"char-\\000-in-the-middle",
|
|
"ends-with-\\000",
|
|
"\\000-at-the-beginning",
|
|
}
|
|
for _, tc := range tcases {
|
|
configFile := filepath.Join(tmpDir, "conf1.hcl")
|
|
err := os.WriteFile(configFile, []byte(`
|
|
region = "`+tc+`"
|
|
client{
|
|
enabled = true
|
|
}`), 0600)
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
// Make a new command. We preemptively close the shutdownCh
|
|
// so that the command exits immediately instead of blocking.
|
|
ui := cli.NewMockUi()
|
|
shutdownCh := make(chan struct{})
|
|
close(shutdownCh)
|
|
cmd := &Command{
|
|
Version: version.GetVersion(),
|
|
Ui: ui,
|
|
ShutdownCh: shutdownCh,
|
|
}
|
|
|
|
// To prevent test failures on hosts whose hostname resolves to
|
|
// a loopback address, we must append a bind address
|
|
args := []string{"-client", "-data-dir=" + tmpDir, "-config=" + configFile, "-bind=169.254.0.1"}
|
|
if code := cmd.Run(args); code != 1 {
|
|
t.Fatalf("args: %v\nexit: %d\n", args, code)
|
|
}
|
|
|
|
out := ui.ErrorWriter.String()
|
|
exp := "Region contains invalid characters"
|
|
if !strings.Contains(out, exp) {
|
|
t.Fatalf("expect to find %q\n\n%s", exp, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIsValidConfig asserts that invalid configurations return false.
|
|
func TestIsValidConfig(t *testing.T) {
|
|
ci.Parallel(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
conf Config // merged into DefaultConfig()
|
|
|
|
// err should appear in error output; success expected if err
|
|
// is empty
|
|
err string
|
|
}{
|
|
{
|
|
name: "Default",
|
|
conf: Config{
|
|
DataDir: "/tmp",
|
|
Client: &ClientConfig{Enabled: true},
|
|
},
|
|
},
|
|
{
|
|
name: "NoMode",
|
|
conf: Config{
|
|
Client: &ClientConfig{Enabled: false},
|
|
Server: &ServerConfig{Enabled: false},
|
|
},
|
|
err: "Must specify either",
|
|
},
|
|
{
|
|
name: "InvalidRegion",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
},
|
|
Region: "Hello\000World",
|
|
},
|
|
err: "Region contains",
|
|
},
|
|
{
|
|
name: "InvalidDatacenter",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
},
|
|
Datacenter: "Hello\000World",
|
|
},
|
|
err: "Datacenter contains",
|
|
},
|
|
{
|
|
name: "RelativeDir",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
},
|
|
DataDir: "foo/bar",
|
|
},
|
|
err: "must be given as an absolute",
|
|
},
|
|
{
|
|
name: "InvalidNodePoolChar",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
NodePool: "not@valid",
|
|
},
|
|
},
|
|
err: "Invalid node pool",
|
|
},
|
|
{
|
|
name: "InvalidNodePoolName",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
NodePool: structs.NodePoolAll,
|
|
},
|
|
},
|
|
err: "not allowed",
|
|
},
|
|
{
|
|
name: "NegativeMinDynamicPort",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
MinDynamicPort: -1,
|
|
},
|
|
},
|
|
err: "min_dynamic_port",
|
|
},
|
|
{
|
|
name: "NegativeMaxDynamicPort",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
MaxDynamicPort: -1,
|
|
},
|
|
},
|
|
err: "max_dynamic_port",
|
|
},
|
|
{
|
|
name: "BigMinDynamicPort",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
MinDynamicPort: math.MaxInt32,
|
|
},
|
|
},
|
|
err: "min_dynamic_port",
|
|
},
|
|
{
|
|
name: "BigMaxDynamicPort",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
MaxDynamicPort: math.MaxInt32,
|
|
},
|
|
},
|
|
err: "max_dynamic_port",
|
|
},
|
|
{
|
|
name: "MinMaxDynamicPortSwitched",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
MinDynamicPort: 5000,
|
|
MaxDynamicPort: 4000,
|
|
},
|
|
},
|
|
err: "and max",
|
|
},
|
|
{
|
|
name: "DynamicPortOk",
|
|
conf: Config{
|
|
DataDir: "/tmp",
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
MinDynamicPort: 4000,
|
|
MaxDynamicPort: 5000,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "BadReservedPorts",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
Reserved: &Resources{
|
|
ReservedPorts: "3-2147483647",
|
|
},
|
|
},
|
|
},
|
|
err: `reserved.reserved_ports "3-2147483647" invalid: port must be < 65536 but found 2147483647`,
|
|
},
|
|
{
|
|
name: "BadHostNetworkReservedPorts",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
HostNetworks: []*structs.ClientHostNetworkConfig{
|
|
&structs.ClientHostNetworkConfig{
|
|
Name: "test",
|
|
ReservedPorts: "3-2147483647",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
err: `host_network["test"].reserved_ports "3-2147483647" invalid: port must be < 65536 but found 2147483647`,
|
|
},
|
|
{
|
|
name: "BadArtifact",
|
|
conf: Config{
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
Artifact: &config.ArtifactConfig{
|
|
HTTPReadTimeout: pointer.Of("-10m"),
|
|
},
|
|
},
|
|
},
|
|
err: "client.artifact block invalid: http_read_timeout must be > 0",
|
|
},
|
|
{
|
|
name: "BadHostVolumeConfig",
|
|
conf: Config{
|
|
DataDir: "/tmp",
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
HostVolumes: []*structs.ClientHostVolumeConfig{
|
|
{
|
|
Name: "test",
|
|
ReadOnly: true,
|
|
},
|
|
{
|
|
Name: "test",
|
|
ReadOnly: true,
|
|
Path: "/random/path",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
err: "Missing path in host_volume config",
|
|
},
|
|
{
|
|
name: "ValidHostVolumeConfig",
|
|
conf: Config{
|
|
DataDir: "/tmp",
|
|
Client: &ClientConfig{
|
|
Enabled: true,
|
|
HostVolumes: []*structs.ClientHostVolumeConfig{
|
|
{
|
|
Name: "test",
|
|
ReadOnly: true,
|
|
Path: "/random/path1",
|
|
},
|
|
{
|
|
Name: "test",
|
|
ReadOnly: true,
|
|
Path: "/random/path2",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "BadOIDCIssuer",
|
|
conf: Config{
|
|
DataDir: "/tmp",
|
|
Server: &ServerConfig{
|
|
Enabled: true,
|
|
OIDCIssuer: ":/example.com",
|
|
},
|
|
},
|
|
err: "missing protocol scheme",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
mui := cli.NewMockUi()
|
|
cmd := &Command{Ui: mui}
|
|
config := DefaultConfig().Merge(&tc.conf)
|
|
result := cmd.IsValidConfig(config, DefaultConfig())
|
|
if tc.err == "" {
|
|
// No error expected
|
|
assert.True(t, result, mui.ErrorWriter.String())
|
|
return
|
|
}
|
|
|
|
// Error expected
|
|
assert.False(t, result)
|
|
require.Contains(t, mui.ErrorWriter.String(), tc.err)
|
|
t.Logf("%s returned: %s", tc.name, mui.ErrorWriter.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommand_readConfig(t *testing.T) {
|
|
// Don't run in parallel since this test modifies environment variables.
|
|
|
|
configFiles := map[string]string{
|
|
"base.hcl": `
|
|
data_dir = "/tmp/nomad"
|
|
region = "global"
|
|
|
|
server {
|
|
enabled = true
|
|
}
|
|
|
|
client {
|
|
enabled = true
|
|
}
|
|
`,
|
|
"vault.hcl": `
|
|
data_dir = "/tmp/nomad"
|
|
region = "global"
|
|
|
|
server {
|
|
enabled = true
|
|
}
|
|
|
|
client {
|
|
enabled = true
|
|
}
|
|
|
|
vault {
|
|
token = "token-from-config"
|
|
namespace = "ns-from-config"
|
|
}
|
|
`,
|
|
}
|
|
|
|
configDir := t.TempDir()
|
|
for k, v := range configFiles {
|
|
err := os.WriteFile(path.Join(configDir, k), []byte(v), 0644)
|
|
must.NoError(t, err)
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
args []string
|
|
env map[string]string
|
|
checkFn func(*testing.T, *Config)
|
|
}{
|
|
{
|
|
name: "vault token and namespace from env var",
|
|
args: []string{
|
|
"-config", path.Join(configDir, "base.hcl"),
|
|
},
|
|
env: map[string]string{
|
|
"VAULT_TOKEN": "token-from-env",
|
|
"VAULT_NAMESPACE": "ns-from-env",
|
|
},
|
|
checkFn: func(t *testing.T, c *Config) {
|
|
must.Eq(t, "token-from-env", c.Vaults[0].Token)
|
|
must.Eq(t, "ns-from-env", c.Vaults[0].Namespace)
|
|
},
|
|
},
|
|
{
|
|
name: "vault token and namespace from config takes precedence over env var",
|
|
args: []string{
|
|
"-config", path.Join(configDir, "vault.hcl"),
|
|
},
|
|
env: map[string]string{
|
|
"VAULT_TOKEN": "token-from-env",
|
|
"VAULT_NAMESPACE": "ns-from-env",
|
|
},
|
|
checkFn: func(t *testing.T, c *Config) {
|
|
must.Eq(t, "token-from-config", c.Vaults[0].Token)
|
|
must.Eq(t, "ns-from-config", c.Vaults[0].Namespace)
|
|
},
|
|
},
|
|
{
|
|
name: "vault token and namespace from flag takes precedence over env var and config",
|
|
args: []string{
|
|
"-config", path.Join(configDir, "vault.hcl"),
|
|
"-vault-token", "secret-from-cli",
|
|
"-vault-namespace", "ns-from-cli",
|
|
},
|
|
env: map[string]string{
|
|
"VAULT_TOKEN": "secret-from-env",
|
|
"VAULT_NAMESPACE": "ns-from-env",
|
|
},
|
|
checkFn: func(t *testing.T, c *Config) {
|
|
must.Eq(t, "secret-from-cli", c.Vaults[0].Token)
|
|
must.Eq(t, "ns-from-cli", c.Vaults[0].Namespace)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ui := cli.NewMockUi()
|
|
defer func() {
|
|
// Print command stderr in case of a failed test case to help
|
|
// with debugging.
|
|
if t.Failed() {
|
|
t.Log(ui.ErrorWriter.String())
|
|
}
|
|
}()
|
|
|
|
cmd := &Command{
|
|
Ui: ui,
|
|
args: tc.args,
|
|
}
|
|
for k, v := range tc.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
|
|
got := cmd.readConfig()
|
|
must.NotNil(t, got)
|
|
tc.checkFn(t, got)
|
|
})
|
|
}
|
|
}
|