github.com/hernad/nomad@v1.6.112/command/agent/command_test.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package agent 5 6 import ( 7 "math" 8 "os" 9 "path/filepath" 10 "strings" 11 "testing" 12 13 "github.com/hernad/nomad/ci" 14 "github.com/hernad/nomad/helper/pointer" 15 "github.com/mitchellh/cli" 16 "github.com/stretchr/testify/assert" 17 "github.com/stretchr/testify/require" 18 19 "github.com/hernad/nomad/nomad/structs" 20 "github.com/hernad/nomad/nomad/structs/config" 21 "github.com/hernad/nomad/version" 22 ) 23 24 func TestCommand_Implements(t *testing.T) { 25 ci.Parallel(t) 26 var _ cli.Command = &Command{} 27 } 28 29 func TestCommand_Args(t *testing.T) { 30 ci.Parallel(t) 31 tmpDir := t.TempDir() 32 33 type tcase struct { 34 args []string 35 errOut string 36 } 37 tcases := []tcase{ 38 { 39 []string{}, 40 "Must specify either server, client or dev mode for the agent.", 41 }, 42 { 43 []string{"-client", "-data-dir=" + tmpDir, "-bootstrap-expect=1"}, 44 "Bootstrap requires server mode to be enabled", 45 }, 46 { 47 []string{"-data-dir=" + tmpDir, "-server", "-bootstrap-expect=1"}, 48 "WARNING: Bootstrap mode enabled!", 49 }, 50 { 51 []string{"-data-dir=" + tmpDir, "-server", "-bootstrap-expect=2"}, 52 "Number of bootstrap servers should ideally be set to an odd number", 53 }, 54 { 55 []string{"-server"}, 56 "Must specify \"data_dir\" config option or \"data-dir\" CLI flag", 57 }, 58 { 59 []string{"-client", "-alloc-dir="}, 60 "Must specify the state, alloc dir, and plugin dir if data-dir is omitted.", 61 }, 62 { 63 []string{"-client", "-data-dir=" + tmpDir, "-meta=invalid..key=inaccessible-value"}, 64 "Invalid Client.Meta key: invalid..key", 65 }, 66 { 67 []string{"-client", "-data-dir=" + tmpDir, "-meta=.invalid=inaccessible-value"}, 68 "Invalid Client.Meta key: .invalid", 69 }, 70 { 71 []string{"-client", "-data-dir=" + tmpDir, "-meta=invalid.=inaccessible-value"}, 72 "Invalid Client.Meta key: invalid.", 73 }, 74 { 75 []string{"-client", "-node-pool=not@valid"}, 76 "Invalid node pool", 77 }, 78 } 79 for _, tc := range tcases { 80 // Make a new command. We preemptively close the shutdownCh 81 // so that the command exits immediately instead of blocking. 82 ui := cli.NewMockUi() 83 shutdownCh := make(chan struct{}) 84 close(shutdownCh) 85 cmd := &Command{ 86 Version: version.GetVersion(), 87 Ui: ui, 88 ShutdownCh: shutdownCh, 89 } 90 91 // To prevent test failures on hosts whose hostname resolves to 92 // a loopback address, we must append a bind address 93 tc.args = append(tc.args, "-bind=169.254.0.1") 94 if code := cmd.Run(tc.args); code != 1 { 95 t.Fatalf("args: %v\nexit: %d\n", tc.args, code) 96 } 97 98 if expect := tc.errOut; expect != "" { 99 out := ui.ErrorWriter.String() 100 if !strings.Contains(out, expect) { 101 t.Fatalf("expect to find %q\n\n%s", expect, out) 102 } 103 } 104 } 105 } 106 107 func TestCommand_MetaConfigValidation(t *testing.T) { 108 ci.Parallel(t) 109 110 tmpDir := t.TempDir() 111 112 tcases := []string{ 113 "foo..invalid", 114 ".invalid", 115 "invalid.", 116 } 117 for _, tc := range tcases { 118 configFile := filepath.Join(tmpDir, "conf1.hcl") 119 err := os.WriteFile(configFile, []byte(`client{ 120 enabled = true 121 meta = { 122 "valid" = "yes" 123 "`+tc+`" = "kaboom!" 124 "nested.var" = "is nested" 125 "deeply.nested.var" = "is deeply nested" 126 } 127 }`), 0600) 128 if err != nil { 129 t.Fatalf("err: %s", err) 130 } 131 132 // Make a new command. We preemptively close the shutdownCh 133 // so that the command exits immediately instead of blocking. 134 ui := cli.NewMockUi() 135 shutdownCh := make(chan struct{}) 136 close(shutdownCh) 137 cmd := &Command{ 138 Version: version.GetVersion(), 139 Ui: ui, 140 ShutdownCh: shutdownCh, 141 } 142 143 // To prevent test failures on hosts whose hostname resolves to 144 // a loopback address, we must append a bind address 145 args := []string{"-client", "-data-dir=" + tmpDir, "-config=" + configFile, "-bind=169.254.0.1"} 146 if code := cmd.Run(args); code != 1 { 147 t.Fatalf("args: %v\nexit: %d\n", args, code) 148 } 149 150 expect := "Invalid Client.Meta key: " + tc 151 out := ui.ErrorWriter.String() 152 if !strings.Contains(out, expect) { 153 t.Fatalf("expect to find %q\n\n%s", expect, out) 154 } 155 } 156 } 157 158 func TestCommand_InvalidCharInDatacenter(t *testing.T) { 159 ci.Parallel(t) 160 161 tmpDir := t.TempDir() 162 163 tcases := []string{ 164 "char-\\000-in-the-middle", 165 "ends-with-\\000", 166 "\\000-at-the-beginning", 167 "char-*-in-the-middle", 168 "ends-with-*", 169 "*-at-the-beginning", 170 } 171 for _, tc := range tcases { 172 configFile := filepath.Join(tmpDir, "conf1.hcl") 173 err := os.WriteFile(configFile, []byte(` 174 datacenter = "`+tc+`" 175 client{ 176 enabled = true 177 }`), 0600) 178 if err != nil { 179 t.Fatalf("err: %s", err) 180 } 181 182 // Make a new command. We preemptively close the shutdownCh 183 // so that the command exits immediately instead of blocking. 184 ui := cli.NewMockUi() 185 shutdownCh := make(chan struct{}) 186 close(shutdownCh) 187 cmd := &Command{ 188 Version: version.GetVersion(), 189 Ui: ui, 190 ShutdownCh: shutdownCh, 191 } 192 193 // To prevent test failures on hosts whose hostname resolves to 194 // a loopback address, we must append a bind address 195 args := []string{"-client", "-data-dir=" + tmpDir, "-config=" + configFile, "-bind=169.254.0.1"} 196 if code := cmd.Run(args); code != 1 { 197 t.Fatalf("args: %v\nexit: %d\n", args, code) 198 } 199 200 out := ui.ErrorWriter.String() 201 exp := "Datacenter contains invalid characters (null or '*')" 202 if !strings.Contains(out, exp) { 203 t.Fatalf("expect to find %q\n\n%s", exp, out) 204 } 205 } 206 } 207 208 func TestCommand_NullCharInRegion(t *testing.T) { 209 ci.Parallel(t) 210 211 tmpDir := t.TempDir() 212 213 tcases := []string{ 214 "char-\\000-in-the-middle", 215 "ends-with-\\000", 216 "\\000-at-the-beginning", 217 } 218 for _, tc := range tcases { 219 configFile := filepath.Join(tmpDir, "conf1.hcl") 220 err := os.WriteFile(configFile, []byte(` 221 region = "`+tc+`" 222 client{ 223 enabled = true 224 }`), 0600) 225 if err != nil { 226 t.Fatalf("err: %s", err) 227 } 228 229 // Make a new command. We preemptively close the shutdownCh 230 // so that the command exits immediately instead of blocking. 231 ui := cli.NewMockUi() 232 shutdownCh := make(chan struct{}) 233 close(shutdownCh) 234 cmd := &Command{ 235 Version: version.GetVersion(), 236 Ui: ui, 237 ShutdownCh: shutdownCh, 238 } 239 240 // To prevent test failures on hosts whose hostname resolves to 241 // a loopback address, we must append a bind address 242 args := []string{"-client", "-data-dir=" + tmpDir, "-config=" + configFile, "-bind=169.254.0.1"} 243 if code := cmd.Run(args); code != 1 { 244 t.Fatalf("args: %v\nexit: %d\n", args, code) 245 } 246 247 out := ui.ErrorWriter.String() 248 exp := "Region contains invalid characters" 249 if !strings.Contains(out, exp) { 250 t.Fatalf("expect to find %q\n\n%s", exp, out) 251 } 252 } 253 } 254 255 // TestIsValidConfig asserts that invalid configurations return false. 256 func TestIsValidConfig(t *testing.T) { 257 ci.Parallel(t) 258 259 cases := []struct { 260 name string 261 conf Config // merged into DefaultConfig() 262 263 // err should appear in error output; success expected if err 264 // is empty 265 err string 266 }{ 267 { 268 name: "Default", 269 conf: Config{ 270 DataDir: "/tmp", 271 Client: &ClientConfig{Enabled: true}, 272 }, 273 }, 274 { 275 name: "NoMode", 276 conf: Config{ 277 Client: &ClientConfig{Enabled: false}, 278 Server: &ServerConfig{Enabled: false}, 279 }, 280 err: "Must specify either", 281 }, 282 { 283 name: "InvalidRegion", 284 conf: Config{ 285 Client: &ClientConfig{ 286 Enabled: true, 287 }, 288 Region: "Hello\000World", 289 }, 290 err: "Region contains", 291 }, 292 { 293 name: "InvalidDatacenter", 294 conf: Config{ 295 Client: &ClientConfig{ 296 Enabled: true, 297 }, 298 Datacenter: "Hello\000World", 299 }, 300 err: "Datacenter contains", 301 }, 302 { 303 name: "RelativeDir", 304 conf: Config{ 305 Client: &ClientConfig{ 306 Enabled: true, 307 }, 308 DataDir: "foo/bar", 309 }, 310 err: "must be given as an absolute", 311 }, 312 { 313 name: "InvalidNodePoolChar", 314 conf: Config{ 315 Client: &ClientConfig{ 316 Enabled: true, 317 NodePool: "not@valid", 318 }, 319 }, 320 err: "Invalid node pool", 321 }, 322 { 323 name: "InvalidNodePoolName", 324 conf: Config{ 325 Client: &ClientConfig{ 326 Enabled: true, 327 NodePool: structs.NodePoolAll, 328 }, 329 }, 330 err: "not allowed", 331 }, 332 { 333 name: "NegativeMinDynamicPort", 334 conf: Config{ 335 Client: &ClientConfig{ 336 Enabled: true, 337 MinDynamicPort: -1, 338 }, 339 }, 340 err: "min_dynamic_port", 341 }, 342 { 343 name: "NegativeMaxDynamicPort", 344 conf: Config{ 345 Client: &ClientConfig{ 346 Enabled: true, 347 MaxDynamicPort: -1, 348 }, 349 }, 350 err: "max_dynamic_port", 351 }, 352 { 353 name: "BigMinDynamicPort", 354 conf: Config{ 355 Client: &ClientConfig{ 356 Enabled: true, 357 MinDynamicPort: math.MaxInt32, 358 }, 359 }, 360 err: "min_dynamic_port", 361 }, 362 { 363 name: "BigMaxDynamicPort", 364 conf: Config{ 365 Client: &ClientConfig{ 366 Enabled: true, 367 MaxDynamicPort: math.MaxInt32, 368 }, 369 }, 370 err: "max_dynamic_port", 371 }, 372 { 373 name: "MinMaxDynamicPortSwitched", 374 conf: Config{ 375 Client: &ClientConfig{ 376 Enabled: true, 377 MinDynamicPort: 5000, 378 MaxDynamicPort: 4000, 379 }, 380 }, 381 err: "and max", 382 }, 383 { 384 name: "DynamicPortOk", 385 conf: Config{ 386 DataDir: "/tmp", 387 Client: &ClientConfig{ 388 Enabled: true, 389 MinDynamicPort: 4000, 390 MaxDynamicPort: 5000, 391 }, 392 }, 393 }, 394 { 395 name: "BadReservedPorts", 396 conf: Config{ 397 Client: &ClientConfig{ 398 Enabled: true, 399 Reserved: &Resources{ 400 ReservedPorts: "3-2147483647", 401 }, 402 }, 403 }, 404 err: `reserved.reserved_ports "3-2147483647" invalid: port must be < 65536 but found 2147483647`, 405 }, 406 { 407 name: "BadHostNetworkReservedPorts", 408 conf: Config{ 409 Client: &ClientConfig{ 410 Enabled: true, 411 HostNetworks: []*structs.ClientHostNetworkConfig{ 412 &structs.ClientHostNetworkConfig{ 413 Name: "test", 414 ReservedPorts: "3-2147483647", 415 }, 416 }, 417 }, 418 }, 419 err: `host_network["test"].reserved_ports "3-2147483647" invalid: port must be < 65536 but found 2147483647`, 420 }, 421 { 422 name: "BadArtifact", 423 conf: Config{ 424 Client: &ClientConfig{ 425 Enabled: true, 426 Artifact: &config.ArtifactConfig{ 427 HTTPReadTimeout: pointer.Of("-10m"), 428 }, 429 }, 430 }, 431 err: "client.artifact block invalid: http_read_timeout must be > 0", 432 }, 433 { 434 name: "BadHostVolumeConfig", 435 conf: Config{ 436 DataDir: "/tmp", 437 Client: &ClientConfig{ 438 Enabled: true, 439 HostVolumes: []*structs.ClientHostVolumeConfig{ 440 { 441 Name: "test", 442 ReadOnly: true, 443 }, 444 { 445 Name: "test", 446 ReadOnly: true, 447 Path: "/random/path", 448 }, 449 }, 450 }, 451 }, 452 err: "Missing path in host_volume config", 453 }, 454 { 455 name: "ValidHostVolumeConfig", 456 conf: Config{ 457 DataDir: "/tmp", 458 Client: &ClientConfig{ 459 Enabled: true, 460 HostVolumes: []*structs.ClientHostVolumeConfig{ 461 { 462 Name: "test", 463 ReadOnly: true, 464 Path: "/random/path1", 465 }, 466 { 467 Name: "test", 468 ReadOnly: true, 469 Path: "/random/path2", 470 }, 471 }, 472 }, 473 }, 474 }, 475 } 476 477 for _, tc := range cases { 478 t.Run(tc.name, func(t *testing.T) { 479 mui := cli.NewMockUi() 480 cmd := &Command{Ui: mui} 481 config := DefaultConfig().Merge(&tc.conf) 482 result := cmd.IsValidConfig(config, DefaultConfig()) 483 if tc.err == "" { 484 // No error expected 485 assert.True(t, result, mui.ErrorWriter.String()) 486 return 487 } 488 489 // Error expected 490 assert.False(t, result) 491 require.Contains(t, mui.ErrorWriter.String(), tc.err) 492 t.Logf("%s returned: %s", tc.name, mui.ErrorWriter.String()) 493 }) 494 } 495 }