Skip to content

Commit 80dae12

Browse files
authored
[DEVOPS-570] Added command fact plugin (#73)
1 parent cb61bc0 commit 80dae12

File tree

9 files changed

+235
-0
lines changed

9 files changed

+235
-0
lines changed

docs/src/.vuepress/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ module.exports = {
9393
path: '/reference/collect',
9494
collapsable: false,
9595
children: [
96+
['/reference/collect/command', 'command'],
9697
['/reference/collect/database-search', 'database:search'],
9798
['/reference/collect/docker-command', 'docker:command'],
9899
['/reference/collect/docker-images', 'docker:images'],

docs/src/reference/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The following Connection plugins are available:
1010
## Collect plugins
1111

1212
The following Collect/Fact plugins are available:
13+
- [command](../reference/collect/command)
1314
- [database:search](../reference/collect/database-search)
1415
- [docker:command](../reference/collect/docker-command)
1516
- [docker:images](../reference/collect/docker-images)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# command
2+
3+
The `command` collect plugin executes a command and returns the output, error and exit code as a map.
4+
5+
## Plugin fields
6+
7+
| Field | Description | Required | Default |
8+
| ----- | ------------------------------------------- | :------: | :-----: |
9+
| cmd | The main command to run. | Yes | "" |
10+
| args | A list of arguments to pass to the command. | No | [] |
11+
12+
<Content :page-key="$site.pages.find(p => p.path === '/reference/common/collect.html').key"/>
13+
14+
## Return format
15+
16+
A map with the following fields:
17+
18+
| Field | Description |
19+
| ----- | ----------- |
20+
| out | The command output. |
21+
| err | The command error. |
22+
| code | The command exit code. |
23+
24+
## Example
25+
26+
<<< @/../examples/remediation.yml{3-9}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
## Common fields
2+
3+
| Field | Description | Required | Default |
4+
| ----------------- | --------------------------------------------------------------------------------------------------- | :------: | :-----: |
5+
| name | The name/identifier of the plugin - this is the yaml key in the config file when defining the fact. | Yes | - |
6+
| connection | The connection to use for collecting the fact. | No | "" |
7+
| input | A previous input to use when collecting the fact. | No | "" |
8+
| additional-inputs | Additional previous inputs to use when collecting the fact. | No | [] |

examples/remediation.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
collect:
2+
db-tfa-module:
3+
command:
4+
cmd: bash
5+
args:
6+
- -c
7+
- |
8+
set -o pipefail
9+
drush pm:list --no-core --package=Security --fields=name,status --format=json|jq -r '.tfa.status'
10+

pkg/command/command.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,15 @@ func GetMsgFromCommandError(err error) string {
5959
}
6060
return errMsg
6161
}
62+
63+
func GetExitCode(err error) int {
64+
var exitErr *exec.ExitError
65+
if errors.As(err, &exitErr) {
66+
return exitErr.ExitCode()
67+
}
68+
69+
if err != nil {
70+
return 1
71+
}
72+
return 0
73+
}

pkg/command/command_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,24 @@ func TestGetMsgFromCommandError(t *testing.T) {
9292
assert.Equal("basic error", msg)
9393
})
9494
}
95+
96+
func TestGetExitCode(t *testing.T) {
97+
assert := assert.New(t)
98+
99+
t.Run("exitError", func(t *testing.T) {
100+
exitCode := command.GetExitCode(&exec.ExitError{
101+
Stderr: []byte("some error"),
102+
})
103+
assert.Equal(-1, exitCode)
104+
})
105+
106+
t.Run("error", func(t *testing.T) {
107+
exitCode := command.GetExitCode(errors.New("basic error"))
108+
assert.Equal(1, exitCode)
109+
})
110+
111+
t.Run("nil", func(t *testing.T) {
112+
exitCode := command.GetExitCode(nil)
113+
assert.Equal(0, exitCode)
114+
})
115+
}

pkg/fact/command/command.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package command
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
8+
log "github.com/sirupsen/logrus"
9+
10+
"github.com/salsadigitalauorg/shipshape/pkg/command"
11+
"github.com/salsadigitalauorg/shipshape/pkg/connection"
12+
"github.com/salsadigitalauorg/shipshape/pkg/data"
13+
"github.com/salsadigitalauorg/shipshape/pkg/fact"
14+
)
15+
16+
// Command is a representation of a shell command.
17+
type Command struct {
18+
// Common fields.
19+
Name string `yaml:"name"`
20+
Format data.DataFormat `yaml:"format"`
21+
ConnectionName string `yaml:"connection"`
22+
InputName string `yaml:"input"`
23+
AdditionalInputNames []string `yaml:"additional-inputs"`
24+
25+
connection connection.Connectioner
26+
input fact.Facter
27+
additionalInputs []fact.Facter
28+
errors []error
29+
data interface{}
30+
31+
// Plugin fields.
32+
Cmd string `yaml:"cmd"`
33+
Args []string `yaml:"args"`
34+
}
35+
36+
//go:generate go run ../../../cmd/gen.go fact-plugin --plugin=Command --package=command
37+
38+
func init() {
39+
fact.Registry["command"] = func(n string) fact.Facter {
40+
return &Command{Name: n, Format: data.FormatMapString}
41+
}
42+
}
43+
44+
func (p *Command) PluginName() string {
45+
return "command"
46+
}
47+
48+
func (p *Command) SupportedConnections() (fact.SupportLevel, []string) {
49+
return fact.SupportNone, []string{}
50+
}
51+
52+
func (p *Command) SupportedInputs() (fact.SupportLevel, []string) {
53+
return fact.SupportNone, []string{}
54+
}
55+
56+
func (p *Command) Collect() {
57+
log.WithFields(log.Fields{
58+
"fact-plugin": p.PluginName(),
59+
"fact": p.Name,
60+
"cmd": p.Cmd,
61+
"args": p.Args,
62+
}).Debug("collecting data")
63+
64+
res := map[string]string{
65+
"code": "0",
66+
"stdout": "",
67+
"stderr": "",
68+
}
69+
70+
data, err := command.ShellCommander(p.Cmd, p.Args...).Output()
71+
log.WithFields(log.Fields{
72+
"stdout": string(data),
73+
"stderr": fmt.Sprintf("%#v", err),
74+
}).Debug("command output")
75+
76+
res["stdout"] = strings.Trim(string(data), " \n")
77+
if err != nil {
78+
res["code"] = strconv.Itoa(command.GetExitCode(err))
79+
res["stderr"] = command.GetMsgFromCommandError(err)
80+
}
81+
82+
p.data = res
83+
}

pkg/fact/command/command_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package command_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/salsadigitalauorg/shipshape/pkg/fact"
9+
. "github.com/salsadigitalauorg/shipshape/pkg/fact/command"
10+
"github.com/salsadigitalauorg/shipshape/pkg/internal"
11+
)
12+
13+
func TestCommandInit(t *testing.T) {
14+
assert := assert.New(t)
15+
16+
// Test that the command plugin is registered.
17+
factPlugin := fact.Registry["command"]("TestCommand")
18+
assert.NotNil(factPlugin)
19+
keyFacter, ok := factPlugin.(*Command)
20+
assert.True(ok)
21+
assert.Equal("TestCommand", keyFacter.Name)
22+
}
23+
24+
func TestCommandPluginName(t *testing.T) {
25+
commandF := Command{Name: "TestCommand"}
26+
assert.Equal(t, "command", commandF.PluginName())
27+
}
28+
29+
func TestCommandSupportedConnections(t *testing.T) {
30+
commandF := Command{Name: "TestCommand"}
31+
supportLevel, connections := commandF.SupportedConnections()
32+
assert.Equal(t, fact.SupportNone, supportLevel)
33+
assert.Empty(t, connections)
34+
}
35+
36+
func TestCommandSupportedInputs(t *testing.T) {
37+
commandF := Command{Name: "TestCommand"}
38+
supportLevel, inputs := commandF.SupportedInputs()
39+
assert.Equal(t, fact.SupportNone, supportLevel)
40+
assert.ElementsMatch(t, []string{}, inputs)
41+
}
42+
43+
func TestCommandCollect(t *testing.T) {
44+
tests := []internal.FactCollectTest{
45+
{
46+
Name: "emptyCommand",
47+
Facter: &Command{Name: "TestCommand"},
48+
ExpectedData: map[string]string{
49+
"code": "1", "stderr": "exec: no command", "stdout": "",
50+
},
51+
},
52+
{
53+
Name: "echo",
54+
Facter: &Command{Name: "TestCommand", Cmd: "echo", Args: []string{"hello"}},
55+
ExpectedData: map[string]string{
56+
"code": "0", "stderr": "", "stdout": "hello",
57+
},
58+
},
59+
{
60+
Name: "multiline",
61+
Facter: &Command{Name: "TestCommand", Cmd: "ls", Args: []string{"-A1"}},
62+
ExpectedData: map[string]string{
63+
"code": "0", "stderr": "", "stdout": "command.go\ncommand_gen.go\ncommand_test.go",
64+
},
65+
},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.Name, func(t *testing.T) {
70+
internal.TestFactCollect(t, tt)
71+
})
72+
}
73+
}

0 commit comments

Comments
 (0)