Support Minecraft server 1.13 and newer (#5733)
This commit is contained in:
parent
01eecee8cf
commit
3c57dafece
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
Server string `toml:"server"`
|
||||
Port string `toml:"port"`
|
||||
Password string `toml:"password"`
|
||||
|
||||
client Client
|
||||
clientSet bool
|
||||
}
|
||||
|
||||
// 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 err != nil {
|
||||
return err
|
||||
}
|
||||
s.clientSet = true
|
||||
}
|
||||
|
||||
// (*RCON).Gather() takes an RCONClientProducer for testing purposes
|
||||
d := defaultClientProducer{
|
||||
Server: s.Server,
|
||||
Port: s.Port,
|
||||
}
|
||||
|
||||
scores, err := s.client.Gather(d)
|
||||
if s.client == nil {
|
||||
connector, err := NewConnector(s.Server, s.Port, s.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, score := range scores {
|
||||
player, err := ParsePlayerName(score)
|
||||
client, err := NewClient(connector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.client = client
|
||||
}
|
||||
|
||||
players, err := s.client.Players()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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{
|
||||
|
|
|
@ -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",
|
||||
now := time.Unix(0, 0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
client *MockClient
|
||||
metrics []telegraf.Metric
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "no players",
|
||||
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 (😂)`,
|
||||
ConnectF: func() error {
|
||||
return nil
|
||||
},
|
||||
Err: nil,
|
||||
PlayersF: func() ([]string, error) {
|
||||
return []string{}, nil
|
||||
},
|
||||
clientSet: true,
|
||||
},
|
||||
metrics: []telegraf.Metric{},
|
||||
},
|
||||
{
|
||||
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")
|
||||
}
|
||||
},
|
||||
},
|
||||
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,
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := &Minecraft{
|
||||
Server: "example.org",
|
||||
Port: "25575",
|
||||
Password: "xyzzy",
|
||||
client: tt.client,
|
||||
}
|
||||
|
||||
err := testConfig.Gather(&acc)
|
||||
var acc testutil.Accumulator
|
||||
acc.TimeFunc = func() time.Time { return now }
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("gather returned error. Error: %s\n", err)
|
||||
err := plugin.Gather(&acc)
|
||||
|
||||
require.Equal(t, tt.err, err)
|
||||
testutil.RequireMetricsEqual(t, tt.metrics, acc.GetTelegrafMetrics())
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
t.Errorf("Expected value %d\n got value %d\n", expectedValue, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf(
|
||||
"Could not find measurement \"%s\" with requested tags within %s, Actual: %d",
|
||||
measurement, field, actualValue)
|
||||
t.Fatal(msg)
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue