diff --git a/internal/controller/agent.go b/internal/controller/agent.go index a5bfcbd..32a3efb 100644 --- a/internal/controller/agent.go +++ b/internal/controller/agent.go @@ -29,8 +29,17 @@ var ( ErrAgentAlreadyExists = errors.New("agent already exists") ) +type Agent struct { + Hostname string `json:"hostname"` + Tags []string `json:"tags"` + LogSources []string `json:"log_sources"` + Metrics bool `json:"metrics"` + MetricsTargets []string `json:"metrics_targets"` + Profiles bool `json:"profiles"` +} + type ControllerAgent interface { - RegisterAgent(hostname string, tags, logSources []string, metrics bool, profiles bool) (string, error) + RegisterAgent(agent *Agent) (string, error) DeregisterAgent(rid string) error CreateAgentConfig(rid string) ([]byte, error) ListAgents() ([]map[string]string, error) @@ -186,6 +195,19 @@ prometheus.receive_http "default" { } forward_to = [prometheus.remote_write.default.receiver] } + +{{ if .MetricsTargets }} +{{ range $index, $source := .MetricsTargets }} +prometheus.scrape "custom_{{ $index }}" { + targets = [{"__address__" = "{{ $source.Address }}"}] + forward_to = [prometheus.remote_write.default.receiver] + scrape_interval = "15s" + {{ if $source.MetricsPath }} + metrics_path = "{{ $source.MetricsPath }}" + {{- end }} +} +{{ end -}} +{{ end -}} {{ end -}} {{ if .Profiles }} @@ -233,15 +255,15 @@ http: {{- end }} ` -func (c *controller) RegisterAgent(hostname string, tags, logSources []string, Metrics bool, Profiles bool) (string, error) { - slog.Debug("Register Agent", "hostname", hostname, "tags", tags, "logSources", logSources, "metrics", Metrics, "profiles", Profiles) +func (c *controller) RegisterAgent(data *Agent) (string, error) { + slog.Debug("Register Agent", "data", fmt.Sprintf("%+v", data)) - agent, err := c.marshalAgent(hostname, tags, logSources, Metrics, Profiles) + agent, err := c.marshalAgent(data) if err != nil { return "", err } - exists, err := c.model.GetAgent(&model.Agent{Hostname: hostname}) + exists, err := c.model.GetAgent(&model.Agent{Hostname: data.Hostname}) if err != nil && !errors.Is(err, model.ErrAgentNotFound) { return "", err } @@ -319,7 +341,11 @@ func (c *controller) CreateAgentConfig(rid string) ([]byte, error) { Docker bool Files []string } - Metrics bool + Metrics bool + MetricsTargets []struct { + Address string + MetricsPath string + } Profiles bool }{ Hostname: agent.Hostname, @@ -336,7 +362,11 @@ func (c *controller) CreateAgentConfig(rid string) ([]byte, error) { Docker: false, Files: make([]string, 0), }, - Metrics: agent.Metrics, + Metrics: agent.Metrics, + MetricsTargets: make([]struct { + Address string + MetricsPath string + }, 0), Profiles: agent.Profiles, } @@ -353,12 +383,27 @@ func (c *controller) CreateAgentConfig(rid string) ([]byte, error) { data.LogSources.Docker = true case "file": files = append(files, fmt.Sprintf("{__path__ = \"%s\"}", uri.Path)) - data.LogSources.Files = files // fmt.Sprintf("[%s,]", strings.Join(files, ", ")) + data.LogSources.Files = files default: continue } } + for _, source := range agent.MetricsTargets { + uri, err := url.Parse(source) + if err != nil { + continue + } + entry := struct { + Address string + MetricsPath string + }{ + Address: uri.Host, + MetricsPath: uri.Path, + } + data.MetricsTargets = append(data.MetricsTargets, entry) + } + buf := new(bytes.Buffer) if err := tmpl.Execute(buf, data); err != nil { return nil, err @@ -408,17 +453,17 @@ func (c *controller) GetAgent(rid string) (*model.Agent, error) { return agent, nil } -func (c *controller) marshalAgent(hostname string, tags, logSources []string, metrics bool, profiles bool) (*model.Agent, error) { - if hostname == "" { +func (c *controller) marshalAgent(data *Agent) (*model.Agent, error) { + if data.Hostname == "" { return nil, fmt.Errorf("hostname must not be empty") } - if len(logSources) == 0 { + if len(data.LogSources) == 0 { return nil, fmt.Errorf("at least one log source must be specified") } var effectiveLogSources []string - for _, logSource := range logSources { + for _, logSource := range data.LogSources { uri, err := url.Parse(logSource) if err != nil { continue @@ -434,6 +479,21 @@ func (c *controller) marshalAgent(hostname string, tags, logSources []string, me return nil, fmt.Errorf("no valid log source specified") } + var effectiveMetricsTargets []string + for _, metricsTarget := range data.MetricsTargets { + uri, err := url.Parse(metricsTarget) + if err != nil { + continue + } + if !slices.Contains([]string{"http", "https"}, uri.Scheme) { + continue + } + if uri.Host == "" { + continue + } + effectiveMetricsTargets = append(effectiveMetricsTargets, uri.String()) + } + password := rand.Text() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -445,15 +505,16 @@ func (c *controller) marshalAgent(hostname string, tags, logSources []string, me } agent := &model.Agent{ - Hostname: hostname, - LogSources: effectiveLogSources, - Metrics: metrics, - Profiles: profiles, - Tags: tags, - ResourceId: fmt.Sprintf("rid:finch:%s:agent:%s", c.config.Id(), uuid.New().String()), - Username: rand.Text(), - Password: password, - PasswordHash: string(hash), + Hostname: data.Hostname, + LogSources: effectiveLogSources, + Metrics: data.Metrics, + MetricsTargets: effectiveMetricsTargets, + Profiles: data.Profiles, + Tags: data.Tags, + ResourceId: fmt.Sprintf("rid:finch:%s:agent:%s", c.config.Id(), uuid.New().String()), + Username: rand.Text(), + Password: password, + PasswordHash: string(hash), } return agent, nil diff --git a/internal/controller/agent_test.go b/internal/controller/agent_test.go index 01f0bcb..e96efe9 100644 --- a/internal/controller/agent_test.go +++ b/internal/controller/agent_test.go @@ -71,15 +71,26 @@ func Test_RegisterAgentReturnsError_BadParameters(t *testing.T) { ctrl := New(model, &mockedConfig) assert.NotNil(t, ctrl, "create controller") - _, err := ctrl.RegisterAgent("", nil, nil, false, false) + data := Agent{ + Hostname: "", + Tags: nil, + LogSources: nil, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + _, err := ctrl.RegisterAgent(&data) expected := "hostname must not be empty" assert.EqualError(t, err, expected, "register agent with empty hostname") - _, err = ctrl.RegisterAgent("test-host", nil, nil, false, false) + data.Hostname = "test-host" + _, err = ctrl.RegisterAgent(&data) expected = "at least one log source must be specified" assert.EqualError(t, err, expected, "register agent with no log sources") - _, err = ctrl.RegisterAgent("test-host", nil, []string{"invalid://source"}, false, false) + data.LogSources = []string{"invalid://source"} + _, err = ctrl.RegisterAgent(&data) expected = "no valid log source specified" assert.EqualError(t, err, expected, "register agent with invalid log source") } @@ -92,7 +103,16 @@ func Test_RegisterAgentReturnsError_InvalidSecret(t *testing.T) { ctrl := New(model, &config) assert.NotNil(t, ctrl, "create controller") - _, err := ctrl.RegisterAgent("test-host", []string{"tag1"}, []string{"journal://"}, false, false) + data := Agent{ + Hostname: "test-host", + Tags: []string{"tag1"}, + LogSources: []string{"journal://"}, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + _, err := ctrl.RegisterAgent(&data) assert.Error(t, err, "register agent with invalid config secret") } @@ -102,7 +122,16 @@ func Test_RegisterAgentReturnsResourceId(t *testing.T) { ctrl := New(model, &mockedConfig) assert.NotNil(t, ctrl, "create controller") - rid, err := ctrl.RegisterAgent("test-host", []string{"tag1", "tag2"}, []string{"file:///var/log/syslog"}, false, false) + data := Agent{ + Hostname: "test-host", + Tags: []string{"tag1", "tag2"}, + LogSources: []string{"file:///var/log/syslog"}, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + rid, err := ctrl.RegisterAgent(&data) assert.NoError(t, err, "register agent with valid parameters") assert.NotEmpty(t, rid, "resource ID not empty") @@ -133,7 +162,16 @@ func Test_DeregisterAgentReturnsNil(t *testing.T) { ctrl := New(model, &mockedConfig) assert.NotNil(t, ctrl, "create controller") - rid, err := ctrl.RegisterAgent("test-host", []string{"tag1"}, []string{"file:///var/log/syslog"}, false, false) + data := Agent{ + Hostname: "test-host", + Tags: []string{"tag1"}, + LogSources: []string{"file:///var/log/syslog"}, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + rid, err := ctrl.RegisterAgent(&data) assert.NoError(t, err, "register agent with valid parameters") err = ctrl.DeregisterAgent(rid) @@ -157,7 +195,16 @@ func Test_CreateAgentConfigReturnsConfig(t *testing.T) { ctrl := New(model, &mockedConfig) assert.NotNil(t, ctrl, "create controller") - rid, err := ctrl.RegisterAgent("test-host", []string{"tag1"}, []string{"file:///var/log/syslog"}, false, false) + data := Agent{ + Hostname: "test-host", + Tags: []string{"tag1"}, + LogSources: []string{"file:///var/log/syslog"}, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + rid, err := ctrl.RegisterAgent(&data) assert.NoError(t, err, "register agent with valid parameters") config, err := ctrl.CreateAgentConfig(rid) @@ -182,7 +229,16 @@ func Test_GetAgentReturnsAgent(t *testing.T) { ctrl := New(model, &mockedConfig) assert.NotNil(t, ctrl, "create controller") - rid, err := ctrl.RegisterAgent("test-host", []string{"tag1"}, []string{"file:///var/log/syslog"}, false, false) + data := Agent{ + Hostname: "test-host", + Tags: []string{"tag1"}, + LogSources: []string{"file:///var/log/syslog"}, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + rid, err := ctrl.RegisterAgent(&data) assert.NoError(t, err, "register agent with valid parameters") agent, err := ctrl.GetAgent(rid) @@ -207,10 +263,28 @@ func Test_ListAgentsReturnsAgents(t *testing.T) { ctrl := New(model, &mockedConfig) assert.NotNil(t, ctrl, "create controller") - _, err := ctrl.RegisterAgent("test-host-1", []string{"tag1"}, []string{"file:///var/log/syslog"}, false, false) + data := Agent{ + Hostname: "test-host-1", + Tags: []string{"tag1"}, + LogSources: []string{"file:///var/log/syslog"}, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + _, err := ctrl.RegisterAgent(&data) assert.NoError(t, err, "register first agent") - _, err = ctrl.RegisterAgent("test-host-2", []string{"tag2"}, []string{"file:///var/log/syslog"}, false, false) + data = Agent{ + Hostname: "test-host-2", + Tags: []string{"tag2"}, + LogSources: []string{"file:///var/log/syslog"}, + Metrics: false, + MetricsTargets: nil, + Profiles: false, + } + + _, err = ctrl.RegisterAgent(&data) assert.NoError(t, err, "register second agent") agents, err := ctrl.ListAgents() diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 8dfd510..9fde800 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -26,14 +26,7 @@ func (h *handler) registerAgentHandlers() { } func (h *handler) CreateAgent(w http.ResponseWriter, r *http.Request) { - type payload struct { - Hostname string `json:"hostname"` - Tags []string `json:"tags"` - LogSources []string `json:"log_sources"` - Metrics bool `json:"metrics"` - Profiles bool `json:"profiles"` - } - var p payload + var p controller.Agent if err := json.NewDecoder(r.Body).Decode(&p); err != nil { go h.makeLog(r, http.StatusBadRequest, slog.LevelError, "invalid request body") @@ -41,7 +34,7 @@ func (h *handler) CreateAgent(w http.ResponseWriter, r *http.Request) { return } - rid, err := h.controller.RegisterAgent(p.Hostname, p.Tags, p.LogSources, p.Metrics, p.Profiles) + rid, err := h.controller.RegisterAgent(&p) if err != nil { status := http.StatusInternalServerError if errors.Is(err, controller.ErrAgentAlreadyExists) { diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index f05c2a5..eb0f3d0 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -50,7 +50,7 @@ var mockedConfig = mockConfig{ type mockController struct{} -func (m *mockController) RegisterAgent(hostname string, tags, logSources []string, metrics bool, profiles bool) (string, error) { +func (m *mockController) RegisterAgent(data *controller.Agent) (string, error) { return "rid:12345", nil } func (m *mockController) DeregisterAgent(rid string) error { diff --git a/internal/handler/openapi.yaml b/internal/handler/openapi.yaml index c2c5e20..5a8631b 100644 --- a/internal/handler/openapi.yaml +++ b/internal/handler/openapi.yaml @@ -36,6 +36,11 @@ components: metrics: type: boolean example: true + metrics_targets: + type: array + items: + type: string + example: ["http://localhost:9100/metrics"] profiles: type: boolean example: true diff --git a/internal/model/agent.go b/internal/model/agent.go index fa6d1d6..1cb1c01 100644 --- a/internal/model/agent.go +++ b/internal/model/agent.go @@ -12,21 +12,22 @@ import ( ) type Agent struct { - ID uint `gorm:"primarykey" json:"-"` - CreatedAt time.Time `json:"-"` - UpdatedAt time.Time `json:"-"` - Active bool `gorm:"not null;default:true" json:"active"` - Hostname string `gorm:"not null;unique" json:"hostname"` - LastSeen *time.Time `gorm:"default:NULL" json:"last_seen"` - LogSources []string `gorm:"not null;serializer:json" json:"log_sources"` - Metrics bool `gorm:"not null;default:false" json:"metrics"` - Profiles bool `gorm:"not null;default:false" json:"profiles"` - Password string `gorm:"not null" json:"-"` - PasswordHash string `gorm:"not null" json:"-"` - RegisteredAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"registered_at"` - ResourceId string `gorm:"not null;unique;uniqueIndex:uidx_agents_resource_id" json:"resource_id"` - Tags []string `gorm:"serializer:json" json:"tags"` - Username string `gorm:"not null" json:"-"` + ID uint `gorm:"primarykey" json:"-"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` + Active bool `gorm:"not null;default:true" json:"active"` + Hostname string `gorm:"not null;unique" json:"hostname"` + LastSeen *time.Time `gorm:"default:NULL" json:"last_seen"` + LogSources []string `gorm:"not null;default:'[]';serializer:json" json:"log_sources"` + Metrics bool `gorm:"not null;default:false" json:"metrics"` + MetricsTargets []string `gorm:"not null;default:'[]';serializer:json" json:"metrics_targets"` + Profiles bool `gorm:"not null;default:false" json:"profiles"` + Password string `gorm:"not null" json:"-"` + PasswordHash string `gorm:"not null" json:"-"` + RegisteredAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"registered_at"` + ResourceId string `gorm:"not null;unique;uniqueIndex:uidx_agents_resource_id" json:"resource_id"` + Tags []string `gorm:"serializer:json" json:"tags"` + Username string `gorm:"not null" json:"-"` } type ModelAgent interface {