Add the ability to specify node profiles in the multus validation test.
This addresses a few points of early feedback on the validation tool.
Statements below critique the tool's behavior before this patch.
1. The tool assumes all daemons are on public and cluster network, which
means users who have a significantly smaller cluster net (a
design choice) cannot run a single test to determine if Rook is
likely to install correctly.
2. The tool does not have placement options to select only a subset of
Kubernetes nodes to run validation on.
3. Users of multus seem to have a dedicated pool of storage nodes more
often than the average Rook install. This makes sense for security-
and perforance-minded users. The tool cannot run a single test to
verify storage-only and general-workload nodes at one time.
These points are addressed by allowing users to specify configurations
for different "NodeTypes."
Each NodeType config has options for selecting the number of OSDs as
well as the number of other (non-OSD) Ceph daemons. This limits the
unnecessary exhaustion of cluster network addresses from critique 1.
Each NodeType config has its own placement (critique 2).
Users can define as many NodeTypes as needed to test the network for
their planned CephCluster. Specifically, this allows the tool to test
storage-only nodes and generalized-workload nodes at the same time. An
arbitrary number of NodeTypes are allowed to support even more highly
specialized cluster setups, such as multiple tiers of storage nodes
where some storage-only nodes may run more OSDs than others.
Signed-off-by: Blaine Gardner <blaine.gardner@ibm.com>
(cherry picked from commit 0c721e05d5)
299 lines
10 KiB
Go
299 lines
10 KiB
Go
/*
|
|
Copyright 2023 The Rook Authors. All rights reserved.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package multus
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rook/rook/pkg/operator/k8sutil"
|
|
"gopkg.in/yaml.v2"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metavalidation "k8s.io/apimachinery/pkg/util/validation"
|
|
)
|
|
|
|
var (
|
|
//go:embed config.yaml
|
|
ConfigYaml string
|
|
)
|
|
|
|
var (
|
|
DefaultValidationNamespace = "rook-ceph"
|
|
|
|
DefaultValidationOSDsPerNode = 3
|
|
|
|
DefaultValidationOtherDaemonsPerNode = 16
|
|
|
|
DefaultValidationNginxImage = "nginxinc/nginx-unprivileged:stable-alpine"
|
|
|
|
DefaultValidationResourceTimeout = 3 * time.Minute
|
|
|
|
DefaultStorageNodeLabelKey = "storage-node"
|
|
DefaultStorageNodeLabelValue = "true"
|
|
|
|
DefaultArbiterNodeLabelKey = "topology.kubernetes.io/zone"
|
|
DefaultArbiterNodeLabelValue = "arbiter"
|
|
DefaultArbiterTolerationKey = "node-role.kubernetes.io/control-plane"
|
|
)
|
|
|
|
const DefaultValidationNodeType = "shared-storage-and-worker-nodes"
|
|
|
|
func init() {
|
|
// the default namespace is the current namespace the operator pod is running in if possible
|
|
ns := os.Getenv(k8sutil.PodNamespaceEnvVar)
|
|
if ns != "" {
|
|
DefaultValidationNamespace = ns
|
|
}
|
|
}
|
|
|
|
// ValidationTestConfig is a configuration for a Multus validation test. To prevent documentation
|
|
// for this struct from getting out of date, see the output of ValidationTestConfig.ToYAML() for
|
|
// usage text for each field.
|
|
type ValidationTestConfig struct {
|
|
Namespace string `yaml:"namespace"`
|
|
PublicNetwork string `yaml:"publicNetwork"`
|
|
ClusterNetwork string `yaml:"clusterNetwork"`
|
|
ResourceTimeout time.Duration `yaml:"resourceTimeout"`
|
|
NginxImage string `yaml:"nginxImage"`
|
|
NodeTypes map[string]NodeConfig `yaml:"nodeTypes"`
|
|
}
|
|
|
|
type NodeConfig struct {
|
|
// OSD daemons per node
|
|
OSDsPerNode int `yaml:"osdsPerNode"`
|
|
// Non-OSD daemons per node.
|
|
OtherDaemonsPerNode int `yaml:"otherDaemonsPerNode"`
|
|
Placement PlacementConfig `yaml:"placement"`
|
|
}
|
|
|
|
// NodeSelector and Tolerations are intentionally the only configurable parameters here.
|
|
// Affinity/Anti-Affinity is too relaxed of a specification to ensure the validation test runs the
|
|
// exact number of daemons per node that it should be running. Only allow the minimum selection
|
|
// configs that can be used to define nodes this test can run on.
|
|
type PlacementConfig struct {
|
|
NodeSelector map[string]string `yaml:"nodeSelector"`
|
|
Tolerations []TolerationType `yaml:"tolerations"`
|
|
}
|
|
|
|
type TolerationType corev1.Toleration
|
|
|
|
// ToJSON renders a toleration as a single-line JSON string. The JSON rendering is just as easy to
|
|
// read as the YAML rendering and is easier to format in the config.yaml template using Golang
|
|
// text templating compared to YAML.
|
|
// Need to define our own because corev1.Toleration.Marshal() does not render as expected.
|
|
func (t *TolerationType) ToJSON() (string, error) {
|
|
j, err := json.Marshal(*t)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to convert toleration into JSON: %w", err)
|
|
}
|
|
return string(j), nil
|
|
}
|
|
|
|
// NewDefaultValidationTestConfig returns a new ValidationTestConfig with default values.
|
|
// The default test is a converged-node test with no placement.
|
|
func NewDefaultValidationTestConfig() *ValidationTestConfig {
|
|
return &ValidationTestConfig{
|
|
Namespace: DefaultValidationNamespace,
|
|
ResourceTimeout: DefaultValidationResourceTimeout,
|
|
NginxImage: DefaultValidationNginxImage,
|
|
NodeTypes: map[string]NodeConfig{
|
|
DefaultValidationNodeType: {
|
|
OSDsPerNode: DefaultValidationOSDsPerNode,
|
|
OtherDaemonsPerNode: DefaultValidationOtherDaemonsPerNode,
|
|
// Placement empty
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ToYAML converts the validation test config into a YAML representation with user-readable comments
|
|
// describing how to use the various parameters.
|
|
func (c *ValidationTestConfig) ToYAML() (string, error) {
|
|
// No Go YAML libraries seem to support fields with default-comments attached to them. It would
|
|
// be silly to use some super-advanced reflection techniques or to extend our own YAML library,
|
|
// so it is at least straightforward to render the config file from a Go template.
|
|
t, err := loadTemplate("config.yaml", ConfigYaml, c)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to load config into yaml template: %w", err)
|
|
}
|
|
return string(t), nil
|
|
}
|
|
|
|
// String implements the Stringer interface
|
|
func (c *ValidationTestConfig) String() string {
|
|
out, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return "failed quick marshal of validation test config!"
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// ValidationTestConfigFromYAML loads a YAML-formatted string into a new ValidationTestConfig.
|
|
func ValidationTestConfigFromYAML(y string) (*ValidationTestConfig, error) {
|
|
c := &ValidationTestConfig{}
|
|
err := yaml.Unmarshal([]byte(y), c)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal config from yaml: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (c *ValidationTestConfig) TotalDaemonsPerNode() int {
|
|
return c.TotalOSDsPerNode() + c.TotalOtherDaemonsPerNode()
|
|
}
|
|
|
|
func (c *ValidationTestConfig) TotalOSDsPerNode() int {
|
|
t := 0
|
|
for _, config := range c.NodeTypes {
|
|
t += config.OSDsPerNode
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (c *ValidationTestConfig) TotalOtherDaemonsPerNode() int {
|
|
t := 0
|
|
for _, config := range c.NodeTypes {
|
|
t += config.OtherDaemonsPerNode
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (c *ValidationTestConfig) BestNodePlacementForServer() (PlacementConfig, error) {
|
|
// the web server MUST be placed on a node with both public and cluster networks available
|
|
// since OSDs must have both, picking a node type with OSDs is a GOOD guess
|
|
// BEST can't be determined easily, but a good approximation of BEST is the node likely to have
|
|
// the most system resources available
|
|
// the node type with the most OSDs will have high overall resource needs in production, which
|
|
// is a good approximation of most overall system resources
|
|
// in the case of a tie for num OSDs, more overall daemons means more resources
|
|
best := NodeConfig{}
|
|
for _, config := range c.NodeTypes {
|
|
if (config.OSDsPerNode > best.OSDsPerNode) ||
|
|
(config.OSDsPerNode == best.OSDsPerNode && config.OtherDaemonsPerNode > best.OtherDaemonsPerNode) {
|
|
best = config
|
|
}
|
|
}
|
|
if best.OSDsPerNode == 0 {
|
|
return PlacementConfig{}, fmt.Errorf("cannot place web server in cluster with no OSDs")
|
|
}
|
|
return best.Placement, nil
|
|
}
|
|
|
|
// Validate reports any validation test configuration problems as errors.
|
|
func (c *ValidationTestConfig) Validate() error {
|
|
errs := []string{}
|
|
if c.Namespace == "" {
|
|
errs = append(errs, "namespace must be specified")
|
|
}
|
|
if c.PublicNetwork == "" && c.ClusterNetwork == "" {
|
|
errs = append(errs, "at least one of publicNetwork and clusterNetwork must be specified")
|
|
}
|
|
if c.ResourceTimeout < 1*time.Minute {
|
|
errs = append(errs, "resourceTimeout must be at least one minute (two or more are recommended)")
|
|
}
|
|
if c.NginxImage == "" {
|
|
errs = append(errs, "nginxImage must be specified")
|
|
}
|
|
if c.TotalOSDsPerNode() == 0 {
|
|
errs = append(errs, "osdsPerNode must be set in at least one config")
|
|
}
|
|
// Do not care if the total number of OtherDaemonsPerNode is zero. OSDs run on both public and
|
|
// cluster network, so OSDsPerNode can test all daemon types, but not vice-versa.
|
|
for nodeType := range c.NodeTypes {
|
|
mvErrs := metavalidation.IsDNS1123Subdomain(nodeType)
|
|
if len(mvErrs) > 0 {
|
|
errs = append(errs, fmt.Sprintf("nodeType identifier %q must meet RFC 1123 requirements: %v", nodeType, mvErrs))
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("validation test config is invalid: %s", strings.Join(errs, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NewSharedStorageAndWorkerNodesValidationTestConfig() *ValidationTestConfig {
|
|
return NewDefaultValidationTestConfig()
|
|
}
|
|
|
|
const (
|
|
DedicatedStorageNodeType = "storage-nodes"
|
|
DedicatedWorkerNodeType = "worker-nodes"
|
|
)
|
|
|
|
var dedicatedStorageNodeConfig = NodeConfig{
|
|
OSDsPerNode: DefaultValidationOSDsPerNode,
|
|
OtherDaemonsPerNode: DefaultValidationOtherDaemonsPerNode,
|
|
Placement: PlacementConfig{
|
|
NodeSelector: map[string]string{
|
|
DefaultStorageNodeLabelKey: DefaultStorageNodeLabelValue,
|
|
},
|
|
Tolerations: []TolerationType{
|
|
{Key: DefaultStorageNodeLabelKey, Value: DefaultStorageNodeLabelValue},
|
|
},
|
|
},
|
|
}
|
|
|
|
var dedicatedWorkerNodeConfig = NodeConfig{
|
|
OSDsPerNode: 0,
|
|
OtherDaemonsPerNode: 6, // CSI plugins only
|
|
// Placement empty
|
|
}
|
|
|
|
func NewDedicatedStorageNodesValidationTestConfig() *ValidationTestConfig {
|
|
return &ValidationTestConfig{
|
|
Namespace: DefaultValidationNamespace,
|
|
ResourceTimeout: DefaultValidationResourceTimeout,
|
|
NginxImage: DefaultValidationNginxImage,
|
|
NodeTypes: map[string]NodeConfig{
|
|
DedicatedStorageNodeType: dedicatedStorageNodeConfig,
|
|
DedicatedWorkerNodeType: dedicatedWorkerNodeConfig,
|
|
},
|
|
}
|
|
}
|
|
|
|
const (
|
|
DedicatedArbiterNodeType = "arbiter-node"
|
|
)
|
|
|
|
func NewArbiterValidationTestConfig() *ValidationTestConfig {
|
|
return &ValidationTestConfig{
|
|
Namespace: DefaultValidationNamespace,
|
|
ResourceTimeout: DefaultValidationResourceTimeout,
|
|
NginxImage: DefaultValidationNginxImage,
|
|
NodeTypes: map[string]NodeConfig{
|
|
DedicatedStorageNodeType: dedicatedStorageNodeConfig,
|
|
DedicatedWorkerNodeType: dedicatedWorkerNodeConfig,
|
|
DedicatedArbiterNodeType: {
|
|
OSDsPerNode: 0,
|
|
OtherDaemonsPerNode: 10, // 1 mon, plus all 9 CSI provisioners and plugins (optional)
|
|
Placement: PlacementConfig{
|
|
NodeSelector: map[string]string{
|
|
DefaultArbiterNodeLabelKey: DefaultArbiterNodeLabelValue,
|
|
},
|
|
Tolerations: []TolerationType{
|
|
{Key: DefaultArbiterTolerationKey, Operator: corev1.TolerationOpExists},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|