show full formatted results for windows commands in fleetctl (#14922)

for #14912 this adds the full results to the "RESULTS" column of
`fleetctl get mdm-command-results`.

Additionally I included formatting of the XML output to improve
readability.
This commit is contained in:
Roberto Dip 2023-11-03 12:01:43 -03:00 committed by GitHub
parent 91db043094
commit 33db665d63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 148 additions and 41 deletions

View File

@ -1,7 +1,6 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@ -12,6 +11,7 @@ import (
"strconv"
"time"
"github.com/beevik/etree"
"github.com/fatih/color"
"github.com/fleetdm/fleet/v4/pkg/rawjson"
"github.com/fleetdm/fleet/v4/pkg/secure"
@ -1122,6 +1122,15 @@ func printKeyValueTable(c *cli.Context, rows [][]string) {
table.Render()
}
func printTableWithXML(c *cli.Context, columns []string, data [][]string) {
table := defaultTable(c.App.Writer)
table.SetHeader(columns)
table.SetReflowDuringAutoWrap(false)
table.SetAutoWrapText(false)
table.AppendBulk(data)
table.Render()
}
func getTeamsJSONFlag() cli.Flag {
return &cli.BoolFlag{
Name: jsonFlagName,
@ -1424,9 +1433,14 @@ func getMDMCommandResultsCommand() *cli.Command {
// print the results as a table
data := [][]string{}
for _, r := range res {
if bytes.Contains(r.Result, []byte("\t")) {
// tabs in the XML result tends to break the table formatting
r.Result = bytes.ReplaceAll(r.Result, []byte("\t"), []byte(" "))
formattedResult, err := formatXML(r.Result)
// if we get an error, just log it and use the
// unformatted command
if err != nil {
if getDebug(c) {
log(c, fmt.Sprintf("error formatting command: %s\n", err))
}
formattedResult = r.Result
}
data = append(data, []string{
r.CommandUUID,
@ -1434,11 +1448,11 @@ func getMDMCommandResultsCommand() *cli.Command {
r.RequestType,
r.Status,
r.Hostname,
string(r.Result),
string(formattedResult),
})
}
columns := []string{"ID", "TIME", "TYPE", "STATUS", "HOSTNAME", "RESULTS"}
printTable(c, columns, data)
printTableWithXML(c, columns, data)
return nil
},
@ -1493,3 +1507,12 @@ func getMDMCommandsCommand() *cli.Command {
},
}
}
func formatXML(in []byte) ([]byte, error) {
doc := etree.NewDocument()
if err := doc.ReadFromBytes(in); err != nil {
return nil, err
}
doc.Indent(2)
return doc.WriteToBytes()
}

View File

@ -2182,33 +2182,43 @@ func TestGetMDMCommandResults(t *testing.T) {
t.Run("command results", func(t *testing.T) {
expectedOutput := strings.TrimSpace(`
+-----------+----------------------+------+--------------+----------+---------------------------------------------------+
| ID | TIME | TYPE | STATUS | HOSTNAME | RESULTS |
+-----------+----------------------+------+--------------+----------+---------------------------------------------------+
| valid-cmd | 2023-04-04T15:29:00Z | test | Acknowledged | host1 | <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE |
| | | | | | plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" |
| | | | | | "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| | | | | | <plist version="1.0"> <dict> |
| | | | | | <key>Command</key> <dict> |
| | | | | | <key>ManagedOnly</key> <false/> |
| | | | | | <key>RequestType</key> |
| | | | | | <string>ProfileList</string> |
| | | | | | </dict> <key>CommandUUID</key> |
| | | | | | <string>0001_ProfileList</string> </dict> |
| | | | | | </plist> |
+-----------+----------------------+------+--------------+----------+---------------------------------------------------+
| valid-cmd | 2023-04-04T15:29:00Z | test | Error | host2 | <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE |
| | | | | | plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" |
| | | | | | "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| | | | | | <plist version="1.0"> <dict> |
| | | | | | <key>Command</key> <dict> |
| | | | | | <key>ManagedOnly</key> <false/> |
| | | | | | <key>RequestType</key> |
| | | | | | <string>ProfileList</string> |
| | | | | | </dict> <key>CommandUUID</key> |
| | | | | | <string>0001_ProfileList</string> </dict> |
| | | | | | </plist> |
+-----------+----------------------+------+--------------+----------+---------------------------------------------------+
+-----------+----------------------+------+--------------+----------+--------------------------------------------------------------------------------------------------------+
| ID | TIME | TYPE | STATUS | HOSTNAME | RESULTS |
+-----------+----------------------+------+--------------+----------+--------------------------------------------------------------------------------------------------------+
| valid-cmd | 2023-04-04T15:29:00Z | test | Acknowledged | host1 | <?xml version="1.0" encoding="UTF-8"?> |
| | | | | | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| | | | | | <plist version="1.0"> |
| | | | | | <dict> |
| | | | | | <key>Command</key> |
| | | | | | <dict> |
| | | | | | <key>ManagedOnly</key> |
| | | | | | <false/> |
| | | | | | <key>RequestType</key> |
| | | | | | <string>ProfileList</string> |
| | | | | | </dict> |
| | | | | | <key>CommandUUID</key> |
| | | | | | <string>0001_ProfileList</string> |
| | | | | | </dict> |
| | | | | | </plist> |
| | | | | | |
+-----------+----------------------+------+--------------+----------+--------------------------------------------------------------------------------------------------------+
| valid-cmd | 2023-04-04T15:29:00Z | test | Error | host2 | <?xml version="1.0" encoding="UTF-8"?> |
| | | | | | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| | | | | | <plist version="1.0"> |
| | | | | | <dict> |
| | | | | | <key>Command</key> |
| | | | | | <dict> |
| | | | | | <key>ManagedOnly</key> |
| | | | | | <false/> |
| | | | | | <key>RequestType</key> |
| | | | | | <string>ProfileList</string> |
| | | | | | </dict> |
| | | | | | <key>CommandUUID</key> |
| | | | | | <string>0001_ProfileList</string> |
| | | | | | </dict> |
| | | | | | </plist> |
| | | | | | |
+-----------+----------------------+------+--------------+----------+--------------------------------------------------------------------------------------------------------+
`)
platform = "darwin"
@ -2466,3 +2476,56 @@ func TestGetConfigAgentOptionsSSOAndSMTP(t *testing.T) {
})
}
}
func TestFormatXML(t *testing.T) {
tests := []struct {
name string
input []byte
want []byte
wantErr bool
}{
{
name: "Basic XML",
input: []byte(`<root><element>content</element></root>`),
want: []byte("<root>\n <element>content</element>\n</root>\n"),
wantErr: false,
},
{
name: "Empty XML",
input: []byte(""),
want: nil,
wantErr: false,
},
{
name: "Invalid XML",
input: []byte(`<root><element>content</root`),
want: nil,
wantErr: true,
},
{
name: "XML With Attributes",
input: []byte(`<root attr="value"><element key="val">content</element></root>`),
want: []byte("<root attr=\"value\">\n <element key=\"val\">content</element>\n</root>\n"),
wantErr: false,
},
{
name: "Nested XML",
input: []byte(`<root><parent><child>data</child></parent></root>`),
want: []byte("<root>\n <parent>\n <child>data</child>\n </parent>\n</root>\n"),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := formatXML(tt.input)
if tt.wantErr {
require.Error(t, err, "Expected error but got none")
} else {
require.NoError(t, err, "Unexpected error")
require.Equal(t, tt.want, got, "Output XML does not match expected")
}
})
}
}

View File

@ -347,7 +347,7 @@ SELECT
wmcr.status_code as status,
wmcr.updated_at,
wmc.target_loc_uri as request_type,
wmcr.raw_result as result
wmr.raw_response as result
FROM
windows_mdm_command_results wmcr
INNER JOIN
@ -358,6 +358,10 @@ INNER JOIN
mdm_windows_enrollments mwe
ON
wmcr.enrollment_id = mwe.id
INNER JOIN
windows_mdm_responses wmr
ON
wmr.id = wmcr.response_id
WHERE
wmcr.command_uuid = ?
`

View File

@ -635,7 +635,8 @@ func testMDMWindowsCommandResults(t *testing.T, ds *Datastore) {
_, err = insertDB(t, `INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri) VALUES (?, ?, ?)`, cmdUUID, rawCmd, cmdTarget)
require.NoError(t, err)
responseID, err := insertDB(t, `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`, enrollmentID, "some-response")
rawResponse := []byte("some-response")
responseID, err := insertDB(t, `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`, enrollmentID, rawResponse)
require.NoError(t, err)
rawResult := []byte("some-result")
@ -652,7 +653,7 @@ func testMDMWindowsCommandResults(t *testing.T, ds *Datastore) {
require.Len(t, results, 1)
require.Equal(t, dev.HostUUID, results[0].HostUUID)
require.Equal(t, cmdUUID, results[0].CommandUUID)
require.Equal(t, rawResult, results[0].Result)
require.Equal(t, rawResponse, results[0].Result)
require.Equal(t, cmdTarget, results[0].RequestType)
require.Equal(t, statusCode, results[0].Status)
require.Empty(t, results[0].Hostname) // populated only at the service layer

View File

@ -4573,8 +4573,9 @@ func (s *integrationMDMTestSuite) TestMDMWindowsCommandResults() {
})
var responseID int64
rawResponse := []byte("some-response")
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
res, err := q.ExecContext(ctx, `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`, enrollmentID, "some-response")
res, err := q.ExecContext(ctx, `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`, enrollmentID, rawResponse)
if err != nil {
return err
}
@ -4594,7 +4595,7 @@ func (s *integrationMDMTestSuite) TestMDMWindowsCommandResults() {
require.Len(t, resp.Results, 1)
require.Equal(t, dev.HostUUID, resp.Results[0].HostUUID)
require.Equal(t, cmdUUID, resp.Results[0].CommandUUID)
require.Equal(t, rawResult, resp.Results[0].Result)
require.Equal(t, rawResponse, resp.Results[0].Result)
require.Equal(t, cmdTarget, resp.Results[0].RequestType)
require.Equal(t, statusCode, resp.Results[0].Status)
require.Equal(t, h.Hostname, resp.Results[0].Hostname)
@ -7455,17 +7456,32 @@ func (s *integrationMDMTestSuite) TestWindowsMDM() {
require.Len(t, cmds, 1)
// check command results
getCommandFullResult := func(cmdUUID string) []byte {
var fullResult []byte
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &fullResult, `
SELECT raw_response
FROM windows_mdm_responses wmr
JOIN windows_mdm_command_results wmcr ON wmcr.response_id = wmr.id
WHERE command_uuid = ?
`, cmdUUID)
})
return fullResult
}
var getMDMCmdResp getMDMCommandResultsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/commandresults", nil, http.StatusOK, &getMDMCmdResp, "command_uuid", cmdOneUUID)
require.Len(t, getMDMCmdResp.Results, 1)
require.NotZero(t, getMDMCmdResp.Results[0].UpdatedAt)
getMDMCmdResp.Results[0].UpdatedAt = time.Time{}
fmt.Println(string(getMDMCmdResp.Results[0].Result))
require.Equal(t, &fleet.MDMCommandResult{
HostUUID: orbitHost.UUID,
CommandUUID: cmdOneUUID,
Status: "200",
RequestType: "./Device/Vendor/MSFT/Reboot/RebootNow",
Result: []byte{},
Result: getCommandFullResult(cmdOneUUID),
Hostname: "TestIntegrationsMDM/TestWindowsMDMh1.local",
}, getMDMCmdResp.Results[0])
@ -7478,7 +7494,7 @@ func (s *integrationMDMTestSuite) TestWindowsMDM() {
CommandUUID: cmdTwoUUID,
Status: "200",
RequestType: "./Device/Vendor/MSFT/DMClient/Provider/DEMO%%20MDM/SignedEntDMID",
Result: []byte(fmt.Sprintf(`<Results xmlns="SYNCML:SYNCML1.2"><CmdID>%s</CmdID><MsgRef>1</MsgRef><CmdRef>%s</CmdRef><Cmd>Replace</Cmd><Data>200</Data><Item><Source><LocURI>./Device/Vendor/MSFT/DMClient/Provider/DEMO%%20MDM/SignedEntDMID</LocURI></Source><Data>0</Data></Item></Results>`, cmdTwoRespUUID, cmdTwoUUID)),
Result: getCommandFullResult(cmdTwoUUID),
Hostname: "TestIntegrationsMDM/TestWindowsMDMh1.local",
}, getMDMCmdResp.Results[0])
@ -7491,7 +7507,7 @@ func (s *integrationMDMTestSuite) TestWindowsMDM() {
CommandUUID: cmdThreeUUID,
Status: "200",
RequestType: "./Device/Vendor/MSFT/DMClient/Provider/DEMO%%20MDM/SignedEntDMID",
Result: []byte{},
Result: getCommandFullResult(cmdThreeUUID),
Hostname: "TestIntegrationsMDM/TestWindowsMDMh1.local",
}, getMDMCmdResp.Results[0])
}