Implement Monlet MVP stack and UI updates

This commit is contained in:
Stanislav Rossovskii
2026-05-27 10:01:59 +04:00
parent dcea096327
commit edc51e9c59
145 changed files with 15618 additions and 248 deletions

View File

@@ -4,7 +4,9 @@ import (
"fmt"
"os"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/BurntSushi/toml"
)
@@ -13,22 +15,27 @@ var idPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
const (
maxIDLen = 128
maxLabelKeyLen = 64
maxLabelValueLen = 256
maxLabels = 32
AgentVersionLabel = "monlet_agent_version"
defaultHeartbeat = 30 * time.Second
defaultBatch = 10 * time.Second
defaultMetricsAddr = "127.0.0.1:9465"
)
type Config struct {
AgentID string `toml:"agent_id"`
Hostname string `toml:"hostname"`
Mode string `toml:"mode"`
StateDir string `toml:"state_dir"`
Server ServerConfig `toml:"server"`
Metrics MetricsConfig `toml:"metrics"`
Checks []CheckConfig `toml:"checks"`
AgentID string `toml:"agent_id"`
Hostname string `toml:"hostname"`
StateDir string `toml:"state_dir"`
Labels map[string]string `toml:"labels"`
Server ServerConfig `toml:"server"`
Metrics MetricsConfig `toml:"metrics"`
Checks []CheckConfig `toml:"checks"`
}
type ServerConfig struct {
Enabled *bool `toml:"enabled"`
URL string `toml:"url"`
Token string `toml:"token"`
HeartbeatInterval Duration `toml:"heartbeat_interval"`
@@ -41,13 +48,13 @@ type MetricsConfig struct {
}
type CheckConfig struct {
ID string `toml:"id"`
Name string `toml:"name"`
Command []string `toml:"command"`
Interval Duration `toml:"interval"`
Timeout Duration `toml:"timeout"`
NotificationOwner string `toml:"notification_owner"`
DedupeKey string `toml:"dedupe_key"`
ID string `toml:"id"`
Name string `toml:"name"`
Command string `toml:"command"`
Interval Duration `toml:"interval"`
Timeout Duration `toml:"timeout"`
NotificationsEnabled *bool `toml:"notifications_enabled"`
DedupeKey string `toml:"dedupe_key"`
}
type Duration struct{ time.Duration }
@@ -63,9 +70,17 @@ func (d *Duration) UnmarshalText(b []byte) error {
func Load(path string) (*Config, error) {
var c Config
if _, err := toml.DecodeFile(path, &c); err != nil {
md, err := toml.DecodeFile(path, &c)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
if undecoded := md.Undecoded(); len(undecoded) > 0 {
keys := make([]string, len(undecoded))
for i, key := range undecoded {
keys[i] = key.String()
}
return nil, fmt.Errorf("unknown config keys: %s", strings.Join(keys, ", "))
}
c.applyDefaults()
if err := c.Validate(); err != nil {
return nil, err
@@ -74,9 +89,6 @@ func Load(path string) (*Config, error) {
}
func (c *Config) applyDefaults() {
if c.Mode == "" {
c.Mode = "hybrid"
}
if c.Hostname == "" {
if h, err := os.Hostname(); err == nil {
c.Hostname = h
@@ -99,21 +111,27 @@ func (c *Config) Validate() error {
return err
}
}
switch c.Mode {
case "prometheus_only", "push_only", "hybrid":
default:
return fmt.Errorf("invalid mode %q", c.Mode)
if c.Hostname == "" {
return fmt.Errorf("hostname is required")
}
if c.StateDir == "" {
return fmt.Errorf("state_dir is required")
}
if err := validateLabels(c.Labels); err != nil {
return err
}
if c.PushesToServer() {
if c.Server.URL == "" {
return fmt.Errorf("server.url is required for mode %q", c.Mode)
return fmt.Errorf("server.url is required when server.enabled = true")
}
if c.Server.Token == "" {
return fmt.Errorf("server.token is required for mode %q", c.Mode)
return fmt.Errorf("server.token is required when server.enabled = true")
}
} else if c.Server.URL != "" || c.Server.Token != "" {
return fmt.Errorf("server.url/token require server.enabled = true")
}
if !c.PushesToServer() && !c.ExposesMetrics() {
return fmt.Errorf("server.enabled or metrics.enabled must be true")
}
if len(c.Checks) == 0 {
return fmt.Errorf("at least one check is required")
@@ -128,7 +146,7 @@ func (c *Config) Validate() error {
return fmt.Errorf("duplicate check id %q", ch.ID)
}
seen[ch.ID] = struct{}{}
if len(ch.Command) == 0 {
if strings.TrimSpace(ch.Command) == "" {
return fmt.Errorf("check %q: command is required", ch.ID)
}
if ch.Interval.Duration <= 0 {
@@ -140,14 +158,6 @@ func (c *Config) Validate() error {
if ch.Timeout.Duration > ch.Interval.Duration {
return fmt.Errorf("check %q: timeout must be <= interval", ch.ID)
}
switch ch.NotificationOwner {
case "", "server", "prometheus", "none":
default:
return fmt.Errorf("check %q: invalid notification_owner %q", ch.ID, ch.NotificationOwner)
}
if ch.NotificationOwner == "" {
ch.NotificationOwner = "server"
}
if len(ch.DedupeKey) > 256 {
return fmt.Errorf("check %q: dedupe_key too long", ch.ID)
}
@@ -155,15 +165,31 @@ func (c *Config) Validate() error {
return nil
}
// PushesToServer is true when the mode requires heartbeat/events push.
// PushesToServer is true when heartbeat/events push is enabled.
func (c *Config) PushesToServer() bool {
return c.Mode == "push_only" || c.Mode == "hybrid"
return c.Server.Enabled != nil && *c.Server.Enabled
}
// ExposesMetrics is true when the agent should serve /metrics.
// `metrics.enabled` is an independent gate; both must agree.
func (c *Config) ExposesMetrics() bool {
return c.Metrics.Enabled && (c.Mode == "prometheus_only" || c.Mode == "hybrid")
return c.Metrics.Enabled
}
func (c *Config) HeartbeatLabels(version string) map[string]string {
labels := make(map[string]string, len(c.Labels)+1)
for k, v := range c.Labels {
labels[k] = v
}
labels[AgentVersionLabel] = version
return labels
}
func (c CheckConfig) NotificationsOn() bool {
return c.NotificationsEnabled == nil || *c.NotificationsEnabled
}
func (c CheckConfig) Argv() []string {
return []string{"/bin/sh", "-c", c.Command}
}
func ValidateID(field, v string) error {
@@ -178,3 +204,40 @@ func ValidateID(field, v string) error {
}
return nil
}
func validateLabels(labels map[string]string) error {
if len(labels) > maxLabels {
return fmt.Errorf("labels exceed %d entries", maxLabels)
}
if _, hasVersion := labels[AgentVersionLabel]; !hasVersion && len(labels) >= maxLabels {
return fmt.Errorf("labels leave no room for reserved %q label", AgentVersionLabel)
}
for k, v := range labels {
if k == "" {
return fmt.Errorf("label key is empty")
}
if len(k) > maxLabelKeyLen {
return fmt.Errorf("label key %q exceeds %d chars", k, maxLabelKeyLen)
}
if !idPattern.MatchString(k) {
return fmt.Errorf("label key %q has invalid characters", k)
}
if isSensitiveLabelKey(k) {
return fmt.Errorf("label key %q is not allowed", k)
}
if utf8.RuneCountInString(v) > maxLabelValueLen {
return fmt.Errorf("label %q value exceeds %d chars", k, maxLabelValueLen)
}
}
return nil
}
func isSensitiveLabelKey(k string) bool {
normalized := strings.NewReplacer("-", "_", ".", "_", ":", "_").Replace(strings.ToLower(k))
for _, word := range []string{"token", "secret", "password", "credential", "authorization", "cookie", "api_key", "apikey"} {
if strings.Contains(normalized, word) {
return true
}
}
return false
}

View File

@@ -1,8 +1,10 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -20,12 +22,13 @@ agent_id = "host-1"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = ["true"]
command = "true"
interval = "10s"
timeout = "5s"
`
@@ -35,14 +38,105 @@ func TestLoadMinimal(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if c.Mode != "hybrid" {
t.Errorf("default mode: %q", c.Mode)
if !c.PushesToServer() || c.ExposesMetrics() {
t.Errorf("features: push=%v metrics=%v", c.PushesToServer(), c.ExposesMetrics())
}
if c.Server.HeartbeatInterval.Duration == 0 {
t.Error("default heartbeat not applied")
}
if c.Checks[0].NotificationOwner != "server" {
t.Errorf("default notification_owner: %q", c.Checks[0].NotificationOwner)
if !c.Checks[0].NotificationsOn() {
t.Error("notifications should default on")
}
}
func TestLoadMultilineCommand(t *testing.T) {
c, err := Load(writeTmp(t, `
agent_id = "host-1"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = '''
set -eu
echo ok
'''
interval = "10s"
timeout = "5s"
`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(c.Checks[0].Command, "echo ok") {
t.Fatalf("command not loaded: %q", c.Checks[0].Command)
}
if got := c.Checks[0].Argv(); len(got) != 3 || got[0] != "/bin/sh" || got[1] != "-c" {
t.Fatalf("argv: %#v", got)
}
}
func TestCommandArrayRejected(t *testing.T) {
_, err := Load(writeTmp(t, `
agent_id = "host-1"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = ["true"]
interval = "10s"
timeout = "5s"
`))
if err == nil {
t.Fatal("expected command array to be rejected")
}
}
func TestLoadLabels(t *testing.T) {
c, err := Load(writeTmp(t, `
agent_id = "host-1"
state_dir = "/tmp/x"
[labels]
env = "prod"
role = "api"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = "true"
interval = "10s"
timeout = "5s"
`))
if err != nil {
t.Fatal(err)
}
labels := c.HeartbeatLabels("1.2.3")
if labels["env"] != "prod" || labels["role"] != "api" {
t.Fatalf("configured labels missing: %#v", labels)
}
if labels[AgentVersionLabel] != "1.2.3" {
t.Fatalf("version label missing: %#v", labels)
}
}
func TestHeartbeatLabelsVersionLabelWins(t *testing.T) {
c := &Config{Labels: map[string]string{AgentVersionLabel: "manual"}}
labels := c.HeartbeatLabels("real")
if labels[AgentVersionLabel] != "real" {
t.Fatalf("version label must be generated from binary version, got %q", labels[AgentVersionLabel])
}
}
@@ -52,11 +146,12 @@ func TestValidateBadID(t *testing.T) {
agent_id = "bad id!"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = ["true"]
command = "true"
interval = "10s"
timeout = "5s"
`))
@@ -70,11 +165,12 @@ func TestValidateTimeoutExceedsInterval(t *testing.T) {
agent_id = "x"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = ["true"]
command = "true"
interval = "5s"
timeout = "10s"
`))
@@ -83,13 +179,69 @@ timeout = "10s"
}
}
func TestPrometheusOnlyAllowsMissingServer(t *testing.T) {
c, err := Load(writeTmp(t, `
mode = "prometheus_only"
func TestValidateBadLabelKey(t *testing.T) {
_, err := Load(writeTmp(t, `
agent_id = "x"
state_dir = "/tmp/x"
[labels]
"bad key" = "x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = ["true"]
command = "true"
interval = "10s"
timeout = "5s"
`))
if err == nil {
t.Fatal("expected bad label key error")
}
}
func TestValidateSensitiveLabelKey(t *testing.T) {
for _, key := range []string{"token", "api_key", "api-key", "password", "credential", "authorization", "cookie"} {
if err := validateLabels(map[string]string{key: "x"}); err == nil {
t.Fatalf("expected sensitive label key error for %q", key)
}
}
}
func TestValidateLabelsReservedSlot(t *testing.T) {
labels := make(map[string]string, maxLabels)
for i := 0; i < maxLabels; i++ {
labels[fmt.Sprintf("k%d", i)] = "v"
}
if err := validateLabels(labels); err == nil {
t.Fatal("expected reserved label slot error")
}
delete(labels, "k0")
labels[AgentVersionLabel] = "manual"
if err := validateLabels(labels); err != nil {
t.Fatal(err)
}
}
func TestValidateLabelValueMaxLength(t *testing.T) {
if err := validateLabels(map[string]string{"note": strings.Repeat("ю", maxLabelValueLen)}); err != nil {
t.Fatal(err)
}
if err := validateLabels(map[string]string{"note": strings.Repeat("x", maxLabelValueLen+1)}); err == nil {
t.Fatal("expected label value length error")
}
}
func TestPrometheusOnlyAllowsMissingServer(t *testing.T) {
c, err := Load(writeTmp(t, `
state_dir = "/tmp/x"
[server]
enabled = false
[metrics]
enabled = true
[[checks]]
id = "c1"
command = "true"
interval = "10s"
timeout = "5s"
`))
@@ -97,17 +249,21 @@ timeout = "5s"
t.Fatal(err)
}
if c.PushesToServer() {
t.Fatal("prometheus_only must not push")
t.Fatal("metrics-only config must not push")
}
if !c.ExposesMetrics() {
t.Fatal("metrics-only config must expose metrics")
}
}
func TestPushOnlyRequiresServer(t *testing.T) {
_, err := Load(writeTmp(t, `
mode = "push_only"
state_dir = "/tmp/x"
[server]
enabled = true
[[checks]]
id = "c1"
command = ["true"]
command = "true"
interval = "10s"
timeout = "5s"
`))
@@ -116,21 +272,52 @@ timeout = "5s"
}
}
func TestUnknownConfigKeyRejected(t *testing.T) {
_, err := Load(writeTmp(t, `
mode = "hybrid"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = "true"
interval = "10s"
timeout = "5s"
`))
if err == nil {
t.Fatal("expected unknown key error")
}
}
func TestNotificationsEnabledCanDisable(t *testing.T) {
c, err := Load(writeTmp(t, minimal+`
notifications_enabled = false
`))
if err != nil {
t.Fatal(err)
}
if c.Checks[0].NotificationsOn() {
t.Fatal("notifications_enabled=false was not applied")
}
}
func TestExposesMetricsGate(t *testing.T) {
cases := []struct {
mode string
enabled bool
server bool
metrics bool
want bool
}{
{"hybrid", true, true},
{"hybrid", false, false},
{"prometheus_only", true, true},
{"push_only", true, false},
{true, true, true},
{true, false, false},
{false, true, true},
{false, false, false},
}
for _, tc := range cases {
c := &Config{Mode: tc.mode, Metrics: MetricsConfig{Enabled: tc.enabled}}
c := &Config{Server: ServerConfig{Enabled: &tc.server}, Metrics: MetricsConfig{Enabled: tc.metrics}}
if got := c.ExposesMetrics(); got != tc.want {
t.Errorf("mode=%s enabled=%v want=%v got=%v", tc.mode, tc.enabled, tc.want, got)
t.Errorf("server=%v metrics=%v want=%v got=%v", tc.server, tc.metrics, tc.want, got)
}
}
}
@@ -140,16 +327,17 @@ func TestValidateDuplicateCheckID(t *testing.T) {
agent_id = "x"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
token = "t"
[[checks]]
id = "c1"
command = ["true"]
command = "true"
interval = "10s"
timeout = "5s"
[[checks]]
id = "c1"
command = ["true"]
command = "true"
interval = "10s"
timeout = "5s"
`))