Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/vitodeploy/agent/pkg/disk"
"github.com/vitodeploy/agent/pkg/host"
"github.com/vitodeploy/agent/pkg/memory"
"github.com/vitodeploy/agent/pkg/services"
)

type Payload struct {
Expand All @@ -36,6 +37,10 @@ type Payload struct {
OOMKillCount int64 `json:"oom_kill_count"`
UptimeSeconds float64 `json:"uptime_seconds"`
RebootRequired bool `json:"reboot_required"`

// Services is omitted entirely when no service statuses are available, which
// older Vito versions expect.
Services []services.ServiceStatus `json:"services,omitempty"`
}

func main() {
Expand All @@ -45,6 +50,7 @@ func main() {
diskInfo := disk.GetDiskInfo()
memoryInfo := memory.GetMemoryInfo()
hostInfo := host.GetHostInfo()
serviceStatuses := services.GetServiceStatuses(cfg.Services)
payload := Payload{
Load: cpuInfo.Load,
DiskTotal: diskInfo.Total,
Expand All @@ -67,6 +73,8 @@ func main() {
OOMKillCount: hostInfo.OOMKillCount,
UptimeSeconds: hostInfo.UptimeSeconds,
RebootRequired: hostInfo.RebootRequired,

Services: serviceStatuses,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
Expand Down
105 changes: 105 additions & 0 deletions cmd/agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"encoding/json"
"strings"
"testing"

"github.com/vitodeploy/agent/pkg/services"
)

func samplePayload() Payload {
return Payload{
Load: 1.2,
DiskTotal: "100M",
DiskFree: "40M",
DiskUsed: "60M",
MemoryTotal: "2000M",
MemoryFree: "500M",
MemoryUsed: "1500M",

CPUCores: 4,
CPUPhysicalCores: 2,
CPUUsagePercent: 12.5,
CPUPerCoreUsage: []float64{1.5, 2.5},
CPUStealPercent: 0.1,
MemoryUsedPercent: 75,
SwapTotal: "1000M",
SwapFree: "900M",
SwapUsed: "100M",
SwapUsedPercent: 10,
OOMKillCount: 3,
UptimeSeconds: 99999.5,
RebootRequired: true,
}
}

// The marshalled payload of an agent without service statuses must stay
// byte-for-byte identical to the one previous agent versions sent. The expected
// value was captured from the agent before service reporting was added.
func TestMarshalPayloadWithoutServices(t *testing.T) {
expected := `{"load":1.2,"disk_total":"100M","disk_free":"40M","disk_used":"60M","memory_total":"2000M","memory_free":"500M","memory_used":"1500M","cpu_cores":4,"cpu_physical_cores":2,"cpu_usage_percent":12.5,"cpu_per_core_usage_percent":[1.5,2.5],"cpu_steal_percent":0.1,"memory_used_percent":75,"swap_total":"1000M","swap_free":"900M","swap_used":"100M","swap_used_percent":10,"oom_kill_count":3,"uptime_seconds":99999.5,"reboot_required":true}`

payload, err := json.Marshal(samplePayload())
if err != nil {
t.Fatalf("marshalling failed: %v", err)
}

if string(payload) != expected {
t.Errorf("payload changed:\nexpected %s\ngot %s", expected, payload)
}
}

func TestMarshalZeroPayloadWithoutServices(t *testing.T) {
expected := `{"load":0,"disk_total":"","disk_free":"","disk_used":"","memory_total":"","memory_free":"","memory_used":"","cpu_cores":0,"cpu_physical_cores":0,"cpu_usage_percent":0,"cpu_per_core_usage_percent":null,"cpu_steal_percent":0,"memory_used_percent":0,"swap_total":"","swap_free":"","swap_used":"","swap_used_percent":0,"oom_kill_count":0,"uptime_seconds":0,"reboot_required":false}`

payload, err := json.Marshal(Payload{})
if err != nil {
t.Fatalf("marshalling failed: %v", err)
}

if string(payload) != expected {
t.Errorf("payload changed:\nexpected %s\ngot %s", expected, payload)
}
}

// A nil or empty slice must omit the key entirely rather than send null or [],
// which the server reads as an agent that does not report services.
func TestMarshalPayloadOmitsServicesKey(t *testing.T) {
for name, statuses := range map[string][]services.ServiceStatus{
"nil": nil,
"empty": {},
} {
t.Run(name, func(t *testing.T) {
payload := samplePayload()
payload.Services = statuses

marshalled, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshalling failed: %v", err)
}

if strings.Contains(string(marshalled), "services") {
t.Errorf("expected no services key, got %s", marshalled)
}
})
}
}

func TestMarshalPayloadWithServices(t *testing.T) {
payload := samplePayload()
payload.Services = []services.ServiceStatus{
{Id: 3, Status: "active"},
{Id: 7, Status: "failed"},
}

marshalled, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshalling failed: %v", err)
}

expected := `,"services":[{"id":3,"status":"active"},{"id":7,"status":"failed"}]}`
if !strings.HasSuffix(string(marshalled), expected) {
t.Errorf("expected payload to end with %s, got %s", expected, marshalled)
}
}
8 changes: 8 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ import (
const configDir = "/etc/vito-agent"
const configFile = "config.json"

type ServiceConfig struct {
Id int64 `json:"id"`
Unit string `json:"unit"`
}

type Config struct {
Url string `json:"url"`
Secret string `json:"secret"`
// Services is optional and is absent in configs written by older Vito versions.
// omitempty keeps the self-created default config file identical to previous versions.
Services []ServiceConfig `json:"services,omitempty"`
}

func GetConfig() *Config {
Expand Down
89 changes: 89 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package config

import (
"bytes"
"encoding/json"
"testing"
)

func TestDecodeConfigWithoutServices(t *testing.T) {
config := &Config{}
input := `{"url":"https://vito.example/api/servers/1/agent/12","secret":"a-uuid"}`

if err := json.Unmarshal([]byte(input), config); err != nil {
t.Fatalf("decoding failed: %v", err)
}

if config.Url != "https://vito.example/api/servers/1/agent/12" {
t.Errorf("got url %q", config.Url)
}
if config.Secret != "a-uuid" {
t.Errorf("got secret %q", config.Secret)
}
if config.Services != nil {
t.Errorf("expected nil services, got %#v", config.Services)
}
}

func TestDecodeConfigWithServices(t *testing.T) {
config := &Config{}
input := `{"url":"https://vito.example","secret":"a-uuid","services":[{"id":3,"unit":"nginx"},{"id":7,"unit":"php8.4-fpm"}]}`

if err := json.Unmarshal([]byte(input), config); err != nil {
t.Fatalf("decoding failed: %v", err)
}

expected := []ServiceConfig{{Id: 3, Unit: "nginx"}, {Id: 7, Unit: "php8.4-fpm"}}
if len(config.Services) != len(expected) {
t.Fatalf("expected %d services, got %d", len(expected), len(config.Services))
}
for i, service := range expected {
if config.Services[i] != service {
t.Errorf("service %d: expected %#v, got %#v", i, service, config.Services[i])
}
}
}

func TestDecodeConfigWithNullServices(t *testing.T) {
config := &Config{}
input := `{"url":"https://vito.example","secret":"a-uuid","services":null}`

if err := json.Unmarshal([]byte(input), config); err != nil {
t.Fatalf("decoding failed: %v", err)
}

if config.Services != nil {
t.Errorf("expected nil services, got %#v", config.Services)
}
}

// Configs written by newer Vito versions may contain keys this agent does not
// know about.
func TestDecodeConfigIgnoresUnknownFields(t *testing.T) {
config := &Config{}
input := `{"url":"https://vito.example","secret":"a-uuid","something_new":{"a":1}}`

if err := json.Unmarshal([]byte(input), config); err != nil {
t.Fatalf("decoding failed: %v", err)
}

if config.Url != "https://vito.example" {
t.Errorf("got url %q", config.Url)
}
}

// The self-created default config file must stay identical to the one older
// agent versions wrote.
func TestEncodeDefaultConfig(t *testing.T) {
buffer := &bytes.Buffer{}
encoder := json.NewEncoder(buffer)
encoder.SetIndent("", " ")
if err := encoder.Encode(&Config{Url: "", Secret: ""}); err != nil {
t.Fatalf("encoding failed: %v", err)
}

expected := "{\n \"url\": \"\",\n \"secret\": \"\"\n}\n"
if buffer.String() != expected {
t.Errorf("expected %q, got %q", expected, buffer.String())
}
}
134 changes: 134 additions & 0 deletions pkg/services/services.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package services

import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"

"github.com/vitodeploy/agent/pkg/config"
)

// The server rejects the whole request when more than maxServices entries are
// sent, or when a status is longer than maxStatusChars.
const maxServices = 100
const maxStatusChars = 32

// These are variables so tests can stub out systemctl and shorten the waits.
var commandContext = exec.CommandContext
var commandTimeout = 5 * time.Second
var waitDelay = time.Second

type ServiceStatus struct {
Id int64 `json:"id"`
Status string `json:"status"`
}

// GetServiceStatuses reports the raw `systemctl is-active` status of each
// configured service. It returns nil when there is nothing to report or when
// the statuses cannot be collected, so that metrics delivery is never blocked.
func GetServiceStatuses(services []config.ServiceConfig) []ServiceStatus {
entries := selectEntries(services)
if len(entries) == 0 {
return nil
}

output, err := isActive(units(entries))
if err != nil {
fmt.Println("Error running systemctl is-active:", err)
return nil
}

return parseStatuses(entries, output)
}

// selectEntries drops unusable config entries and caps the result at the number
// of services the server accepts.
func selectEntries(services []config.ServiceConfig) []config.ServiceConfig {
entries := make([]config.ServiceConfig, 0, len(services))
for _, service := range services {
if service.Id == 0 || strings.TrimSpace(service.Unit) == "" {
continue
}
if len(entries) == maxServices {
break
}
entries = append(entries, service)
}
return entries
}

func units(entries []config.ServiceConfig) []string {
units := make([]string, 0, len(entries))
for _, entry := range entries {
units = append(units, entry.Unit)
}
return units
}

// isActive returns the stdout of `systemctl is-active`, which prints one line
// per unit in argument order. A non-zero exit code only means that a unit is
// not active, so it is ignored.
func isActive(units []string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()

args := append([]string{"is-active"}, units...)
cmd := commandContext(ctx, "systemctl", args...)
// Killing systemctl on timeout only closes the pipe once every process
// holding it has exited, so a lingering child would otherwise block the
// metrics loop forever. WaitDelay bounds that wait.
cmd.WaitDelay = waitDelay

output, err := cmd.Output()
if ctx.Err() != nil {
return nil, ctx.Err()
}
var exitErr *exec.ExitError
if err != nil && !errors.As(err, &exitErr) {
return nil, err
}

return output, nil
}

// parseStatuses pairs each line of the is-active output with the entry it was
// requested for. The whole collection is discarded when the line count does not
// match the unit count, rather than risking a misaligned status.
func parseStatuses(entries []config.ServiceConfig, output []byte) []ServiceStatus {
trimmed := strings.TrimRight(string(output), "\n")
if trimmed == "" {
fmt.Println("Unexpected output from systemctl is-active: no output")
return nil
}

lines := strings.Split(trimmed, "\n")
if len(lines) != len(entries) {
fmt.Println("Unexpected output from systemctl is-active: expected", len(entries), "lines, got", len(lines))
return nil
}

statuses := make([]ServiceStatus, 0, len(entries))
for i, entry := range entries {
status := truncate(strings.TrimSpace(lines[i]))
if status == "" {
continue
}
statuses = append(statuses, ServiceStatus{Id: entry.Id, Status: status})
}
if len(statuses) == 0 {
return nil
}

return statuses
}

func truncate(status string) string {
runes := []rune(status)
if len(runes) <= maxStatusChars {
return status
}
return string(runes[:maxStatusChars])
}
Loading