init monlet repo with stage 0-2 (docs, contract, agent MVP)
This commit is contained in:
180
agent/internal/config/config.go
Normal file
180
agent/internal/config/config.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
|
||||
|
||||
const (
|
||||
maxIDLen = 128
|
||||
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"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
URL string `toml:"url"`
|
||||
Token string `toml:"token"`
|
||||
HeartbeatInterval Duration `toml:"heartbeat_interval"`
|
||||
BatchInterval Duration `toml:"batch_interval"`
|
||||
}
|
||||
|
||||
type MetricsConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Listen string `toml:"listen"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type Duration struct{ time.Duration }
|
||||
|
||||
func (d *Duration) UnmarshalText(b []byte) error {
|
||||
v, err := time.ParseDuration(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Duration = v
|
||||
return nil
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
var c Config
|
||||
if _, err := toml.DecodeFile(path, &c); err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
c.applyDefaults()
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Mode == "" {
|
||||
c.Mode = "hybrid"
|
||||
}
|
||||
if c.Hostname == "" {
|
||||
if h, err := os.Hostname(); err == nil {
|
||||
c.Hostname = h
|
||||
}
|
||||
}
|
||||
if c.Server.HeartbeatInterval.Duration == 0 {
|
||||
c.Server.HeartbeatInterval.Duration = defaultHeartbeat
|
||||
}
|
||||
if c.Server.BatchInterval.Duration == 0 {
|
||||
c.Server.BatchInterval.Duration = defaultBatch
|
||||
}
|
||||
if c.Metrics.Listen == "" {
|
||||
c.Metrics.Listen = defaultMetricsAddr
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.AgentID != "" {
|
||||
if err := ValidateID("agent_id", c.AgentID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch c.Mode {
|
||||
case "prometheus_only", "push_only", "hybrid":
|
||||
default:
|
||||
return fmt.Errorf("invalid mode %q", c.Mode)
|
||||
}
|
||||
if c.StateDir == "" {
|
||||
return fmt.Errorf("state_dir is required")
|
||||
}
|
||||
if c.PushesToServer() {
|
||||
if c.Server.URL == "" {
|
||||
return fmt.Errorf("server.url is required for mode %q", c.Mode)
|
||||
}
|
||||
if c.Server.Token == "" {
|
||||
return fmt.Errorf("server.token is required for mode %q", c.Mode)
|
||||
}
|
||||
}
|
||||
if len(c.Checks) == 0 {
|
||||
return fmt.Errorf("at least one check is required")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(c.Checks))
|
||||
for i := range c.Checks {
|
||||
ch := &c.Checks[i]
|
||||
if err := ValidateID("checks[].id", ch.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, dup := seen[ch.ID]; dup {
|
||||
return fmt.Errorf("duplicate check id %q", ch.ID)
|
||||
}
|
||||
seen[ch.ID] = struct{}{}
|
||||
if len(ch.Command) == 0 {
|
||||
return fmt.Errorf("check %q: command is required", ch.ID)
|
||||
}
|
||||
if ch.Interval.Duration <= 0 {
|
||||
return fmt.Errorf("check %q: interval must be > 0", ch.ID)
|
||||
}
|
||||
if ch.Timeout.Duration <= 0 {
|
||||
return fmt.Errorf("check %q: timeout must be > 0", ch.ID)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushesToServer is true when the mode requires heartbeat/events push.
|
||||
func (c *Config) PushesToServer() bool {
|
||||
return c.Mode == "push_only" || c.Mode == "hybrid"
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
func ValidateID(field, v string) error {
|
||||
if v == "" {
|
||||
return fmt.Errorf("%s is empty", field)
|
||||
}
|
||||
if len(v) > maxIDLen {
|
||||
return fmt.Errorf("%s exceeds %d chars", field, maxIDLen)
|
||||
}
|
||||
if !idPattern.MatchString(v) {
|
||||
return fmt.Errorf("%s has invalid characters", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
159
agent/internal/config/config_test.go
Normal file
159
agent/internal/config/config_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTmp(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "c.toml")
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
const minimal = `
|
||||
agent_id = "host-1"
|
||||
state_dir = "/tmp/x"
|
||||
|
||||
[server]
|
||||
url = "http://localhost"
|
||||
token = "t"
|
||||
|
||||
[[checks]]
|
||||
id = "c1"
|
||||
command = ["true"]
|
||||
interval = "10s"
|
||||
timeout = "5s"
|
||||
`
|
||||
|
||||
func TestLoadMinimal(t *testing.T) {
|
||||
c, err := Load(writeTmp(t, minimal))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Mode != "hybrid" {
|
||||
t.Errorf("default mode: %q", c.Mode)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBadID(t *testing.T) {
|
||||
body := minimal + "\n"
|
||||
_, err := Load(writeTmp(t, body[:0]+`
|
||||
agent_id = "bad id!"
|
||||
state_dir = "/tmp/x"
|
||||
[server]
|
||||
url = "http://localhost"
|
||||
token = "t"
|
||||
[[checks]]
|
||||
id = "c1"
|
||||
command = ["true"]
|
||||
interval = "10s"
|
||||
timeout = "5s"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad agent_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTimeoutExceedsInterval(t *testing.T) {
|
||||
_, err := Load(writeTmp(t, `
|
||||
agent_id = "x"
|
||||
state_dir = "/tmp/x"
|
||||
[server]
|
||||
url = "http://localhost"
|
||||
token = "t"
|
||||
[[checks]]
|
||||
id = "c1"
|
||||
command = ["true"]
|
||||
interval = "5s"
|
||||
timeout = "10s"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for timeout > interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrometheusOnlyAllowsMissingServer(t *testing.T) {
|
||||
c, err := Load(writeTmp(t, `
|
||||
mode = "prometheus_only"
|
||||
state_dir = "/tmp/x"
|
||||
[[checks]]
|
||||
id = "c1"
|
||||
command = ["true"]
|
||||
interval = "10s"
|
||||
timeout = "5s"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.PushesToServer() {
|
||||
t.Fatal("prometheus_only must not push")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushOnlyRequiresServer(t *testing.T) {
|
||||
_, err := Load(writeTmp(t, `
|
||||
mode = "push_only"
|
||||
state_dir = "/tmp/x"
|
||||
[[checks]]
|
||||
id = "c1"
|
||||
command = ["true"]
|
||||
interval = "10s"
|
||||
timeout = "5s"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("push_only must require server.url/token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExposesMetricsGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
enabled bool
|
||||
want bool
|
||||
}{
|
||||
{"hybrid", true, true},
|
||||
{"hybrid", false, false},
|
||||
{"prometheus_only", true, true},
|
||||
{"push_only", true, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c := &Config{Mode: tc.mode, Metrics: MetricsConfig{Enabled: tc.enabled}}
|
||||
if got := c.ExposesMetrics(); got != tc.want {
|
||||
t.Errorf("mode=%s enabled=%v want=%v got=%v", tc.mode, tc.enabled, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDuplicateCheckID(t *testing.T) {
|
||||
_, err := Load(writeTmp(t, `
|
||||
agent_id = "x"
|
||||
state_dir = "/tmp/x"
|
||||
[server]
|
||||
url = "http://localhost"
|
||||
token = "t"
|
||||
[[checks]]
|
||||
id = "c1"
|
||||
command = ["true"]
|
||||
interval = "10s"
|
||||
timeout = "5s"
|
||||
[[checks]]
|
||||
id = "c1"
|
||||
command = ["true"]
|
||||
interval = "10s"
|
||||
timeout = "5s"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate id error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user