Support Minecraft server 1.13 and newer (#5733)

This commit is contained in:
Daniel Nelson 2019-04-23 11:14:35 -07:00 committed by GitHub
parent 01eecee8cf
commit 3c57dafece
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 623 additions and 566 deletions

View File

@ -1,66 +1,84 @@
# Minecraft Plugin
# Minecraft Input Plugin
This plugin uses the RCON protocol to collect [statistics](http://minecraft.gamepedia.com/Statistics) from a [scoreboard](http://minecraft.gamepedia.com/Scoreboard) on a
Minecraft server.
The `minecraft` plugin connects to a Minecraft server using the RCON protocol
to collects scores from the server [scoreboard][].
To enable [RCON](http://wiki.vg/RCON) on the minecraft server, add this to your server configuration in the `server.properties` file:
This plugin is known to support Minecraft Java Edition versions 1.11 - 1.14.
When using an version of Minecraft earlier than 1.13, be aware that the values
for some criterion has changed and may need to be modified.
```
#### Server Setup
Enable [RCON][] on the Minecraft server, add this to your server configuration
in the [server.properties][] file:
```conf
enable-rcon=true
rcon.password=<your password>
rcon.port=<1-65535>
```
To create a new scoreboard objective called `jump` on a minecraft server tracking the `stat.jump` criteria, run this command
in the Minecraft console:
Scoreboard [Objectives][] must be added using the server console for the
plugin to collect. These can be added in game by players with op status,
from the server console, or over an RCON connection.
`/scoreboard objectives add jump stat.jump`
Stats are collected with the following RCON command, issued by the plugin:
`/scoreboard players list *`
### Configuration:
When getting started pick an easy to test objective. This command will add an
objective that counts the number of times a player has jumped:
```
/scoreboard objectives add jumps minecraft.custom:minecraft.jump
```
Once a player has triggered the event they will be added to the scoreboard,
you can then list all players with recorded scores:
```
/scoreboard players list
```
View the current scores with a command, substituting your player name:
```
/scoreboard players list Etho
```
### Configuration
```toml
[[inputs.minecraft]]
# server address for minecraft
server = "localhost"
# port for RCON
port = "25575"
# password RCON for mincraft server
password = "replace_me"
## Address of the Minecraft server.
# server = "localhost"
## Server RCON Port.
# port = "25575"
## Server RCON Password.
password = ""
```
### Measurements & Fields:
### Metrics
*This plugin uses only one measurement, titled* `minecraft`
- The field name is the scoreboard objective name.
- The field value is the count of the scoreboard objective
- `minecraft`
- minecraft
- tags:
- player
- port (port of the server)
- server (hostname:port, deprecated in 1.11; use `source` and `port` tags)
- source (hostname of the server)
- fields:
- `<objective_name>` (integer, count)
### Tags:
- The `minecraft` measurement:
- `server`: the Minecraft RCON server
- `player`: the Minecraft player
### Sample Queries:
Get the number of jumps per player in the last hour:
```
SELECT SPREAD("jump") FROM "minecraft" WHERE time > now() - 1h GROUP BY "player"
SELECT SPREAD("jumps") FROM "minecraft" WHERE time > now() - 1h GROUP BY "player"
```
### Example Output:
```
minecraft,player=notch,source=127.0.0.1,port=25575 jumps=178i 1498261397000000000
minecraft,player=dinnerbone,source=127.0.0.1,port=25575 deaths=1i,jumps=1999i,cow_kills=1i 1498261397000000000
minecraft,player=jeb,source=127.0.0.1,port=25575 d_pickaxe=1i,damage_dealt=80i,d_sword=2i,hunger=20i,health=20i,kills=1i,level=33i,jumps=264i,armor=15i 1498261397000000000
```
```
$ telegraf --input-filter minecraft --test
* Plugin: inputs.minecraft, Collection 1
> minecraft,player=notch,server=127.0.0.1:25575 jumps=178i 1498261397000000000
> minecraft,player=dinnerbone,server=127.0.0.1:25575 deaths=1i,jumps=1999i,cow_kills=1i 1498261397000000000
> minecraft,player=jeb,server=127.0.0.1:25575 d_pickaxe=1i,damage_dealt=80i,d_sword=2i,hunger=20i,health=20i,kills=1i,level=33i,jumps=264i,armor=15i 1498261397000000000
```
[server.properies]: https://minecraft.gamepedia.com/Server.properties
[scoreboard]: http://minecraft.gamepedia.com/Scoreboard
[objectives]: https://minecraft.gamepedia.com/Scoreboard#Objectives
[rcon]: http://wiki.vg/RCON

View File

@ -0,0 +1,205 @@
package minecraft
import (
"regexp"
"strconv"
"strings"
"github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon"
)
var (
scoreboardRegexLegacy = regexp.MustCompile(`(?U):\s(?P<value>\d+)\s\((?P<name>.*)\)`)
scoreboardRegex = regexp.MustCompile(`\[(?P<name>[^\]]+)\]: (?P<value>\d+)`)
)
// Connection is an established connection to the Minecraft server.
type Connection interface {
// Execute runs a command.
Execute(command string) (string, error)
}
// Connector is used to create connections to the Minecraft server.
type Connector interface {
// Connect establishes a connection to the server.
Connect() (Connection, error)
}
func NewConnector(hostname, port, password string) (*connector, error) {
return &connector{
hostname: hostname,
port: port,
password: password,
}, nil
}
type connector struct {
hostname string
port string
password string
}
func (c *connector) Connect() (Connection, error) {
p, err := strconv.Atoi(c.port)
if err != nil {
return nil, err
}
rcon, err := rcon.NewClient(c.hostname, p)
if err != nil {
return nil, err
}
_, err = rcon.Authorize(c.password)
if err != nil {
return nil, err
}
return &connection{rcon: rcon}, nil
}
func NewClient(connector Connector) (*client, error) {
return &client{connector: connector}, nil
}
type client struct {
connector Connector
conn Connection
}
func (c *client) Connect() error {
conn, err := c.connector.Connect()
if err != nil {
return err
}
c.conn = conn
return nil
}
func (c *client) Players() ([]string, error) {
if c.conn == nil {
err := c.Connect()
if err != nil {
return nil, err
}
}
resp, err := c.conn.Execute("/scoreboard players list")
if err != nil {
c.conn = nil
return nil, err
}
players, err := parsePlayers(resp)
if err != nil {
c.conn = nil
return nil, err
}
return players, nil
}
func (c *client) Scores(player string) ([]Score, error) {
if c.conn == nil {
err := c.Connect()
if err != nil {
return nil, err
}
}
resp, err := c.conn.Execute("/scoreboard players list " + player)
if err != nil {
c.conn = nil
return nil, err
}
scores, err := parseScores(resp)
if err != nil {
c.conn = nil
return nil, err
}
return scores, nil
}
type connection struct {
rcon *rcon.Client
}
func (c *connection) Execute(command string) (string, error) {
packet, err := c.rcon.Execute(command)
if err != nil {
return "", err
}
return packet.Body, nil
}
func parsePlayers(input string) ([]string, error) {
parts := strings.SplitAfterN(input, ":", 2)
if len(parts) != 2 {
return []string{}, nil
}
names := strings.Split(parts[1], ",")
// Detect Minecraft <= 1.12
if strings.Contains(parts[0], "players on the scoreboard") && len(names) > 0 {
// Split the last two player names: ex: "notch and dinnerbone"
head := names[:len(names)-1]
tail := names[len(names)-1]
names = append(head, strings.SplitN(tail, " and ", 2)...)
}
var players []string
for _, name := range names {
name := strings.TrimSpace(name)
if name == "" {
continue
}
players = append(players, name)
}
return players, nil
}
// Score is an individual tracked scoreboard stat.
type Score struct {
Name string
Value int64
}
func parseScores(input string) ([]Score, error) {
if strings.Contains(input, "has no scores") {
return []Score{}, nil
}
// Detect Minecraft <= 1.12
var re *regexp.Regexp
if strings.Contains(input, "tracked objective") {
re = scoreboardRegexLegacy
} else {
re = scoreboardRegex
}
var scores []Score
matches := re.FindAllStringSubmatch(input, -1)
for _, match := range matches {
score := Score{}
for i, subexp := range re.SubexpNames() {
switch subexp {
case "name":
score.Name = match[i]
case "value":
value, err := strconv.ParseInt(match[i], 10, 64)
if err != nil {
continue
}
score.Value = value
default:
continue
}
}
scores = append(scores, score)
}
return scores, nil
}

View File

@ -0,0 +1,195 @@
package minecraft
import (
"testing"
"github.com/stretchr/testify/require"
)
type MockConnection struct {
commands map[string]string
}
func (c *MockConnection) Execute(command string) (string, error) {
return c.commands[command], nil
}
type MockConnector struct {
conn *MockConnection
}
func (c *MockConnector) Connect() (Connection, error) {
return c.conn, nil
}
func TestClient_Player(t *testing.T) {
tests := []struct {
name string
commands map[string]string
expected []string
}{
{
name: "minecraft 1.12 no players",
commands: map[string]string{
"/scoreboard players list": "There are no tracked players on the scoreboard",
},
expected: []string{},
},
{
name: "minecraft 1.12 single player",
commands: map[string]string{
"/scoreboard players list": "Showing 1 tracked players on the scoreboard:Etho",
},
expected: []string{"Etho"},
},
{
name: "minecraft 1.12 two players",
commands: map[string]string{
"/scoreboard players list": "Showing 2 tracked players on the scoreboard:Etho and torham",
},
expected: []string{"Etho", "torham"},
},
{
name: "minecraft 1.12 three players",
commands: map[string]string{
"/scoreboard players list": "Showing 3 tracked players on the scoreboard:Etho, notch and torham",
},
expected: []string{"Etho", "notch", "torham"},
},
{
name: "minecraft 1.12 players space in username",
commands: map[string]string{
"/scoreboard players list": "Showing 4 tracked players on the scoreboard:with space, Etho, notch and torham",
},
expected: []string{"with space", "Etho", "notch", "torham"},
},
{
name: "minecraft 1.12 players and in username",
commands: map[string]string{
"/scoreboard players list": "Showing 5 tracked players on the scoreboard:left and right, with space,Etho, notch and torham",
},
expected: []string{"left and right", "with space", "Etho", "notch", "torham"},
},
{
name: "minecraft 1.13 no players",
commands: map[string]string{
"/scoreboard players list": "There are no tracked entities",
},
expected: []string{},
},
{
name: "minecraft 1.13 single player",
commands: map[string]string{
"/scoreboard players list": "There are 1 tracked entities: torham",
},
expected: []string{"torham"},
},
{
name: "minecraft 1.13 multiple player",
commands: map[string]string{
"/scoreboard players list": "There are 3 tracked entities: Etho, notch, torham",
},
expected: []string{"Etho", "notch", "torham"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connector := &MockConnector{
conn: &MockConnection{commands: tt.commands},
}
client, err := NewClient(connector)
require.NoError(t, err)
actual, err := client.Players()
require.NoError(t, err)
require.Equal(t, tt.expected, actual)
})
}
}
func TestClient_Scores(t *testing.T) {
tests := []struct {
name string
player string
commands map[string]string
expected []Score
}{
{
name: "minecraft 1.12 player with no scores",
player: "Etho",
commands: map[string]string{
"/scoreboard players list Etho": "Player Etho has no scores recorded",
},
expected: []Score{},
},
{
name: "minecraft 1.12 player with one score",
player: "Etho",
commands: map[string]string{
"/scoreboard players list Etho": "Showing 1 tracked objective(s) for Etho:- jump: 2 (jump)",
},
expected: []Score{
{Name: "jump", Value: 2},
},
},
{
name: "minecraft 1.12 player with many scores",
player: "Etho",
commands: map[string]string{
"/scoreboard players list Etho": "Showing 3 tracked objective(s) for Etho:- hopper: 2 (hopper)- dropper: 2 (dropper)- redstone: 1 (redstone)",
},
expected: []Score{
{Name: "hopper", Value: 2},
{Name: "dropper", Value: 2},
{Name: "redstone", Value: 1},
},
},
{
name: "minecraft 1.13 player with no scores",
player: "Etho",
commands: map[string]string{
"/scoreboard players list Etho": "Etho has no scores to show",
},
expected: []Score{},
},
{
name: "minecraft 1.13 player with one score",
player: "Etho",
commands: map[string]string{
"/scoreboard players list Etho": "Etho has 1 scores:[jumps]: 1",
},
expected: []Score{
{Name: "jumps", Value: 1},
},
},
{
name: "minecraft 1.13 player with many scores",
player: "Etho",
commands: map[string]string{
"/scoreboard players list Etho": "Etho has 3 scores:[hopper]: 2[dropper]: 2[redstone]: 1",
},
expected: []Score{
{Name: "hopper", Value: 2},
{Name: "dropper", Value: 2},
{Name: "redstone", Value: 1},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connector := &MockConnector{
conn: &MockConnection{commands: tt.commands},
}
client, err := NewClient(connector)
require.NoError(t, err)
actual, err := client.Scores(tt.player)
require.NoError(t, err)
require.Equal(t, tt.expected, actual)
})
}
}

View File

@ -1,95 +1,89 @@
package minecraft
import (
"fmt"
"regexp"
"strconv"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/plugins/inputs"
)
const sampleConfig = `
## server address for minecraft
## Address of the Minecraft server.
# server = "localhost"
## port for RCON
## Server RCON Port.
# port = "25575"
## password RCON for mincraft server
# password = ""
## Server RCON Password.
password = ""
## Uncomment to remove deprecated metric components.
# tagdrop = ["server"]
`
var (
playerNameRegex = regexp.MustCompile(`for\s([^:]+):-`)
scoreboardRegex = regexp.MustCompile(`(?U):\s(\d+)\s\((.*)\)`)
)
// Client is an interface for a client which gathers data from a minecraft server
// Client is a client for the Minecraft server.
type Client interface {
Gather(producer RCONClientProducer) ([]string, error)
// Connect establishes a connection to the server.
Connect() error
// Players returns the players on the scoreboard.
Players() ([]string, error)
// Scores return the objective scores for a player.
Scores(player string) ([]Score, error)
}
// Minecraft represents a connection to a minecraft server
// Minecraft is the plugin type.
type Minecraft struct {
Server string
Port string
Password string
client Client
clientSet bool
Server string `toml:"server"`
Port string `toml:"port"`
Password string `toml:"password"`
client Client
}
// Description gives a brief description.
func (s *Minecraft) Description() string {
return "Collects scores from a minecraft server's scoreboard using the RCON protocol"
return "Collects scores from a Minecraft server's scoreboard using the RCON protocol"
}
// SampleConfig returns our sampleConfig.
func (s *Minecraft) SampleConfig() string {
return sampleConfig
}
// Gather uses the RCON protocol to collect player and
// scoreboard stats from a minecraft server.
//var hasClient bool = false
func (s *Minecraft) Gather(acc telegraf.Accumulator) error {
// can't simply compare s.client to nil, because comparing an interface
// to nil often does not produce the desired result
if !s.clientSet {
var err error
s.client, err = NewRCON(s.Server, s.Port, s.Password)
if s.client == nil {
connector, err := NewConnector(s.Server, s.Port, s.Password)
if err != nil {
return err
}
s.clientSet = true
client, err := NewClient(connector)
if err != nil {
return err
}
s.client = client
}
// (*RCON).Gather() takes an RCONClientProducer for testing purposes
d := defaultClientProducer{
Server: s.Server,
Port: s.Port,
}
scores, err := s.client.Gather(d)
players, err := s.client.Players()
if err != nil {
return err
}
for _, score := range scores {
player, err := ParsePlayerName(score)
for _, player := range players {
scores, err := s.client.Scores(player)
if err != nil {
return err
}
tags := map[string]string{
"player": player,
"server": s.Server + ":" + s.Port,
"source": s.Server,
"port": s.Port,
}
stats, err := ParseScoreboard(score)
if err != nil {
return err
}
var fields = make(map[string]interface{}, len(stats))
for _, stat := range stats {
fields[stat.Name] = stat.Value
var fields = make(map[string]interface{}, len(scores))
for _, score := range scores {
fields[score.Name] = score.Value
}
acc.AddFields("minecraft", fields, tags)
@ -98,51 +92,6 @@ func (s *Minecraft) Gather(acc telegraf.Accumulator) error {
return nil
}
// ParsePlayerName takes an input string from rcon, to parse
// the player.
func ParsePlayerName(input string) (string, error) {
playerMatches := playerNameRegex.FindAllStringSubmatch(input, -1)
if playerMatches == nil {
return "", fmt.Errorf("no player was matched")
}
return playerMatches[0][1], nil
}
// Score is an individual tracked scoreboard stat.
type Score struct {
Name string
Value int
}
// ParseScoreboard takes an input string from rcon, to parse
// scoreboard stats.
func ParseScoreboard(input string) ([]Score, error) {
scoreMatches := scoreboardRegex.FindAllStringSubmatch(input, -1)
if scoreMatches == nil {
return nil, fmt.Errorf("No scores found")
}
var scores []Score
for _, match := range scoreMatches {
number := match[1]
name := match[2]
n, err := strconv.Atoi(number)
// Not necessary in current state, because regex can only match integers,
// maybe become necessary if regex is modified to match more types of
// numbers
if err != nil {
return nil, fmt.Errorf("Failed to parse score")
}
s := Score{
Name: name,
Value: n,
}
scores = append(scores, s)
}
return scores, nil
}
func init() {
inputs.Add("minecraft", func() telegraf.Input {
return &Minecraft{

View File

@ -1,234 +1,124 @@
package minecraft
import (
"fmt"
"reflect"
"testing"
"time"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/testutil"
"github.com/stretchr/testify/require"
)
// TestParsePlayerName tests different Minecraft RCON inputs for players
func TestParsePlayerName(t *testing.T) {
// Test a valid input string to ensure player is extracted
input := "1 tracked objective(s) for divislight:- jumps: 178 (jumps)"
got, err := ParsePlayerName(input)
want := "divislight"
if err != nil {
t.Fatalf("player returned error. Error: %s\n", err)
}
if got != want {
t.Errorf("got %s\nwant %s\n", got, want)
}
// Test an invalid input string to ensure error is returned
input = ""
got, err = ParsePlayerName(input)
want = ""
if err == nil {
t.Fatal("Expected error when player not present. No error found.")
}
if got != want {
t.Errorf("got %s\n want %s\n", got, want)
}
// Test an invalid input string to ensure error is returned
input = "1 tracked objective(s) for 😂:- jumps: 178 (jumps)"
got, err = ParsePlayerName(input)
want = "😂"
if err != nil {
t.Fatalf("player returned error. Error: %s\n", err)
}
if got != want {
t.Errorf("got %s\n want %s\n", got, want)
}
}
// TestParseScoreboard tests different Minecraft RCON inputs for scoreboard stats.
func TestParseScoreboard(t *testing.T) {
// test a valid input string to ensure stats are parsed correctly.
input := `1 tracked objective(s) for divislight:- jumps: 178 (jumps)- sword: 5 (sword)`
got, err := ParseScoreboard(input)
if err != nil {
t.Fatal("Unexpected error")
}
want := []Score{
{
Name: "jumps",
Value: 178,
},
{
Name: "sword",
Value: 5,
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Got: \n%#v\nWant: %#v", got, want)
}
// Tests a partial input string.
input = `1 tracked objective(s) for divislight:- jumps: (jumps)- sword: 5 (sword)`
got, err = ParseScoreboard(input)
if err != nil {
t.Fatal("Unexpected error")
}
want = []Score{
{
Name: "sword",
Value: 5,
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Got: \n%#v\nWant:\n%#v", got, want)
}
// Tests an empty string.
input = ``
_, err = ParseScoreboard(input)
if err == nil {
t.Fatal("Expected input error, but error was nil")
}
// Tests when a number isn't an integer.
input = `1 tracked objective(s) for divislight:- jumps: 178.5 (jumps)- sword: 5 (sword)`
got, err = ParseScoreboard(input)
if err != nil {
t.Fatal("Unexpected error")
}
want = []Score{
{
Name: "sword",
Value: 5,
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Got: \n%#v\nWant: %#v", got, want)
}
//Testing a real life data scenario with unicode characters
input = `7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`
got, err = ParseScoreboard(input)
if err != nil {
t.Fatal("Unexpected error")
}
want = []Score{
{
Name: "total_kills",
Value: 39,
},
{
Name: "dalevel",
Value: 37,
},
{
Name: "lvl",
Value: 37,
},
{
Name: "jumps",
Value: 1290,
},
{
Name: "iron_pickaxe",
Value: 284,
},
{
Name: "cow_kills",
Value: 1,
},
{
Name: "😂",
Value: 37,
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Got: \n%#v\nWant: %#v", got, want)
}
}
type MockClient struct {
Result []string
Err error
ConnectF func() error
PlayersF func() ([]string, error)
ScoresF func(player string) ([]Score, error)
}
func (m *MockClient) Gather(d RCONClientProducer) ([]string, error) {
return m.Result, m.Err
func (c *MockClient) Connect() error {
return c.ConnectF()
}
func (c *MockClient) Players() ([]string, error) {
return c.PlayersF()
}
func (c *MockClient) Scores(player string) ([]Score, error) {
return c.ScoresF(player)
}
func TestGather(t *testing.T) {
var acc testutil.Accumulator
testConfig := Minecraft{
Server: "biffsgang.net",
Port: "25575",
client: &MockClient{
Result: []string{
`1 tracked objective(s) for divislight:- jumps: 178 (jumps)`,
`7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`,
`5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`,
now := time.Unix(0, 0)
tests := []struct {
name string
client *MockClient
metrics []telegraf.Metric
err error
}{
{
name: "no players",
client: &MockClient{
ConnectF: func() error {
return nil
},
PlayersF: func() ([]string, error) {
return []string{}, nil
},
},
Err: nil,
metrics: []telegraf.Metric{},
},
clientSet: true,
}
err := testConfig.Gather(&acc)
if err != nil {
t.Fatalf("gather returned error. Error: %s\n", err)
}
if !testConfig.clientSet {
t.Fatalf("clientSet should be true, client should be set")
}
tags := map[string]string{
"player": "divislight",
"server": "biffsgang.net:25575",
}
assertContainsTaggedStat(t, &acc, "minecraft", "jumps", 178, tags)
tags["player"] = "mauxlaim"
assertContainsTaggedStat(t, &acc, "minecraft", "cow_kills", 1, tags)
tags["player"] = "torham"
assertContainsTaggedStat(t, &acc, "minecraft", "total_kills", 29, tags)
}
func assertContainsTaggedStat(
t *testing.T,
acc *testutil.Accumulator,
measurement string,
field string,
expectedValue int,
tags map[string]string,
) {
var actualValue int
for _, pt := range acc.Metrics {
if pt.Measurement == measurement && reflect.DeepEqual(pt.Tags, tags) {
for fieldname, value := range pt.Fields {
if fieldname == field {
actualValue = value.(int)
if value == expectedValue {
return
{
name: "one player without scores",
client: &MockClient{
ConnectF: func() error {
return nil
},
PlayersF: func() ([]string, error) {
return []string{"Etho"}, nil
},
ScoresF: func(player string) ([]Score, error) {
switch player {
case "Etho":
return []Score{}, nil
default:
panic("unknown player")
}
t.Errorf("Expected value %d\n got value %d\n", expectedValue, value)
}
}
}
},
},
metrics: []telegraf.Metric{},
},
{
name: "one player with scores",
client: &MockClient{
ConnectF: func() error {
return nil
},
PlayersF: func() ([]string, error) {
return []string{"Etho"}, nil
},
ScoresF: func(player string) ([]Score, error) {
switch player {
case "Etho":
return []Score{{Name: "jumps", Value: 42}}, nil
default:
panic("unknown player")
}
},
},
metrics: []telegraf.Metric{
testutil.MustMetric(
"minecraft",
map[string]string{
"player": "Etho",
"server": "example.org:25575",
"source": "example.org",
"port": "25575",
},
map[string]interface{}{
"jumps": 42,
},
now,
),
},
},
}
msg := fmt.Sprintf(
"Could not find measurement \"%s\" with requested tags within %s, Actual: %d",
measurement, field, actualValue)
t.Fatal(msg)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plugin := &Minecraft{
Server: "example.org",
Port: "25575",
Password: "xyzzy",
client: tt.client,
}
var acc testutil.Accumulator
acc.TimeFunc = func() time.Time { return now }
err := plugin.Gather(&acc)
require.Equal(t, tt.err, err)
testutil.RequireMetricsEqual(t, tt.metrics, acc.GetTelegrafMetrics())
})
}
}

View File

@ -1,112 +0,0 @@
package minecraft
import (
"strconv"
"strings"
"github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon"
)
const (
// NoMatches is a sentinel value returned when there are no statistics defined on the
//minecraft server
NoMatches = `All matches failed`
// ScoreboardPlayerList is the command to see all player statistics
ScoreboardPlayerList = `scoreboard players list *`
)
// RCONClient is a representation of RCON command authorizaiton and exectution
type RCONClient interface {
Authorize(password string) (*rcon.Packet, error)
Execute(command string) (*rcon.Packet, error)
}
// RCON represents a RCON server connection
type RCON struct {
Server string
Port string
Password string
client RCONClient
}
// RCONClientProducer is an interface which defines how a new client will be
// produced in the event of a network disconnect. It exists mainly for
// testing purposes
type RCONClientProducer interface {
newClient() (RCONClient, error)
}
type defaultClientProducer struct {
Server string
Port string
}
func (d defaultClientProducer) newClient() (RCONClient, error) {
return newClient(d.Server, d.Port)
}
// NewRCON creates a new RCON
func NewRCON(server, port, password string) (*RCON, error) {
client, err := newClient(server, port)
if err != nil {
return nil, err
}
return &RCON{
Server: server,
Port: port,
Password: password,
client: client,
}, nil
}
func newClient(server, port string) (*rcon.Client, error) {
p, err := strconv.Atoi(port)
if err != nil {
return nil, err
}
client, err := rcon.NewClient(server, p)
// If we've encountered any error, the contents of `client` could be corrupted,
// so we must return nil, err
if err != nil {
return nil, err
}
return client, nil
}
// Gather receives all player scoreboard information and returns it per player.
func (r *RCON) Gather(producer RCONClientProducer) ([]string, error) {
if r.client == nil {
var err error
r.client, err = producer.newClient()
if err != nil {
return nil, err
}
}
if _, err := r.client.Authorize(r.Password); err != nil {
// Potentially a network problem where the client will need to be
// re-initialized
r.client = nil
return nil, err
}
packet, err := r.client.Execute(ScoreboardPlayerList)
if err != nil {
// Potentially a network problem where the client will need to be
// re-initialized
r.client = nil
return nil, err
}
if !strings.Contains(packet.Body, NoMatches) {
players := strings.Split(packet.Body, "Showing")
if len(players) > 1 {
return players[1:], nil
}
}
return []string{}, nil
}

View File

@ -1,36 +0,0 @@
package minecraft
import (
"errors"
"testing"
)
type MockRCONProducer struct {
Err error
}
func (m *MockRCONProducer) newClient() (RCONClient, error) {
return nil, m.Err
}
func TestRCONErrorHandling(t *testing.T) {
m := &MockRCONProducer{
Err: errors.New("Error: failed connection"),
}
c := &RCON{
Server: "craftstuff.com",
Port: "2222",
Password: "pass",
//Force fetching of new client
client: nil,
}
_, err := c.Gather(m)
if err == nil {
t.Errorf("Error nil, unexpected result")
}
if c.client != nil {
t.Fatal("c.client should be nil, unexpected result")
}
}

View File

@ -1,68 +0,0 @@
package minecraft
import (
"testing"
"github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon"
)
type MockRCONClient struct {
Result *rcon.Packet
Err error
}
func (m *MockRCONClient) Authorize(password string) (*rcon.Packet, error) {
return m.Result, m.Err
}
func (m *MockRCONClient) Execute(command string) (*rcon.Packet, error) {
return m.Result, m.Err
}
// TestRCONGather test the RCON gather function
func TestRCONGather(t *testing.T) {
mock := &MockRCONClient{
Result: &rcon.Packet{
Body: `Showing 1 tracked objective(s) for divislight:- jumps: 178 (jumps)Showing 7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)Showing 5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`,
},
Err: nil,
}
want := []string{
` 1 tracked objective(s) for divislight:- jumps: 178 (jumps)`,
` 7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`,
` 5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`,
}
client := &RCON{
Server: "craftstuff.com",
Port: "2222",
Password: "pass",
client: mock,
}
d := defaultClientProducer{}
got, err := client.Gather(d)
if err != nil {
t.Fatalf("Gather returned an error. Error %s\n", err)
}
for i, s := range got {
if want[i] != s {
t.Fatalf("Got %s at index %d, want %s at index %d", s, i, want[i], i)
}
}
client.client = &MockRCONClient{
Result: &rcon.Packet{
Body: "",
},
Err: nil,
}
got, err = client.Gather(defaultClientProducer{})
if err != nil {
t.Fatalf("Gather returned an error. Error %s\n", err)
}
if len(got) != 0 {
t.Fatalf("Expected empty slice of length %d, got slice of length %d", 0, len(got))
}
}

View File

@ -45,12 +45,22 @@ type Accumulator struct {
Errors []error
debug bool
delivered chan telegraf.DeliveryInfo
TimeFunc func() time.Time
}
func (a *Accumulator) NMetrics() uint64 {
return atomic.LoadUint64(&a.nMetrics)
}
func (a *Accumulator) GetTelegrafMetrics() []telegraf.Metric {
metrics := []telegraf.Metric{}
for _, m := range a.Metrics {
metrics = append(metrics, FromTestMetric(m))
}
return metrics
}
func (a *Accumulator) FirstError() error {
if len(a.Errors) == 0 {
return nil
@ -101,6 +111,12 @@ func (a *Accumulator) AddFields(
t = timestamp[0]
} else {
t = time.Now()
if a.TimeFunc == nil {
t = time.Now()
} else {
t = a.TimeFunc()
}
}
if a.debug {