feat: agent metadata with custom collectors

This commit is contained in:
2023-03-02 13:05:24 +01:00
parent 3310c09320
commit 1ff29ae1fb
40 changed files with 998 additions and 256 deletions

View File

@ -4,13 +4,21 @@ import (
"context"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/client"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type Agent struct {
thumbprint string
privateKey jwk.Key
client *client.Client
controllers []Controller
interval time.Duration
collectors []metadata.Collector
}
func (a *Agent) Run(ctx context.Context) error {
@ -21,10 +29,20 @@ func (a *Agent) Run(ctx context.Context) error {
ticker := time.NewTicker(a.interval)
defer ticker.Stop()
ctx = withClient(ctx, a.client)
for {
select {
case <-ticker.C:
logger.Debug(ctx, "registering agent")
if err := a.registerAgent(ctx, state); err != nil {
logger.Error(ctx, "could not register agent", logger.E(errors.WithStack(err)))
continue
}
logger.Debug(ctx, "state before reconciliation", logger.F("state", state))
if err := a.Reconcile(ctx, state); err != nil {
@ -58,14 +76,67 @@ func (a *Agent) Reconcile(ctx context.Context, state *State) error {
return nil
}
func New(funcs ...OptionFunc) *Agent {
func (a *Agent) registerAgent(ctx context.Context, state *State) error {
meta, err := a.collectMetadata(ctx)
if err != nil {
return errors.WithStack(err)
}
sorted := metadata.Sort(meta)
agent, err := a.client.RegisterAgent(ctx, a.privateKey, a.thumbprint, sorted)
if err != nil {
return errors.WithStack(err)
}
state.agentID = agent.ID
return nil
}
func (a *Agent) collectMetadata(ctx context.Context) (map[string]any, error) {
metadata := make(map[string]any)
for _, collector := range a.collectors {
name, value, err := collector.Collect(ctx)
if err != nil {
logger.Error(
ctx, "could not collect metadata",
logger.E(errors.WithStack(err)), logger.F("name", name),
)
continue
}
metadata[name] = value
}
return metadata, nil
}
func isAPIError(err error, code api.ErrorCode) (bool, any) {
apiError := &api.Error{}
if errors.As(err, &apiError) && apiError.Code == code {
return true, apiError.Data
}
return false, nil
}
func New(serverURL string, privateKey jwk.Key, thumbprint string, funcs ...OptionFunc) *Agent {
opt := defaultOption()
for _, fn := range funcs {
fn(opt)
}
client := client.New(serverURL)
return &Agent{
privateKey: privateKey,
thumbprint: thumbprint,
client: client,
controllers: opt.Controllers,
interval: opt.Interval,
collectors: opt.Collectors,
}
}

27
internal/agent/context.go Normal file
View File

@ -0,0 +1,27 @@
package agent
import (
"context"
"forge.cadoles.com/Cadoles/emissary/internal/client"
"github.com/pkg/errors"
)
type contextKey string
const (
contextKeyClient contextKey = "client"
)
func withClient(ctx context.Context, client *client.Client) context.Context {
return context.WithValue(ctx, contextKeyClient, client)
}
func Client(ctx context.Context) *client.Client {
client, ok := ctx.Value(contextKeyClient).(*client.Client)
if !ok {
panic(errors.New("could not retrieve client from context"))
}
return client
}

View File

@ -4,18 +4,13 @@ import (
"context"
"forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/agent/machineid"
"forge.cadoles.com/Cadoles/emissary/internal/client"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/server"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type Controller struct {
client *client.Client
}
type Controller struct{}
// Name implements node.Controller.
func (c *Controller) Name() string {
@ -24,56 +19,31 @@ func (c *Controller) Name() string {
// Reconcile implements node.Controller.
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
machineID, err := machineid.Get()
cl := agent.Client(ctx)
agents, _, err := cl.QueryAgents(
ctx,
client.WithQueryAgentsLimit(1),
client.WithQueryAgentsID(state.AgentID()),
)
if err != nil {
return errors.WithStack(err)
}
ctx = logger.With(ctx, logger.F("machineID", machineID))
agent, err := c.client.RegisterAgent(ctx, machineID)
isAlreadyRegisteredErr, _ := isAPIError(err, server.ErrCodeAlreadyRegistered)
switch {
case isAlreadyRegisteredErr:
agents, _, err := c.client.QueryAgents(
ctx,
client.WithQueryAgentsLimit(1),
client.WithQueryAgentsRemoteID(machineID),
)
if err != nil {
return errors.WithStack(err)
}
if len(agents) == 0 {
logger.Error(ctx, "could not find remote matching agent")
return nil
}
if err := c.reconcileAgent(ctx, state, agents[0]); err != nil {
return errors.WithStack(err)
}
if len(agents) == 0 {
logger.Error(ctx, "could not find remote matching agent")
return nil
}
case agent != nil:
if err := c.reconcileAgent(ctx, state, agent); err != nil {
return errors.WithStack(err)
}
return nil
case err != nil:
logger.Error(ctx, "could not contact server", logger.E(errors.WithStack(err)))
return nil
if err := c.reconcileAgent(ctx, cl, state, agents[0]); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Controller) reconcileAgent(ctx context.Context, state *agent.State, agent *datastore.Agent) error {
func (c *Controller) reconcileAgent(ctx context.Context, client *client.Client, state *agent.State, agent *datastore.Agent) error {
ctx = logger.With(ctx, logger.F("agentID", agent.ID))
if agent.Status != datastore.AgentStatusAccepted {
@ -82,7 +52,7 @@ func (c *Controller) reconcileAgent(ctx context.Context, state *agent.State, age
return nil
}
specs, err := c.client.GetAgentSpecs(ctx, agent.ID)
specs, err := client.GetAgentSpecs(ctx, agent.ID)
if err != nil {
logger.Error(ctx, "could not retrieve agent specs", logger.E(errors.WithStack(err)))
@ -98,19 +68,8 @@ func (c *Controller) reconcileAgent(ctx context.Context, state *agent.State, age
return nil
}
func NewController(serverURL string) *Controller {
client := client.New(serverURL)
return &Controller{client}
}
func isAPIError(err error, code api.ErrorCode) (bool, any) {
apiError := &api.Error{}
if errors.As(err, &apiError) && apiError.Code == code {
return true, apiError.Data
}
return false, nil
func NewController() *Controller {
return &Controller{}
}
var _ agent.Controller = &Controller{}

View File

@ -1,29 +0,0 @@
package machineid
import (
"github.com/btcsuite/btcd/btcutil/base58"
"github.com/denisbrodbeck/machineid"
"github.com/pkg/errors"
)
const salt = "emissary.cadoles.com"
func Get() (string, error) {
machineID, err := getProtectedMachineID()
if err != nil {
return "", errors.WithStack(err)
}
return machineID, nil
}
func getProtectedMachineID() (string, error) {
id, err := machineid.ProtectedID(salt)
if err != nil {
return "", errors.WithStack(err)
}
encoded := base58.Encode([]byte(id))
return encoded, nil
}

View File

@ -0,0 +1,12 @@
package metadata
import (
"context"
"errors"
)
var ErrMetadataNotAvailable = errors.New("metadata not available")
type Collector interface {
Collect(context.Context) (string, string, error)
}

View File

@ -0,0 +1,31 @@
package buildinfo
import (
"context"
"runtime/debug"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"github.com/pkg/errors"
)
const (
MetadataBuildInfo = "buildinfo"
)
type Collector struct{}
// Collect implements agent.MetadataCollector
func (c *Collector) Collect(ctx context.Context) (string, string, error) {
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
return "", "", errors.WithStack(metadata.ErrMetadataNotAvailable)
}
return MetadataBuildInfo, buildInfo.String(), nil
}
func NewCollector() *Collector {
return &Collector{}
}
var _ metadata.Collector = &Collector{}

View File

@ -0,0 +1,46 @@
package shell
import (
"bytes"
"context"
"os"
"os/exec"
"strings"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"github.com/pkg/errors"
)
type Collector struct {
name string
command string
args []string
}
// Collect implements agent.MetadataCollector
func (c *Collector) Collect(ctx context.Context) (string, string, error) {
cmd := exec.CommandContext(ctx, c.command, c.args...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", "", errors.WithStack(err)
}
value := strings.TrimSpace(buf.String())
return c.name, value, nil
}
func NewCollector(name string, command string, args ...string) *Collector {
return &Collector{
name: name,
command: command,
args: args,
}
}
var _ metadata.Collector = &Collector{}

View File

@ -0,0 +1,3 @@
package metadata
type Metadata map[string]any

View File

@ -0,0 +1,37 @@
package metadata
import (
"sort"
)
type Tuple struct {
Key string `json:"key"`
Value any `json:"value"`
}
func Sort(metadata map[string]any) []Tuple {
keys := make([]string, 0, len(metadata))
for k := range metadata {
keys = append(keys, k)
}
sort.Strings(keys)
tuples := make([]Tuple, len(keys))
for i, k := range keys {
tuples[i] = Tuple{k, metadata[k]}
}
return tuples
}
func FromSorted(tuples []Tuple) map[string]any {
metadata := make(map[string]any)
for _, t := range tuples {
metadata[t.Key] = t.Value
}
return metadata
}

View File

@ -1,31 +0,0 @@
package agent
import (
"time"
)
type Option struct {
Interval time.Duration
Controllers []Controller
}
type OptionFunc func(*Option)
func defaultOption() *Option {
return &Option{
Controllers: make([]Controller, 0),
Interval: 10 * time.Second,
}
}
func WithControllers(controllers ...Controller) OptionFunc {
return func(opt *Option) {
opt.Controllers = controllers
}
}
func WithInterval(interval time.Duration) OptionFunc {
return func(opt *Option) {
opt.Interval = interval
}
}

43
internal/agent/options.go Normal file
View File

@ -0,0 +1,43 @@
package agent
import (
"time"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/client"
)
type Options struct {
Client *client.Client
Interval time.Duration
Controllers []Controller
Collectors []metadata.Collector
}
type OptionFunc func(*Options)
func defaultOption() *Options {
return &Options{
Controllers: make([]Controller, 0),
Interval: 10 * time.Second,
Collectors: make([]metadata.Collector, 0),
}
}
func WithControllers(controllers ...Controller) OptionFunc {
return func(opt *Options) {
opt.Controllers = controllers
}
}
func WithInterval(interval time.Duration) OptionFunc {
return func(opt *Options) {
opt.Interval = interval
}
}
func WithCollectors(collectors ...metadata.Collector) OptionFunc {
return func(opts *Options) {
opts.Collectors = collectors
}
}

View File

@ -3,6 +3,7 @@ package agent
import (
"encoding/json"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
@ -13,7 +14,8 @@ var ErrSpecNotFound = errors.New("spec not found")
type Specs map[spec.Name]spec.Spec
type State struct {
specs Specs `json:"specs"`
agentID datastore.AgentID
specs Specs
}
func NewState() *State {
@ -24,8 +26,10 @@ func NewState() *State {
func (s *State) MarshalJSON() ([]byte, error) {
state := struct {
ID datastore.AgentID `json:"agentId"`
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
}{
ID: s.agentID,
Specs: func(specs map[spec.Name]spec.Spec) map[spec.Name]*spec.RawSpec {
rawSpecs := make(map[spec.Name]*spec.RawSpec)
@ -51,7 +55,8 @@ func (s *State) MarshalJSON() ([]byte, error) {
func (s *State) UnmarshalJSON(data []byte) error {
state := struct {
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
AgentID datastore.AgentID `json:"agentId"`
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
}{}
if err := json.Unmarshal(data, &state); err != nil {
@ -71,6 +76,10 @@ func (s *State) UnmarshalJSON(data []byte) error {
return nil
}
func (s *State) AgentID() datastore.AgentID {
return s.agentID
}
func (s *State) Specs() Specs {
return s.specs
}