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 The `minecraft` plugin connects to a Minecraft server using the RCON protocol
Minecraft server. 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 enable-rcon=true
rcon.password=<your password> rcon.password=<your password>
rcon.port=<1-65535> rcon.port=<1-65535>
``` ```
To create a new scoreboard objective called `jump` on a minecraft server tracking the `stat.jump` criteria, run this command Scoreboard [Objectives][] must be added using the server console for the
in the Minecraft console: 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` 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:
Stats are collected with the following RCON command, issued by the plugin:
`/scoreboard players list *`
### Configuration:
``` ```
/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]] [[inputs.minecraft]]
# server address for minecraft ## Address of the Minecraft server.
server = "localhost" # server = "localhost"
# port for RCON
port = "25575" ## Server RCON Port.
# password RCON for mincraft server # port = "25575"
password = "replace_me"
## Server RCON Password.
password = ""
``` ```
### Measurements & Fields: ### Metrics
*This plugin uses only one measurement, titled* `minecraft` - minecraft
- tags:
- The field name is the scoreboard objective name. - player
- The field value is the count of the scoreboard objective - port (port of the server)
- server (hostname:port, deprecated in 1.11; use `source` and `port` tags)
- `minecraft` - source (hostname of the server)
- fields:
- `<objective_name>` (integer, count) - `<objective_name>` (integer, count)
### Tags:
- The `minecraft` measurement:
- `server`: the Minecraft RCON server
- `player`: the Minecraft player
### Sample Queries: ### Sample Queries:
Get the number of jumps per player in the last hour: 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: ### 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
```
``` [server.properies]: https://minecraft.gamepedia.com/Server.properties
$ telegraf --input-filter minecraft --test [scoreboard]: http://minecraft.gamepedia.com/Scoreboard
* Plugin: inputs.minecraft, Collection 1 [objectives]: https://minecraft.gamepedia.com/Scoreboard#Objectives
> minecraft,player=notch,server=127.0.0.1:25575 jumps=178i 1498261397000000000 [rcon]: http://wiki.vg/RCON
> 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
```

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 package minecraft
import ( import (
"fmt"
"regexp"
"strconv"
"github.com/influxdata/telegraf" "github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/plugins/inputs" "github.com/influxdata/telegraf/plugins/inputs"
) )
const sampleConfig = ` const sampleConfig = `
## server address for minecraft ## Address of the Minecraft server.
# server = "localhost" # server = "localhost"
## port for RCON
## Server RCON Port.
# port = "25575" # port = "25575"
## password RCON for mincraft server
# password = "" ## Server RCON Password.
password = ""
## Uncomment to remove deprecated metric components.
# tagdrop = ["server"]
` `
var ( // Client is a client for the Minecraft server.
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
type Client interface { 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 { type Minecraft struct {
Server string Server string `toml:"server"`
Port string Port string `toml:"port"`
Password string Password string `toml:"password"`
client Client
clientSet bool client Client
} }
// Description gives a brief description.
func (s *Minecraft) Description() string { 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 { func (s *Minecraft) SampleConfig() string {
return sampleConfig 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 { func (s *Minecraft) Gather(acc telegraf.Accumulator) error {
// can't simply compare s.client to nil, because comparing an interface if s.client == nil {
// to nil often does not produce the desired result connector, err := NewConnector(s.Server, s.Port, s.Password)
if !s.clientSet {
var err error
s.client, err = NewRCON(s.Server, s.Port, s.Password)
if err != nil { if err != nil {
return err 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 players, err := s.client.Players()
d := defaultClientProducer{
Server: s.Server,
Port: s.Port,
}
scores, err := s.client.Gather(d)
if err != nil { if err != nil {
return err return err
} }
for _, score := range scores { for _, player := range players {
player, err := ParsePlayerName(score) scores, err := s.client.Scores(player)
if err != nil { if err != nil {
return err return err
} }
tags := map[string]string{ tags := map[string]string{
"player": player, "player": player,
"server": s.Server + ":" + s.Port, "server": s.Server + ":" + s.Port,
"source": s.Server,
"port": s.Port,
} }
stats, err := ParseScoreboard(score) var fields = make(map[string]interface{}, len(scores))
if err != nil { for _, score := range scores {
return err fields[score.Name] = score.Value
}
var fields = make(map[string]interface{}, len(stats))
for _, stat := range stats {
fields[stat.Name] = stat.Value
} }
acc.AddFields("minecraft", fields, tags) acc.AddFields("minecraft", fields, tags)
@ -98,51 +92,6 @@ func (s *Minecraft) Gather(acc telegraf.Accumulator) error {
return nil 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() { func init() {
inputs.Add("minecraft", func() telegraf.Input { inputs.Add("minecraft", func() telegraf.Input {
return &Minecraft{ return &Minecraft{

View File

@ -1,234 +1,124 @@
package minecraft package minecraft
import ( import (
"fmt"
"reflect"
"testing" "testing"
"time"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/testutil" "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 { type MockClient struct {
Result []string ConnectF func() error
Err error PlayersF func() ([]string, error)
ScoresF func(player string) ([]Score, error)
} }
func (m *MockClient) Gather(d RCONClientProducer) ([]string, error) { func (c *MockClient) Connect() error {
return m.Result, m.Err 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) { func TestGather(t *testing.T) {
var acc testutil.Accumulator now := time.Unix(0, 0)
testConfig := Minecraft{
Server: "biffsgang.net", tests := []struct {
Port: "25575", name string
client: &MockClient{ client *MockClient
Result: []string{ metrics []telegraf.Metric
`1 tracked objective(s) for divislight:- jumps: 178 (jumps)`, err error
`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 (😂)`, {
name: "no players",
client: &MockClient{
ConnectF: func() error {
return nil
},
PlayersF: func() ([]string, error) {
return []string{}, nil
},
}, },
Err: nil, metrics: []telegraf.Metric{},
}, },
clientSet: true, {
} name: "one player without scores",
client: &MockClient{
err := testConfig.Gather(&acc) ConnectF: func() error {
return nil
if err != nil { },
t.Fatalf("gather returned error. Error: %s\n", err) PlayersF: func() ([]string, error) {
} return []string{"Etho"}, nil
},
if !testConfig.clientSet { ScoresF: func(player string) ([]Score, error) {
t.Fatalf("clientSet should be true, client should be set") switch player {
} case "Etho":
return []Score{}, nil
tags := map[string]string{ default:
"player": "divislight", panic("unknown player")
"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
} }
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( for _, tt := range tests {
"Could not find measurement \"%s\" with requested tags within %s, Actual: %d", t.Run(tt.name, func(t *testing.T) {
measurement, field, actualValue) plugin := &Minecraft{
t.Fatal(msg) 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 Errors []error
debug bool debug bool
delivered chan telegraf.DeliveryInfo delivered chan telegraf.DeliveryInfo
TimeFunc func() time.Time
} }
func (a *Accumulator) NMetrics() uint64 { func (a *Accumulator) NMetrics() uint64 {
return atomic.LoadUint64(&a.nMetrics) 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 { func (a *Accumulator) FirstError() error {
if len(a.Errors) == 0 { if len(a.Errors) == 0 {
return nil return nil
@ -101,6 +111,12 @@ func (a *Accumulator) AddFields(
t = timestamp[0] t = timestamp[0]
} else { } else {
t = time.Now() t = time.Now()
if a.TimeFunc == nil {
t = time.Now()
} else {
t = a.TimeFunc()
}
} }
if a.debug { if a.debug {