feat(host_analyzer): add host sysctl analyzer (#1681)

* feat(host_analyzer): add host sysctl analyzer

* chore: add e2e tests to support bundle collection

* chore: missing spec e2e test update

* chore: cleanup remote collector and use parse operator

* chore: update schemas
This commit is contained in:
João Antunes
2024-11-08 18:55:24 +00:00
committed by GitHub
parent 2f62240ed3
commit 197f6de425
17 changed files with 832 additions and 4 deletions

View File

@@ -2655,6 +2655,55 @@ spec:
required:
- outcomes
type: object
sysctl:
properties:
annotations:
additionalProperties:
type: string
type: object
checkName:
type: string
collectorName:
type: string
exclude:
type: BoolString
outcomes:
items:
properties:
fail:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
pass:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
warn:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
type: object
type: array
strict:
type: BoolString
required:
- outcomes
type: object
systemPackages:
properties:
annotations:

View File

@@ -895,6 +895,55 @@ spec:
required:
- outcomes
type: object
sysctl:
properties:
annotations:
additionalProperties:
type: string
type: object
checkName:
type: string
collectorName:
type: string
exclude:
type: BoolString
outcomes:
items:
properties:
fail:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
pass:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
warn:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
type: object
type: array
strict:
type: BoolString
required:
- outcomes
type: object
systemPackages:
properties:
annotations:

View File

@@ -895,6 +895,55 @@ spec:
required:
- outcomes
type: object
sysctl:
properties:
annotations:
additionalProperties:
type: string
type: object
checkName:
type: string
collectorName:
type: string
exclude:
type: BoolString
outcomes:
items:
properties:
fail:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
pass:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
warn:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
type: object
type: array
strict:
type: BoolString
required:
- outcomes
type: object
systemPackages:
properties:
annotations:

View File

@@ -19542,6 +19542,55 @@ spec:
required:
- outcomes
type: object
sysctl:
properties:
annotations:
additionalProperties:
type: string
type: object
checkName:
type: string
collectorName:
type: string
exclude:
type: BoolString
outcomes:
items:
properties:
fail:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
pass:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
warn:
properties:
message:
type: string
uri:
type: string
when:
type: string
type: object
type: object
type: array
strict:
type: BoolString
required:
- outcomes
type: object
systemPackages:
properties:
annotations:

View File

@@ -5,6 +5,14 @@ metadata:
spec:
collectors:
- sysctl:
collectorName: sysctl
#TODO add analyzer once implemented
analyzers: []
collectorName: host sysctl
analyzers:
- sysctl:
collectorName: host sysctl
outcomes:
- warn:
when: 'kern.ostype == Darwin'
message: "Running sysctl on a Darwin host"
- pass:
when: 'net.ipv4.conf.default.arp_ignore > 0'
message: "ARP ignore is enabled for the default interfaces interfaces on the host."

View File

@@ -63,6 +63,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b
return &AnalyzeHostJsonCompare{analyzer.JsonCompare}, true
case analyzer.NetworkNamespaceConnectivity != nil:
return &AnalyzeHostNetworkNamespaceConnectivity{analyzer.NetworkNamespaceConnectivity}, true
case analyzer.Sysctl != nil:
return &AnalyzeHostSysctl{analyzer.Sysctl}, true
default:
return nil, false
}

106
pkg/analyze/host_sysctl.go Normal file
View File

@@ -0,0 +1,106 @@
package analyzer
import (
"encoding/json"
"fmt"
"strconv"
"github.com/pkg/errors"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
)
// Ensure `AnalyzeHostSysctl` implements `HostAnalyzer` interface at compile time.
var _ HostAnalyzer = (*AnalyzeHostSysctl)(nil)
type AnalyzeHostSysctl struct {
hostAnalyzer *troubleshootv1beta2.HostSysctlAnalyze
}
func (a *AnalyzeHostSysctl) Title() string {
return hostAnalyzerTitleOrDefault(a.hostAnalyzer.AnalyzeMeta, "Sysctl")
}
func (a *AnalyzeHostSysctl) IsExcluded() (bool, error) {
return isExcluded(a.hostAnalyzer.Exclude)
}
func (a *AnalyzeHostSysctl) Analyze(
getCollectedFileContents func(string) ([]byte, error), findFiles getChildCollectedFileContents,
) ([]*AnalyzeResult, error) {
result := AnalyzeResult{Title: a.Title()}
// Use the generic function to collect both local and remote data
collectedContents, err := retrieveCollectedContents(
getCollectedFileContents,
collect.HostSysctlPath, // Local path
collect.NodeInfoBaseDir, // Remote base directory
collect.HostSysctlFileName, // Remote file name
)
if err != nil {
return []*AnalyzeResult{&result}, err
}
results, err := analyzeHostCollectorResults(collectedContents, a.hostAnalyzer.Outcomes, a.CheckCondition, a.Title())
if err != nil {
return nil, errors.Wrap(err, "failed to analyze sysctl output")
}
return results, nil
}
// checkCondition checks the condition of the when clause
func (a *AnalyzeHostSysctl) CheckCondition(when string, data []byte) (bool, error) {
sysctl := map[string]string{}
if err := json.Unmarshal(data, &sysctl); err != nil {
return false, errors.Wrap(err, "failed to unmarshal data")
}
// <1:key> <2:operator> <3:value>
matches := sysctlWhenRX.FindStringSubmatch(when)
if len(matches) < 4 {
return false, fmt.Errorf("expected 3 parts in when %q", when)
}
param := matches[1]
expected := matches[3]
opString := matches[2]
operator, err := ParseComparisonOperator(opString)
if err != nil {
return false, errors.Wrap(err, fmt.Sprintf("failed to parse comparison operator %q", opString))
}
if _, ok := sysctl[param]; !ok {
return false, fmt.Errorf("kernel parameter %q does not exist on collected sysctl output", param)
}
switch operator {
case Equal:
return expected == sysctl[param], nil
}
// operator used is an inequality operator, the only valid inputs should be ints, if not we'll error out
value, err := strconv.Atoi(sysctl[param])
if err != nil {
return false, fmt.Errorf("collected sysctl param %q has value %q, cannot be used with provided operator %q", param, sysctl[param], opString)
}
expectedInt, err := strconv.Atoi(expected)
if err != nil {
return false, fmt.Errorf("expected value for sysctl param %q has value %q, cannot be used with provided operator %q", param, expected, opString)
}
switch operator {
case LessThan:
return value < expectedInt, nil
case LessThanOrEqual:
return value <= expectedInt, nil
case GreaterThan:
return value > expectedInt, nil
case GreaterThanOrEqual:
return value >= expectedInt, nil
default:
return false, fmt.Errorf("unsupported operator %q", opString)
}
}

View File

@@ -0,0 +1,321 @@
package analyzer
import (
"encoding/json"
"fmt"
"testing"
"github.com/pkg/errors"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAnalyzeHostSysctlCheckCondition(t *testing.T) {
tests := []struct {
name string
conditional string
collected string
expected bool
expectErr string
}{
{
name: "errors out if we can't unmarshal data",
conditional: "net.ipv4.conf.all.arp_filter = 0",
collected: `{not JSON}`,
expected: false,
expectErr: "failed to unmarshal data",
},
{
name: "errors out if the matched conditional is missing elements",
conditional: "net.ipv4.conf.all.arp_filter =",
collected: `{}`,
expected: false,
expectErr: `expected 3 parts in when "net.ipv4.conf.all.arp_filter ="`,
},
{
name: "errors out if the parameter in the condition was not collected",
conditional: "net.ipv4.conf.all.arp_filter = 0",
collected: `{"net.ipv4.conf.all.arp_ignore": "0"}`,
expected: false,
expectErr: `"net.ipv4.conf.all.arp_filter" does not exist on collected sysctl`,
},
{
name: "errors out if the collected parameter does not support inequalities",
conditional: "net.ipv4.tcp_available_congestion_control > 0",
collected: `{"net.ipv4.tcp_available_congestion_control": "reno cubic"}`,
expected: false,
expectErr: `has value "reno cubic", cannot be used with provided operator ">"`,
},
{
name: "errors out if the provided value for the conditional does not support inequalities",
conditional: "net.ipv4.conf.all.arp_filter > broken",
collected: `{"net.ipv4.conf.all.arp_filter": "0"}`,
expected: false,
expectErr: `has value "broken", cannot be used with provided operator ">"`,
},
{
name: "errors out if the provided operator is unsupported",
conditional: "net.ipv4.conf.all.arp_filter <== 0",
collected: `{"net.ipv4.conf.all.arp_filter": "0"}`,
expected: false,
expectErr: `failed to parse comparison operator "<=="`,
},
{
name: "equals with ints",
conditional: "net.ipv4.conf.all.arp_filter = 0",
collected: `{"net.ipv4.conf.all.arp_filter": "0"}`,
expected: true,
},
{
name: "equals with different data types",
conditional: "net.ipv4.conf.all.arp_filter = will be false",
collected: `{"net.ipv4.conf.all.arp_filter": "0"}`,
expected: false,
},
{
name: "equals with strings",
conditional: "net.ipv4.tcp_available_congestion_control = reno cubic",
collected: `{"net.ipv4.tcp_available_congestion_control": "reno cubic"}`,
expected: true,
},
{
name: "triple equals works",
conditional: "net.ipv4.tcp_available_congestion_control === reno cubic",
collected: `{"net.ipv4.tcp_available_congestion_control": "reno cubic"}`,
expected: true,
},
{
name: "double equals works",
conditional: "net.ipv4.tcp_available_congestion_control == reno cubic",
collected: `{"net.ipv4.tcp_available_congestion_control": "reno cubic"}`,
expected: true,
},
{
name: "lower than succeeds",
conditional: "net.ipv4.conf.default.arp_ignore < 1",
collected: `{"net.ipv4.conf.default.arp_ignore": "0"}`,
expected: true,
},
{
name: "lower than fails",
conditional: "net.ipv4.conf.default.arp_ignore < 1",
collected: `{"net.ipv4.conf.default.arp_ignore": "1"}`,
expected: false,
},
{
name: "lower than or equals succeeds",
conditional: "net.ipv4.conf.default.arp_ignore <= 1",
collected: `{"net.ipv4.conf.default.arp_ignore": "1"}`,
expected: true,
},
{
name: "lower than or equals fails",
conditional: "net.ipv4.conf.default.arp_ignore <= 1",
collected: `{"net.ipv4.conf.default.arp_ignore": "2"}`,
expected: false,
},
{
name: "higher than succeeds",
conditional: "net.ipv4.conf.default.arp_ignore > 1",
collected: `{"net.ipv4.conf.default.arp_ignore": "2"}`,
expected: true,
},
{
name: "higher than fails",
conditional: "net.ipv4.conf.default.arp_ignore > 1",
collected: `{"net.ipv4.conf.default.arp_ignore": "1"}`,
expected: false,
},
{
name: "higher than or equals succeeds",
conditional: "net.ipv4.conf.default.arp_ignore >= 1",
collected: `{"net.ipv4.conf.default.arp_ignore": "1"}`,
expected: true,
},
{
name: "higher than or equals fails",
conditional: "net.ipv4.conf.default.arp_ignore >= 2",
collected: `{"net.ipv4.conf.default.arp_ignore": "1"}`,
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := require.New(t)
analyzeHostSysctl := AnalyzeHostSysctl{}
// JSON encoded sysctl collected output
data := []byte(test.collected)
// Call the CheckCondition method
result, err := analyzeHostSysctl.CheckCondition(test.conditional, data)
if test.expectErr != "" {
req.ErrorContains(err, test.expectErr)
} else {
req.NoError(err)
}
assert.Equal(t, test.expected, result)
})
}
}
func TestAnalyzeHostSysctl(t *testing.T) {
tests := []struct {
name string
hostAnalyzer *troubleshootv1beta2.HostSysctlAnalyze
getCollectedFileContents func(string) ([]byte, error)
expectedResults []*AnalyzeResult
expectedError string
}{
{
name: "Pass on successful condition (local)",
hostAnalyzer: &troubleshootv1beta2.HostSysctlAnalyze{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
When: "net.ipv4.conf.default.arp_ignore >= 1",
Message: "ARP ignore is enabled",
},
},
},
},
getCollectedFileContents: func(path string) ([]byte, error) {
// Simulate local sysctl content retrieval
if path == collect.HostSysctlPath {
data := map[string]string{
"net.ipv4.conf.default.arp_ignore": "2",
}
return json.Marshal(data)
}
return nil, errors.New("file not found")
},
expectedResults: []*AnalyzeResult{
{
Title: "Sysctl",
IsPass: true,
Message: "ARP ignore is enabled",
},
},
expectedError: "",
},
{
name: "Fail on condition (remote node)",
hostAnalyzer: &troubleshootv1beta2.HostSysctlAnalyze{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Fail: &troubleshootv1beta2.SingleOutcome{
When: "net.ipv4.conf.default.arp_filter = 0",
Message: "ARP filter is disabled, please enable it via `sysctl net.ipv4.conf.default.arp_filter=1`",
},
},
},
},
getCollectedFileContents: func(path string) ([]byte, error) {
// Simulate remote node list and sysctl content retrieval
if path == constants.NODE_LIST_FILE {
nodeNames := nodeNames{Nodes: []string{"node1"}}
return json.Marshal(nodeNames)
}
if path == fmt.Sprintf("%s/node1/%s", collect.NodeInfoBaseDir, collect.HostSysctlFileName) {
data := map[string]string{
"net.ipv4.conf.default.arp_filter": "0",
}
return json.Marshal(data)
}
return nil, errors.New("file not found")
},
expectedResults: []*AnalyzeResult{
{
Title: "Sysctl - Node node1",
IsFail: true,
Message: "ARP filter is disabled, please enable it via `sysctl net.ipv4.conf.default.arp_filter=1`",
},
},
expectedError: "",
},
{
name: "Warn on condition(remote node)",
hostAnalyzer: &troubleshootv1beta2.HostSysctlAnalyze{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Warn: &troubleshootv1beta2.SingleOutcome{
When: "net.ipv4.tcp_available_congestion_control = reno cubic",
Message: "Unexpected TCP congestion control algorithm available",
},
},
},
},
getCollectedFileContents: func(path string) ([]byte, error) {
// Simulate remote node list and sysctl content retrieval
if path == constants.NODE_LIST_FILE {
nodeNames := nodeNames{Nodes: []string{"node1"}}
return json.Marshal(nodeNames)
}
if path == fmt.Sprintf("%s/node1/%s", collect.NodeInfoBaseDir, collect.HostSysctlFileName) {
data := map[string]string{
"net.ipv4.tcp_available_congestion_control": "reno cubic",
}
return json.Marshal(data)
}
return nil, errors.New("file not found")
},
expectedResults: []*AnalyzeResult{
{
Title: "Sysctl - Node node1",
IsWarn: true,
Message: "Unexpected TCP congestion control algorithm available",
},
},
expectedError: "",
},
{
name: "Return error if collection fails",
hostAnalyzer: &troubleshootv1beta2.HostSysctlAnalyze{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Warn: &troubleshootv1beta2.SingleOutcome{
When: "net.ipv4.tcp_available_congestion_control = reno cubic",
Message: "Unexpected TCP congestion control algorithm available",
},
},
},
},
getCollectedFileContents: func(path string) ([]byte, error) {
return nil, errors.New("file not found")
},
expectedResults: []*AnalyzeResult{
{
Title: "Sysctl",
},
},
expectedError: "file not found",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := require.New(t)
analyzeHostSysctl := AnalyzeHostSysctl{
hostAnalyzer: test.hostAnalyzer,
}
results, err := analyzeHostSysctl.Analyze(test.getCollectedFileContents, nil)
if test.expectedError != "" {
req.ErrorContains(err, test.expectedError)
} else {
req.NoError(err)
}
req.Equal(test.expectedResults, results)
})
}
}

View File

@@ -38,7 +38,7 @@ func (a *AnalyzeHostTime) Analyze(
getCollectedFileContents,
collect.HostTimePath,
collect.NodeInfoBaseDir,
collect.HostMemoryFileName,
collect.HostTimeFileName,
)
if err != nil {
return []*AnalyzeResult{&result}, err

View File

@@ -135,6 +135,12 @@ type KernelConfigsAnalyze struct {
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
}
type HostSysctlAnalyze struct {
AnalyzeMeta `json:",inline" yaml:",inline"`
CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
}
type HostAnalyze struct {
CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"`
TCPLoadBalancer *TCPLoadBalancerAnalyze `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"`
@@ -160,4 +166,5 @@ type HostAnalyze struct {
KernelConfigs *KernelConfigsAnalyze `json:"kernelConfigs,omitempty" yaml:"kernelConfigs,omitempty"`
JsonCompare *JsonCompare `json:"jsonCompare,omitempty" yaml:"jsonCompare,omitempty"`
NetworkNamespaceConnectivity *NetworkNamespaceConnectivityAnalyze `json:"networkNamespaceConnectivity,omitempty" yaml:"networkNamespaceConnectivity,omitempty"`
Sysctl *HostSysctlAnalyze `json:"sysctl,omitempty" yaml:"sysctl,omitempty"`
}

View File

@@ -1925,6 +1925,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) {
*out = new(NetworkNamespaceConnectivityAnalyze)
(*in).DeepCopyInto(*out)
}
if in.Sysctl != nil {
in, out := &in.Sysctl, &out.Sysctl
*out = new(HostSysctlAnalyze)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze.
@@ -2707,6 +2712,33 @@ func (in *HostSysctl) DeepCopy() *HostSysctl {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HostSysctlAnalyze) DeepCopyInto(out *HostSysctlAnalyze) {
*out = *in
in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta)
if in.Outcomes != nil {
in, out := &in.Outcomes, &out.Outcomes
*out = make([]*Outcome, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(Outcome)
(*in).DeepCopyInto(*out)
}
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostSysctlAnalyze.
func (in *HostSysctlAnalyze) DeepCopy() *HostSysctlAnalyze {
if in == nil {
return nil
}
out := new(HostSysctlAnalyze)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HostSystemPackages) DeepCopyInto(out *HostSystemPackages) {
*out = *in

View File

@@ -19,6 +19,7 @@ var _ HostCollector = (*CollectHostSysctl)(nil)
var execCommand = exec.Command
const HostSysctlPath = `host-collectors/system/sysctl.json`
const HostSysctlFileName = `sysctl.json`
type CollectHostSysctl struct {
hostCollector *troubleshootv1beta2.HostSysctl

View File

@@ -4052,6 +4052,82 @@
}
}
},
"sysctl": {
"type": "object",
"required": [
"outcomes"
],
"properties": {
"annotations": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"checkName": {
"type": "string"
},
"collectorName": {
"type": "string"
},
"exclude": {
"oneOf": [{"type": "string"},{"type": "boolean"}]
},
"outcomes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fail": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"uri": {
"type": "string"
},
"when": {
"type": "string"
}
}
},
"pass": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"uri": {
"type": "string"
},
"when": {
"type": "string"
}
}
},
"warn": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"uri": {
"type": "string"
},
"when": {
"type": "string"
}
}
}
}
}
},
"strict": {
"oneOf": [{"type": "string"},{"type": "boolean"}]
}
}
},
"systemPackages": {
"type": "object",
"required": [

View File

@@ -18507,6 +18507,82 @@
}
}
},
"sysctl": {
"type": "object",
"required": [
"outcomes"
],
"properties": {
"annotations": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"checkName": {
"type": "string"
},
"collectorName": {
"type": "string"
},
"exclude": {
"oneOf": [{"type": "string"},{"type": "boolean"}]
},
"outcomes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fail": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"uri": {
"type": "string"
},
"when": {
"type": "string"
}
}
},
"pass": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"uri": {
"type": "string"
},
"when": {
"type": "string"
}
}
},
"warn": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"uri": {
"type": "string"
},
"when": {
"type": "string"
}
}
}
}
}
},
"strict": {
"oneOf": [{"type": "string"},{"type": "boolean"}]
}
}
},
"systemPackages": {
"type": "object",
"required": [

View File

@@ -25,6 +25,7 @@ func TestHostLocalCollector(t *testing.T) {
"hostos_info.json",
"ipv4Interfaces.json",
"memory.json",
"sysctl.json",
},
notExpectedPaths: []string{
"node_list.json",

View File

@@ -11,6 +11,7 @@ spec:
- memory: {}
- blockDevices: {}
- kernelConfigs: {}
- sysctl: {}
- copy:
collectorName: etc-resolv
path: /etc/resolv.conf

View File

@@ -12,6 +12,7 @@ spec:
- memory: {}
- blockDevices: {}
- kernelConfigs: {}
- sysctl: {}
- copy:
collectorName: etc-resolv
path: /etc/resolv.conf